diff --git a/openspec/changes/composer-attachment-improvements/.openspec.yaml b/openspec/changes/composer-attachment-improvements/.openspec.yaml new file mode 100644 index 000000000..878dc3156 --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/composer-attachment-improvements/design.md b/openspec/changes/composer-attachment-improvements/design.md new file mode 100644 index 000000000..9f4d558fd --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/design.md @@ -0,0 +1,32 @@ +## Context + +See proposal.md - Why. Currently, the rich text editors across doubts and forums have mismatched capabilities: +1. `AskDoubtFormScreen` lacked attachment support entirely. +2. `DoubtDetailScreen` reply composer used `FilePicker` (document picker) instead of native photo gallery picker. +3. `ForumPostCreateScreen` only allowed picking images. +4. `ForumEditorToolbar` lacked native camera photo capture. + +## Goals / Non-Goals + +**Goals:** +- Provide camera photo capture for the doubt composition screen. +- Standardize doubts composers to use `ImagePicker` for picking gallery images. +- Transition `ForumPostCreateScreen` to use `FilePicker` to support PDFs, docx, and txt files. +- Enable `ForumAttachmentPreview` to display correct thumbnails for both images and non-image files. + +**Non-Goals:** +- Allowing more than 3 attachments. + +## Decisions + +### Decision: Update ForumAttachmentPreview to handle non-image files +- **Rationale**: Rendering non-image files directly using `Image.file` crashes or renders nothing. Checking the file extension (e.g., `.pdf`) and falling back to Lucide file icons resolves this. +- **Alternatives considered**: None. Necessary for standard file display. + +### Decision: Transition ForumPostCreateScreen from ImagePicker to FilePicker +- **Rationale**: The `file_picker` package supports picking both images and document formats, satisfying the client's request for general attachments. +- **Alternatives considered**: Keeping `ImagePicker` and adding a separate file upload. Rejected as too complex UI-wise. + +## Risks / Trade-offs + +- **[Risk]** Large file uploads → **[Mitigation]** The server limits the file upload size, and we preserve the limit of 3 attachments. diff --git a/openspec/changes/composer-attachment-improvements/proposal.md b/openspec/changes/composer-attachment-improvements/proposal.md new file mode 100644 index 000000000..088398cb7 --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/proposal.md @@ -0,0 +1,23 @@ +## Why + +The client requested camera photo capture in the Ask Doubt composer. We also standardized the Doubt Detail Reply composer (switching from `FilePicker` to `ImagePicker` to show the media library) and enabled general document/file picker attachments for the Forum Post Create screen to allow attaching PDFs, docx, and txt files. + +## What Changes + +- Add native camera capture option next to the gallery option in the Ask Doubt composer toolbar. +- Refactor Doubt Detail Reply composer to use `ImagePicker` instead of `FilePicker` to align with other media-oriented composers. +- Enable general File Picker on the Forum Post Create form to support uploading non-image attachments such as PDFs, docx, and text files. +- Update the attachment preview thumbnails to dynamically render file type icons (like a PDF icon or generic file icon) for non-image files. + +## Capabilities + +### Modified Capabilities +- doubts-compose-ui: Support device camera photo capture and uniform photo gallery selectors. +- forum-create-ui: Support general file picker attachments (PDF, docx, txt) in addition to images. + +## Impact + +- `packages/discussions/lib/screens/ask_doubt_form_screen.dart` +- `packages/discussions/lib/screens/doubt_detail_screen.dart` +- `packages/discussions/lib/screens/forum_post_create_screen.dart` +- `packages/discussions/lib/widgets/forum_composer.dart` diff --git a/openspec/changes/composer-attachment-improvements/specs/doubts-compose-ui/spec.md b/openspec/changes/composer-attachment-improvements/specs/doubts-compose-ui/spec.md new file mode 100644 index 000000000..d7076af15 --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/specs/doubts-compose-ui/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Rich Text Editor +The system SHALL provide a rich-text editor for the doubt content to support structured questions. +- **Formatting**: The editor SHALL support bold, italic, bulleted lists, and code blocks. +- **Media**: The editor SHALL support picking up to 3 images from the photo library/gallery or capturing directly from the device's camera. +- **Inline Embedding**: Any selected or captured images SHALL be uploaded to the doubt image endpoint and embedded inline as `` tags within the description HTML. +- **Validation**: The validation of the description field SHALL allow submission if either the plain-text representation is not empty or at least one image attachment is selected. + +#### Scenario: Applying formatting +- **WHEN** the user selects text and taps the "Bold" toolbar action +- **THEN** the selected text SHALL be rendered in bold weight + +#### Scenario: Attaching images from gallery +- **WHEN** the user taps the image toolbar button and selects images +- **THEN** the system SHALL show the images in the attachment preview list + +#### Scenario: Capturing image from camera +- **WHEN** the user taps the camera toolbar button and captures a photo +- **THEN** the system SHALL add the captured photo to the attachment preview list + +#### Scenario: Submitting with images only +- **WHEN** the user has provided a title, selected a category, and selected at least one image attachment without any text description +- **THEN** the system SHALL enable the submit button diff --git a/openspec/changes/composer-attachment-improvements/specs/forum-create-ui/spec.md b/openspec/changes/composer-attachment-improvements/specs/forum-create-ui/spec.md new file mode 100644 index 000000000..b8e699102 --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/specs/forum-create-ui/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Image Attachment UI +**Renamed to**: File Attachment UI +The system SHALL allow users to attach up to 3 files (images, PDFs, doc, docx, txt) to the post. +- Each attachment MUST display a preview chip or pill with a remove action. +- Image files MUST display an image preview thumbnail. Non-image files (like PDFs) MUST display a file type icon representation instead. +- The attachment action MUST be disabled once the limit of 3 files is reached. + +#### Scenario: Attaching an image +- **WHEN** user picks an image from the gallery/picker +- **THEN** the image SHALL appear in the attachment preview section with a remove (X) button + +#### Scenario: Attaching a document file +- **WHEN** user picks a PDF or text file from the file picker +- **THEN** the file SHALL appear in the attachment preview section showing a document icon and a remove (X) button diff --git a/openspec/changes/composer-attachment-improvements/tasks.md b/openspec/changes/composer-attachment-improvements/tasks.md new file mode 100644 index 000000000..2676ec8cb --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/tasks.md @@ -0,0 +1,30 @@ +## 1. Rich Editor Layout Changes + +- [x] 1.1 Add onCameraPick callback to ForumEditorToolbar and _ToolbarButtons +- [x] 1.2 Render camera button in composer editor toolbar using LucideIcons.camera +- [x] 1.3 Add dynamic file type thumbnail rendering in ForumAttachmentPreview for PDFs and documents + +## 2. Ask Doubt Form Screen + +- [x] 2.1 Enable camera/image callbacks and attachments state inside AskDoubtFormScreen +- [x] 2.2 Add ForumAttachmentPreview list to the AskDoubtFormScreen layout +- [x] 2.3 Update submit logic to upload attachments to repository and append inline HTML img tags + +## 3. Doubt Detail Screen Alignment + +- [x] 3.1 Replace FilePicker with ImagePicker in DoubtDetailScreen reply composer +- [x] 3.2 Update image selection logic to use pickMultiImage + +## 4. Forum Post Create Screen + +- [x] 4.1 Replace ImagePicker with FilePicker in ForumPostCreateScreen +- [x] 4.2 Support picking and previewing multiple file types (PDF, docx, txt) +- [x] 4.3 Fix title text input content padding alignment + +## 5. File Upload Pipeline & Error Handling + +- [x] 5.1 Implement generic uploadFile method in DataSource, HttpDataSource, and MockDataSource +- [x] 5.2 Differentiate upload pipeline and format links as anchor tags for files in forum providers +- [x] 5.3 Wrap pickers in try-catch blocks to catch PlatformExceptions and surface them via AppToast +- [x] 5.4 Centralize image file extension checks under AttachmentUtils.isImageFile +- [x] 5.5 Rename confusing isFile parameter in editor toolbar to showFileIcon diff --git a/packages/core/lib/data/sources/data_source.dart b/packages/core/lib/data/sources/data_source.dart index 5f5b5de87..9212b3765 100644 --- a/packages/core/lib/data/sources/data_source.dart +++ b/packages/core/lib/data/sources/data_source.dart @@ -101,6 +101,8 @@ abstract class DataSource { Future uploadImage(File file); + Future uploadFile(File file); + /// Fetch per-lesson progress for a user. Future> getUserProgress(String userId); diff --git a/packages/core/lib/data/sources/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index 8e63fe02a..41bff4112 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -363,6 +363,21 @@ class HttpDataSource implements DataSource { ); } + // Reuses ApiEndpoints.imageUpload as a fallback because the backend lacks a separate generic uploader route. + // Keeping uploadFile as a separate signature decouples client logic if endpoints are split in the future. + @override + Future uploadFile(File file) async { + final fileName = file.path.split('/').last; + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(file.path, filename: fileName), + }); + + return performNetworkRequest( + _dio.post(ApiEndpoints.imageUpload, data: formData), + fromJson: (json) => json['url'] as String, + ); + } + @override Future postForumThread({ required String title, diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index f6f98d496..8ac8db22c 100644 --- a/packages/core/lib/data/sources/mock_data_source.dart +++ b/packages/core/lib/data/sources/mock_data_source.dart @@ -993,6 +993,12 @@ class MockDataSource implements DataSource { return 'https://mock.url/${file.path.split('/').last}'; } + @override + Future uploadFile(File file) async { + await Future.delayed(const Duration(milliseconds: 500)); + return 'https://mock.url/${file.path.split('/').last}'; + } + @override Future postForumThread({ required String title, diff --git a/packages/discussions/lib/providers/forum_providers.dart b/packages/discussions/lib/providers/forum_providers.dart index d3a812314..f19a934f0 100644 --- a/packages/discussions/lib/providers/forum_providers.dart +++ b/packages/discussions/lib/providers/forum_providers.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:core/data/data.dart'; import '../repositories/forum_repository.dart'; +import '../utils/attachment_utils.dart'; part 'forum_providers.g.dart'; @@ -74,13 +75,24 @@ class PostForumComment extends _$PostForumComment { String finalContent = content; if (attachments.isNotEmpty) { - final uploadFutures = attachments.map( - (path) => repo.uploadImage(File(path)), - ); + final uploadFutures = attachments.map((path) { + final isImage = AttachmentUtils.isImageFile(path); + return isImage + ? repo.uploadImage(File(path)) + : repo.uploadFile(File(path)); + }); final urls = await Future.wait(uploadFutures); - for (final url in urls) { - finalContent += '
'; + for (int i = 0; i < urls.length; i++) { + final url = urls[i]; + final path = attachments[i]; + final fileName = path.split('/').last; + final isImage = AttachmentUtils.isImageFile(path); + if (isImage) { + finalContent += '
'; + } else { + finalContent += '
$fileName'; + } } } @@ -109,13 +121,24 @@ class CreateForumThread extends _$CreateForumThread { String finalContent = content; if (attachments.isNotEmpty) { - final uploadFutures = attachments.map( - (path) => repo.uploadImage(File(path)), - ); + final uploadFutures = attachments.map((path) { + final isImage = AttachmentUtils.isImageFile(path); + return isImage + ? repo.uploadImage(File(path)) + : repo.uploadFile(File(path)); + }); final urls = await Future.wait(uploadFutures); - for (final url in urls) { - finalContent += '
'; + for (int i = 0; i < urls.length; i++) { + final url = urls[i]; + final path = attachments[i]; + final fileName = path.split('/').last; + final isImage = AttachmentUtils.isImageFile(path); + if (isImage) { + finalContent += '
'; + } else { + finalContent += '
$fileName'; + } } } diff --git a/packages/discussions/lib/providers/forum_providers.g.dart b/packages/discussions/lib/providers/forum_providers.g.dart index f22c03173..f6df61c20 100644 --- a/packages/discussions/lib/providers/forum_providers.g.dart +++ b/packages/discussions/lib/providers/forum_providers.g.dart @@ -6,7 +6,7 @@ part of 'forum_providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$forumRepositoryHash() => r'085755f0b01a618f5faaf049e8ed0dfe66ae4989'; +String _$forumRepositoryHash() => r'87a91de2fccbbbb42983cd55f7bd294e33b99528'; /// See also [forumRepository]. @ProviderFor(forumRepository) @@ -42,7 +42,7 @@ final globalForumCategoriesProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef GlobalForumCategoriesRef = FutureProviderRef>; -String _$postForumCommentHash() => r'dff11df23405bb814a6b893d54a56694ade1f7ec'; +String _$postForumCommentHash() => r'093026bcf5c815f087309a8c7178a1dea1601e9e'; /// See also [PostForumComment]. @ProviderFor(PostForumComment) @@ -58,7 +58,7 @@ final postForumCommentProvider = ); typedef _$PostForumComment = AutoDisposeAsyncNotifier; -String _$createForumThreadHash() => r'68830dfb776fb7f593f0f6ebd16e3a2127fcad1e'; +String _$createForumThreadHash() => r'e9d1cc773a155e37b49d788419f4b902c9e817cf'; /// See also [CreateForumThread]. @ProviderFor(CreateForumThread) @@ -74,7 +74,7 @@ final createForumThreadProvider = ); typedef _$CreateForumThread = AutoDisposeAsyncNotifier; -String _$globalForumFeedHash() => r'736c942f31480e9c06c8589528dd7157b96c7c81'; +String _$globalForumFeedHash() => r'eec2f0bbfb8385ed885244fb4db02ab1e359d13a'; /// Copied from Dart SDK class _SystemHash { diff --git a/packages/discussions/lib/repositories/forum_repository.dart b/packages/discussions/lib/repositories/forum_repository.dart index a1206b91b..7470bf5d3 100644 --- a/packages/discussions/lib/repositories/forum_repository.dart +++ b/packages/discussions/lib/repositories/forum_repository.dart @@ -163,6 +163,15 @@ class ForumRepository { } } + Future uploadFile(File file) async { + try { + return await _source.uploadFile(file); + } catch (e, st) { + _sentryService.captureException(e, stackTrace: st); + rethrow; + } + } + Future createThread({ required String title, required String html, diff --git a/packages/discussions/lib/screens/ask_doubt_form_screen.dart b/packages/discussions/lib/screens/ask_doubt_form_screen.dart index 97264a3aa..f54ffed0f 100644 --- a/packages/discussions/lib/screens/ask_doubt_form_screen.dart +++ b/packages/discussions/lib/screens/ask_doubt_form_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:image_picker/image_picker.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import 'package:courses/courses.dart'; @@ -43,8 +44,38 @@ class _AskDoubtFormScreenState extends ConsumerState { late final quill.QuillController _quillController; final ScrollController _scrollController = ScrollController(); final FocusNode _focusNode = FocusNode(); + final List _attachments = []; + final ImagePicker _picker = ImagePicker(); int? _finalizedTopicId; + + Future _pickImages() async { + if (_attachments.length >= 3) return; + + final images = await _picker.pickMultiImage(); + if (images.isNotEmpty) { + setState(() { + final remaining = 3 - _attachments.length; + _attachments.addAll(images.take(remaining).map((image) => image.path)); + }); + } + } + + Future _pickFromCamera() async { + if (_attachments.length >= 3) return; + + final image = await _picker.pickImage(source: ImageSource.camera); + if (image != null) { + setState(() { + _attachments.add(image.path); + }); + } + } + + void _removeAttachment(int index) { + setState(() => _attachments.removeAt(index)); + } + bool _isTopicFinalized = false; bool _isSubmitSheetOpen = false; bool _isSubmitting = false; @@ -118,7 +149,12 @@ class _AskDoubtFormScreenState extends ConsumerState { const SizedBox(height: 24), _sectionLabel(l10n.doubtsFormDescriptionLabel), const SizedBox(height: 8), - ForumEditorToolbar(controller: _quillController), + ForumEditorToolbar( + controller: _quillController, + onImagePick: _pickImages, + onCameraPick: _pickFromCamera, + isImageLimitReached: _attachments.length >= 3, + ), const SizedBox(height: 4), ForumEditorField( controller: _quillController, @@ -128,6 +164,13 @@ class _AskDoubtFormScreenState extends ConsumerState { minHeight: 160, maxHeight: 240, ), + if (_attachments.isNotEmpty) ...[ + const SizedBox(height: 12), + ForumAttachmentPreview( + imageUrls: _attachments, + onRemove: _removeAttachment, + ), + ], const SizedBox(height: 24), _sectionLabel(l10n.doubtsFormCategoryLabel), const SizedBox(height: 8), @@ -231,7 +274,8 @@ class _AskDoubtFormScreenState extends ConsumerState { Widget _actionBar(DesignConfig design, AppLocalizations l10n) { final canSubmit = _titleController.text.trim().isNotEmpty && - _quillController.document.toPlainText().trim().isNotEmpty && + (_quillController.document.toPlainText().trim().isNotEmpty || + _attachments.isNotEmpty) && _isTopicFinalized; return SafeArea( @@ -301,7 +345,9 @@ class _AskDoubtFormScreenState extends ConsumerState { Future _submitDoubt(DoubtQueryType queryType) async { final title = _titleController.text.trim(); final contentText = _quillController.document.toPlainText().trim(); - if (title.isEmpty || contentText.isEmpty || !_isTopicFinalized) { + if (title.isEmpty || + (contentText.isEmpty && _attachments.isEmpty) || + !_isTopicFinalized) { return; } @@ -319,6 +365,16 @@ class _AskDoubtFormScreenState extends ConsumerState { final repo = await ref.read(doubtRepositoryProvider.future); String finalHtml = descriptionHtml; + if (_attachments.isNotEmpty) { + final uploadFutures = _attachments.map( + (path) => repo.uploadDoubtImage(path), + ); + final urls = await Future.wait(uploadFutures); + for (final url in urls) { + finalHtml += '
'; + } + } + final newDoubtId = await repo.createDoubt( title: title, description: finalHtml, diff --git a/packages/discussions/lib/screens/doubt_detail_screen.dart b/packages/discussions/lib/screens/doubt_detail_screen.dart index 691fd2b86..d1982a3f7 100644 --- a/packages/discussions/lib/screens/doubt_detail_screen.dart +++ b/packages/discussions/lib/screens/doubt_detail_screen.dart @@ -2,7 +2,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; @@ -654,6 +654,7 @@ class _DoubtReplyComposerState extends ConsumerState<_DoubtReplyComposer> { bool _showToolbar = true; bool _isSubmitting = false; final List _attachments = []; + final _picker = ImagePicker(); @override void dispose() { @@ -776,16 +777,11 @@ class _DoubtReplyComposerState extends ConsumerState<_DoubtReplyComposer> { Future _pickAttachments() async { if (_attachments.length >= 3) return; - final result = await FilePicker.pickFiles( - allowMultiple: true, - type: FileType.custom, - allowedExtensions: ['jpg', 'jpeg', 'png'], - ); - - if (result != null && result.paths.isNotEmpty) { + final images = await _picker.pickMultiImage(); + if (images.isNotEmpty) { setState(() { final remaining = 3 - _attachments.length; - _attachments.addAll(result.paths.whereType().take(remaining)); + _attachments.addAll(images.take(remaining).map((image) => image.path)); }); } } diff --git a/packages/discussions/lib/screens/forum_post_create_screen.dart b/packages/discussions/lib/screens/forum_post_create_screen.dart index 02ed78c5c..2738d0074 100644 --- a/packages/discussions/lib/screens/forum_post_create_screen.dart +++ b/packages/discussions/lib/screens/forum_post_create_screen.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import 'package:image_picker/image_picker.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import '../providers/forum_providers.dart'; @@ -25,7 +25,6 @@ class _ForumPostCreateScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); final FocusNode _focusNode = FocusNode(); final List _attachments = []; - final ImagePicker _picker = ImagePicker(); bool _isSubmitting = false; int? _selectedCategoryId; @@ -56,14 +55,19 @@ class _ForumPostCreateScreenState extends ConsumerState { bool get _hasDescription => _quillController.document.toPlainText().trim().isNotEmpty; - Future _pickImages() async { + Future _pickFiles() async { if (_attachments.length >= 3) return; - final images = await _picker.pickMultiImage(); - if (images.isNotEmpty) { + final result = await FilePicker.pickFiles( + allowMultiple: true, + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx', 'txt'], + ); + + if (result != null && result.paths.isNotEmpty) { setState(() { final remaining = 3 - _attachments.length; - _attachments.addAll(images.take(remaining).map((image) => image.path)); + _attachments.addAll(result.paths.whereType().take(remaining)); }); } } @@ -167,10 +171,6 @@ class _ForumPostCreateScreenState extends ConsumerState { controller: _titleController, autofocus: true, textStyle: design.typography.bodySmall, - contentPadding: EdgeInsets.symmetric( - vertical: design.spacing.sm, - horizontal: 0, - ), ), SizedBox(height: design.spacing.lg), _buildCategoryPicker(design, categoriesAsync), @@ -182,8 +182,9 @@ class _ForumPostCreateScreenState extends ConsumerState { SizedBox(height: design.spacing.xs), ForumEditorToolbar( controller: _quillController, - onImagePick: _pickImages, + onImagePick: _pickFiles, isImageLimitReached: _attachments.length >= 3, + showFileIcon: true, ), const SizedBox(height: 4), ForumEditorField( diff --git a/packages/discussions/lib/utils/attachment_utils.dart b/packages/discussions/lib/utils/attachment_utils.dart new file mode 100644 index 000000000..7f49be8ea --- /dev/null +++ b/packages/discussions/lib/utils/attachment_utils.dart @@ -0,0 +1,16 @@ +class AttachmentUtils { + AttachmentUtils._(); + + static const List imageExtensions = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ]; + + static bool isImageFile(String path) { + final lower = path.toLowerCase(); + return imageExtensions.any((ext) => lower.endsWith('.$ext')); + } +} diff --git a/packages/discussions/lib/widgets/forum_composer.dart b/packages/discussions/lib/widgets/forum_composer.dart index 8accf0bfc..5a61f1dd4 100644 --- a/packages/discussions/lib/widgets/forum_composer.dart +++ b/packages/discussions/lib/widgets/forum_composer.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart'; import 'package:core/core.dart'; +import '../utils/attachment_utils.dart'; // ───────────────────────────────────────────────────── // Service Layer @@ -26,13 +27,17 @@ class QuillEditorService { class ForumEditorToolbar extends StatelessWidget { final quill.QuillController controller; final VoidCallback? onImagePick; + final VoidCallback? onCameraPick; final bool isImageLimitReached; + final bool showFileIcon; const ForumEditorToolbar({ super.key, required this.controller, this.onImagePick, + this.onCameraPick, this.isImageLimitReached = false, + this.showFileIcon = false, }); @override @@ -46,7 +51,9 @@ class ForumEditorToolbar extends StatelessWidget { builder: (context, _) => _ToolbarButtons( controller: controller, onImagePick: onImagePick, + onCameraPick: onCameraPick, isImageLimitReached: isImageLimitReached, + showFileIcon: showFileIcon, ), ), ); @@ -207,12 +214,16 @@ class ForumAttachmentPreview extends StatelessWidget { class _ToolbarButtons extends StatelessWidget { final quill.QuillController controller; final VoidCallback? onImagePick; + final VoidCallback? onCameraPick; final bool isImageLimitReached; + final bool showFileIcon; const _ToolbarButtons({ required this.controller, this.onImagePick, + this.onCameraPick, this.isImageLimitReached = false, + this.showFileIcon = false, }); @override @@ -255,10 +266,16 @@ class _ToolbarButtons extends StatelessWidget { const ForumToolbarDivider(), if (onImagePick != null) ForumToolbarButton( - icon: LucideIcons.image, + icon: showFileIcon ? LucideIcons.paperclip : LucideIcons.image, onTap: isImageLimitReached ? () {} : onImagePick!, isDisabled: isImageLimitReached, ), + if (onCameraPick != null) + ForumToolbarButton( + icon: LucideIcons.camera, + onTap: isImageLimitReached ? () {} : onCameraPick!, + isDisabled: isImageLimitReached, + ), ], ), ); @@ -434,49 +451,148 @@ class _AttachmentItem extends StatelessWidget { final design = Design.of(context); const size = 64.0; - return Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(design.radius.md), - border: Border.all(color: design.colors.divider), + final isPdf = imageUrl.toLowerCase().endsWith('.pdf'); + final isImage = AttachmentUtils.isImageFile(imageUrl); + + Widget child; + if (isImage) { + child = Image.file( + File(imageUrl), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Center( + child: Icon( + LucideIcons.file, + size: 20, + color: design.colors.textSecondary, + ), + ), + ); + } else if (isPdf) { + child = Center( + child: Icon( + LucideIcons.fileText, + size: 20, + color: design.colors.accent2, + ), + ); + } else { + child = Center( + child: Icon( + LucideIcons.file, + size: 20, + color: design.colors.textSecondary, + ), + ); + } + + if (isImage) { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(design.radius.md), + border: Border.all(color: design.colors.divider), + ), + clipBehavior: Clip.antiAlias, + child: child, ), - clipBehavior: Clip.antiAlias, - child: Image.file( - File(imageUrl), - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Center( - child: Icon( - LucideIcons.imageOff, - size: 20, - color: design.colors.textSecondary, + Positioned( + top: -10, + right: -10, + child: AppSemantics.button( + label: context.l10n.deleteAction, + onTap: onRemove, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: design.colors.textPrimary, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.x, + size: 14, + color: design.colors.card, + ), + ), + ), ), ), ), - ), - Positioned( - top: -10, - right: -10, - child: GestureDetector( - onTap: onRemove, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: design.colors.textPrimary, - shape: BoxShape.circle, + ], + ); + } else { + final fileName = imageUrl.split('/').last; + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 180, + height: size, + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(design.radius.md), + border: Border.all(color: design.colors.divider), + ), + padding: EdgeInsets.symmetric(horizontal: design.spacing.sm), + child: Row( + children: [ + Icon( + isPdf ? LucideIcons.fileText : LucideIcons.file, + size: 24, + color: isPdf + ? design.colors.accent2 + : design.colors.textSecondary, + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: AppText.caption( + fileName, + color: design.colors.textPrimary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox(width: design.spacing.xs), + ], + ), + ), + Positioned( + top: -10, + right: -10, + child: AppSemantics.button( + label: context.l10n.deleteAction, + onTap: onRemove, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: design.colors.textPrimary, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.x, + size: 14, + color: design.colors.card, + ), + ), ), - child: Icon(LucideIcons.x, size: 14, color: design.colors.card), ), ), ), - ), - ], - ); + ], + ); + } } }