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),
),
),
),
- ),
- ],
- );
+ ],
+ );
+ }
}
}