mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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}