Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-07
32 changes: 32 additions & 0 deletions openspec/changes/composer-attachment-improvements/design.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions openspec/changes/composer-attachment-improvements/proposal.md
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
@@ -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 `<img src="..." />` 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
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions openspec/changes/composer-attachment-improvements/tasks.md
Original file line number Diff line number Diff line change
@@ -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 <a> 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
2 changes: 2 additions & 0 deletions packages/core/lib/data/sources/data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ abstract class DataSource {

Future<String> uploadImage(File file);

Future<String> uploadFile(File file);

/// Fetch per-lesson progress for a user.
Future<List<UserProgressDto>> getUserProgress(String userId);

Expand Down
15 changes: 15 additions & 0 deletions packages/core/lib/data/sources/http_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<ForumThreadDto> postForumThread({
required String title,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/lib/data/sources/mock_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,12 @@ class MockDataSource implements DataSource {
return 'https://mock.url/${file.path.split('/').last}';
}

@override
Future<String> uploadFile(File file) async {
await Future.delayed(const Duration(milliseconds: 500));
return 'https://mock.url/${file.path.split('/').last}';
}

@override
Future<ForumThreadDto> postForumThread({
required String title,
Expand Down
43 changes: 33 additions & 10 deletions packages/discussions/lib/providers/forum_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 += '<br><img src="$url" />';
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 += '<br><img src="$url" />';
} else {
finalContent += '<br><a href="$url" target="_blank">$fileName</a>';
}
}
}

Expand Down Expand Up @@ -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 += '<br><img src="$url" />';
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 += '<br><img src="$url" />';
} else {
finalContent += '<br><a href="$url" target="_blank">$fileName</a>';
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions packages/discussions/lib/providers/forum_providers.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/discussions/lib/repositories/forum_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ class ForumRepository {
}
}

Future<String> uploadFile(File file) async {
try {
return await _source.uploadFile(file);
} catch (e, st) {
_sentryService.captureException(e, stackTrace: st);
rethrow;
}
}

Future<ForumThreadDto> createThread({
required String title,
required String html,
Expand Down
Loading