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.

at main 2094 lines 78 kB view raw
1import 'dart:async'; 2import 'dart:convert'; 3import 'dart:io'; 4import 'dart:ui' as ui; 5 6import 'package:bluesky_text/bluesky_text.dart'; 7import 'package:flutter/foundation.dart'; 8import 'package:flutter/material.dart'; 9import 'package:flutter_bloc/flutter_bloc.dart'; 10import 'package:image_picker/image_picker.dart'; 11import 'package:intl/intl.dart'; 12import 'package:lazurite/core/database/app_database.dart'; 13import 'package:lazurite/core/l10n/l10n.dart'; 14import 'package:lazurite/core/logging/app_logger.dart'; 15import 'package:lazurite/core/theme/theme_extensions.dart'; 16import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 17import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 18import 'package:lazurite/features/compose/data/link_preview_service.dart'; 19import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 20import 'package:lazurite/features/profile/data/profile_repository.dart'; 21import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 22import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 23import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 24import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 25import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; 26import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 27import 'package:lazurite/shared/utils/format_utils.dart'; 28import 'package:video_player/video_player.dart'; 29 30class ComposeScreen extends StatefulWidget { 31 const ComposeScreen({ 32 super.key, 33 this.replyParentUri, 34 this.replyParentCid, 35 this.replyRootUri, 36 this.replyRootCid, 37 this.replyAuthorHandle, 38 this.quoteUri, 39 this.quoteCid, 40 this.quoteAuthorHandle, 41 this.quoteText, 42 this.draftId, 43 this.initialText, 44 this.editPostUri, 45 this.editPostCid, 46 this.editRecord, 47 this.typeaheadRepository, 48 this.linkPreviewService, 49 }); 50 51 final String? replyParentUri; 52 final String? replyParentCid; 53 final String? replyRootUri; 54 final String? replyRootCid; 55 final String? replyAuthorHandle; 56 final String? quoteUri; 57 final String? quoteCid; 58 final String? quoteAuthorHandle; 59 final String? quoteText; 60 final int? draftId; 61 final String? initialText; 62 final String? editPostUri; 63 final String? editPostCid; 64 final Map<String, dynamic>? editRecord; 65 final TypeaheadRepository? typeaheadRepository; 66 final LinkPreviewService? linkPreviewService; 67 68 @override 69 State<ComposeScreen> createState() => _ComposeScreenState(); 70} 71 72class _ComposeScreenState extends State<ComposeScreen> { 73 late final _FacetHighlightController _textController; 74 final FocusNode _textFocusNode = FocusNode(); 75 final ImagePicker _imagePicker = ImagePicker(); 76 Timer? _mentionDebounce; 77 Timer? _linkPreviewDebounce; 78 int _mentionQueryStart = -1; 79 int _mentionQueryEnd = -1; 80 int _mentionSearchGeneration = 0; 81 int _linkPreviewGeneration = 0; 82 List<TypeaheadResult> _mentionSuggestions = const []; 83 bool _isSearchingMentions = false; 84 TypeaheadRepository? _typeaheadRepository; 85 late final LinkPreviewService _linkPreviewService; 86 LinkPreviewData? _linkPreview; 87 bool _isLoadingLinkPreview = false; 88 String? _hiddenPreviewUrl; 89 bool _showDrafts = false; 90 String? _composerAvatarDid; 91 Future<String?>? _composerAvatarFuture; 92 bool _didLogMissingAuthProviderForAvatar = false; 93 bool _didLogMissingProfileRepositoryForAvatar = false; 94 bool _didLogComposerAvatarLookupFailure = false; 95 96 @override 97 void initState() { 98 super.initState(); 99 final isEditing = widget.editPostUri != null && widget.editPostCid != null && widget.editRecord != null; 100 _textController = _FacetHighlightController(); 101 _typeaheadRepository = widget.typeaheadRepository; 102 if (_typeaheadRepository == null) { 103 try { 104 _typeaheadRepository = context.read<TypeaheadRepository>(); 105 } catch (_) { 106 _typeaheadRepository = null; 107 } 108 } 109 _linkPreviewService = widget.linkPreviewService ?? LinkPreviewService(); 110 _textFocusNode.addListener(() { 111 if (!_textFocusNode.hasFocus) { 112 _clearMentionSuggestions(); 113 } 114 }); 115 if (widget.initialText?.isNotEmpty ?? false) { 116 _textController.text = widget.initialText!; 117 } 118 119 if (isEditing) { 120 context.read<ComposeBloc>().add( 121 EditContextSet( 122 postUri: widget.editPostUri!, 123 postCid: widget.editPostCid!, 124 record: Map<String, dynamic>.from(widget.editRecord!), 125 initialText: widget.initialText, 126 ), 127 ); 128 } 129 130 if (!isEditing && widget.draftId != null) { 131 context.read<ComposeBloc>().add(DraftLoaded(widget.draftId!)); 132 } 133 134 if (!isEditing && widget.replyParentUri != null && widget.replyParentCid != null) { 135 context.read<ComposeBloc>().add( 136 ReplyContextSet( 137 parentUri: widget.replyParentUri!, 138 parentCid: widget.replyParentCid!, 139 rootUri: widget.replyRootUri ?? widget.replyParentUri!, 140 rootCid: widget.replyRootCid ?? widget.replyParentCid!, 141 ), 142 ); 143 } 144 145 if (!isEditing && widget.quoteUri != null && widget.quoteCid != null) { 146 context.read<ComposeBloc>().add(QuoteContextSet(quoteUri: widget.quoteUri!, quoteCid: widget.quoteCid!)); 147 } 148 149 _textController.addListener(_onTextChanged); 150 if (!isEditing && widget.initialText?.isNotEmpty == true) { 151 context.read<ComposeBloc>().add(TextChanged(widget.initialText!)); 152 } 153 } 154 155 @override 156 void dispose() { 157 _textController.removeListener(_onTextChanged); 158 _textController.dispose(); 159 _textFocusNode.dispose(); 160 _mentionDebounce?.cancel(); 161 _linkPreviewDebounce?.cancel(); 162 super.dispose(); 163 } 164 165 @override 166 void didChangeDependencies() { 167 super.didChangeDependencies(); 168 _syncComposerAvatarFuture(); 169 } 170 171 void _syncComposerAvatarFuture() { 172 AuthState? authState; 173 try { 174 authState = context.read<AuthBloc>().state; 175 } catch (_) { 176 if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { 177 log.d('ComposeScreen: auth provider unavailable for composer avatar.'); 178 _didLogMissingAuthProviderForAvatar = true; 179 } 180 } 181 182 final did = authState?.tokens?.did.trim(); 183 if (did == null || did.isEmpty) { 184 _composerAvatarDid = null; 185 _composerAvatarFuture = null; 186 return; 187 } 188 189 if (_composerAvatarDid == did && _composerAvatarFuture != null) { 190 return; 191 } 192 193 _composerAvatarDid = did; 194 _composerAvatarFuture = _loadComposerAvatarUrl(did); 195 } 196 197 Future<String?> _loadComposerAvatarUrl(String did) async { 198 ProfileRepository repository; 199 try { 200 repository = context.read<ProfileRepository>(); 201 } catch (_) { 202 if (kDebugMode && !_didLogMissingProfileRepositoryForAvatar) { 203 log.d('ComposeScreen: profile repository unavailable for composer avatar.'); 204 _didLogMissingProfileRepositoryForAvatar = true; 205 } 206 return null; 207 } 208 209 try { 210 final profile = await repository.getProfile(did); 211 return profile.avatar; 212 } catch (_) { 213 if (kDebugMode && !_didLogComposerAvatarLookupFailure) { 214 log.d('ComposeScreen: composer avatar lookup failed.'); 215 _didLogComposerAvatarLookupFailure = true; 216 } 217 return null; 218 } 219 } 220 221 void _onTextChanged() { 222 final bloc = context.read<ComposeBloc>(); 223 final text = _textController.text; 224 _scheduleMentionLookup(text); 225 _scheduleLinkPreviewLookup(text); 226 if (bloc.state.text == text) { 227 return; 228 } 229 bloc.add(TextChanged(text)); 230 } 231 232 void _scheduleMentionLookup(String text) { 233 final repository = _typeaheadRepository; 234 if (repository == null || !_textFocusNode.hasFocus) { 235 _clearMentionSuggestions(); 236 return; 237 } 238 239 final activeMention = _activeMentionQuery(text, _textController.selection); 240 if (activeMention == null || activeMention.query.length < 2) { 241 _clearMentionSuggestions(); 242 return; 243 } 244 245 _mentionQueryStart = activeMention.start; 246 _mentionQueryEnd = activeMention.end; 247 _mentionDebounce?.cancel(); 248 _mentionDebounce = Timer(const Duration(milliseconds: 220), () async { 249 final generation = ++_mentionSearchGeneration; 250 if (!mounted) { 251 return; 252 } 253 setState(() => _isSearchingMentions = true); 254 try { 255 final results = await repository.search(query: activeMention.query, limit: 6); 256 if (!mounted || generation != _mentionSearchGeneration) { 257 return; 258 } 259 setState(() { 260 _mentionSuggestions = results; 261 _isSearchingMentions = false; 262 }); 263 } catch (_) { 264 if (!mounted || generation != _mentionSearchGeneration) { 265 return; 266 } 267 setState(() { 268 _mentionSuggestions = const []; 269 _isSearchingMentions = false; 270 }); 271 } 272 }); 273 } 274 275 void _scheduleLinkPreviewLookup(String text) { 276 if (context.read<ComposeBloc>().state.isEditing) { 277 _clearLinkPreview(); 278 return; 279 } 280 281 if (_hiddenPreviewUrl != null && !text.contains(_hiddenPreviewUrl!)) { 282 _hiddenPreviewUrl = null; 283 } 284 285 final firstLink = LinkPreviewService.firstLink(text); 286 if (firstLink == null) { 287 _clearLinkPreview(); 288 return; 289 } 290 291 if (_hiddenPreviewUrl != null && _hiddenPreviewUrl == firstLink) { 292 _clearLinkPreview(); 293 return; 294 } 295 296 if (_linkPreview?.uri == firstLink) { 297 return; 298 } 299 300 _linkPreviewDebounce?.cancel(); 301 _linkPreviewDebounce = Timer(const Duration(milliseconds: 320), () async { 302 final generation = ++_linkPreviewGeneration; 303 if (!mounted) { 304 return; 305 } 306 setState(() => _isLoadingLinkPreview = true); 307 try { 308 final preview = await _linkPreviewService.fetch(firstLink); 309 if (!mounted || generation != _linkPreviewGeneration) { 310 return; 311 } 312 setState(() { 313 _linkPreview = preview; 314 _isLoadingLinkPreview = false; 315 }); 316 } catch (_) { 317 if (!mounted || generation != _linkPreviewGeneration) { 318 return; 319 } 320 setState(() { 321 _linkPreview = null; 322 _isLoadingLinkPreview = false; 323 }); 324 } 325 }); 326 } 327 328 void _clearMentionSuggestions() { 329 _mentionDebounce?.cancel(); 330 _mentionQueryStart = -1; 331 _mentionQueryEnd = -1; 332 _mentionSearchGeneration++; 333 if (_mentionSuggestions.isNotEmpty || _isSearchingMentions) { 334 setState(() { 335 _mentionSuggestions = const []; 336 _isSearchingMentions = false; 337 }); 338 } 339 } 340 341 void _clearLinkPreview() { 342 _linkPreviewDebounce?.cancel(); 343 _linkPreviewGeneration++; 344 if (_linkPreview != null || _isLoadingLinkPreview) { 345 setState(() { 346 _linkPreview = null; 347 _isLoadingLinkPreview = false; 348 }); 349 } 350 } 351 352 void _applyMention(TypeaheadResult result) { 353 final start = _mentionQueryStart; 354 final end = _mentionQueryEnd; 355 if (start < 0 || end < start || end > _textController.text.length) { 356 return; 357 } 358 359 final currentText = _textController.text; 360 final replacement = '@${result.handle} '; 361 final nextText = '${currentText.substring(0, start)}$replacement${currentText.substring(end)}'; 362 final cursorOffset = start + replacement.length; 363 _textController.value = TextEditingValue( 364 text: nextText, 365 selection: TextSelection.collapsed(offset: cursorOffset), 366 ); 367 _clearMentionSuggestions(); 368 } 369 370 ({int start, int end, String query})? _activeMentionQuery(String text, TextSelection selection) { 371 if (!selection.isValid || !selection.isCollapsed) { 372 return null; 373 } 374 375 final cursor = selection.baseOffset; 376 if (cursor <= 0 || cursor > text.length) { 377 return null; 378 } 379 380 final left = text.substring(0, cursor); 381 final mentionStart = left.lastIndexOf('@'); 382 if (mentionStart < 0) { 383 return null; 384 } 385 386 if (mentionStart > 0) { 387 final prefixChar = text[mentionStart - 1]; 388 const allowedPrefix = '([{"\''; 389 if (!RegExp(r'\s').hasMatch(prefixChar) && !allowedPrefix.contains(prefixChar)) { 390 return null; 391 } 392 } 393 394 final candidate = text.substring(mentionStart + 1, cursor); 395 if (candidate.isEmpty || candidate.contains(RegExp(r'\s'))) { 396 return null; 397 } 398 399 final suffix = cursor < text.length ? text[cursor] : ''; 400 if (suffix.isNotEmpty && RegExp(r'[A-Za-z0-9._-]').hasMatch(suffix)) { 401 return null; 402 } 403 404 return (start: mentionStart, end: cursor, query: candidate); 405 } 406 407 Future<void> _pickImage() async { 408 final state = context.read<ComposeBloc>().state; 409 if (!state.canAddMoreMedia) { 410 if (mounted) { 411 showAppSnackBar(context, context.l10n.messageComposeImageMaxCount, isError: true); 412 } 413 return; 414 } 415 416 try { 417 final XFile? image = await _imagePicker.pickImage( 418 source: ImageSource.gallery, 419 maxWidth: 2048, 420 maxHeight: 2048, 421 imageQuality: 85, 422 ); 423 424 if (image != null && mounted) { 425 final file = File(image.path); 426 final fileSize = await file.length(); 427 const maxSize = 1 * 1024 * 1024; 428 if (fileSize > maxSize) { 429 if (mounted) { 430 showAppSnackBar(context, context.l10n.messageComposeImageMustBeUnder1Mb, isError: true); 431 } 432 return; 433 } 434 435 final extension = image.path.toLowerCase().split('.').last; 436 const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; 437 if (!validExtensions.contains(extension)) { 438 if (mounted) { 439 showAppSnackBar(context, context.l10n.messageComposeImageMustBeJpegPngWebp, isError: true); 440 } 441 return; 442 } 443 444 final bytes = await file.readAsBytes(); 445 final ui.Codec codec = await ui.instantiateImageCodec(bytes); 446 final ui.FrameInfo frameInfo = await codec.getNextFrame(); 447 final int width = frameInfo.image.width; 448 final int height = frameInfo.image.height; 449 450 if (mounted) { 451 context.read<ComposeBloc>().add(MediaAttached(image.path, width: width, height: height)); 452 } 453 } 454 } catch (e) { 455 if (mounted) { 456 showAppSnackBar(context, context.l10n.formatComposeFailedToPickImage(e), isError: true); 457 } 458 } 459 } 460 461 Future<void> _pickVideo() async { 462 final state = context.read<ComposeBloc>().state; 463 if (!state.canAddVideo) { 464 if (mounted) { 465 showAppSnackBar(context, context.l10n.messageComposeRemoveExistingMediaBeforeVideo, isError: true); 466 } 467 return; 468 } 469 470 try { 471 final XFile? video = await _imagePicker.pickVideo(source: ImageSource.gallery); 472 if (video != null && mounted) { 473 context.read<ComposeBloc>().add(VideoAttached(video.path)); 474 } 475 } catch (e) { 476 if (mounted) { 477 showAppSnackBar(context, context.l10n.formatComposeFailedToPickVideo(e), isError: true); 478 } 479 } 480 } 481 482 Future<void> _showVideoAltTextDialog(VideoAttachment video) async { 483 final result = await showDialog<String>( 484 context: context, 485 builder: (dialogContext) => _VideoAltTextDialog( 486 video: video, 487 onCancel: () => Navigator.pop(dialogContext), 488 onSave: (altText) => Navigator.pop(dialogContext, altText), 489 ), 490 ); 491 492 if (result != null && mounted) { 493 context.read<ComposeBloc>().add(VideoAltTextUpdated(result)); 494 } 495 } 496 497 Future<void> _showAltTextDialog(int index, MediaAttachment attachment) async { 498 final result = await showDialog<String>( 499 context: context, 500 builder: (dialogContext) => _ImageAltTextDialog( 501 imagePath: attachment.localPath, 502 initialAltText: attachment.altText, 503 onCancel: () => Navigator.pop(dialogContext), 504 onSave: (altText) => Navigator.pop(dialogContext, altText), 505 ), 506 ); 507 508 if (result != null && mounted) { 509 context.read<ComposeBloc>().add(AltTextUpdated(index: index, altText: result)); 510 } 511 } 512 513 Future<void> _showSchedulePicker() async { 514 final now = DateTime.now(); 515 final initialDate = now.add(const Duration(minutes: 5)); 516 517 final DateTime? date = await showDatePicker( 518 context: context, 519 initialDate: initialDate, 520 firstDate: now, 521 lastDate: now.add(const Duration(days: 365)), 522 ); 523 524 if (date == null) return; 525 526 if (!mounted) return; 527 final TimeOfDay? time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initialDate)); 528 529 if (time == null) return; 530 531 if (!mounted) return; 532 final scheduledDateTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); 533 534 if (scheduledDateTime.isAfter(now)) { 535 context.read<ComposeBloc>().add(PostScheduled(scheduledDateTime)); 536 } 537 } 538 539 void _toggleDrafts() { 540 if (context.read<ComposeBloc>().state.isEditing) return; 541 final willShow = !_showDrafts; 542 setState(() => _showDrafts = willShow); 543 if (willShow) { 544 context.read<ComposeBloc>().add(const DraftsRequested()); 545 } 546 } 547 548 String _formatDraftTime(BuildContext context, DateTime dateTime) { 549 return formatRelativeTime( 550 dateTime, 551 nowLabel: context.l10n.commonJustNow, 552 includeAgo: true, 553 locale: Localizations.localeOf(context).toString(), 554 ); 555 } 556 557 String _videoStatusLabel(BuildContext context, VideoAttachment video) { 558 return switch (video.status) { 559 VideoUploadStatus.idle => context.l10n.messageVideoReadyToUpload, 560 VideoUploadStatus.checkingLimits => context.l10n.messageVideoCheckingUploadLimits, 561 VideoUploadStatus.uploading => 562 video.uploadProgress > 0 563 ? '${context.l10n.messageVideoUploading} ${video.uploadProgress}%' 564 : context.l10n.messageVideoUploading, 565 VideoUploadStatus.processing => 566 video.uploadProgress > 0 567 ? '${context.l10n.messageVideoProcessing} ${video.uploadProgress}%' 568 : context.l10n.messageVideoProcessing, 569 VideoUploadStatus.ready => 570 video.altText.isNotEmpty 571 ? context.l10n.formatComposeVideoReadyWithAltText(video.altText) 572 : context.l10n.messageVideoReady, 573 VideoUploadStatus.error => _localizedComposeError( 574 context, 575 video.errorMessage ?? context.l10n.messageVideoUploadFailed, 576 ), 577 }; 578 } 579 580 Widget _buildDraftsPanel() { 581 return BlocBuilder<ComposeBloc, ComposeState>( 582 builder: (context, state) { 583 final colorScheme = theme.colorScheme; 584 return Column( 585 mainAxisSize: MainAxisSize.min, 586 children: [ 587 Container( 588 height: 8, 589 decoration: BoxDecoration( 590 color: colorScheme.surfaceContainerLow, 591 border: Border( 592 top: BorderSide(color: colorScheme.outlineVariant), 593 bottom: BorderSide(color: colorScheme.outlineVariant), 594 ), 595 ), 596 ), 597 Container( 598 constraints: const BoxConstraints(maxHeight: 292), 599 decoration: BoxDecoration( 600 color: colorScheme.surface, 601 border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), 602 ), 603 child: Column( 604 mainAxisSize: MainAxisSize.min, 605 children: [ 606 Padding( 607 padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), 608 child: Row( 609 mainAxisAlignment: MainAxisAlignment.spaceBetween, 610 children: [ 611 Text( 612 context.l10n.messageComposeDrafts, 613 style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 614 ), 615 Text( 616 context.l10n.formatDraftCount(state.drafts.length), 617 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 618 ), 619 ], 620 ), 621 ), 622 if (state.isLoadingDrafts) 623 const Padding( 624 padding: EdgeInsets.symmetric(vertical: 26), 625 child: Center(child: CircularProgressIndicator()), 626 ) 627 else if (state.drafts.isEmpty) 628 Padding( 629 padding: const EdgeInsets.symmetric(vertical: 26), 630 child: Center( 631 child: Text( 632 context.l10n.messageComposeNoDraftsSaved, 633 style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 634 ), 635 ), 636 ) 637 else 638 Flexible( 639 child: ListView.separated( 640 shrinkWrap: true, 641 itemCount: state.drafts.length, 642 separatorBuilder: (_, _) => Divider(height: 1, color: colorScheme.outlineVariant), 643 itemBuilder: (context, index) { 644 final draft = state.drafts[index]; 645 return _DraftListItem( 646 draft: draft, 647 formattedTime: _formatDraftTime(context, draft.updatedAt), 648 onTap: () { 649 setState(() => _showDrafts = false); 650 context.read<ComposeBloc>().add(DraftLoaded(draft.id)); 651 }, 652 onDelete: () { 653 final bloc = context.read<ComposeBloc>(); 654 showConfirmationDialog( 655 context: context, 656 title: Text(context.l10n.dialogDeleteDraftTitle), 657 content: Text(context.l10n.dialogDeletePostContent), 658 confirmLabel: context.l10n.buttonDelete, 659 confirmDestructive: true, 660 ).then((confirmed) { 661 if (confirmed && mounted) { 662 bloc.add(DraftDeleted(draft.id)); 663 } 664 }); 665 }, 666 ); 667 }, 668 ), 669 ), 670 ], 671 ), 672 ), 673 ], 674 ); 675 }, 676 ); 677 } 678 679 Widget _buildMentionAutocompletePanel() { 680 if (!_textFocusNode.hasFocus) { 681 return const SizedBox.shrink(); 682 } 683 if (!_isSearchingMentions && _mentionSuggestions.isEmpty) { 684 return const SizedBox.shrink(); 685 } 686 687 return Container( 688 margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), 689 decoration: BoxDecoration( 690 color: theme.colorScheme.surfaceContainerLow, 691 borderRadius: BorderRadius.circular(12), 692 border: Border.all(color: theme.colorScheme.outlineVariant), 693 ), 694 constraints: const BoxConstraints(maxHeight: 220), 695 child: _isSearchingMentions 696 ? const Padding( 697 padding: EdgeInsets.symmetric(vertical: 16), 698 child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), 699 ) 700 : ListView.separated( 701 shrinkWrap: true, 702 itemCount: _mentionSuggestions.length, 703 separatorBuilder: (_, _) => Divider(height: 1, color: theme.colorScheme.outlineVariant), 704 itemBuilder: (context, index) { 705 final actor = _mentionSuggestions[index]; 706 return ListTile( 707 dense: true, 708 leading: CircleAvatar( 709 radius: 14, 710 backgroundImage: actor.avatarUrl != null ? NetworkImage(actor.avatarUrl!) : null, 711 child: actor.avatarUrl == null 712 ? Text((actor.displayName ?? actor.handle).substring(0, 1).toUpperCase()) 713 : null, 714 ), 715 title: Text( 716 actor.displayName ?? actor.handle, 717 maxLines: 1, 718 overflow: TextOverflow.ellipsis, 719 style: theme.textTheme.bodyMedium, 720 ), 721 subtitle: Text('@${actor.handle}', style: theme.textTheme.bodySmall), 722 onTap: () => _applyMention(actor), 723 ); 724 }, 725 ), 726 ); 727 } 728 729 Widget _buildQuotePreview(ComposeState state) { 730 if (!state.isQuote) { 731 return const SizedBox.shrink(); 732 } 733 734 final quotedHandle = widget.quoteAuthorHandle?.trim(); 735 final quotedText = widget.quoteText?.trim() ?? ''; 736 737 return Container( 738 margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), 739 padding: const EdgeInsets.all(12), 740 decoration: BoxDecoration( 741 color: theme.colorScheme.surfaceContainerLow, 742 borderRadius: BorderRadius.circular(12), 743 border: Border.all(color: theme.colorScheme.outlineVariant), 744 ), 745 child: Row( 746 crossAxisAlignment: CrossAxisAlignment.start, 747 children: [ 748 Icon(Icons.format_quote, size: 18, color: theme.colorScheme.onSurfaceVariant), 749 const SizedBox(width: 10), 750 Expanded( 751 child: Column( 752 crossAxisAlignment: CrossAxisAlignment.start, 753 children: [ 754 Text( 755 quotedHandle != null && quotedHandle.isNotEmpty 756 ? context.l10n.formatComposeQuotingHandle(quotedHandle) 757 : context.l10n.messageComposeQuotingPost, 758 style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), 759 ), 760 if (quotedText.isNotEmpty) ...[ 761 const SizedBox(height: 4), 762 Text( 763 quotedText, 764 maxLines: 3, 765 overflow: TextOverflow.ellipsis, 766 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 767 ), 768 ], 769 ], 770 ), 771 ), 772 IconButton( 773 onPressed: () => context.read<ComposeBloc>().add(const QuoteContextCleared()), 774 icon: const Icon(Icons.close), 775 visualDensity: VisualDensity.compact, 776 tooltip: context.l10n.messageComposeRemoveQuotedPost, 777 ), 778 ], 779 ), 780 ); 781 } 782 783 Widget _buildComposerLinkPreview(ComposeState state) { 784 if (_linkPreview == null || state.isQuote || state.hasMedia || state.hasVideo || state.isEditing) { 785 return const SizedBox.shrink(); 786 } 787 788 return Padding( 789 padding: const EdgeInsets.fromLTRB(68, 0, 16, 8), 790 child: ExternalLinkPreviewCard( 791 uri: _linkPreview!.uri, 792 title: _linkPreview!.title, 793 description: _linkPreview!.description, 794 thumbUrl: _linkPreview!.thumbnailUrl, 795 compact: true, 796 onRemove: () { 797 setState(() { 798 _hiddenPreviewUrl = _linkPreview!.uri; 799 _linkPreview = null; 800 }); 801 }, 802 ), 803 ); 804 } 805 806 ThemeData get theme => Theme.of(context); 807 808 void _submitPost() { 809 context.read<ComposeBloc>().add(PostSubmitted(suppressedLinkUri: _hiddenPreviewUrl)); 810 } 811 812 void _saveDraft() { 813 if (context.read<ComposeBloc>().state.isEditing) return; 814 context.read<ComposeBloc>().add(const DraftSaved()); 815 if (mounted) { 816 showAppSnackBar(context, context.l10n.messageComposeDraftSaved); 817 } 818 } 819 820 Future<void> _showEditAlgorithmInfo() async { 821 await showConfirmationDialog( 822 context: context, 823 title: Text(context.l10n.dialogEditAlgorithmTitle), 824 content: Text(context.l10n.dialogEditAlgorithmContent), 825 confirmLabel: context.l10n.buttonOk, 826 showCancel: false, 827 ); 828 } 829 830 void _handleBackNavigation(BuildContext context) { 831 final state = context.read<ComposeBloc>().state; 832 final navigator = Navigator.of(context); 833 834 final hasContent = state.text.trim().isNotEmpty || state.mediaAttachments.isNotEmpty; 835 836 if (state.isEditing) { 837 if (state.isDraftDirty) { 838 showConfirmationDialog( 839 context: context, 840 title: Text(context.l10n.dialogDiscardChangesTitle), 841 content: Text(context.l10n.dialogDiscardChangesContent), 842 confirmLabel: context.l10n.buttonDiscard, 843 ).then((shouldDiscard) { 844 if (shouldDiscard && mounted) { 845 navigator.pop(false); 846 } 847 }); 848 } else { 849 navigator.pop(false); 850 } 851 return; 852 } 853 854 if (hasContent && state.isDraftDirty) { 855 showConfirmationDialog( 856 context: context, 857 title: Text(context.l10n.dialogSaveDraftTitle), 858 content: Text(context.l10n.dialogSaveDraftContent), 859 cancelLabel: context.l10n.buttonDiscard, 860 confirmLabel: context.l10n.buttonSave, 861 ).then((shouldSave) { 862 if (shouldSave) { 863 _saveDraft(); 864 } 865 if (mounted) { 866 navigator.pop(); 867 } 868 }); 869 } else { 870 navigator.pop(); 871 } 872 } 873 874 String _composerAvatarFallbackText() { 875 try { 876 final tokens = context.read<AuthBloc>().state.tokens; 877 final displayName = tokens?.displayName?.trim(); 878 if (displayName != null && displayName.isNotEmpty) { 879 return displayName; 880 } 881 882 final handle = tokens?.handle.trim(); 883 if (handle != null && handle.isNotEmpty) { 884 return handle; 885 } 886 } catch (_) { 887 if (kDebugMode && !_didLogMissingAuthProviderForAvatar) { 888 log.d('ComposeScreen: auth provider unavailable for avatar fallback.'); 889 _didLogMissingAuthProviderForAvatar = true; 890 } 891 } 892 893 return context.l10n.appTitle; 894 } 895 896 Widget _buildComposerAvatar() { 897 final avatar = ProfileAvatar( 898 key: const ValueKey('compose_author_avatar'), 899 size: 40, 900 imageUrl: null, 901 fallbackText: _composerAvatarFallbackText(), 902 backgroundColor: theme.colorScheme.surfaceContainerHighest, 903 placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( 904 color: theme.colorScheme.onSurface, 905 fontWeight: FontWeight.w700, 906 ), 907 ); 908 909 final avatarFuture = _composerAvatarFuture; 910 if (avatarFuture == null) { 911 return avatar; 912 } 913 914 return FutureBuilder<String?>( 915 future: avatarFuture, 916 builder: (context, snapshot) { 917 return ProfileAvatar( 918 key: const ValueKey('compose_author_avatar'), 919 size: 40, 920 imageUrl: snapshot.data, 921 fallbackText: _composerAvatarFallbackText(), 922 backgroundColor: theme.colorScheme.surfaceContainerHighest, 923 placeholderTextStyle: theme.textTheme.labelMedium?.copyWith( 924 color: theme.colorScheme.onSurface, 925 fontWeight: FontWeight.w700, 926 ), 927 ); 928 }, 929 ); 930 } 931 932 Widget _buildComposerTextArea() => Expanded( 933 child: Padding( 934 padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 935 child: Row( 936 crossAxisAlignment: CrossAxisAlignment.start, 937 children: [ 938 _buildComposerAvatar(), 939 const SizedBox(width: 12), 940 Expanded( 941 child: TextField( 942 controller: _textController, 943 focusNode: _textFocusNode, 944 autofocus: true, 945 maxLines: null, 946 expands: true, 947 textAlignVertical: TextAlignVertical.top, 948 decoration: InputDecoration( 949 hintText: context.l10n.messageComposePlaceholder, 950 hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), 951 border: InputBorder.none, 952 contentPadding: EdgeInsets.zero, 953 ), 954 style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), 955 ), 956 ), 957 ], 958 ), 959 ), 960 ); 961 962 Widget _buildScheduledPill(ComposeState state) { 963 if (!state.hasScheduledTime) { 964 return const SizedBox.shrink(); 965 } 966 967 final colorScheme = theme.colorScheme; 968 return Align( 969 alignment: Alignment.centerLeft, 970 child: Container( 971 margin: const EdgeInsets.fromLTRB(68, 4, 16, 12), 972 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), 973 decoration: BoxDecoration( 974 color: colorScheme.primary, 975 borderRadius: BorderRadius.circular(999), 976 border: Border.all(color: colorScheme.primary), 977 ), 978 child: Row( 979 mainAxisSize: MainAxisSize.min, 980 children: [ 981 Icon(Icons.schedule, size: 15, color: colorScheme.onPrimary), 982 const SizedBox(width: 7), 983 Text( 984 context.l10n.formatComposeScheduledFor( 985 DateFormat.yMMMd(Localizations.localeOf(context).toString()).add_jm().format(state.scheduledAt!), 986 ), 987 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onPrimary, fontWeight: FontWeight.w600), 988 ), 989 const SizedBox(width: 2), 990 IconButton( 991 onPressed: () => context.read<ComposeBloc>().add(const ScheduleCleared()), 992 icon: Icon(Icons.close, size: 16, color: colorScheme.onPrimary), 993 tooltip: context.l10n.messageComposeClearScheduledTime, 994 constraints: const BoxConstraints(minWidth: 40, minHeight: 40), 995 padding: EdgeInsets.zero, 996 visualDensity: VisualDensity.compact, 997 style: IconButton.styleFrom( 998 tapTargetSize: MaterialTapTargetSize.shrinkWrap, 999 minimumSize: const Size(40, 40), 1000 ), 1001 ), 1002 ], 1003 ), 1004 ), 1005 ); 1006 } 1007 1008 Widget _buildToolbarIconButton({ 1009 required IconData icon, 1010 required String tooltip, 1011 required VoidCallback? onPressed, 1012 bool isActive = false, 1013 }) { 1014 final colorScheme = theme.colorScheme; 1015 final foregroundColor = onPressed == null 1016 ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) 1017 : colorScheme.primary; 1018 1019 return IconButton( 1020 onPressed: onPressed, 1021 icon: Icon(icon, color: foregroundColor), 1022 tooltip: tooltip, 1023 style: IconButton.styleFrom( 1024 fixedSize: const Size(40, 40), 1025 minimumSize: const Size(40, 40), 1026 padding: EdgeInsets.zero, 1027 backgroundColor: isActive ? colorScheme.surfaceContainerLow : Colors.transparent, 1028 shape: const CircleBorder(), 1029 ), 1030 ); 1031 } 1032 1033 @override 1034 Widget build(BuildContext context) { 1035 return BlocListener<ComposeBloc, ComposeState>( 1036 listener: (context, state) { 1037 if (state.text != _textController.text) { 1038 _textController.text = state.text; 1039 _textController.selection = TextSelection.collapsed(offset: state.text.length); 1040 } 1041 1042 if (state.isSuccess) { 1043 if (state.isEditing) { 1044 showAppSnackBar(context, context.l10n.messageChangesSaved, behavior: SnackBarBehavior.floating); 1045 } 1046 Navigator.of(context).pop( 1047 state.isEditing 1048 ? {'editedText': state.text} 1049 : { 1050 'status': state.hasScheduledTime ? 'scheduled' : 'posted', 1051 'isReply': state.isReply, 1052 'replyParentUri': state.replyParentUri, 1053 'replyRootUri': state.replyRootUri, 1054 }, 1055 ); 1056 } 1057 1058 if (state.hasError && state.errorMessage != null) { 1059 showAppSnackBar( 1060 context, 1061 _localizedComposeError(context, state.errorMessage!), 1062 behavior: SnackBarBehavior.floating, 1063 isError: true, 1064 ); 1065 } 1066 }, 1067 child: PopScope( 1068 canPop: false, 1069 onPopInvokedWithResult: (bool didPop, dynamic result) { 1070 if (didPop) return; 1071 _handleBackNavigation(context); 1072 }, 1073 child: Scaffold( 1074 appBar: AppBar( 1075 elevation: 0, 1076 scrolledUnderElevation: 0, 1077 backgroundColor: theme.colorScheme.surface, 1078 surfaceTintColor: Colors.transparent, 1079 shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), 1080 leading: TextButton( 1081 onPressed: () => _handleBackNavigation(context), 1082 child: Text(context.l10n.buttonCancel), 1083 ), 1084 leadingWidth: 80, 1085 title: BlocBuilder<ComposeBloc, ComposeState>( 1086 builder: (context, state) => Text( 1087 state.isEditing ? context.l10n.labelEditPost : context.l10n.labelNewPost, 1088 style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), 1089 ), 1090 ), 1091 centerTitle: true, 1092 actions: [ 1093 BlocBuilder<ComposeBloc, ComposeState>( 1094 builder: (context, state) { 1095 final isOffline = context.select<ConnectivityCubit, bool>((cubit) => cubit.state.isOffline); 1096 final button = FilledButton( 1097 onPressed: !isOffline && state.canSubmit && !state.isSubmitting ? _submitPost : null, 1098 style: FilledButton.styleFrom( 1099 minimumSize: const Size(64, 36), 1100 padding: const EdgeInsets.symmetric(horizontal: 18), 1101 visualDensity: VisualDensity.compact, 1102 ), 1103 child: state.isSubmitting 1104 ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) 1105 : Text(state.isEditing ? context.l10n.buttonSaveChanges : context.l10n.buttonPost), 1106 ); 1107 1108 return Padding( 1109 padding: const EdgeInsets.only(right: 8), 1110 child: isOffline 1111 ? Tooltip( 1112 message: context.l10n.formatOfflineReconnectAction(context.l10n.actionPublishYourPost), 1113 child: button, 1114 ) 1115 : button, 1116 ); 1117 }, 1118 ), 1119 ], 1120 ), 1121 body: SafeArea( 1122 child: Column( 1123 children: [ 1124 BlocBuilder<ComposeBloc, ComposeState>( 1125 builder: (context, state) { 1126 if (state.isEditing) { 1127 return Container( 1128 margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 1129 padding: const EdgeInsets.all(12), 1130 decoration: BoxDecoration( 1131 color: theme.colorScheme.surfaceContainerHighest, 1132 border: Border.all(color: theme.colorScheme.outlineVariant), 1133 ), 1134 child: Row( 1135 crossAxisAlignment: CrossAxisAlignment.start, 1136 children: [ 1137 Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant, size: 20), 1138 const SizedBox(width: 12), 1139 Expanded( 1140 child: Text( 1141 context.l10n.messageComposeEditNotice, 1142 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 1143 ), 1144 ), 1145 IconButton( 1146 onPressed: _showEditAlgorithmInfo, 1147 icon: const Icon(Icons.help_outline), 1148 tooltip: context.l10n.labelMoreInfo, 1149 ), 1150 ], 1151 ), 1152 ); 1153 } 1154 1155 if (!state.isReply || widget.replyAuthorHandle == null) { 1156 return _buildQuotePreview(state); 1157 } 1158 return Column( 1159 children: [ 1160 Container( 1161 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 1162 decoration: BoxDecoration( 1163 color: theme.colorScheme.surfaceContainerHighest, 1164 border: Border(bottom: BorderSide(color: theme.dividerColor)), 1165 ), 1166 child: Row( 1167 children: [ 1168 Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), 1169 const SizedBox(width: 8), 1170 Text( 1171 '${context.l10n.messageReplyingTo} ', 1172 style: Theme.of( 1173 context, 1174 ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 1175 ), 1176 Text( 1177 '@${widget.replyAuthorHandle}', 1178 style: theme.textTheme.bodySmall?.copyWith( 1179 color: theme.colorScheme.primary, 1180 fontWeight: FontWeight.w500, 1181 ), 1182 ), 1183 ], 1184 ), 1185 ), 1186 _buildQuotePreview(state), 1187 ], 1188 ); 1189 }, 1190 ), 1191 _buildComposerTextArea(), 1192 _buildMentionAutocompletePanel(), 1193 BlocBuilder<ComposeBloc, ComposeState>(builder: (context, state) => _buildComposerLinkPreview(state)), 1194 if (_isLoadingLinkPreview) 1195 const Padding( 1196 padding: EdgeInsets.only(bottom: 8), 1197 child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), 1198 ), 1199 BlocBuilder<ComposeBloc, ComposeState>(builder: (context, state) => _buildScheduledPill(state)), 1200 1201 BlocBuilder<ComposeBloc, ComposeState>( 1202 builder: (context, state) { 1203 if (state.isEditing) { 1204 return const SizedBox.shrink(); 1205 } 1206 if (state.mediaAttachments.isEmpty) return const SizedBox.shrink(); 1207 1208 return Padding( 1209 padding: const EdgeInsets.fromLTRB(68, 0, 16, 16), 1210 child: GridView.builder( 1211 shrinkWrap: true, 1212 physics: const NeverScrollableScrollPhysics(), 1213 itemCount: state.mediaAttachments.length, 1214 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 1215 crossAxisCount: 2, 1216 mainAxisSpacing: 8, 1217 crossAxisSpacing: 8, 1218 childAspectRatio: 4 / 3, 1219 ), 1220 itemBuilder: (context, index) { 1221 final attachment = state.mediaAttachments[index]; 1222 return Stack( 1223 fit: StackFit.expand, 1224 children: [ 1225 Positioned.fill( 1226 child: DecoratedBox( 1227 decoration: BoxDecoration( 1228 color: theme.colorScheme.surfaceContainerLow, 1229 borderRadius: BorderRadius.circular(12), 1230 ), 1231 child: const SizedBox.expand(), 1232 ), 1233 ), 1234 Positioned.fill( 1235 child: ClipRRect( 1236 borderRadius: BorderRadius.circular(12), 1237 child: Image.file(File(attachment.localPath), fit: BoxFit.cover), 1238 ), 1239 ), 1240 Positioned( 1241 left: 6, 1242 bottom: 6, 1243 child: Material( 1244 color: attachment.altText.isNotEmpty ? theme.colorScheme.primary : Colors.black54, 1245 borderRadius: BorderRadius.circular(4), 1246 child: InkWell( 1247 borderRadius: BorderRadius.circular(4), 1248 onTap: () => _showAltTextDialog(index, attachment), 1249 child: ConstrainedBox( 1250 constraints: const BoxConstraints(minWidth: 40, minHeight: 30), 1251 child: Center( 1252 child: Text( 1253 context.l10n.labelAlt, 1254 style: theme.textTheme.labelSmall?.copyWith( 1255 color: attachment.altText.isNotEmpty 1256 ? theme.colorScheme.onPrimary 1257 : Colors.white, 1258 fontWeight: FontWeight.bold, 1259 ), 1260 ), 1261 ), 1262 ), 1263 ), 1264 ), 1265 ), 1266 Positioned( 1267 top: 6, 1268 right: 6, 1269 child: SizedBox( 1270 width: 32, 1271 height: 32, 1272 child: IconButton( 1273 padding: EdgeInsets.zero, 1274 style: IconButton.styleFrom(backgroundColor: Colors.black54), 1275 onPressed: () => context.read<ComposeBloc>().add(MediaRemoved(index)), 1276 icon: const Icon(Icons.close, size: 16, color: Colors.white), 1277 tooltip: context.l10n.messageComposeRemoveImage, 1278 ), 1279 ), 1280 ), 1281 ], 1282 ); 1283 }, 1284 ), 1285 ); 1286 }, 1287 ), 1288 BlocBuilder<ComposeBloc, ComposeState>( 1289 builder: (context, state) { 1290 if (state.isEditing) { 1291 return const SizedBox.shrink(); 1292 } 1293 final video = state.videoAttachment; 1294 if (video == null) return const SizedBox.shrink(); 1295 1296 return Container( 1297 margin: const EdgeInsets.fromLTRB(68, 0, 16, 16), 1298 padding: const EdgeInsets.all(12), 1299 decoration: BoxDecoration( 1300 color: theme.colorScheme.surfaceContainerLow, 1301 borderRadius: BorderRadius.circular(12), 1302 border: Border.all(color: video.hasError ? theme.colorScheme.error : theme.dividerColor), 1303 ), 1304 child: Row( 1305 children: [ 1306 Container( 1307 width: 48, 1308 height: 48, 1309 decoration: BoxDecoration( 1310 color: theme.colorScheme.primaryContainer, 1311 borderRadius: BorderRadius.circular(8), 1312 ), 1313 child: video.isActive 1314 ? Padding( 1315 padding: const EdgeInsets.all(12), 1316 child: CircularProgressIndicator( 1317 strokeWidth: 2, 1318 value: video.isActive && video.uploadProgress > 0 1319 ? video.uploadProgress / 100 1320 : null, 1321 ), 1322 ) 1323 : Icon( 1324 video.hasError ? Icons.error_outline : Icons.videocam_outlined, 1325 color: video.hasError 1326 ? theme.colorScheme.error 1327 : theme.colorScheme.onPrimaryContainer, 1328 ), 1329 ), 1330 const SizedBox(width: 12), 1331 Expanded( 1332 child: Column( 1333 crossAxisAlignment: CrossAxisAlignment.start, 1334 children: [ 1335 Text( 1336 video.localPath.split('/').last, 1337 style: theme.textTheme.bodyMedium, 1338 maxLines: 1, 1339 overflow: TextOverflow.ellipsis, 1340 ), 1341 const SizedBox(height: 2), 1342 Text( 1343 _videoStatusLabel(context, video), 1344 style: theme.textTheme.bodySmall?.copyWith( 1345 color: video.hasError 1346 ? theme.colorScheme.error 1347 : theme.colorScheme.onSurfaceVariant, 1348 ), 1349 maxLines: 2, 1350 overflow: TextOverflow.ellipsis, 1351 ), 1352 if (video.isActive && video.uploadProgress > 0) ...[ 1353 const SizedBox(height: 4), 1354 LinearProgressIndicator( 1355 value: video.uploadProgress / 100, 1356 borderRadius: BorderRadius.circular(2), 1357 ), 1358 ], 1359 ], 1360 ), 1361 ), 1362 if (video.isReady) ...[ 1363 IconButton( 1364 icon: const Icon(Icons.subtitles_outlined), 1365 tooltip: context.l10n.messageComposeAddAltText, 1366 onPressed: () => _showVideoAltTextDialog(video), 1367 color: video.altText.isNotEmpty 1368 ? theme.colorScheme.primary 1369 : theme.colorScheme.onSurfaceVariant, 1370 ), 1371 ], 1372 IconButton( 1373 icon: const Icon(Icons.close), 1374 onPressed: () => context.read<ComposeBloc>().add(const VideoRemoved()), 1375 color: theme.colorScheme.onSurfaceVariant, 1376 tooltip: context.l10n.buttonRemove, 1377 ), 1378 ], 1379 ), 1380 ); 1381 }, 1382 ), 1383 AnimatedSize( 1384 duration: const Duration(milliseconds: 200), 1385 curve: Curves.easeInOut, 1386 child: context.select<ComposeBloc, bool>((bloc) => bloc.state.isEditing) 1387 ? const SizedBox.shrink() 1388 : (_showDrafts ? _buildDraftsPanel() : const SizedBox.shrink()), 1389 ), 1390 Container( 1391 decoration: BoxDecoration( 1392 color: theme.colorScheme.surface, 1393 border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant)), 1394 ), 1395 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 1396 child: SafeArea( 1397 top: false, 1398 child: Row( 1399 children: [ 1400 BlocBuilder<ComposeBloc, ComposeState>( 1401 builder: (context, state) { 1402 if (state.isEditing) return const SizedBox.shrink(); 1403 return _buildToolbarIconButton( 1404 icon: Icons.image_outlined, 1405 tooltip: context.l10n.messageComposeAddImage, 1406 onPressed: state.canAddMoreMedia ? _pickImage : null, 1407 ); 1408 }, 1409 ), 1410 BlocBuilder<ComposeBloc, ComposeState>( 1411 builder: (context, state) { 1412 if (state.isEditing) return const SizedBox.shrink(); 1413 return _buildToolbarIconButton( 1414 icon: Icons.videocam_outlined, 1415 tooltip: context.l10n.messageComposeAddVideo, 1416 onPressed: state.canAddVideo ? _pickVideo : null, 1417 ); 1418 }, 1419 ), 1420 BlocBuilder<ComposeBloc, ComposeState>( 1421 builder: (context, state) { 1422 if (state.isEditing) return const SizedBox.shrink(); 1423 final hasDraftableContent = 1424 state.text.trim().isNotEmpty || 1425 state.hasMedia || 1426 state.hasVideo || 1427 state.hasScheduledTime; 1428 return _buildToolbarIconButton( 1429 icon: Icons.save_outlined, 1430 tooltip: context.l10n.messageComposeSaveDraft, 1431 onPressed: hasDraftableContent ? _saveDraft : null, 1432 ); 1433 }, 1434 ), 1435 BlocBuilder<ComposeBloc, ComposeState>( 1436 builder: (context, state) { 1437 if (state.isEditing) return const SizedBox.shrink(); 1438 return _buildToolbarIconButton( 1439 icon: Icons.drive_file_rename_outline, 1440 tooltip: context.l10n.messageComposeDrafts, 1441 onPressed: _toggleDrafts, 1442 isActive: _showDrafts, 1443 ); 1444 }, 1445 ), 1446 BlocBuilder<ComposeBloc, ComposeState>( 1447 builder: (context, state) { 1448 if (state.isEditing) return const SizedBox.shrink(); 1449 return _buildToolbarIconButton( 1450 icon: Icons.schedule, 1451 tooltip: context.l10n.labelSchedule, 1452 onPressed: _showSchedulePicker, 1453 isActive: state.hasScheduledTime, 1454 ); 1455 }, 1456 ), 1457 const Spacer(), 1458 BlocBuilder<ComposeBloc, ComposeState>( 1459 builder: (context, state) { 1460 return _CharCounter(count: state.graphemeCount, maxCount: kMaxGraphemes); 1461 }, 1462 ), 1463 ], 1464 ), 1465 ), 1466 ), 1467 ], 1468 ), 1469 ), 1470 ), 1471 ), 1472 ); 1473 } 1474 1475 String _localizedComposeError(BuildContext context, String message) { 1476 final imageTooLarge = RegExp(r'^Image "(.+)" is ([0-9.]+) MB .+ max 1 MB\.$').firstMatch(message); 1477 if (imageTooLarge != null) { 1478 return context.l10n.formatComposeImageTooLarge(imageTooLarge.group(1)!, imageTooLarge.group(2)!); 1479 } 1480 1481 final videoTooLarge = RegExp(r'^Video is ([0-9.]+) MB .+ exceeds the 100 MB limit\.$').firstMatch(message); 1482 if (videoTooLarge != null) { 1483 return context.l10n.formatComposeVideoTooLarge(videoTooLarge.group(1)!); 1484 } 1485 1486 if (message.startsWith('Failed to pick image: ')) { 1487 return context.l10n.formatComposeFailedToPickImage(message.substring('Failed to pick image: '.length)); 1488 } 1489 if (message.startsWith('Failed to pick video: ')) { 1490 return context.l10n.formatComposeFailedToPickVideo(message.substring('Failed to pick video: '.length)); 1491 } 1492 if (message.startsWith('Failed to save changes: ')) { 1493 return context.l10n.formatComposeFailedToSaveChanges(message.substring('Failed to save changes: '.length)); 1494 } 1495 if (message.startsWith('Failed to submit post: ')) { 1496 return context.l10n.formatComposeFailedToSubmitPost(message.substring('Failed to submit post: '.length)); 1497 } 1498 if (message.startsWith('Upload failed: ')) { 1499 return context.l10n.messageVideoUploadFailed; 1500 } 1501 1502 return switch (message) { 1503 'Daily video upload limit reached.' => context.l10n.messageVideoDailyUploadLimitReached, 1504 'Upload failed — please try again.' => context.l10n.messageVideoUploadFailed, 1505 'Video processing failed.' => context.l10n.messageVideoProcessingFailed, 1506 'Video processing timed out.' => context.l10n.messageVideoProcessingTimedOut, 1507 'Edit context is missing. Please reopen the editor and try again.' => context.l10n.errorComposeEditContextMissing, 1508 'Failed to save changes. Please try again.' => context.l10n.errorComposeFailedToSaveChanges, 1509 'Image file not found. Please re-attach and try again.' => context.l10n.errorComposeImageFileNotFound, 1510 'Unsupported image format. Use JPEG, PNG, or WebP.' => context.l10n.errorComposeUnsupportedImageFormat, 1511 'Failed to upload image. Please try again.' => context.l10n.errorComposeFailedToUploadImage, 1512 'Failed to create post. Please try again.' => context.l10n.errorComposeFailedToCreatePost, 1513 'Network error — post saved as draft.' => context.l10n.errorComposeNetworkSavedAsDraft, 1514 'This post was changed elsewhere. Reopen it and try editing again.' => context.l10n.errorComposeChangedElsewhere, 1515 'Could not save changes. Your original post was restored.' => context.l10n.errorComposeOriginalPostRestored, 1516 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.' => 1517 context.l10n.errorComposeCouldNotSaveAndConfirmRecovery, 1518 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.' => 1519 context.l10n.errorComposeCouldNotConfirmEdit, 1520 _ => message, 1521 }; 1522 } 1523} 1524 1525class _DraftListItem extends StatelessWidget { 1526 const _DraftListItem({required this.draft, required this.formattedTime, required this.onTap, required this.onDelete}); 1527 1528 final DraftEntry draft; 1529 final String formattedTime; 1530 final VoidCallback onTap; 1531 final VoidCallback onDelete; 1532 1533 @override 1534 Widget build(BuildContext context) { 1535 final theme = Theme.of(context); 1536 final colorScheme = theme.colorScheme; 1537 1538 return InkWell( 1539 onTap: onTap, 1540 child: Padding( 1541 padding: const EdgeInsets.fromLTRB(16, 11, 8, 11), 1542 child: Row( 1543 crossAxisAlignment: CrossAxisAlignment.start, 1544 children: [ 1545 Expanded( 1546 child: Column( 1547 crossAxisAlignment: CrossAxisAlignment.start, 1548 children: [ 1549 Text( 1550 draft.content.isEmpty ? context.l10n.messageComposeNoText : draft.content, 1551 maxLines: 2, 1552 overflow: TextOverflow.ellipsis, 1553 style: theme.textTheme.bodyMedium?.copyWith(height: 1.35), 1554 ), 1555 const SizedBox(height: 6), 1556 Row( 1557 children: [ 1558 Text( 1559 formattedTime, 1560 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 1561 ), 1562 if (draft.scheduledAt != null) ...[ 1563 const SizedBox(width: 8), 1564 Container( 1565 padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 1566 decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), 1567 child: Text( 1568 context.l10n.labelScheduled, 1569 style: theme.textTheme.labelSmall?.copyWith( 1570 color: colorScheme.onPrimary, 1571 fontWeight: FontWeight.w700, 1572 ), 1573 ), 1574 ), 1575 ], 1576 ], 1577 ), 1578 ], 1579 ), 1580 ), 1581 IconButton( 1582 icon: Icon(Icons.delete_outline, color: colorScheme.error), 1583 onPressed: onDelete, 1584 tooltip: context.l10n.labelDeleteDraft, 1585 visualDensity: VisualDensity.compact, 1586 ), 1587 ], 1588 ), 1589 ), 1590 ); 1591 } 1592} 1593 1594/// A [TextEditingController] that highlights AT Protocol facets (mentions, 1595/// links, hashtags) inline as the user types. 1596/// 1597/// Byte-offset → code-unit conversion is done via UTF-8 re-encode so that 1598/// multi-byte characters (emoji, CJK, etc.) are handled correctly. 1599class _FacetHighlightController extends TextEditingController { 1600 @override 1601 TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) { 1602 final text = value.text; 1603 if (text.isEmpty) return TextSpan(style: style); 1604 1605 final entities = BlueskyText(text).entities.where((e) => e.type != EntityType.markdownLink).toList(); 1606 if (entities.isEmpty) return TextSpan(style: style, text: text); 1607 1608 final colorScheme = context.colorScheme; 1609 final textBytes = utf8.encode(text); 1610 final spans = <InlineSpan>[]; 1611 int lastCharEnd = 0; 1612 1613 for (final entity in entities) { 1614 final charStart = _byteToCharOffset(textBytes, entity.indices.start); 1615 final charEnd = _byteToCharOffset(textBytes, entity.indices.end); 1616 1617 if (charStart < lastCharEnd || charStart >= charEnd) continue; 1618 1619 if (charStart > lastCharEnd) { 1620 spans.add(TextSpan(style: style, text: text.substring(lastCharEnd, charStart))); 1621 } 1622 1623 final Color entityColor; 1624 if (entity.isHandle) { 1625 entityColor = colorScheme.primary; 1626 } else if (entity.isLink) { 1627 entityColor = colorScheme.tertiary; 1628 } else { 1629 entityColor = colorScheme.secondary; 1630 } 1631 1632 spans.add( 1633 TextSpan( 1634 style: style?.copyWith(color: entityColor), 1635 text: text.substring(charStart, charEnd), 1636 ), 1637 ); 1638 lastCharEnd = charEnd; 1639 } 1640 1641 if (lastCharEnd < text.length) { 1642 spans.add(TextSpan(style: style, text: text.substring(lastCharEnd))); 1643 } 1644 1645 return TextSpan(children: spans); 1646 } 1647 1648 /// Converts a UTF-8 byte offset into a Dart [String] code-unit offset. 1649 static int _byteToCharOffset(List<int> textBytes, int byteOffset) { 1650 if (byteOffset <= 0) return 0; 1651 if (byteOffset >= textBytes.length) return utf8.decode(textBytes, allowMalformed: true).length; 1652 return utf8.decode(textBytes.sublist(0, byteOffset), allowMalformed: true).length; 1653 } 1654} 1655 1656class _ImageAltTextDialog extends StatefulWidget { 1657 const _ImageAltTextDialog({ 1658 required this.imagePath, 1659 required this.initialAltText, 1660 required this.onCancel, 1661 required this.onSave, 1662 }); 1663 1664 final String imagePath; 1665 final String initialAltText; 1666 final VoidCallback onCancel; 1667 final ValueChanged<String> onSave; 1668 1669 @override 1670 State<_ImageAltTextDialog> createState() => _ImageAltTextDialogState(); 1671} 1672 1673class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { 1674 late final TextEditingController _controller = TextEditingController(text: widget.initialAltText); 1675 1676 @override 1677 void dispose() { 1678 _controller.dispose(); 1679 super.dispose(); 1680 } 1681 1682 @override 1683 Widget build(BuildContext context) { 1684 final theme = Theme.of(context); 1685 final colorScheme = theme.colorScheme; 1686 final size = MediaQuery.sizeOf(context); 1687 final imageHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); 1688 1689 return Dialog( 1690 clipBehavior: Clip.antiAlias, 1691 insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), 1692 child: ConstrainedBox( 1693 constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), 1694 child: SingleChildScrollView( 1695 padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), 1696 child: Column( 1697 mainAxisSize: MainAxisSize.min, 1698 crossAxisAlignment: CrossAxisAlignment.stretch, 1699 children: [ 1700 Row( 1701 children: [ 1702 Expanded( 1703 child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), 1704 ), 1705 IconButton( 1706 tooltip: context.l10n.labelClose, 1707 onPressed: widget.onCancel, 1708 icon: const Icon(Icons.close), 1709 ), 1710 ], 1711 ), 1712 const SizedBox(height: 12), 1713 Container( 1714 height: imageHeight, 1715 clipBehavior: Clip.antiAlias, 1716 decoration: BoxDecoration( 1717 color: colorScheme.surfaceContainerHighest, 1718 borderRadius: BorderRadius.circular(10), 1719 border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), 1720 ), 1721 child: Image.file( 1722 key: const ValueKey('alt-text-image-preview'), 1723 File(widget.imagePath), 1724 fit: BoxFit.contain, 1725 errorBuilder: (context, error, stackTrace) => 1726 Center(child: Icon(Icons.broken_image_outlined, size: 40, color: colorScheme.onSurfaceVariant)), 1727 ), 1728 ), 1729 const SizedBox(height: 16), 1730 TextField( 1731 key: const ValueKey('alt-text-field'), 1732 controller: _controller, 1733 minLines: 3, 1734 maxLines: 5, 1735 maxLength: 1000, 1736 textInputAction: TextInputAction.newline, 1737 decoration: InputDecoration( 1738 hintText: context.l10n.messageComposeDescribeImage, 1739 border: const OutlineInputBorder(), 1740 ), 1741 ), 1742 const SizedBox(height: 8), 1743 Row( 1744 mainAxisAlignment: MainAxisAlignment.end, 1745 children: [ 1746 TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), 1747 const SizedBox(width: 8), 1748 FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), 1749 ], 1750 ), 1751 ], 1752 ), 1753 ), 1754 ), 1755 ); 1756 } 1757} 1758 1759class _VideoAltTextDialog extends StatefulWidget { 1760 const _VideoAltTextDialog({required this.video, required this.onCancel, required this.onSave}); 1761 1762 final VideoAttachment video; 1763 final VoidCallback onCancel; 1764 final ValueChanged<String> onSave; 1765 1766 @override 1767 State<_VideoAltTextDialog> createState() => _VideoAltTextDialogState(); 1768} 1769 1770class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { 1771 late final TextEditingController _controller = TextEditingController(text: widget.video.altText); 1772 1773 @override 1774 void dispose() { 1775 _controller.dispose(); 1776 super.dispose(); 1777 } 1778 1779 @override 1780 Widget build(BuildContext context) { 1781 final theme = Theme.of(context); 1782 final size = MediaQuery.sizeOf(context); 1783 final previewHeight = (size.height * 0.32).clamp(140.0, 280.0).toDouble(); 1784 1785 return Dialog( 1786 clipBehavior: Clip.antiAlias, 1787 insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), 1788 child: ConstrainedBox( 1789 constraints: BoxConstraints(maxWidth: 560, maxHeight: size.height * 0.9), 1790 child: SingleChildScrollView( 1791 padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), 1792 child: Column( 1793 mainAxisSize: MainAxisSize.min, 1794 crossAxisAlignment: CrossAxisAlignment.stretch, 1795 children: [ 1796 Row( 1797 children: [ 1798 Expanded( 1799 child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), 1800 ), 1801 IconButton( 1802 tooltip: context.l10n.labelClose, 1803 onPressed: widget.onCancel, 1804 icon: const Icon(Icons.close), 1805 ), 1806 ], 1807 ), 1808 const SizedBox(height: 12), 1809 _LocalVideoPreview(videoPath: widget.video.localPath, height: previewHeight), 1810 const SizedBox(height: 16), 1811 TextField( 1812 key: const ValueKey('video-alt-text-field'), 1813 controller: _controller, 1814 minLines: 3, 1815 maxLines: 5, 1816 maxLength: 1000, 1817 textInputAction: TextInputAction.newline, 1818 decoration: InputDecoration( 1819 hintText: context.l10n.messageComposeDescribeVideo, 1820 border: const OutlineInputBorder(), 1821 ), 1822 ), 1823 const SizedBox(height: 8), 1824 Row( 1825 mainAxisAlignment: MainAxisAlignment.end, 1826 children: [ 1827 TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), 1828 const SizedBox(width: 8), 1829 FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), 1830 ], 1831 ), 1832 ], 1833 ), 1834 ), 1835 ), 1836 ); 1837 } 1838} 1839 1840class _LocalVideoPreview extends StatefulWidget { 1841 const _LocalVideoPreview({required this.videoPath, required this.height}); 1842 1843 final String videoPath; 1844 final double height; 1845 1846 @override 1847 State<_LocalVideoPreview> createState() => _LocalVideoPreviewState(); 1848} 1849 1850class _LocalVideoPreviewState extends State<_LocalVideoPreview> { 1851 VideoPlayerController? _controller; 1852 Object? _error; 1853 bool _isInitializing = false; 1854 1855 @override 1856 void initState() { 1857 super.initState(); 1858 _initialize(); 1859 } 1860 1861 @override 1862 void dispose() { 1863 _controller?.dispose(); 1864 super.dispose(); 1865 } 1866 1867 @override 1868 Widget build(BuildContext context) { 1869 final theme = Theme.of(context); 1870 final colorScheme = theme.colorScheme; 1871 final controller = _controller; 1872 final filename = widget.videoPath.split('/').last; 1873 1874 return Container( 1875 key: const ValueKey('video-alt-preview'), 1876 height: widget.height, 1877 clipBehavior: Clip.antiAlias, 1878 decoration: BoxDecoration( 1879 color: colorScheme.surfaceContainerHighest, 1880 borderRadius: BorderRadius.circular(10), 1881 border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), 1882 ), 1883 child: GestureDetector( 1884 onTap: controller != null && controller.value.isInitialized ? _togglePlayback : null, 1885 child: Stack( 1886 fit: StackFit.expand, 1887 alignment: Alignment.center, 1888 children: [ 1889 if (controller != null && controller.value.isInitialized) 1890 FittedBox( 1891 fit: BoxFit.contain, 1892 child: SizedBox( 1893 width: controller.value.size.width, 1894 height: controller.value.size.height, 1895 child: VideoPlayer(controller), 1896 ), 1897 ) 1898 else 1899 _VideoPreviewFallback(filename: filename, isLoading: _isInitializing, error: _error), 1900 Center( 1901 child: Container( 1902 width: 56, 1903 height: 56, 1904 decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle), 1905 child: Icon( 1906 controller?.value.isPlaying == true ? Icons.pause : Icons.play_arrow, 1907 color: Colors.white, 1908 size: 30, 1909 ), 1910 ), 1911 ), 1912 ], 1913 ), 1914 ), 1915 ); 1916 } 1917 1918 Future<void> _initialize() async { 1919 final file = File(widget.videoPath); 1920 if (!file.existsSync()) { 1921 return; 1922 } 1923 1924 setState(() { 1925 _isInitializing = true; 1926 }); 1927 1928 try { 1929 final controller = VideoPlayerController.file(file); 1930 await controller.initialize(); 1931 await controller.setLooping(true); 1932 1933 if (!mounted) { 1934 await controller.dispose(); 1935 return; 1936 } 1937 1938 setState(() { 1939 _controller = controller; 1940 _isInitializing = false; 1941 }); 1942 } catch (error) { 1943 if (!mounted) { 1944 return; 1945 } 1946 setState(() { 1947 _error = error; 1948 _isInitializing = false; 1949 }); 1950 } 1951 } 1952 1953 Future<void> _togglePlayback() async { 1954 final controller = _controller; 1955 if (controller == null || !controller.value.isInitialized) { 1956 return; 1957 } 1958 1959 if (controller.value.isPlaying) { 1960 await controller.pause(); 1961 } else { 1962 await controller.play(); 1963 } 1964 1965 if (mounted) { 1966 setState(() {}); 1967 } 1968 } 1969} 1970 1971class _VideoPreviewFallback extends StatelessWidget { 1972 const _VideoPreviewFallback({required this.filename, required this.isLoading, required this.error}); 1973 1974 final String filename; 1975 final bool isLoading; 1976 final Object? error; 1977 1978 @override 1979 Widget build(BuildContext context) { 1980 final theme = Theme.of(context); 1981 final colorScheme = theme.colorScheme; 1982 1983 return Center( 1984 child: Padding( 1985 padding: const EdgeInsets.all(20), 1986 child: Column( 1987 mainAxisSize: MainAxisSize.min, 1988 children: [ 1989 if (isLoading) 1990 const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.4)) 1991 else 1992 Icon(Icons.videocam_outlined, size: 40, color: colorScheme.onSurfaceVariant), 1993 const SizedBox(height: 10), 1994 Text( 1995 filename.isEmpty ? context.l10n.labelVideo : filename, 1996 key: const ValueKey('video-alt-preview-filename'), 1997 style: theme.textTheme.bodyMedium, 1998 maxLines: 1, 1999 overflow: TextOverflow.ellipsis, 2000 textAlign: TextAlign.center, 2001 ), 2002 if (error != null) ...[ 2003 const SizedBox(height: 4), 2004 Text( 2005 context.l10n.messageComposePreviewUnavailable, 2006 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 2007 textAlign: TextAlign.center, 2008 ), 2009 ], 2010 ], 2011 ), 2012 ), 2013 ); 2014 } 2015} 2016 2017class _CharCounter extends StatelessWidget { 2018 const _CharCounter({required this.count, required this.maxCount}); 2019 2020 final int count; 2021 final int maxCount; 2022 2023 @override 2024 Widget build(BuildContext context) { 2025 final remaining = maxCount - count; 2026 final progress = count / maxCount; 2027 final theme = Theme.of(context); 2028 Color color; 2029 if (progress < 0.8) { 2030 color = theme.colorScheme.primary; 2031 } else if (progress < 0.95) { 2032 color = theme.colorScheme.error.withValues(alpha: 0.7); 2033 } else { 2034 color = theme.colorScheme.error; 2035 } 2036 2037 return Row( 2038 mainAxisSize: MainAxisSize.min, 2039 children: [ 2040 Text( 2041 '$remaining', 2042 style: Theme.of( 2043 context, 2044 ).textTheme.bodySmall?.copyWith(color: color, fontFeatures: const [FontFeature.tabularFigures()]), 2045 ), 2046 const SizedBox(width: 8), 2047 SizedBox( 2048 width: 28, 2049 height: 28, 2050 child: CustomPaint( 2051 painter: _ProgressRingPainter( 2052 progress: progress.clamp(0.0, 1.0), 2053 color: color, 2054 backgroundColor: theme.colorScheme.surfaceContainerHighest, 2055 ), 2056 ), 2057 ), 2058 ], 2059 ); 2060 } 2061} 2062 2063class _ProgressRingPainter extends CustomPainter { 2064 _ProgressRingPainter({required this.progress, required this.color, required this.backgroundColor}); 2065 2066 final double progress; 2067 final Color color; 2068 final Color backgroundColor; 2069 2070 @override 2071 void paint(Canvas canvas, Size size) { 2072 final center = Offset(size.width / 2, size.height / 2); 2073 final radius = (size.width - 4) / 2; 2074 2075 final backgroundPaint = Paint() 2076 ..color = backgroundColor 2077 ..style = PaintingStyle.stroke 2078 ..strokeWidth = 2.5; 2079 2080 canvas.drawCircle(center, radius, backgroundPaint); 2081 2082 final progressPaint = Paint() 2083 ..color = color 2084 ..style = PaintingStyle.stroke 2085 ..strokeWidth = 2.5 2086 ..strokeCap = StrokeCap.round; 2087 2088 final sweepAngle = 2 * 3.14159 * progress; 2089 canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -3.14159 / 2, sweepAngle, false, progressPaint); 2090 } 2091 2092 @override 2093 bool shouldRepaint(covariant CustomPainter oldDelegate) => true; 2094}