From 29671f70093e839d49da0e2e12870e5d0593c9b0 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 18:28:48 +0530 Subject: [PATCH 1/7] feat: implement native camera capture in doubts form, standardize image picking, and enable multi-format file attachments for forum posts. --- .../.openspec.yaml | 2 + .../design.md | 32 +++ .../proposal.md | 23 +++ .../specs/doubts-compose-ui/spec.md | 24 +++ .../specs/forum-create-ui/spec.md | 16 ++ .../composer-attachment-improvements/tasks.md | 21 ++ .../lib/screens/ask_doubt_form_screen.dart | 62 +++++- .../lib/screens/doubt_detail_screen.dart | 14 +- .../lib/screens/forum_post_create_screen.dart | 19 +- .../lib/widgets/forum_composer.dart | 187 ++++++++++++++---- 10 files changed, 344 insertions(+), 56 deletions(-) create mode 100644 openspec/changes/composer-attachment-improvements/.openspec.yaml create mode 100644 openspec/changes/composer-attachment-improvements/design.md create mode 100644 openspec/changes/composer-attachment-improvements/proposal.md create mode 100644 openspec/changes/composer-attachment-improvements/specs/doubts-compose-ui/spec.md create mode 100644 openspec/changes/composer-attachment-improvements/specs/forum-create-ui/spec.md create mode 100644 openspec/changes/composer-attachment-improvements/tasks.md 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..08b313627 --- /dev/null +++ b/openspec/changes/composer-attachment-improvements/tasks.md @@ -0,0 +1,21 @@ +## 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) 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..46f747219 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)); }); } } @@ -182,8 +186,9 @@ class _ForumPostCreateScreenState extends ConsumerState { SizedBox(height: design.spacing.xs), ForumEditorToolbar( controller: _quillController, - onImagePick: _pickImages, + onImagePick: _pickFiles, isImageLimitReached: _attachments.length >= 3, + isFile: true, ), const SizedBox(height: 4), ForumEditorField( diff --git a/packages/discussions/lib/widgets/forum_composer.dart b/packages/discussions/lib/widgets/forum_composer.dart index 8accf0bfc..709f75f8b 100644 --- a/packages/discussions/lib/widgets/forum_composer.dart +++ b/packages/discussions/lib/widgets/forum_composer.dart @@ -26,13 +26,17 @@ class QuillEditorService { class ForumEditorToolbar extends StatelessWidget { final quill.QuillController controller; final VoidCallback? onImagePick; + final VoidCallback? onCameraPick; final bool isImageLimitReached; + final bool isFile; const ForumEditorToolbar({ super.key, required this.controller, this.onImagePick, + this.onCameraPick, this.isImageLimitReached = false, + this.isFile = false, }); @override @@ -46,7 +50,9 @@ class ForumEditorToolbar extends StatelessWidget { builder: (context, _) => _ToolbarButtons( controller: controller, onImagePick: onImagePick, + onCameraPick: onCameraPick, isImageLimitReached: isImageLimitReached, + isFile: isFile, ), ), ); @@ -207,12 +213,16 @@ class ForumAttachmentPreview extends StatelessWidget { class _ToolbarButtons extends StatelessWidget { final quill.QuillController controller; final VoidCallback? onImagePick; + final VoidCallback? onCameraPick; final bool isImageLimitReached; + final bool isFile; const _ToolbarButtons({ required this.controller, this.onImagePick, + this.onCameraPick, this.isImageLimitReached = false, + this.isFile = false, }); @override @@ -255,10 +265,16 @@ class _ToolbarButtons extends StatelessWidget { const ForumToolbarDivider(), if (onImagePick != null) ForumToolbarButton( - icon: LucideIcons.image, + icon: isFile ? LucideIcons.paperclip : LucideIcons.image, onTap: isImageLimitReached ? () {} : onImagePick!, isDisabled: isImageLimitReached, ), + if (onCameraPick != null) + ForumToolbarButton( + icon: LucideIcons.camera, + onTap: isImageLimitReached ? () {} : onCameraPick!, + isDisabled: isImageLimitReached, + ), ], ), ); @@ -434,49 +450,146 @@ 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 = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].any((ext) => imageUrl.toLowerCase().endsWith('.$ext')); + + 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: 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: 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), ), ), ), - ), - ], - ); + ], + ); + } } } From c95c244ef3007c573469f9baac70082820dea91e Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 18:29:47 +0530 Subject: [PATCH 2/7] refactor: remove explicit content padding from forum post title input --- openspec/changes/composer-attachment-improvements/tasks.md | 1 + .../discussions/lib/screens/forum_post_create_screen.dart | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/openspec/changes/composer-attachment-improvements/tasks.md b/openspec/changes/composer-attachment-improvements/tasks.md index 08b313627..652f887fe 100644 --- a/openspec/changes/composer-attachment-improvements/tasks.md +++ b/openspec/changes/composer-attachment-improvements/tasks.md @@ -19,3 +19,4 @@ - [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 diff --git a/packages/discussions/lib/screens/forum_post_create_screen.dart b/packages/discussions/lib/screens/forum_post_create_screen.dart index 46f747219..b956e61d3 100644 --- a/packages/discussions/lib/screens/forum_post_create_screen.dart +++ b/packages/discussions/lib/screens/forum_post_create_screen.dart @@ -171,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), From 2d62f507030bbe1a27ab1daef7624f124fdf9432 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 19:50:28 +0530 Subject: [PATCH 3/7] feat: implement generic file upload support in data sources and forum providers --- .../core/lib/data/sources/data_source.dart | 2 + .../lib/data/sources/http_data_source.dart | 13 ++++ .../lib/data/sources/mock_data_source.dart | 6 ++ .../lib/providers/forum_providers.dart | 66 ++++++++++++++++--- .../lib/repositories/forum_repository.dart | 9 +++ 5 files changed, 86 insertions(+), 10 deletions(-) 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..aa048c596 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -363,6 +363,19 @@ class HttpDataSource implements DataSource { ); } + @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..0895fe771 100644 --- a/packages/discussions/lib/providers/forum_providers.dart +++ b/packages/discussions/lib/providers/forum_providers.dart @@ -74,13 +74,36 @@ 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 = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].any((ext) => path.toLowerCase().endsWith('.$ext')); + 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 = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].any((ext) => path.toLowerCase().endsWith('.$ext')); + if (isImage) { + finalContent += '
'; + } else { + finalContent += '
$fileName'; + } } } @@ -109,13 +132,36 @@ 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 = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].any((ext) => path.toLowerCase().endsWith('.$ext')); + 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 = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + ].any((ext) => path.toLowerCase().endsWith('.$ext')); + if (isImage) { + finalContent += '
'; + } else { + finalContent += '
$fileName'; + } } } 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, From 73417d630b2ade19b28a306cc881b6f34306550d Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 20:07:57 +0530 Subject: [PATCH 4/7] refactor: centralize attachment utilities, improve upload pipeline error handling, and refactor forum providers to support anchor tags for non-image files. --- .../composer-attachment-improvements/tasks.md | 8 +++ .../lib/providers/forum_providers.dart | 33 ++---------- .../lib/providers/forum_providers.g.dart | 8 +-- .../lib/screens/ask_doubt_form_screen.dart | 51 +++++++++++++++---- .../lib/screens/forum_post_create_screen.dart | 36 +++++++++---- .../lib/utils/attachment_utils.dart | 16 ++++++ .../lib/widgets/forum_composer.dart | 21 +++----- 7 files changed, 106 insertions(+), 67 deletions(-) create mode 100644 packages/discussions/lib/utils/attachment_utils.dart diff --git a/openspec/changes/composer-attachment-improvements/tasks.md b/openspec/changes/composer-attachment-improvements/tasks.md index 652f887fe..2676ec8cb 100644 --- a/openspec/changes/composer-attachment-improvements/tasks.md +++ b/openspec/changes/composer-attachment-improvements/tasks.md @@ -20,3 +20,11 @@ - [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/discussions/lib/providers/forum_providers.dart b/packages/discussions/lib/providers/forum_providers.dart index 0895fe771..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'; @@ -75,13 +76,7 @@ class PostForumComment extends _$PostForumComment { if (attachments.isNotEmpty) { final uploadFutures = attachments.map((path) { - final isImage = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - ].any((ext) => path.toLowerCase().endsWith('.$ext')); + final isImage = AttachmentUtils.isImageFile(path); return isImage ? repo.uploadImage(File(path)) : repo.uploadFile(File(path)); @@ -92,13 +87,7 @@ class PostForumComment extends _$PostForumComment { final url = urls[i]; final path = attachments[i]; final fileName = path.split('/').last; - final isImage = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - ].any((ext) => path.toLowerCase().endsWith('.$ext')); + final isImage = AttachmentUtils.isImageFile(path); if (isImage) { finalContent += '
'; } else { @@ -133,13 +122,7 @@ class CreateForumThread extends _$CreateForumThread { if (attachments.isNotEmpty) { final uploadFutures = attachments.map((path) { - final isImage = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - ].any((ext) => path.toLowerCase().endsWith('.$ext')); + final isImage = AttachmentUtils.isImageFile(path); return isImage ? repo.uploadImage(File(path)) : repo.uploadFile(File(path)); @@ -150,13 +133,7 @@ class CreateForumThread extends _$CreateForumThread { final url = urls[i]; final path = attachments[i]; final fileName = path.split('/').last; - final isImage = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - ].any((ext) => path.toLowerCase().endsWith('.$ext')); + final isImage = AttachmentUtils.isImageFile(path); if (isImage) { finalContent += '
'; } else { 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/screens/ask_doubt_form_screen.dart b/packages/discussions/lib/screens/ask_doubt_form_screen.dart index f54ffed0f..ef0f7b4ea 100644 --- a/packages/discussions/lib/screens/ask_doubt_form_screen.dart +++ b/packages/discussions/lib/screens/ask_doubt_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:image_picker/image_picker.dart'; @@ -52,23 +53,51 @@ class _AskDoubtFormScreenState extends ConsumerState { 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)); - }); + try { + final images = await _picker.pickMultiImage(); + if (images.isNotEmpty) { + setState(() { + final remaining = 3 - _attachments.length; + _attachments.addAll( + images.take(remaining).map((image) => image.path), + ); + }); + } + } on PlatformException catch (e) { + if (mounted) { + AppToast.show( + context, + message: e.message ?? 'Permission denied or error picking images', + ); + } + } catch (e) { + if (mounted) { + AppToast.show(context, message: 'Error picking images'); + } } } Future _pickFromCamera() async { if (_attachments.length >= 3) return; - final image = await _picker.pickImage(source: ImageSource.camera); - if (image != null) { - setState(() { - _attachments.add(image.path); - }); + try { + final image = await _picker.pickImage(source: ImageSource.camera); + if (image != null) { + setState(() { + _attachments.add(image.path); + }); + } + } on PlatformException catch (e) { + if (mounted) { + AppToast.show( + context, + message: e.message ?? 'Permission denied or error capturing photo', + ); + } + } catch (e) { + if (mounted) { + AppToast.show(context, message: 'Error capturing photo'); + } } } diff --git a/packages/discussions/lib/screens/forum_post_create_screen.dart b/packages/discussions/lib/screens/forum_post_create_screen.dart index b956e61d3..bcd4ff6f2 100644 --- a/packages/discussions/lib/screens/forum_post_create_screen.dart +++ b/packages/discussions/lib/screens/forum_post_create_screen.dart @@ -3,6 +3,7 @@ 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:flutter/services.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import '../providers/forum_providers.dart'; @@ -58,17 +59,30 @@ class _ForumPostCreateScreenState extends ConsumerState { Future _pickFiles() async { if (_attachments.length >= 3) return; - final result = await FilePicker.pickFiles( - allowMultiple: true, - type: FileType.custom, - allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx', 'txt'], - ); + try { + 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(result.paths.whereType().take(remaining)); - }); + if (result != null && result.paths.isNotEmpty) { + setState(() { + final remaining = 3 - _attachments.length; + _attachments.addAll(result.paths.whereType().take(remaining)); + }); + } + } on PlatformException catch (e) { + if (mounted) { + AppToast.show( + context, + message: e.message ?? 'Permission denied or error picking files', + ); + } + } catch (e) { + if (mounted) { + AppToast.show(context, message: 'Error picking files'); + } } } @@ -184,7 +198,7 @@ class _ForumPostCreateScreenState extends ConsumerState { controller: _quillController, onImagePick: _pickFiles, isImageLimitReached: _attachments.length >= 3, - isFile: true, + 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 709f75f8b..ad6704cf3 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 @@ -28,7 +29,7 @@ class ForumEditorToolbar extends StatelessWidget { final VoidCallback? onImagePick; final VoidCallback? onCameraPick; final bool isImageLimitReached; - final bool isFile; + final bool showFileIcon; const ForumEditorToolbar({ super.key, @@ -36,7 +37,7 @@ class ForumEditorToolbar extends StatelessWidget { this.onImagePick, this.onCameraPick, this.isImageLimitReached = false, - this.isFile = false, + this.showFileIcon = false, }); @override @@ -52,7 +53,7 @@ class ForumEditorToolbar extends StatelessWidget { onImagePick: onImagePick, onCameraPick: onCameraPick, isImageLimitReached: isImageLimitReached, - isFile: isFile, + showFileIcon: showFileIcon, ), ), ); @@ -215,14 +216,14 @@ class _ToolbarButtons extends StatelessWidget { final VoidCallback? onImagePick; final VoidCallback? onCameraPick; final bool isImageLimitReached; - final bool isFile; + final bool showFileIcon; const _ToolbarButtons({ required this.controller, this.onImagePick, this.onCameraPick, this.isImageLimitReached = false, - this.isFile = false, + this.showFileIcon = false, }); @override @@ -265,7 +266,7 @@ class _ToolbarButtons extends StatelessWidget { const ForumToolbarDivider(), if (onImagePick != null) ForumToolbarButton( - icon: isFile ? LucideIcons.paperclip : LucideIcons.image, + icon: showFileIcon ? LucideIcons.paperclip : LucideIcons.image, onTap: isImageLimitReached ? () {} : onImagePick!, isDisabled: isImageLimitReached, ), @@ -451,13 +452,7 @@ class _AttachmentItem extends StatelessWidget { const size = 64.0; final isPdf = imageUrl.toLowerCase().endsWith('.pdf'); - final isImage = [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - ].any((ext) => imageUrl.toLowerCase().endsWith('.$ext')); + final isImage = AttachmentUtils.isImageFile(imageUrl); Widget child; if (isImage) { From ab60d3e7471f21e377344403dc4d76b024f95798 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 20:11:49 +0530 Subject: [PATCH 5/7] refactor: simplify image picking logic by removing redundant try-catch error handling blocks --- .../lib/screens/ask_doubt_form_screen.dart | 51 ++++--------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/packages/discussions/lib/screens/ask_doubt_form_screen.dart b/packages/discussions/lib/screens/ask_doubt_form_screen.dart index ef0f7b4ea..f54ffed0f 100644 --- a/packages/discussions/lib/screens/ask_doubt_form_screen.dart +++ b/packages/discussions/lib/screens/ask_doubt_form_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:image_picker/image_picker.dart'; @@ -53,51 +52,23 @@ class _AskDoubtFormScreenState extends ConsumerState { Future _pickImages() async { if (_attachments.length >= 3) return; - try { - final images = await _picker.pickMultiImage(); - if (images.isNotEmpty) { - setState(() { - final remaining = 3 - _attachments.length; - _attachments.addAll( - images.take(remaining).map((image) => image.path), - ); - }); - } - } on PlatformException catch (e) { - if (mounted) { - AppToast.show( - context, - message: e.message ?? 'Permission denied or error picking images', - ); - } - } catch (e) { - if (mounted) { - AppToast.show(context, message: 'Error picking images'); - } + 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; - try { - final image = await _picker.pickImage(source: ImageSource.camera); - if (image != null) { - setState(() { - _attachments.add(image.path); - }); - } - } on PlatformException catch (e) { - if (mounted) { - AppToast.show( - context, - message: e.message ?? 'Permission denied or error capturing photo', - ); - } - } catch (e) { - if (mounted) { - AppToast.show(context, message: 'Error capturing photo'); - } + final image = await _picker.pickImage(source: ImageSource.camera); + if (image != null) { + setState(() { + _attachments.add(image.path); + }); } } From 7aa9934cc1fbc0543962402c57ec9a413cd2436c Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 20:11:57 +0530 Subject: [PATCH 6/7] refactor: remove redundant error handling from file picker implementation in forum post screen --- .../lib/screens/forum_post_create_screen.dart | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/discussions/lib/screens/forum_post_create_screen.dart b/packages/discussions/lib/screens/forum_post_create_screen.dart index bcd4ff6f2..cd72d49a4 100644 --- a/packages/discussions/lib/screens/forum_post_create_screen.dart +++ b/packages/discussions/lib/screens/forum_post_create_screen.dart @@ -59,30 +59,17 @@ class _ForumPostCreateScreenState extends ConsumerState { Future _pickFiles() async { if (_attachments.length >= 3) return; - try { - final result = await FilePicker.pickFiles( - allowMultiple: true, - type: FileType.custom, - allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx', 'txt'], - ); + 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(result.paths.whereType().take(remaining)); - }); - } - } on PlatformException catch (e) { - if (mounted) { - AppToast.show( - context, - message: e.message ?? 'Permission denied or error picking files', - ); - } - } catch (e) { - if (mounted) { - AppToast.show(context, message: 'Error picking files'); - } + if (result != null && result.paths.isNotEmpty) { + setState(() { + final remaining = 3 - _attachments.length; + _attachments.addAll(result.paths.whereType().take(remaining)); + }); } } From 8f3cfb2e0de8f52bc3d9db32c28fd1f7f7c23635 Mon Sep 17 00:00:00 2001 From: syed-tp Date: Fri, 7 Aug 2026 20:12:38 +0530 Subject: [PATCH 7/7] feat: implement file upload endpoint logic and add semantic accessibility labels to forum attachment remove buttons --- .../lib/data/sources/http_data_source.dart | 2 + .../lib/screens/forum_post_create_screen.dart | 1 - .../lib/widgets/forum_composer.dart | 64 +++++++++++-------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/core/lib/data/sources/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index aa048c596..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,8 @@ 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; diff --git a/packages/discussions/lib/screens/forum_post_create_screen.dart b/packages/discussions/lib/screens/forum_post_create_screen.dart index cd72d49a4..2738d0074 100644 --- a/packages/discussions/lib/screens/forum_post_create_screen.dart +++ b/packages/discussions/lib/screens/forum_post_create_screen.dart @@ -3,7 +3,6 @@ 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:flutter/services.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import '../providers/forum_providers.dart'; diff --git a/packages/discussions/lib/widgets/forum_composer.dart b/packages/discussions/lib/widgets/forum_composer.dart index ad6704cf3..5a61f1dd4 100644 --- a/packages/discussions/lib/widgets/forum_composer.dart +++ b/packages/discussions/lib/widgets/forum_composer.dart @@ -502,21 +502,25 @@ class _AttachmentItem extends StatelessWidget { Positioned( top: -10, right: -10, - child: GestureDetector( + child: AppSemantics.button( + label: context.l10n.deleteAction, 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: 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, + ), ), ), ), @@ -563,21 +567,25 @@ class _AttachmentItem extends StatelessWidget { Positioned( top: -10, right: -10, - child: GestureDetector( + child: AppSemantics.button( + label: context.l10n.deleteAction, 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: 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, + ), ), ), ),