import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/profile.model.dart'; import '../../../models/programmer_task.model.dart'; import '../../../models/programmer_task_comment.model.dart'; import '../../../providers/profile_provider.dart'; import '../../../providers/programmer_task_comments_provider.dart'; import '../../../utils/app_time.dart'; import '../../../widgets/app_section_header.dart'; import '../../../widgets/m3_card.dart'; import '../../../widgets/quill_field.dart'; import '../programmer_task_prompts.dart'; const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}; bool _isImage(String name) => _imageExts.contains(name.split('.').last.toLowerCase()); /// Comment thread + composer. Any user who can see the task may comment and /// attach files. The assignee is prompted to pause after commenting. class CommentSection extends ConsumerStatefulWidget { const CommentSection({super.key, required this.task}); final ProgrammerTask task; @override ConsumerState createState() => _CommentSectionState(); } class _CommentSectionState extends ConsumerState { final _bodyQuill = quillControllerFromDelta(null); final List _pending = []; bool _sending = false; @override void dispose() { _bodyQuill.dispose(); super.dispose(); } Future _pickFiles() async { final messenger = ScaffoldMessenger.of(context); final result = await FilePicker.platform.pickFiles( type: FileType.any, withData: true, allowMultiple: true, ); if (result == null) return; for (final f in result.files) { if (f.bytes == null) continue; if (f.bytes!.length > 25 * 1024 * 1024) { messenger.showSnackBar( SnackBar(content: Text('${f.name} exceeds 25MB — skipped')), ); continue; } _pending.add((name: f.name, bytes: f.bytes!)); } if (mounted) setState(() {}); } Future _send() async { final hasText = quillPlainText(_bodyQuill).isNotEmpty; if ((!hasText && _pending.isEmpty) || _sending) return; setState(() => _sending = true); final messenger = ScaffoldMessenger.of(context); try { await ref.read(programmerTaskCommentsControllerProvider).addComment( taskId: widget.task.id, body: hasText ? quillToDeltaJson(_bodyQuill) : '', files: List.of(_pending), ); _bodyQuill.clear(); _pending.clear(); if (!mounted) return; await maybePromptPauseTimer(context, ref, widget.task); } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Could not send: $e'))); } finally { if (mounted) setState(() => _sending = false); } } @override Widget build(BuildContext context) { final tt = Theme.of(context).textTheme; final cs = Theme.of(context).colorScheme; final commentsAsync = ref.watch(programmerTaskCommentsProvider(widget.task.id)); final profiles = ref.watch(profilesProvider).valueOrNull ?? const []; final profileById = {for (final p in profiles) p.id: p}; final comments = commentsAsync.valueOrNull ?? const []; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const AppSectionHeader('Comments'), // Composer QuillFieldEditor( controller: _bodyQuill, placeholder: 'Add a comment…', height: 110, ), if (_pending.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (var i = 0; i < _pending.length; i++) InputChip( label: Text(_pending[i].name, overflow: TextOverflow.ellipsis), onDeleted: () => setState(() => _pending.removeAt(i)), ), ], ), ], const SizedBox(height: 8), Row( children: [ IconButton( tooltip: 'Attach files', onPressed: _sending ? null : _pickFiles, icon: const Icon(Icons.attach_file), ), const Spacer(), FilledButton.tonalIcon( onPressed: _sending ? null : _send, icon: _sending ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send, size: 18), label: const Text('Send'), ), ], ), const SizedBox(height: 12), if (comments.isEmpty) Text('No comments yet.', style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)) else for (final c in comments) _CommentTile(comment: c, author: profileById[c.authorId]), ], ); } } class _CommentTile extends ConsumerWidget { const _CommentTile({required this.comment, required this.author}); final ProgrammerTaskComment comment; final Profile? author; @override Widget build(BuildContext context, WidgetRef ref) { final tt = Theme.of(context).textTheme; final cs = Theme.of(context).colorScheme; final controller = ref.read(programmerTaskCommentsControllerProvider); return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.outlined( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text(author?.fullName ?? 'Someone', style: tt.bodyMedium ?.copyWith(fontWeight: FontWeight.w600)), ), Text(AppTime.relative(comment.createdAt), style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)), ], ), if (comment.body.trim().isNotEmpty) ...[ const SizedBox(height: 4), QuillReadOnly(comment.body, style: tt.bodyMedium), ], if (comment.attachments.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final a in comment.attachments) _AttachmentChip( attachment: a, url: controller.attachmentUrl(a.path), ), ], ), ], ], ), ), ), ); } } class _AttachmentChip extends StatelessWidget { const _AttachmentChip({required this.attachment, required this.url}); final ProgrammerCommentAttachment attachment; final String url; @override Widget build(BuildContext context) { if (_isImage(attachment.name)) { return InkWell( onTap: () => _openImage(context), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( url, width: 84, height: 84, fit: BoxFit.cover, errorBuilder: (_, _, _) => _fileChip(context), ), ), ); } return _fileChip(context); } Widget _fileChip(BuildContext context) => ActionChip( avatar: const Icon(Icons.insert_drive_file_outlined, size: 16), label: Text(attachment.name, overflow: TextOverflow.ellipsis), onPressed: () => _showUrl(context), ); void _openImage(BuildContext context) { showDialog( context: context, builder: (context) => Dialog( child: Column( mainAxisSize: MainAxisSize.min, children: [ InteractiveViewer(child: Image.network(url)), Padding( padding: const EdgeInsets.all(8), child: Text(attachment.name, style: Theme.of(context).textTheme.bodySmall), ), ], ), ), ); } void _showUrl(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( semanticLabel: 'Attachment link', title: Text(attachment.name), content: SelectableText(url), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ), ); } }