From 5f057f7065f5ebe863498f0685d8c481f765a708 Mon Sep 17 00:00:00 2001 From: pugal Date: Sat, 11 Jul 2026 13:18:37 +0530 Subject: [PATCH] feat(discussions): Add activity filter to discussion forum - Introduce a bottom sheet to filter forum threads by user activity (posted, commented, liked, bookmarked). - Integrate filter parameters into the forum repository and network layer. - Update the forum list UI to display, toggle, and clear active filters. --- .../changes/add-forum-filters/.openspec.yaml | 2 + openspec/changes/add-forum-filters/design.md | 42 ++ .../changes/add-forum-filters/proposal.md | 27 + .../specs/forum-activity-filters/spec.md | 9 + .../specs/forum-post/spec.md | 13 + .../specs/forum-sorting/spec.md | 9 + openspec/changes/add-forum-filters/tasks.md | 19 + .../lib/data/models/forum_thread_dto.dart | 6 + .../core/lib/data/sources/data_source.dart | 5 + .../lib/data/sources/http_data_source.dart | 10 + .../lib/data/sources/mock_data_source.dart | 48 +- .../lib/generated/l10n/app_localizations.dart | 72 +++ .../generated/l10n/app_localizations_ar.dart | 36 ++ .../generated/l10n/app_localizations_en.dart | 36 ++ .../generated/l10n/app_localizations_ml.dart | 36 ++ .../generated/l10n/app_localizations_ta.dart | 36 ++ packages/core/lib/l10n/app_ar.arb | 12 + packages/core/lib/l10n/app_en.arb | 13 + packages/core/lib/l10n/app_ml.arb | 12 + packages/core/lib/l10n/app_ta.arb | 12 + .../lib/providers/forum_providers.dart | 7 + .../lib/providers/forum_providers.g.dart | 98 +++- .../lib/repositories/forum_repository.dart | 25 + .../lib/screens/forum_posts_list_screen.dart | 531 ++++++++++++------ .../widgets/forum_filter_bottom_sheet.dart | 196 +++++++ .../discussions/lib/widgets/forum_header.dart | 9 +- 26 files changed, 1109 insertions(+), 212 deletions(-) create mode 100644 openspec/changes/add-forum-filters/.openspec.yaml create mode 100644 openspec/changes/add-forum-filters/design.md create mode 100644 openspec/changes/add-forum-filters/proposal.md create mode 100644 openspec/changes/add-forum-filters/specs/forum-activity-filters/spec.md create mode 100644 openspec/changes/add-forum-filters/specs/forum-post/spec.md create mode 100644 openspec/changes/add-forum-filters/specs/forum-sorting/spec.md create mode 100644 openspec/changes/add-forum-filters/tasks.md create mode 100644 packages/discussions/lib/widgets/forum_filter_bottom_sheet.dart diff --git a/openspec/changes/add-forum-filters/.openspec.yaml b/openspec/changes/add-forum-filters/.openspec.yaml new file mode 100644 index 000000000..074342d55 --- /dev/null +++ b/openspec/changes/add-forum-filters/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-09 diff --git a/openspec/changes/add-forum-filters/design.md b/openspec/changes/add-forum-filters/design.md new file mode 100644 index 000000000..26f7c6935 --- /dev/null +++ b/openspec/changes/add-forum-filters/design.md @@ -0,0 +1,42 @@ +## Context + +The `ForumPostsListScreen` currently utilizes horizontal chips (`_CategoryChips`) to filter forum threads by category. As the need for more complex filtering arises, the UI will be updated to introduce sorting tabs (e.g., "Recent", "Most Liked", "Most Viewed") above the category chips. A new activity filter menu will be introduced via a Bottom Sheet, accessible via a filter icon on the main screen. + +## Goals / Non-Goals + +**Goals:** +- Introduce a sorting segmented control containing tabs for "Recent", "Most Liked", and "Most Viewed" on `ForumPostsListScreen`. +- Retain the horizontal category chips below the new sorting tabs. +- Introduce a Bottom Sheet (`ForumFilterBottomSheet`) accessible via a filter icon. +- Introduce an "Activity Filter" section within the Bottom Sheet with predefined options (e.g., Posted by me, Commented by me). +- Update the `GlobalForumFeed` Riverpod provider to accept and maintain the selected activity filter and sort order alongside the category filter. +- Update `ForumRepository` and the underlying data sources (`DataSource`) to pass the activity filter and sort parameters to the backend. + +**Non-Goals:** +- Redesigning the entire forum feed layout or thread card UI. +- Modifying the underlying backend APIs (this design assumes the backend API for activity filtering is either already capable or will be updated independently to accept these parameters). + +## Decisions + +### 1. UI Architecture: Bottom Sheet +We decided to implement the activity filter menu as a Bottom Sheet (`AppBottomSheet`). +- **Rationale:** A bottom sheet provides a focused, modal interaction model for mobile screens, keeping the filter options easily accessible near the bottom of the screen. + +### 2. State Management for Filters +The `ForumFilterBottomSheet` will trigger filter changes immediately upon selection. +- **Rationale:** We want the user to experience immediate feedback when applying a filter without needing an extra confirmation step. + +### 3. Activity Filter Enum +Introduce a `ForumActivityFilter` enum to represent the different activity filters. +- **Rationale:** Using an enum provides type safety across the repository and provider layers compared to passing raw string values. + +### 4. Data Layer Encapsulation +Pass the `ForumSort` and `ForumActivityFilter` enums natively through the provider layer, resolving them into raw primitive query parameters within the `ForumRepository` before passing them to the `DataSource`. +- **Rationale:** This maintains strict separation of concerns. The network data layer remains decoupled from domain-level enums. The caller (`ForumRepository`) is responsible for translating domain business logic into primitive values for the data source. + +## Risks / Trade-offs + +- **Risk:** The horizontal category chips might become crowded. + - **Mitigation:** The category chips are inside a horizontally scrollable list, allowing them to comfortably accommodate many categories. +- **Risk:** Existing instances of `globalForumFeedProvider` might break if the new activity parameter is not made optional. + - **Mitigation:** The new `activityFilter` parameter will be nullable (`ForumActivityFilter?`) and default to `null` to ensure backward compatibility with any other screens utilizing the feed. diff --git a/openspec/changes/add-forum-filters/proposal.md b/openspec/changes/add-forum-filters/proposal.md new file mode 100644 index 000000000..f56f0b22f --- /dev/null +++ b/openspec/changes/add-forum-filters/proposal.md @@ -0,0 +1,27 @@ +## Why + +Users currently lack an efficient way to filter discussion forum threads beyond basic category selection. A comprehensive filter menu is needed to allow filtering by user activity (e.g., "Posted by me", "Liked by me"), improving navigation and discoverability of relevant forum content. + +## What Changes + +- Add a filter icon to the `ForumPostsListScreen` header or search bar. +- Add a sorting segmented control containing tabs for "Recent", "Most Liked", and "Most Viewed" above the horizontal category chips. +- Retain the existing horizontal category chips. +- Introduce a Bottom Sheet (`ForumFilterBottomSheet`) that contains a new "Filter by Activity" section with options like "Posted by me", "Commented by me", "Liked by me", and "Bookmarked by me". +- Update the `globalForumFeedProvider` to accept and process an activity filter parameter and a sort parameter. +- **Backend Integration**: Implement a robust, layered data flow for the new filters by passing `ForumSort` and `ForumActivityFilter` enums seamlessly through the `GlobalForumFeed` provider, resolving them into primitive query parameters inside `ForumRepository`, and passing those raw values through the abstract `DataSource` contract to keep the network layer decoupled from the domain layer. + +## Capabilities + +### New Capabilities +- `forum-activity-filters`: Introduces filtering forum threads based on user activity (e.g., posted by me, commented by me, liked by me, bookmarked by me). +- `forum-sorting`: Introduces sorting forum threads by "Recent", "Most Liked", and "Most Viewed". + +### Modified Capabilities +- `forum-post`: Updates the forum thread listing requirement to support advanced activity filtering through a Bottom Sheet and sorting through horizontal tabs. + +## Impact + +- **UI/UX**: Modifies the `ForumPostsListScreen` layout to include sorting tabs, and adds a Bottom Sheet for activity filter controls. +- **State Management**: Updates `GlobalForumFeed` provider to maintain activity filter state. +- **Data Layer**: Updates `ForumRepository.fetchThreads` and `DataSource.getForumThreads` API to support activity filtering parameters. diff --git a/openspec/changes/add-forum-filters/specs/forum-activity-filters/spec.md b/openspec/changes/add-forum-filters/specs/forum-activity-filters/spec.md new file mode 100644 index 000000000..09556c4f4 --- /dev/null +++ b/openspec/changes/add-forum-filters/specs/forum-activity-filters/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Activity Filtering +The system SHALL support filtering forum threads based on the user's activity. + +#### Scenario: User selects an activity filter +- **WHEN** a user selects an activity filter (e.g., "Posted by me") from the filter bottom sheet +- **THEN** the system SHALL fetch and display only the threads matching that activity criteria +- **AND** the system SHALL update the feed provider to maintain the active activity filter state diff --git a/openspec/changes/add-forum-filters/specs/forum-post/spec.md b/openspec/changes/add-forum-filters/specs/forum-post/spec.md new file mode 100644 index 000000000..1a82cceb7 --- /dev/null +++ b/openspec/changes/add-forum-filters/specs/forum-post/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Thread Sorting and Filtering UI +The system SHALL provide horizontal tabs for sorting and a Bottom Sheet for advanced filtering. + +#### Scenario: User navigates sorting tabs +- **WHEN** the user views the forum feed +- **THEN** the system SHALL display sorting tabs (Recent, Most Liked, Most Viewed) above the category chips +- **AND** tapping a tab SHALL update the feed order accordingly + +#### Scenario: User opens filter bottom sheet +- **WHEN** user taps the filter icon on the thread list +- **THEN** the system SHALL open a Bottom Sheet containing Activity filters diff --git a/openspec/changes/add-forum-filters/specs/forum-sorting/spec.md b/openspec/changes/add-forum-filters/specs/forum-sorting/spec.md new file mode 100644 index 000000000..272aa919e --- /dev/null +++ b/openspec/changes/add-forum-filters/specs/forum-sorting/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Thread Sorting +The system SHALL support sorting the global forum feed threads. + +#### Scenario: User selects a sort option +- **WHEN** a user selects a sort option (e.g., "Most Liked") from the segmented control +- **THEN** the system SHALL fetch and display the threads ordered by the selected sort criteria +- **AND** the system SHALL maintain the active sort order state within the feed provider diff --git a/openspec/changes/add-forum-filters/tasks.md b/openspec/changes/add-forum-filters/tasks.md new file mode 100644 index 000000000..b549ff476 --- /dev/null +++ b/openspec/changes/add-forum-filters/tasks.md @@ -0,0 +1,19 @@ +## 1. Data Layer & State Management + +- [x] 1.1 Define `ForumActivityFilter` and `ForumSort` enums. +- [x] 1.2 Update `DataSource` interface and `MockDataSource`/`HttpDataSource` to accept primitive query parameters (strings/booleans) for filtering in `getForumThreads`. +- [x] 1.3 Update `ForumRepository.fetchThreads` to translate enums into raw parameters and pass them to `DataSource`. +- [x] 1.4 Update `GlobalForumFeed` Riverpod provider to maintain and accept `activityFilter` and `sortOrder` state. + +## 2. UI Component: Activity Filter Bottom Sheet + +- [x] 2.1 Create `ForumFilterBottomSheet` widget. +- [x] 2.2 Implement the "Filter by Activity" section with predefined options (e.g., Posted by me, Liked by me). +- [x] 2.3 Add "Clear" action to the bottom sheet to reset selections. + +## 3. UI Integration: Main Screen + +- [x] 3.1 Introduce a Sorting Segmented Control ("Recent", "Most Liked", "Most Viewed") above the horizontal `_CategoryChips` in `ForumPostsListScreen`. +- [x] 3.2 Connect the Sorting Segmented Control to update the `sortOrder` in `globalForumFeedProvider`. +- [x] 3.3 Add a filter icon to the `ForumPostsListScreen` header or adjacent to the search bar. +- [x] 3.4 Wire the filter icon to open `ForumFilterBottomSheet` via `AppBottomSheet`. diff --git a/packages/core/lib/data/models/forum_thread_dto.dart b/packages/core/lib/data/models/forum_thread_dto.dart index c467926f7..816bb4f87 100644 --- a/packages/core/lib/data/models/forum_thread_dto.dart +++ b/packages/core/lib/data/models/forum_thread_dto.dart @@ -1,6 +1,12 @@ /// Forum thread status. enum ForumThreadStatus { answered, unanswered, closed, archived, pending } +/// Forum activity filters for narrowing down threads. +enum ForumActivityFilter { posted, commented, liked, bookmarked } + +/// Forum sorting options. +enum ForumSort { recent, mostLiked, mostViewed } + /// Forum category DTO — maps to `/api/v2.3/forum/categories/`. class ForumCategoryDto { final int id; diff --git a/packages/core/lib/data/sources/data_source.dart b/packages/core/lib/data/sources/data_source.dart index 5a1758afd..ff6eeb5ea 100644 --- a/packages/core/lib/data/sources/data_source.dart +++ b/packages/core/lib/data/sources/data_source.dart @@ -66,6 +66,11 @@ abstract class DataSource { int page = 1, int? categoryId, String? searchQuery, + String? sortString, + bool? postedByMe, + bool? commentedByMe, + bool? likedByMe, + bool? bookmarkedByMe, }); /// Fetch a single forum thread by slug. diff --git a/packages/core/lib/data/sources/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index b3edbc930..62fdc2424 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -272,6 +272,11 @@ class HttpDataSource implements DataSource { int page = 1, int? categoryId, String? searchQuery, + String? sortString, + bool? postedByMe, + bool? commentedByMe, + bool? likedByMe, + bool? bookmarkedByMe, }) async { return performNetworkRequest( _dio.get( @@ -281,6 +286,11 @@ class HttpDataSource implements DataSource { 'category': ?categoryId, if (searchQuery != null && searchQuery.isNotEmpty) 'search': searchQuery, + 'sort': ?sortString, + 'posted_by_me': ?postedByMe, + 'commented_by_me': ?commentedByMe, + 'liked_by_me': ?likedByMe, + 'bookmarked_by_me': ?bookmarkedByMe, }, ), fromJson: (json) => PaginatedResponseDto.fromJson( diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index 1098be537..1cb7d8af5 100644 --- a/packages/core/lib/data/sources/mock_data_source.dart +++ b/packages/core/lib/data/sources/mock_data_source.dart @@ -879,8 +879,54 @@ class MockDataSource implements DataSource { int page = 1, int? categoryId, String? searchQuery, + String? sortString, + bool? postedByMe, + bool? commentedByMe, + bool? likedByMe, + bool? bookmarkedByMe, }) async { - final results = mockForumThreads(page: page, categoryId: categoryId); + var results = mockForumThreads(page: page, categoryId: categoryId); + + // Apply search filter + if (searchQuery != null && searchQuery.isNotEmpty) { + final query = searchQuery.toLowerCase(); + results = results + .where( + (t) => + t.title.toLowerCase().contains(query) || + t.summary.toLowerCase().contains(query), + ) + .toList(); + } + + // Apply activity filter (simulated) + if (postedByMe == true) { + results = results.where((t) => t.threadId % 2 == 0).toList(); + } else if (commentedByMe == true) { + results = results.where((t) => t.replyCount > 2).toList(); + } else if (likedByMe == true) { + results = results.where((t) => t.upvotes > 10).toList(); + } else if (bookmarkedByMe == true) { + results = results.where((t) => t.threadId % 3 == 0).toList(); + } + + // Apply sort + if (sortString != null) { + results = List.from(results); + switch (sortString) { + case '-created': + results.sort((a, b) => b.threadId.compareTo(a.threadId)); + break; + case '-upvotes': + results.sort((a, b) => b.upvotes.compareTo(a.upvotes)); + break; + case '-views_count': + // Simulate most viewed by sorting by replies + results.sort((a, b) => b.replyCount.compareTo(a.replyCount)); + break; + } + } + return PaginatedResponseDto( results: results, count: results.length * 5, diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 2a76e44eb..ba5e257dd 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -2693,6 +2693,78 @@ abstract class AppLocalizations { /// **'Discussion Forum'** String get forumTitle; + /// No description provided for @forumFilterTitle. + /// + /// In en, this message translates to: + /// **'Filters'** + String get forumFilterTitle; + + /// No description provided for @forumFilterByActivity. + /// + /// In en, this message translates to: + /// **'Filter by Activity'** + String get forumFilterByActivity; + + /// No description provided for @forumFilterClearAll. + /// + /// In en, this message translates to: + /// **'Clear All'** + String get forumFilterClearAll; + + /// No description provided for @forumBackSemantic. + /// + /// In en, this message translates to: + /// **'Back'** + String get forumBackSemantic; + + /// No description provided for @forumFilterSemantic. + /// + /// In en, this message translates to: + /// **'Filter'** + String get forumFilterSemantic; + + /// No description provided for @forumFilterActivityPosted. + /// + /// In en, this message translates to: + /// **'Posted by me'** + String get forumFilterActivityPosted; + + /// No description provided for @forumFilterActivityCommented. + /// + /// In en, this message translates to: + /// **'Commented by me'** + String get forumFilterActivityCommented; + + /// No description provided for @forumFilterActivityLiked. + /// + /// In en, this message translates to: + /// **'Liked by me'** + String get forumFilterActivityLiked; + + /// No description provided for @forumFilterActivityBookmarked. + /// + /// In en, this message translates to: + /// **'Bookmarked by me'** + String get forumFilterActivityBookmarked; + + /// No description provided for @forumSortRecent. + /// + /// In en, this message translates to: + /// **'Recent'** + String get forumSortRecent; + + /// No description provided for @forumSortMostLiked. + /// + /// In en, this message translates to: + /// **'Most Liked'** + String get forumSortMostLiked; + + /// No description provided for @forumSortMostViewed. + /// + /// In en, this message translates to: + /// **'Most Viewed'** + String get forumSortMostViewed; + /// No description provided for @forumSelectCourse. /// /// In en, this message translates to: diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 2718f646f..4f64e56d7 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -1434,6 +1434,42 @@ class AppLocalizationsAr extends AppLocalizations { @override String get forumTitle => 'منتدى المناقشة'; + @override + String get forumFilterTitle => 'عوامل التصفية'; + + @override + String get forumFilterByActivity => 'تصفية حسب النشاط'; + + @override + String get forumFilterClearAll => 'مسح الكل'; + + @override + String get forumBackSemantic => 'عودة'; + + @override + String get forumFilterSemantic => 'تصفية'; + + @override + String get forumFilterActivityPosted => 'نشرتها أنا'; + + @override + String get forumFilterActivityCommented => 'علقت عليها أنا'; + + @override + String get forumFilterActivityLiked => 'أعجبتني'; + + @override + String get forumFilterActivityBookmarked => 'أشرت إليها كمرجعية'; + + @override + String get forumSortRecent => 'الأحدث'; + + @override + String get forumSortMostLiked => 'الأكثر إعجاباً'; + + @override + String get forumSortMostViewed => 'الأكثر مشاهدة'; + @override String get forumSelectCourse => 'اختر دورة لعرض المناقشات'; diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index ab757b0a6..2d68500a4 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -1437,6 +1437,42 @@ class AppLocalizationsEn extends AppLocalizations { @override String get forumTitle => 'Discussion Forum'; + @override + String get forumFilterTitle => 'Filters'; + + @override + String get forumFilterByActivity => 'Filter by Activity'; + + @override + String get forumFilterClearAll => 'Clear All'; + + @override + String get forumBackSemantic => 'Back'; + + @override + String get forumFilterSemantic => 'Filter'; + + @override + String get forumFilterActivityPosted => 'Posted by me'; + + @override + String get forumFilterActivityCommented => 'Commented by me'; + + @override + String get forumFilterActivityLiked => 'Liked by me'; + + @override + String get forumFilterActivityBookmarked => 'Bookmarked by me'; + + @override + String get forumSortRecent => 'Recent'; + + @override + String get forumSortMostLiked => 'Most Liked'; + + @override + String get forumSortMostViewed => 'Most Viewed'; + @override String get forumSelectCourse => 'Select a course to view discussions'; diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index bfcfa0158..6de901d54 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -1447,6 +1447,42 @@ class AppLocalizationsMl extends AppLocalizations { @override String get forumTitle => 'ചർച്ചാ ഫോറം'; + @override + String get forumFilterTitle => 'ഫിൽറ്ററുകൾ'; + + @override + String get forumFilterByActivity => 'പ്രവർത്തനം അനുസരിച്ച് ഫിൽറ്റർ ചെയ്യുക'; + + @override + String get forumFilterClearAll => 'എല്ലാം മായ്‌ക്കുക'; + + @override + String get forumBackSemantic => 'തിരികെ'; + + @override + String get forumFilterSemantic => 'ഫിൽറ്റർ'; + + @override + String get forumFilterActivityPosted => 'ഞാൻ പോസ്റ്റ് ചെയ്തത്'; + + @override + String get forumFilterActivityCommented => 'ഞാൻ കമന്റ് ചെയ്തത്'; + + @override + String get forumFilterActivityLiked => 'എനിക്ക് ഇഷ്ടപ്പെട്ടത്'; + + @override + String get forumFilterActivityBookmarked => 'ഞാൻ ബുക്ക്മാർക്ക് ചെയ്തത്'; + + @override + String get forumSortRecent => 'ഏറ്റവും പുതിയവ'; + + @override + String get forumSortMostLiked => 'കൂടുതൽ ഇഷ്ടപ്പെട്ടവ'; + + @override + String get forumSortMostViewed => 'കൂടുതൽ ആളുകൾ കണ്ടവ'; + @override String get forumSelectCourse => 'ചർച്ചകൾ കാണുന്നതിനായി ഒരു കോഴ്സ് തിരഞ്ഞെടുക്കുക'; diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index 96d310332..28be73273 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -1453,6 +1453,42 @@ class AppLocalizationsTa extends AppLocalizations { @override String get forumTitle => 'விவாத மன்றம்'; + @override + String get forumFilterTitle => 'வடிகட்டிகள்'; + + @override + String get forumFilterByActivity => 'செயல்பாட்டின்படி வடிகட்டுக'; + + @override + String get forumFilterClearAll => 'அனைத்தையும் அழி'; + + @override + String get forumBackSemantic => 'பின்செல்'; + + @override + String get forumFilterSemantic => 'வடிகட்டி'; + + @override + String get forumFilterActivityPosted => 'நான் பதிவிட்டது'; + + @override + String get forumFilterActivityCommented => 'நான் கருத்துத் தெரிவித்தது'; + + @override + String get forumFilterActivityLiked => 'நான் விரும்பியது'; + + @override + String get forumFilterActivityBookmarked => 'நான் குறித்தது'; + + @override + String get forumSortRecent => 'சமீபத்தியவை'; + + @override + String get forumSortMostLiked => 'அதிகம் விரும்பப்பட்டவை'; + + @override + String get forumSortMostViewed => 'அதிகம் பார்க்கப்பட்டவை'; + @override String get forumSelectCourse => 'விவாதங்களைக் காண ஒரு பாடத்தைத் தேர்ந்தெடுக்கவும்'; diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 05d8da36d..dbccfdf2f 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -502,6 +502,18 @@ "labelMock": "تجريبي", "labelPractice": "ممارسة", "forumTitle": "منتدى المناقشة", + "forumFilterTitle": "عوامل التصفية", + "forumFilterByActivity": "تصفية حسب النشاط", + "forumFilterClearAll": "مسح الكل", + "forumBackSemantic": "عودة", + "forumFilterSemantic": "تصفية", + "forumFilterActivityPosted": "نشرتها أنا", + "forumFilterActivityCommented": "علقت عليها أنا", + "forumFilterActivityLiked": "أعجبتني", + "forumFilterActivityBookmarked": "أشرت إليها كمرجعية", + "forumSortRecent": "الأحدث", + "forumSortMostLiked": "الأكثر إعجاباً", + "forumSortMostViewed": "الأكثر مشاهدة", "forumSelectCourse": "اختر دورة لعرض المناقشات", "forumThreadsCount": "{count, plural, =0{لا توجد مناقشات} =1{مناقشة واحدة} =2{مناقشتان} other{{count} مناقشات}}", "@forumThreadsCount": { diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index 4a21cc7b5..d5dc30302 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -680,7 +680,20 @@ "editProfileLastNameHint": "Enter your last name", "labelMock": "Mock", "labelPractice": "Practice", + "forumTitle": "Discussion Forum", + "forumFilterTitle": "Filters", + "forumFilterByActivity": "Filter by Activity", + "forumFilterClearAll": "Clear All", + "forumBackSemantic": "Back", + "forumFilterSemantic": "Filter", + "forumFilterActivityPosted": "Posted by me", + "forumFilterActivityCommented": "Commented by me", + "forumFilterActivityLiked": "Liked by me", + "forumFilterActivityBookmarked": "Bookmarked by me", + "forumSortRecent": "Recent", + "forumSortMostLiked": "Most Liked", + "forumSortMostViewed": "Most Viewed", "forumSelectCourse": "Select a course to view discussions", "forumThreadsCount": "{count} Threads", "@forumThreadsCount": { diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index c3c69accc..2958785a0 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -502,6 +502,18 @@ "labelMock": "മോക്ക്", "labelPractice": "പ്രാക്ടീസ്", "forumTitle": "ചർച്ചാ ഫോറം", + "forumFilterTitle": "ഫിൽറ്ററുകൾ", + "forumFilterByActivity": "പ്രവർത്തനം അനുസരിച്ച് ഫിൽറ്റർ ചെയ്യുക", + "forumFilterClearAll": "എല്ലാം മായ്‌ക്കുക", + "forumBackSemantic": "തിരികെ", + "forumFilterSemantic": "ഫിൽറ്റർ", + "forumFilterActivityPosted": "ഞാൻ പോസ്റ്റ് ചെയ്തത്", + "forumFilterActivityCommented": "ഞാൻ കമന്റ് ചെയ്തത്", + "forumFilterActivityLiked": "എനിക്ക് ഇഷ്ടപ്പെട്ടത്", + "forumFilterActivityBookmarked": "ഞാൻ ബുക്ക്മാർക്ക് ചെയ്തത്", + "forumSortRecent": "ഏറ്റവും പുതിയവ", + "forumSortMostLiked": "കൂടുതൽ ഇഷ്ടപ്പെട്ടവ", + "forumSortMostViewed": "കൂടുതൽ ആളുകൾ കണ്ടവ", "forumSelectCourse": "ചർച്ചകൾ കാണുന്നതിനായി ഒരു കോഴ്സ് തിരഞ്ഞെടുക്കുക", "forumThreadsCount": "{count, plural, =0{ചർച്ചകൾ ഒന്നുമില്ല} =1{ഒരു ചർച്ച} other{{count} ചർച്ചകൾ}}", "@forumThreadsCount": { diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index 4fc119103..4dc0183fa 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -679,6 +679,18 @@ "editProfileLastNameHint": "உங்கள் கடைசி பெயரை உள்ளிடவும்", "labelMock": "மாதிரி", "forumTitle": "விவாத மன்றம்", + "forumFilterTitle": "வடிகட்டிகள்", + "forumFilterByActivity": "செயல்பாட்டின்படி வடிகட்டுக", + "forumFilterClearAll": "அனைத்தையும் அழி", + "forumBackSemantic": "பின்செல்", + "forumFilterSemantic": "வடிகட்டி", + "forumFilterActivityPosted": "நான் பதிவிட்டது", + "forumFilterActivityCommented": "நான் கருத்துத் தெரிவித்தது", + "forumFilterActivityLiked": "நான் விரும்பியது", + "forumFilterActivityBookmarked": "நான் குறித்தது", + "forumSortRecent": "சமீபத்தியவை", + "forumSortMostLiked": "அதிகம் விரும்பப்பட்டவை", + "forumSortMostViewed": "அதிகம் பார்க்கப்பட்டவை", "forumSelectCourse": "விவாதங்களைக் காண ஒரு பாடத்தைத் தேர்ந்தெடுக்கவும்", "forumThreadsCount": "{count} இழைகள்", "@forumThreadsCount": { diff --git a/packages/discussions/lib/providers/forum_providers.dart b/packages/discussions/lib/providers/forum_providers.dart index 61cf46601..5b5074bd2 100644 --- a/packages/discussions/lib/providers/forum_providers.dart +++ b/packages/discussions/lib/providers/forum_providers.dart @@ -143,14 +143,19 @@ class GlobalForumFeed extends _$GlobalForumFeed { Future build({ int? categoryId, String? searchQuery, + ForumActivityFilter? activityFilter, + ForumSort? sortOrder, }) async { if (searchQuery == null || searchQuery.isEmpty) { ref.keepAlive(); } final repo = await ref.watch(forumRepositoryProvider.future); + final response = await repo.fetchThreads( categoryId: categoryId, searchQuery: searchQuery, + sort: sortOrder, + activityFilter: activityFilter, ); final nextPage = _extractPageNumber(response.next); return GlobalForumFeedState( @@ -176,6 +181,8 @@ class GlobalForumFeed extends _$GlobalForumFeed { page: currentState.nextPage!, categoryId: categoryId, searchQuery: searchQuery, + sort: sortOrder, + activityFilter: activityFilter, ); final existingIds = currentState.items.map((t) => t.threadId).toSet(); diff --git a/packages/discussions/lib/providers/forum_providers.g.dart b/packages/discussions/lib/providers/forum_providers.g.dart index 97ae41173..f22c03173 100644 --- a/packages/discussions/lib/providers/forum_providers.g.dart +++ b/packages/discussions/lib/providers/forum_providers.g.dart @@ -74,7 +74,7 @@ final createForumThreadProvider = ); typedef _$CreateForumThread = AutoDisposeAsyncNotifier; -String _$globalForumFeedHash() => r'daaabf975050b926ac5fda9e7514e99275e18aa3'; +String _$globalForumFeedHash() => r'736c942f31480e9c06c8589528dd7157b96c7c81'; /// Copied from Dart SDK class _SystemHash { @@ -101,8 +101,15 @@ abstract class _$GlobalForumFeed extends BuildlessAutoDisposeAsyncNotifier { late final int? categoryId; late final String? searchQuery; - - FutureOr build({int? categoryId, String? searchQuery}); + late final ForumActivityFilter? activityFilter; + late final ForumSort? sortOrder; + + FutureOr build({ + int? categoryId, + String? searchQuery, + ForumActivityFilter? activityFilter, + ForumSort? sortOrder, + }); } /// See also [GlobalForumFeed]. @@ -115,10 +122,17 @@ class GlobalForumFeedFamily extends Family> { const GlobalForumFeedFamily(); /// See also [GlobalForumFeed]. - GlobalForumFeedProvider call({int? categoryId, String? searchQuery}) { + GlobalForumFeedProvider call({ + int? categoryId, + String? searchQuery, + ForumActivityFilter? activityFilter, + ForumSort? sortOrder, + }) { return GlobalForumFeedProvider( categoryId: categoryId, searchQuery: searchQuery, + activityFilter: activityFilter, + sortOrder: sortOrder, ); } @@ -129,6 +143,8 @@ class GlobalForumFeedFamily extends Family> { return call( categoryId: provider.categoryId, searchQuery: provider.searchQuery, + activityFilter: provider.activityFilter, + sortOrder: provider.sortOrder, ); } @@ -155,22 +171,30 @@ class GlobalForumFeedProvider GlobalForumFeedState > { /// See also [GlobalForumFeed]. - GlobalForumFeedProvider({int? categoryId, String? searchQuery}) - : this._internal( - () => GlobalForumFeed() - ..categoryId = categoryId - ..searchQuery = searchQuery, - from: globalForumFeedProvider, - name: r'globalForumFeedProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$globalForumFeedHash, - dependencies: GlobalForumFeedFamily._dependencies, - allTransitiveDependencies: - GlobalForumFeedFamily._allTransitiveDependencies, - categoryId: categoryId, - searchQuery: searchQuery, - ); + GlobalForumFeedProvider({ + int? categoryId, + String? searchQuery, + ForumActivityFilter? activityFilter, + ForumSort? sortOrder, + }) : this._internal( + () => GlobalForumFeed() + ..categoryId = categoryId + ..searchQuery = searchQuery + ..activityFilter = activityFilter + ..sortOrder = sortOrder, + from: globalForumFeedProvider, + name: r'globalForumFeedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$globalForumFeedHash, + dependencies: GlobalForumFeedFamily._dependencies, + allTransitiveDependencies: + GlobalForumFeedFamily._allTransitiveDependencies, + categoryId: categoryId, + searchQuery: searchQuery, + activityFilter: activityFilter, + sortOrder: sortOrder, + ); GlobalForumFeedProvider._internal( super._createNotifier, { @@ -181,16 +205,25 @@ class GlobalForumFeedProvider required super.from, required this.categoryId, required this.searchQuery, + required this.activityFilter, + required this.sortOrder, }) : super.internal(); final int? categoryId; final String? searchQuery; + final ForumActivityFilter? activityFilter; + final ForumSort? sortOrder; @override FutureOr runNotifierBuild( covariant GlobalForumFeed notifier, ) { - return notifier.build(categoryId: categoryId, searchQuery: searchQuery); + return notifier.build( + categoryId: categoryId, + searchQuery: searchQuery, + activityFilter: activityFilter, + sortOrder: sortOrder, + ); } @override @@ -200,7 +233,9 @@ class GlobalForumFeedProvider override: GlobalForumFeedProvider._internal( () => create() ..categoryId = categoryId - ..searchQuery = searchQuery, + ..searchQuery = searchQuery + ..activityFilter = activityFilter + ..sortOrder = sortOrder, from: from, name: null, dependencies: null, @@ -208,6 +243,8 @@ class GlobalForumFeedProvider debugGetCreateSourceHash: null, categoryId: categoryId, searchQuery: searchQuery, + activityFilter: activityFilter, + sortOrder: sortOrder, ), ); } @@ -222,7 +259,9 @@ class GlobalForumFeedProvider bool operator ==(Object other) { return other is GlobalForumFeedProvider && other.categoryId == categoryId && - other.searchQuery == searchQuery; + other.searchQuery == searchQuery && + other.activityFilter == activityFilter && + other.sortOrder == sortOrder; } @override @@ -230,6 +269,8 @@ class GlobalForumFeedProvider var hash = _SystemHash.combine(0, runtimeType.hashCode); hash = _SystemHash.combine(hash, categoryId.hashCode); hash = _SystemHash.combine(hash, searchQuery.hashCode); + hash = _SystemHash.combine(hash, activityFilter.hashCode); + hash = _SystemHash.combine(hash, sortOrder.hashCode); return _SystemHash.finish(hash); } @@ -244,6 +285,12 @@ mixin GlobalForumFeedRef /// The parameter `searchQuery` of this provider. String? get searchQuery; + + /// The parameter `activityFilter` of this provider. + ForumActivityFilter? get activityFilter; + + /// The parameter `sortOrder` of this provider. + ForumSort? get sortOrder; } class _GlobalForumFeedProviderElement @@ -259,6 +306,11 @@ class _GlobalForumFeedProviderElement int? get categoryId => (origin as GlobalForumFeedProvider).categoryId; @override String? get searchQuery => (origin as GlobalForumFeedProvider).searchQuery; + @override + ForumActivityFilter? get activityFilter => + (origin as GlobalForumFeedProvider).activityFilter; + @override + ForumSort? get sortOrder => (origin as GlobalForumFeedProvider).sortOrder; } // ignore_for_file: type=lint diff --git a/packages/discussions/lib/repositories/forum_repository.dart b/packages/discussions/lib/repositories/forum_repository.dart index 13dc3ab2c..ecc9e6216 100644 --- a/packages/discussions/lib/repositories/forum_repository.dart +++ b/packages/discussions/lib/repositories/forum_repository.dart @@ -22,11 +22,36 @@ class ForumRepository { int page = 1, int? categoryId, String? searchQuery, + ForumSort? sort, + ForumActivityFilter? activityFilter, }) async { + final sortString = switch (sort) { + ForumSort.recent => '-created', + ForumSort.mostLiked => '-upvotes', + ForumSort.mostViewed => '-views_count', + null => null, + }; + + final postedByMe = activityFilter == ForumActivityFilter.posted + ? true + : null; + final commentedByMe = activityFilter == ForumActivityFilter.commented + ? true + : null; + final likedByMe = activityFilter == ForumActivityFilter.liked ? true : null; + final bookmarkedByMe = activityFilter == ForumActivityFilter.bookmarked + ? true + : null; + final response = await _source.getForumThreads( page: page, categoryId: categoryId, searchQuery: searchQuery, + sortString: sortString, + postedByMe: postedByMe, + commentedByMe: commentedByMe, + likedByMe: likedByMe, + bookmarkedByMe: bookmarkedByMe, ); final companions = response.results.map(_dtoToCompanion).toList(); await _db.upsertForumThreads(companions); diff --git a/packages/discussions/lib/screens/forum_posts_list_screen.dart b/packages/discussions/lib/screens/forum_posts_list_screen.dart index 5f8c92848..671bdd3ef 100644 --- a/packages/discussions/lib/screens/forum_posts_list_screen.dart +++ b/packages/discussions/lib/screens/forum_posts_list_screen.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'package:flutter/cupertino.dart' show CupertinoSliverRefreshControl; import 'package:flutter/widgets.dart'; +import 'package:flutter/cupertino.dart' show CupertinoSliverRefreshControl; import 'package:skeletonizer/skeletonizer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; import 'package:core/data/data.dart'; import '../providers/forum_providers.dart'; +import '../widgets/forum_filter_bottom_sheet.dart'; import '../widgets/forum_header.dart'; class ForumPostsListScreen extends ConsumerStatefulWidget { @@ -18,8 +19,11 @@ class ForumPostsListScreen extends ConsumerStatefulWidget { class _ForumPostsListScreenState extends ConsumerState { int? _selectedCategoryId; + ForumActivityFilter? _selectedActivityFilter; + ForumSort _selectedSortOrder = ForumSort.recent; String? _searchQuery; Timer? _debounceTimer; + bool _isFilterSheetOpen = false; @override void dispose() { @@ -46,185 +50,338 @@ class _ForumPostsListScreenState extends ConsumerState { globalForumFeedProvider( categoryId: _selectedCategoryId, searchQuery: _searchQuery, + activityFilter: _selectedActivityFilter, + sortOrder: _selectedSortOrder, ), ); final categoriesAsync = ref.watch(globalForumCategoriesProvider); - return SkeletonizerConfig( - data: SkeletonizerConfigData( - effect: ShimmerEffect( - baseColor: design.colors.skeleton, - highlightColor: design.colors.onSkeleton, - duration: MotionPreferences.duration( - context, - const Duration(milliseconds: 800), - ), - ), - ), - child: DecoratedBox( - decoration: BoxDecoration(color: design.colors.card), - child: Column( - children: [ - ForumHeader( - title: l10n.forumTitle, - showDivider: false, - actions: [ - AppFocusable( - onTap: () { - context.push('/home/discussions/forum/create'); - }, - borderRadius: BorderRadius.circular(design.radius.full), - child: Padding( + final hasFilters = _selectedActivityFilter != null; + + void handleCreatePost() => context.push('/home/discussions/forum/create'); + + return Stack( + children: [ + Container( + color: design.colors.surface, + child: SkeletonizerConfig( + data: SkeletonizerConfigData( + effect: ShimmerEffect( + baseColor: design.colors.skeleton, + highlightColor: design.colors.onSkeleton, + duration: MotionPreferences.duration( + context, + const Duration(milliseconds: 800), + ), + ), + ), + child: DecoratedBox( + decoration: BoxDecoration(color: design.colors.card), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ForumHeader( + title: l10n.forumTitle, + showDivider: false, + actions: [ + AppSemantics.button( + label: l10n.forumFilterSemantic, + onTap: () { + setState(() => _isFilterSheetOpen = true); + }, + child: AppFocusable( + padding: const EdgeInsets.all(13), + onTap: () { + setState(() => _isFilterSheetOpen = true); + }, + child: Stack( + children: [ + Icon( + LucideIcons.filter, + color: design.colors.textPrimary, + size: 22, + ), + if (hasFilters) + Positioned( + top: 0, + right: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: design.colors.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ), + ], + ), + Padding( padding: EdgeInsets.symmetric( - horizontal: design.spacing.sm, + horizontal: design.spacing.md, + vertical: design.spacing.sm, ), - child: AppText.labelSmall( - l10n.forumCreatePost, - color: design.colors.accent2, + child: AppSearchBar( + hintText: l10n.forumSearchDiscussions, + onChanged: _onSearchChanged, + backgroundColor: design.colors.surfaceVariant, ), ), - ), - ], - ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: design.spacing.md, - vertical: design.spacing.sm, - ), - child: AppSearchBar( - hintText: l10n.forumSearchDiscussions, - onChanged: _onSearchChanged, - backgroundColor: design.colors.surfaceVariant, - ), - ), - _CategoryChips( - categoriesAsync: categoriesAsync, - selectedId: _selectedCategoryId, - onCategorySelected: (id) { - setState(() => _selectedCategoryId = id); - }, - ), - Expanded( - child: Builder( - builder: (context) { - final feedState = - feedAsync.valueOrNull ?? - const GlobalForumFeedState(items: []); - final isLoading = - feedAsync.isLoading && feedState.items.isEmpty; - final displayState = isLoading - ? feedState.copyWith(items: _mockSkeletonThreads) - : feedState; - - if (feedAsync.hasError && feedState.items.isEmpty) { - return Center( - child: AppText.body(l10n.errorGenericMessage), - ); - } - - return Skeletonizer( - enabled: isLoading, - child: Column( + Padding( + padding: EdgeInsets.only( + left: design.spacing.md, + right: design.spacing.md, + bottom: design.spacing.sm, + ), + child: Row( children: [ - Container(height: 1, color: design.colors.divider), Expanded( - child: _ThreadList( - state: displayState, - onRefresh: () async { - return ref.refresh( - globalForumFeedProvider( - categoryId: _selectedCategoryId, - searchQuery: _searchQuery, - ).future, - ); - }, - onLoadMore: () { - ref - .read( - globalForumFeedProvider( - categoryId: _selectedCategoryId, - searchQuery: _searchQuery, - ).notifier, - ) - .loadMore(); - }, + child: _SegmentButton( + label: l10n.forumSortRecent, + isSelected: _selectedSortOrder == ForumSort.recent, + onTap: () => setState( + () => _selectedSortOrder = ForumSort.recent, + ), + ), + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: _SegmentButton( + label: l10n.forumSortMostLiked, + isSelected: + _selectedSortOrder == ForumSort.mostLiked, + onTap: () => setState( + () => _selectedSortOrder = ForumSort.mostLiked, + ), + ), + ), + SizedBox(width: design.spacing.sm), + Expanded( + child: _SegmentButton( + label: l10n.forumSortMostViewed, + isSelected: + _selectedSortOrder == ForumSort.mostViewed, + onTap: () => setState( + () => _selectedSortOrder = ForumSort.mostViewed, + ), ), ), ], ), - ); - }, + ), + Container( + height: 36, + margin: EdgeInsets.only(bottom: design.spacing.sm), + child: categoriesAsync.when( + data: (categories) { + return ListView.separated( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + ), + scrollDirection: Axis.horizontal, + itemCount: categories.length + 1, + separatorBuilder: (a, b) => + SizedBox(width: design.spacing.sm), + itemBuilder: (context, index) { + if (index == 0) { + return _CategoryChip( + label: l10n.filterAll, + isSelected: _selectedCategoryId == null, + onTap: () => + setState(() => _selectedCategoryId = null), + ); + } + final cat = categories[index - 1]; + return _CategoryChip( + label: cat.name, + isSelected: _selectedCategoryId == cat.id, + onTap: () => + setState(() => _selectedCategoryId = cat.id), + ); + }, + ); + }, + loading: () => const Center(child: AppLoadingIndicator()), + error: (err, stack) => const SizedBox(), + ), + ), + Expanded( + child: Builder( + builder: (context) { + final feedState = + feedAsync.valueOrNull ?? + const GlobalForumFeedState(items: []); + final isLoading = + feedAsync.isLoading && feedState.items.isEmpty; + final displayState = isLoading + ? feedState.copyWith(items: _mockSkeletonThreads) + : feedState; + + if (feedAsync.hasError && feedState.items.isEmpty) { + return Center( + child: AppText.body(l10n.errorGenericMessage), + ); + } + + return Skeletonizer( + enabled: isLoading, + child: Column( + children: [ + Container(height: 1, color: design.colors.border), + Expanded( + child: _ThreadList( + state: displayState, + onRefresh: () async { + return ref.refresh( + globalForumFeedProvider( + categoryId: _selectedCategoryId, + searchQuery: _searchQuery, + activityFilter: _selectedActivityFilter, + sortOrder: _selectedSortOrder, + ).future, + ); + }, + onLoadMore: () { + ref + .read( + globalForumFeedProvider( + categoryId: _selectedCategoryId, + searchQuery: _searchQuery, + activityFilter: + _selectedActivityFilter, + sortOrder: _selectedSortOrder, + ).notifier, + ) + .loadMore(); + }, + ), + ), + ], + ), + ); + }, + ), + ), + ], ), ), - ], + ), ), - ), + + // FAB Replacement + Positioned( + bottom: design.spacing.lg + MediaQuery.of(context).padding.bottom, + right: design.spacing.lg, + child: AppSemantics.button( + label: l10n.forumCreatePost, + child: AppFocusable( + onTap: handleCreatePost, + borderRadius: BorderRadius.circular(design.radius.full), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: design.colors.primary, + shape: BoxShape.circle, + boxShadow: design.shadows.floating, + ), + child: Center( + child: Icon( + LucideIcons.plus, + color: design.colors.textInverse, + size: 24, + ), + ), + ), + ), + ), + ), + + // Bottom Sheet + AppBottomSheet( + isOpen: _isFilterSheetOpen, + onClose: () => setState(() => _isFilterSheetOpen = false), + child: ForumFilterBottomSheet( + initialActivityFilter: _selectedActivityFilter, + onApply: (activityFilter) { + setState(() { + _selectedActivityFilter = activityFilter; + }); + }, + onClose: () => setState(() => _isFilterSheetOpen = false), + ), + ), + ], ); } } -class _CategoryChips extends StatelessWidget { - final AsyncValue> categoriesAsync; - final int? selectedId; - final ValueChanged onCategorySelected; +class _SegmentButton extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; - const _CategoryChips({ - required this.categoriesAsync, - required this.selectedId, - required this.onCategorySelected, + const _SegmentButton({ + required this.label, + required this.isSelected, + required this.onTap, }); @override Widget build(BuildContext context) { final design = Design.of(context); + final bgColor = isSelected + ? design.colors.primary + : design.colors.surfaceVariant; + final fgColor = isSelected + ? design.colors.textInverse + : design.colors.textPrimary; - final categories = categoriesAsync.valueOrNull ?? []; - final isLoading = categoriesAsync.isLoading && categories.isEmpty; - final displayCategories = isLoading ? _mockSkeletonCategories : categories; - - if (categoriesAsync.hasError || (!isLoading && displayCategories.isEmpty)) { - return const SizedBox.shrink(); - } - - return Skeletonizer( - enabled: isLoading, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: design.spacing.md, - vertical: design.spacing.sm, - ), - height: 48, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: displayCategories.length + 1, - separatorBuilder: (_, _) => SizedBox(width: design.spacing.sm), - itemBuilder: (context, index) { - if (index == 0) { - return _ChipButton( - label: L10n.of(context).filterAll, - isSelected: selectedId == null, - onTap: () => onCategorySelected(null), - ); - } - final category = displayCategories[index - 1]; - return _ChipButton( - label: category.name, - isSelected: selectedId == category.id, - onTap: () => onCategorySelected(category.id), - ); - }, + return AppSemantics.button( + label: label, + onTap: onTap, + child: AppFocusable( + onTap: onTap, + borderRadius: BorderRadius.circular(design.radius.lg), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Container( + height: 38, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(design.radius.lg), + border: isSelected + ? null + : Border.all(color: design.colors.border), + ), + child: Center( + child: AppText.caption( + label, + color: fgColor, + style: TextStyle( + fontSize: 13, + height: 1.25, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), + ), ), ), ); } } -class _ChipButton extends StatelessWidget { +class _CategoryChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback onTap; - const _ChipButton({ + const _CategoryChip({ required this.label, required this.isSelected, required this.onTap, @@ -233,25 +390,34 @@ class _ChipButton extends StatelessWidget { @override Widget build(BuildContext context) { final design = Design.of(context); - return AppSemantics.button( label: label, onTap: onTap, - child: GestureDetector( + child: AppFocusable( onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + borderRadius: BorderRadius.circular(design.radius.full), + child: AnimatedContainer( + duration: MotionPreferences.duration(context, design.motion.fast), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? design.colors.primary - : design.colors.surfaceVariant, + : design.colors.surfaceVariant.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(design.radius.full), + border: isSelected ? null : Border.all(color: design.colors.border), ), - child: AppText.caption( - label, - color: isSelected - ? design.colors.textInverse - : design.colors.textPrimary, + child: Center( + child: AppText.caption( + label.toUpperCase(), + color: isSelected + ? design.colors.textInverse + : design.colors.textPrimary, + style: TextStyle( + fontSize: 11, + letterSpacing: 0.5, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, + ), + ), ), ), ), @@ -344,7 +510,7 @@ class _ThreadListState extends State<_ThreadList> { itemCount: widget.state.items.length + (widget.state.isLoadingMore ? 1 : 0), separatorBuilder: (context, index) => - Container(height: 1, color: design.colors.divider), + Container(height: 1, color: design.colors.border), itemBuilder: (context, index) { if (index >= widget.state.items.length) { return Skeletonizer( @@ -372,33 +538,39 @@ class _ThreadItem extends StatelessWidget { Widget build(BuildContext context) { final design = Design.of(context); - return GestureDetector( + return AppSemantics.button( + label: thread.title, onTap: () => context.push( '/home/discussions/forum/posts/${thread.slug}', extra: thread, ), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: design.spacing.md, - vertical: design.spacing.md, + child: AppFocusable( + onTap: () => context.push( + '/home/discussions/forum/posts/${thread.slug}', + extra: thread, ), - color: design.colors.card, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.cardTitle(thread.title, color: design.colors.textPrimary), - if (thread.summary.trim().isNotEmpty) ...[ - const SizedBox(height: 6), - AppText.cardSubtitle( - thread.summary.trim(), - color: design.colors.textSecondary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.cardTitle(thread.title, color: design.colors.textPrimary), + if (thread.summary.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + AppText.cardSubtitle( + thread.summary.trim(), + color: design.colors.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + SizedBox(height: design.spacing.sm + 4), + _ThreadFooter(thread: thread), ], - const SizedBox(height: 12), - _ThreadFooter(thread: thread), - ], + ), ), ), ); @@ -527,17 +699,6 @@ String _formatDateSafe(String dateStr) { return dateStr; } -final _mockSkeletonCategories = List.generate( - 4, - (i) => ForumCategoryDto( - id: i, - name: 'Category Name', - slug: 'cat-$i', - color: null, - order: i, - ), -); - final _mockSkeletonThreads = List.generate( 5, (index) => ForumThreadDto( diff --git a/packages/discussions/lib/widgets/forum_filter_bottom_sheet.dart b/packages/discussions/lib/widgets/forum_filter_bottom_sheet.dart new file mode 100644 index 000000000..6d70bc56d --- /dev/null +++ b/packages/discussions/lib/widgets/forum_filter_bottom_sheet.dart @@ -0,0 +1,196 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; +import 'package:core/data/data.dart'; + +class ForumFilterBottomSheet extends StatelessWidget { + final ForumActivityFilter? initialActivityFilter; + final ValueChanged onApply; + final VoidCallback onClose; + + const ForumFilterBottomSheet({ + super.key, + required this.initialActivityFilter, + required this.onApply, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + void handleClearAll() { + onApply(null); + onClose(); + } + + return Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.sm, + 0, + design.spacing.sm, + design.spacing.lg, + ), + child: SafeArea( + top: false, + child: Container( + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: BorderRadius.all(Radius.circular(design.radius.xxl)), + boxShadow: design.shadows.floating, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: design.colors.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.title( + l10n.forumFilterByActivity, + color: design.colors.textPrimary, + ), + AppSemantics.button( + label: l10n.forumFilterClearAll, + onTap: handleClearAll, + child: AppFocusable( + onTap: handleClearAll, + child: AppText.labelBold( + l10n.forumFilterClearAll, + color: design.colors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _buildRadioOption( + context, + design, + l10n.forumFilterActivityPosted, + LucideIcons.edit2, + ForumActivityFilter.posted, + ), + _buildRadioOption( + context, + design, + l10n.forumFilterActivityCommented, + LucideIcons.messageSquare, + ForumActivityFilter.commented, + ), + _buildRadioOption( + context, + design, + l10n.forumFilterActivityLiked, + LucideIcons.heart, + ForumActivityFilter.liked, + ), + _buildRadioOption( + context, + design, + l10n.forumFilterActivityBookmarked, + LucideIcons.bookmark, + ForumActivityFilter.bookmarked, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } + + Widget _buildRadioOption( + BuildContext context, + DesignConfig design, + String label, + IconData icon, + ForumActivityFilter filter, + ) { + final isSelected = initialActivityFilter == filter; + + return AppSemantics.button( + label: label, + onTap: () { + onApply(filter); + onClose(); + }, + child: AppFocusable( + onTap: () { + onApply(filter); + onClose(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: _getColorForActivity(design, filter)), + const SizedBox(width: 12), + Expanded( + child: AppText.body(label, color: design.colors.textPrimary), + ), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? design.colors.primary + : design.colors.textSecondary.withValues(alpha: 0.4), + width: 2, + ), + ), + child: isSelected + ? Center( + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: design.colors.primary, + ), + ), + ) + : null, + ), + ], + ), + ), + ), + ); + } + + Color _getColorForActivity(DesignConfig design, ForumActivityFilter filter) { + switch (filter) { + case ForumActivityFilter.posted: + return design.colors.primary; + case ForumActivityFilter.commented: + return design.colors.success; + case ForumActivityFilter.liked: + return design.colors.error; + case ForumActivityFilter.bookmarked: + return design.colors.warning; + } + } +} diff --git a/packages/discussions/lib/widgets/forum_header.dart b/packages/discussions/lib/widgets/forum_header.dart index cb798ef28..0a56017e2 100644 --- a/packages/discussions/lib/widgets/forum_header.dart +++ b/packages/discussions/lib/widgets/forum_header.dart @@ -19,6 +19,7 @@ class ForumHeader extends StatelessWidget { @override Widget build(BuildContext context) { final design = Design.of(context); + final l10n = L10n.of(context); return Container( decoration: BoxDecoration( @@ -40,10 +41,12 @@ class ForumHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Back arrow inline with title - GestureDetector( + AppSemantics.button( + label: l10n.forumBackSemantic, onTap: () => context.pop(), - child: Padding( - padding: const EdgeInsets.only(top: 2), // Optical alignment + child: AppFocusable( + padding: const EdgeInsets.all(13), + onTap: () => context.pop(), child: Icon( LucideIcons.arrowLeft, color: design.colors.textPrimary,