diff --git a/CHANGELOG.md b/CHANGELOG.md index 1918b23..a987aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## 1.3.0 + +### Added +- Added support to filter notifications by category. + ## 1.2.1 ### Added diff --git a/README.md b/README.md index 6745f4f..ee9f72b 100644 --- a/README.md +++ b/README.md @@ -102,74 +102,225 @@ Given below are the arguments of Siren Inbox Widget. | ----------------- | -------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | darkMode | Toggle to enable dark mode when custom theme is not passed | bool | false | | hideTab | Toggle to enable all and unread tabs | bool | false | -| itemsPerFetch | Number of notifications fetch per api request (have a max cap of 50) | int | 20 | +| itemsPerFetch | Number of notifications fetch per api request (max 50) | int | 20 | | listEmptyWidget | Custom widget for empty notification list | Widget | null | -| customCard | Custom widget to display the notification cards | Widget | null | +| customCard | Custom builder for notification cards | Widget Function(NotificationType) | null | | customLoader | Custom widget to display the initial loading state | Widget | null | | customErrorWidget | Custom error widget | Widget | null | | cardParams | Properties of notification card | CardParams | CardParams(hideAvatar: false, disableAutoMarkAsRead: false, hideDelete: false, deleteIcon: Icon(Icons.close), onAvatarClick: Function(NotificationType), hideMediaThumbnail: false, onMediaThumbnailClick: Function(NotificationType)) | -| headerParams | Properties of notification window header | HeaderParams | HeaderParams(hideHeader: false, hideClearAll: false,title: 'Notifications', customHeader: null showBackButton:false, backButton: null, onBackPress: ()=> null ) | -| tabParams | Properties of tab bar | TabParams | TabParams(tabs: [TabItem(key: 'ALL', title: 'All'), TabItem(key: 'UNREAD', title: 'Unread')], activeTabIndex:0,) | +| headerParams | Properties of notification window header | HeaderParams | HeaderParams(hideHeader: false, hideClearAll: false, title: 'Notifications', customHeader: null, showBackButton: false, backButton: null, onBackPress: null) | +| tabParams | Properties of tab bar | TabParams | TabParams(tabs: [TabItem(key: 'ALL', title: 'All'), TabItem(key: 'UNREAD', title: 'Unread')], activeTabIndex: 0) | | onCardClick | Custom click handler for notification cards | Function(NotificationType) | null | | onError | Callback for handling errors | Function(SirenErrorType) | null | | theme | Theme properties for custom color theme | CustomThemeColors | null | | customStyles | Style properties for custom styling | CustomStyles | null | +| customTabIndicator| Custom decoration for tab indicator | BoxDecoration | null | +| filterParams | Properties for configuring the filter dropdown | FilterParams | FilterParams(categoryFilterParams: CategoryFilterParams(showFilters: true, filterIconWidget: null, hideBadge: false, categoryFilterStyles: CategoryFilterStyles(dropdownTextStyle: null))) | #### Theme customization -Here are some of the available theme options: +Here are the available theme options: ```dart theme: CustomThemeColors( - primary: Colors.blue, - highlightedCardColor: Colors.blueAccent, - textColor: Colors.green, - cardColors: CardColors( - titleColor: Colors.grey, - subtitleColor: Colors.grey, - ), - inboxHeaderColors: InboxHeaderColors( - titleColor: Colors.redAccent, - headerActionColor: Colors.purpleAccent, - borderColor: Colors.cyanAccent - ), - ), + backgroundColor: Colors.blue, + primary: Colors.blueAccent, + highlightedCardColor: Colors.blue.shade100, + borderColor: Colors.grey.shade300, + deleteIcon: Colors.red, + clearAllIcon: Colors.grey, + textColor: Colors.black87, + dateColor: Colors.grey, + timerIcon: Colors.blue, + notificationIconColor: Colors.blue, + loaderColor: Colors.blue, + inboxHeaderColors: InboxHeaderColors( + background: Colors.white, + titleColor: Colors.black87, + headerActionColor: Colors.blue, + borderColor: Colors.grey.shade300 + ), + badgeColors: BadgeColors( + backgroundColor: Colors.red, + color: Colors.white + ), + cardColors: CardColors( + borderColor: Colors.grey.shade300, + background: Colors.white, + titleColor: Colors.black87, + subtitleColor: Colors.grey, + descriptionColor: Colors.black54 + ), + tabColors: TabColors( + containerBackgroundColor: Colors.white, + activeTabBackgroundColor: Colors.blue.shade50, + activeTabTextColor: Colors.blue, + inactiveTabTextColor: Colors.grey, + indicatorColor: Colors.blue + ), + filterColors: FilterColors( + categoryFilterColors: CategoryFilterColors( + filterIconBorderColor: Colors.grey.shade300, + filterBadgeColor: Colors.blue, + filterDropdownBackgroundColor: Colors.white, + filterCheckboxCheckedColor: Colors.blue, + filterCheckboxUncheckedColor: Colors.grey.shade300, + filterActionTextColor: Colors.black87, + filterIconColor: Colors.blue, + checkIconColor: Colors.white + ) + ) +) ``` -#### Style options +#### Style customization -Here are some of the custom style options for the notification inbox: +Here are the custom style options for the notification inbox: ```dart customStyles: CustomStyles( container: ContainerStyle( - padding: EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.yellow)), + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8) + ), + margin: EdgeInsets.all(8) + ), cardStyle: CardStyle( cardContainer: ContainerStyle( - padding: EdgeInsets.all(20), + padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.yellow, - border: Border.all(color: Colors.red))), - cardTitle: TextStyle(fontSize: 22, fontWeight: FontWeight.w800), - cardSubtitle: - TextStyle(fontSize: 20, fontWeight: FontWeight.w700), - cardDescription: - TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - dateStyle: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - avatarSize: 30, + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8) + ) + ), + cardTitle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87 + ), + cardSubtitle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey + ), + cardDescription: TextStyle( + fontSize: 14, + color: Colors.black54 ), + dateStyle: TextStyle( + fontSize: 12, + color: Colors.grey + ), + avatarSize: 40 + ), appBarStyle: InboxHeaderStyle( - headerTextStyle: - TextStyle(fontSize: 20, fontWeight: FontWeight.w900), - titlePadding: EdgeInsets.symmetric(horizontal: 30), - borderWidth: 5), - timerIconStyle: TimerIconStyle(size: 30), - deleteIconStyle: DeleteIconStyle(size: 30), - clearAllIconStyle: ClearAllIconStyle(size: 30), -), + headerTextStyle: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87 + ), + titlePadding: EdgeInsets.symmetric(horizontal: 16), + borderWidth: 1 + ), + notificationIconStyle: NotificationIconStyle( + size: 24 + ), + badgeStyle: BadgeStyle( + fontSize: 12, + size: 20, + top: 0, + right: 2 + ), + timerIconStyle: TimerIconStyle( + size: 20 + ), + deleteIconStyle: DeleteIconStyle( + size: 20 + ), + clearAllIconStyle: ClearAllIconStyle( + size: 20 + ), + tabStyles: TabStyles( + containerStyle: ContainerStyle( + padding: EdgeInsets.symmetric(horizontal: 16), + margin: EdgeInsets.only(bottom: 8) + ), + activeTabTextStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.blue + ), + inActiveTabTextStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey + ), + indicatorSize: 2, + indicatorPadding: EdgeInsets.symmetric(horizontal: 16) + ), + hideTabMargin: HideTabMargin( + upper: false, + lower: false + ), + filterStyles: FilterStyles( + categoryFilterStyles: CategoryFilterStyles( + dropdownTextStyle: TextStyle( + fontSize: 14, + color: Colors.black87 + ) + ) + ) +) +``` + +### 2.4. Filter Configuration + +The filter functionality allows users to filter notifications by categories. Here's how to configure it: + +```dart +SirenInbox( + filterParams: FilterParams( + categoryFilterParams: CategoryFilterParams( + showFilters: true, + filterIconWidget: Icon(Icons.filter_list), // Optional custom filter icon + hideBadge: false, // Optional hide badge showing number of selected filters + categoryFilterStyles: CategoryFilterStyles( // Optional custom styles for category filter + dropdownTextStyle: TextStyle( + fontSize: 14, + color: Colors.black87 + ) + ) + ) + ) +) +``` + +#### Filter Features: +- Custom filter icon support +- Badge showing number of selected filters (99+ for more than 99 selections) +- Dropdown with checkbox selection +- Customizable colors and styles for all filter components + +#### Category Filter Styles +You can customize the appearance of the category filter dropdown using `CategoryFilterStyles`: + +```dart +CategoryFilterStyles( + dropdownTextStyle: TextStyle( + fontSize: 14, + color: Colors.black87, + fontWeight: FontWeight.w500 + ) +) ``` +| Style Property | Description | Type | Default Value | +|------------------|------------------------------------------------|-----------|----------------------------------| +| dropdownTextStyle| Style for the category text in dropdown | TextStyle | fontSize: 14, color: Colors.black87 | + ## 3. Siren Class The `Siren Class` provides utility functions for modifying notifications. diff --git a/lib/src/api/fetch_all_notification.dart b/lib/src/api/fetch_all_notification.dart index b14f496..70b79fa 100644 --- a/lib/src/api/fetch_all_notification.dart +++ b/lib/src/api/fetch_all_notification.dart @@ -29,6 +29,7 @@ class FetchAllNotifications { bool? isRead, String? start, String? end, + List? categories, }) async { final apiPath = '${Generics.V2}${Generics.BASE_URL}${SirenDataProvider.instance.recipientId}/notifications'; @@ -53,8 +54,22 @@ class FetchAllNotifications { queryParams['isRead'] = isRead.toString(); } - final queryString = - queryParams.entries.map((e) => '${e.key}=${e.value}').join('&'); + // Build the query string + final queryParts = []; + + // Add all non-category parameters + queryParams.forEach((key, value) { + queryParts.add('$key=${Uri.encodeComponent(value)}'); + }); + + // Add each category as a separate parameter + if (categories != null && categories.isNotEmpty) { + for (final category in categories) { + queryParts.add('category=${Uri.encodeComponent(category)}'); + } + } + + final queryString = queryParts.join('&'); if (SirenDataProvider.instance.tokenVerificationStatus != Status.SUCCESS) { apiError = SirenDataProvider.instance.getVerificationErrorType(); diff --git a/lib/src/api/fetch_categories.dart b/lib/src/api/fetch_categories.dart new file mode 100644 index 0000000..d636211 --- /dev/null +++ b/lib/src/api/fetch_categories.dart @@ -0,0 +1,64 @@ +import 'package:sirenapp_flutter_inbox/src/constants/generics.dart'; +import 'package:sirenapp_flutter_inbox/src/data/siren_data_provider.dart'; +import 'package:sirenapp_flutter_inbox/src/errors/errors.dart'; +import 'package:sirenapp_flutter_inbox/src/models/api_response.dart'; +import 'package:sirenapp_flutter_inbox/src/services/api_client.dart'; +import 'package:sirenapp_flutter_inbox/src/services/api_provider.dart'; + +class FetchCategories { + FetchCategories._internal(); + static final FetchCategories instance = FetchCategories._internal(); + final ApiClient api = ApiClient(apiProvider()); + + List convertJsonToCategoryList(List dataList) { + return dataList.map((dynamic json) { + if (json is String) { + return json; + } + throw const FormatException('Invalid JSON format'); + }).toList(); + } + + Future fetchCategories() async { + final apiPath = + '${Generics.V2}${Generics.BASE_URL}${SirenDataProvider.instance.recipientId}/categories'; + final result = ApiResponse()..isLoading = true; + var apiError = Errors.notificationFetchFailedError; + + if (SirenDataProvider.instance.tokenVerificationStatus != Status.SUCCESS) { + apiError = SirenDataProvider.instance.getVerificationErrorType(); + result + ..isLoading = false + ..isError = true + ..data = null + ..rawResponse = Errors.rawResponseError + ..error = apiError; + return result; + } + + final apiResponse = await api.get( + path: apiPath, + ); + + if (apiResponse.statusCode != 0 && apiResponse.data != null) { + final dataList = + ApiResponse.fromJson(apiResponse.data).data as List?; + result + ..isLoading = false + ..isSuccess = apiResponse.statusCode == 200 + ..isError = apiResponse.statusCode != 200 + ..data = convertJsonToCategoryList(dataList ?? []) + ..rawResponse = apiResponse + ..error = apiError; + } else { + result + ..isLoading = false + ..isSuccess = false + ..isError = true + ..rawResponse = apiResponse + ..error = Errors.defaultError; + } + + return result; + } +} diff --git a/lib/src/models/ui_models.dart b/lib/src/models/ui_models.dart index 5631392..4853183 100644 --- a/lib/src/models/ui_models.dart +++ b/lib/src/models/ui_models.dart @@ -100,6 +100,7 @@ class CustomStyles { this.clearAllIconStyle, this.tabStyles, this.hideTabMargin, + this.filterStyles, }); /// The decoration for the Siren inbox list. @@ -126,9 +127,14 @@ class CustomStyles { /// Style of clear all icon in inbox default header final ClearAllIconStyle? clearAllIconStyle; + /// Styles for customizing the appearance of tabs in the Siren inbox final TabStyles? tabStyles; + /// Controls whether to hide margins above and below the tabs final HideTabMargin? hideTabMargin; + + /// Styles for customizing the appearance of filters + final FilterStyles? filterStyles; } class HideTabMargin { @@ -187,6 +193,7 @@ class CustomThemeColors { this.badgeColors, this.cardColors, this.tabColors, + this.filterColors, }); /// The background color for Siren inbox. @@ -233,6 +240,56 @@ class CustomThemeColors { /// The colors for tab bar final TabColors? tabColors; + + /// The colors for category dropdown + final FilterColors? filterColors; +} + +/// Custom theme colors to configure the appearance of filters +class FilterColors { + const FilterColors({ + this.categoryFilterColors, + }); + + /// The colors for category dropdown + final CategoryFilterColors? categoryFilterColors; +} + +class CategoryFilterColors { + CategoryFilterColors({ + this.filterIconBorderColor, + this.filterBadgeColor, + this.filterDropdownBackgroundColor, + this.filterCheckboxCheckedColor, + this.filterCheckboxUncheckedColor, + this.filterActionTextColor, + this.filterIconColor, + this.checkIconColor, + }); + + /// The border color for the filter icon button + final Color? filterIconBorderColor; + + /// The badge color for the filter icon + final Color? filterBadgeColor; + + /// The background color for the filter dropdown + final Color? filterDropdownBackgroundColor; + + /// The checked color for the filter checkbox + final Color? filterCheckboxCheckedColor; + + /// The unchecked color for the filter checkbox border + final Color? filterCheckboxUncheckedColor; + + /// The menu action text color + final Color? filterActionTextColor; + + /// The text color for the filter icon + final Color? filterIconColor; + + /// The color for the check icon in the filter dropdown + final Color? checkIconColor; } /// Custom theme colors to configure the appearance inbox list item. @@ -468,3 +525,54 @@ class TabColors { /// The color of the tab indicator. final Color? indicatorColor; } + +/// Styles for customizing the appearance of filters +class FilterStyles { + const FilterStyles({ + this.categoryFilterStyles, + }); + final CategoryFilterStyles? categoryFilterStyles; +} + +/// Properties for configuring the appearance of the category dropdown. +class CategoryFilterStyles { + /// Constructs a [CategoryFilterStyles] with optional parameters. + const CategoryFilterStyles({ + this.dropdownTextStyle, + }); + + /// The text style for dropdown items. + final TextStyle? dropdownTextStyle; +} + +class FilterParams { + const FilterParams({ + this.categoryFilterParams, + }); + + final CategoryFilterParams? categoryFilterParams; +} + +/// Properties for configuring the appearance and behavior of the category dropdown. +class CategoryFilterParams { + /// Constructs a [CategoryFilterParams] with optional parameters. + const CategoryFilterParams({ + this.showFilters = true, + this.filterIconWidget, + this.style, + this.hideBadge = false, + }); + + /// Flag to show the categories dropdown in the app bar. + final bool showFilters; + + /// Custom widget to display the filter UI. + /// If not provided, a default filter UI will be used. + final Widget? filterIconWidget; + + /// Style properties for the category dropdown. + final CategoryFilterStyles? style; + + /// Flag to hide the badge showing number of selected filters. + final bool hideBadge; +} diff --git a/lib/src/theme/app_colors.dart b/lib/src/theme/app_colors.dart index 02e95f3..66661b0 100644 --- a/lib/src/theme/app_colors.dart +++ b/lib/src/theme/app_colors.dart @@ -44,6 +44,14 @@ class AppColors { required this.tabBarInActiveColor, required this.textColor, required this.timerIcon, + required this.filterIconBorderColor, + required this.filterBadgeColor, + required this.filterDropdownBackgroundColor, + required this.filterCheckboxCheckedColor, + required this.filterCheckboxUncheckedColor, + required this.filterActionTextColor, + required this.filterIconColor, + required this.checkIconColor, }); factory AppColors.lightColorTheme() => lightColors; @@ -89,4 +97,12 @@ class AppColors { Color tabBarInActiveColor; Color textColor; Color timerIcon; + Color filterIconBorderColor; + Color filterBadgeColor; + Color filterDropdownBackgroundColor; + Color filterCheckboxCheckedColor; + Color filterCheckboxUncheckedColor; + Color filterActionTextColor; + Color filterIconColor; + Color checkIconColor; } diff --git a/lib/src/theme/colors.dart b/lib/src/theme/colors.dart index 39245a9..648d3a3 100644 --- a/lib/src/theme/colors.dart +++ b/lib/src/theme/colors.dart @@ -34,4 +34,22 @@ class SirenAppColors { static const Color avatarIconLight = Color(0xFF98A2B3); static const Color avatarPlaceholderBgDark = Color(0xFF4C4C4C); static const Color avatarIconDark = Color(0xFF999999); + + // Filter (category) dropdown and badge colors + static const Color filterIconBorderLight = Color(0xFFE0E0E0); + static const Color filterBadgeLight = Color(0xFFD32F2F); + static const Color filterDropdownBackgroundLight = Color(0xFFFFFFFF); + static const Color filterCheckboxCheckedLight = Color(0xFFFF7043); + static const Color filterCheckboxUncheckedLight = Color(0xFFBDBDBD); + static const Color filterTextColorLight = Color(0xFF344054); + static const Color checkIconColorLight = Colors.white; + + static const Color filterIconBorderDark = Color(0xFF444444); + static const Color filterBadgeDark = Color(0xFFD32F2F); + static const Color filterDropdownBackgroundDark = Color(0xFF2F2F2F); + static const Color filterCheckboxCheckedDark = Color(0xFFFF7043); + static const Color filterCheckboxUncheckedDark = Color(0xFF888888); + static const Color menuActionTextColorLight = Color(0xFF101928); + static const Color filterTextColorDark = Colors.white; + static const Color checkIconColorDark = Colors.white; } diff --git a/lib/src/theme/dark_colors.dart b/lib/src/theme/dark_colors.dart index 387fd93..a43a892 100644 --- a/lib/src/theme/dark_colors.dart +++ b/lib/src/theme/dark_colors.dart @@ -43,4 +43,12 @@ final darkColors = AppColors( tabBarInActiveColor: SirenAppColors.grey300, textColor: SirenAppColors.grey700Complementary, timerIcon: SirenAppColors.grey400, + filterIconBorderColor: SirenAppColors.filterIconBorderDark, + filterBadgeColor: SirenAppColors.filterBadgeDark, + filterDropdownBackgroundColor: SirenAppColors.filterDropdownBackgroundDark, + filterCheckboxCheckedColor: SirenAppColors.filterCheckboxCheckedDark, + filterCheckboxUncheckedColor: SirenAppColors.filterCheckboxUncheckedDark, + filterActionTextColor: SirenAppColors.avatarPlaceholderBgLight, + filterIconColor: SirenAppColors.grey500Complementary, + checkIconColor: SirenAppColors.checkIconColorDark, ); diff --git a/lib/src/theme/light_colors.dart b/lib/src/theme/light_colors.dart index 17eb429..12d7cd9 100644 --- a/lib/src/theme/light_colors.dart +++ b/lib/src/theme/light_colors.dart @@ -43,4 +43,12 @@ final lightColors = AppColors( tabBarInActiveColor: SirenAppColors.grey700, textColor: SirenAppColors.grey700, timerIcon: SirenAppColors.grey500, + filterIconBorderColor: SirenAppColors.filterIconBorderLight, + filterBadgeColor: SirenAppColors.filterBadgeLight, + filterDropdownBackgroundColor: SirenAppColors.filterDropdownBackgroundLight, + filterCheckboxCheckedColor: SirenAppColors.filterCheckboxCheckedLight, + filterCheckboxUncheckedColor: SirenAppColors.filterCheckboxUncheckedLight, + filterActionTextColor: SirenAppColors.menuActionTextColorLight, + filterIconColor: SirenAppColors.grey500, + checkIconColor: SirenAppColors.checkIconColorLight, ); diff --git a/lib/src/widgets/app_bar.dart b/lib/src/widgets/app_bar.dart index da5df3a..79b76ae 100644 --- a/lib/src/widgets/app_bar.dart +++ b/lib/src/widgets/app_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:sirenapp_flutter_inbox/src/constants/strings.dart'; import 'package:sirenapp_flutter_inbox/src/models/ui_models.dart'; +import 'package:sirenapp_flutter_inbox/src/theme/app_colors.dart'; import 'package:sirenapp_flutter_inbox/src/theme/app_theme.dart'; class SirenAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -11,14 +12,25 @@ class SirenAppBar extends StatelessWidget implements PreferredSizeWidget { this.styles, this.colors, this.isDarkMode, + this.categories = const [], + this.selectedValues = const [], + this.onCategorySelected, + this.filterIconWidget, + this.hideBadge = false, super.key, }); + final VoidCallback? onClearAllPressed; final bool isNonEmptyNotifications; final HeaderParams? headerParams; final CustomStyles? styles; final CustomThemeColors? colors; final bool? isDarkMode; + final List categories; + final List selectedValues; + final void Function(String)? onCategorySelected; + final Widget? filterIconWidget; + final bool hideBadge; @override Size get preferredSize { @@ -48,13 +60,13 @@ class SirenAppBar extends StatelessWidget implements PreferredSizeWidget { : null, ), height: preferredSize.height, - child: headerParams?.customHeader ?? - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(right: 16, left: 20), - child: Row( + child: Container( + margin: const EdgeInsets.only(right: 16, left: 20), + child: headerParams?.customHeader ?? + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( children: [ if (headerParams?.showBackButton ?? false) Semantics( @@ -93,49 +105,352 @@ class SirenAppBar extends StatelessWidget implements PreferredSizeWidget { ), ], ), - ), - if (!(headerParams?.hideClearAll ?? false)) - Semantics( - label: 'siren-header-clear-all', - hint: 'Tap to clear all notifications', - child: GestureDetector( - key: const Key('siren-header-clear-all'), - onTap: () { - if (isNonEmptyNotifications && - onClearAllPressed != null) { - onClearAllPressed!(); - } - }, - child: Opacity( - opacity: isNonEmptyNotifications ? 1 : 0.4, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.clear_all, - size: styles?.clearAllIconStyle?.size ?? 24, - color: colors?.clearAllIcon ?? - defaultColors.appBarActionText, + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (categories.isNotEmpty) + Semantics( + label: 'siren-filter', + hint: 'Tap to filter notifications by categories', + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + key: const Key('siren-filter'), + width: 44, + height: 44, + child: _CategoryFilter( + categories: categories, + selectedValues: selectedValues, + onSelectionChanged: onCategorySelected, + colors: colors, + defaultColors: defaultColors, + styles: styles, + filterIconWidget: filterIconWidget, + hideBadge: hideBadge, ), ), - Text( - Strings.clear_all, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colors - ?.inboxHeaderColors?.headerActionColor ?? - defaultColors.appBarActionText, + ), + ), + if (!(headerParams?.hideClearAll ?? false)) + Semantics( + label: 'siren-header-clear-all', + hint: 'Tap to clear all notifications', + child: GestureDetector( + key: const Key('siren-header-clear-all'), + onTap: () { + if (isNonEmptyNotifications && + onClearAllPressed != null) { + onClearAllPressed!(); + } + }, + child: Opacity( + opacity: isNonEmptyNotifications ? 1 : 0.4, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + Icons.clear_all, + size: styles?.clearAllIconStyle?.size ?? 24, + color: colors?.clearAllIcon ?? + defaultColors.appBarActionText, + ), + ), + Text( + Strings.clear_all, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors?.inboxHeaderColors + ?.headerActionColor ?? + defaultColors.appBarActionText, + ), + ), + ], ), ), - ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _CategoryFilter extends StatefulWidget { + const _CategoryFilter({ + required this.categories, + required this.selectedValues, + required this.onSelectionChanged, + required this.colors, + required this.defaultColors, + required this.styles, + this.filterIconWidget, + this.hideBadge = false, + }); + + final List categories; + final List selectedValues; + final void Function(String)? onSelectionChanged; + final CustomThemeColors? colors; + final AppColors defaultColors; + final Widget? filterIconWidget; + final CustomStyles? styles; + final bool hideBadge; + + @override + State<_CategoryFilter> createState() => _CategoryFilterState(); +} + +class _CategoryFilterState extends State<_CategoryFilter> { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + bool _isDropdownOpen = false; + + void _toggleDropdown() { + if (_isDropdownOpen) { + _removeOverlay(); + } else { + _showOverlay(); + } + } + + void _showOverlay() { + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + _isDropdownOpen = true; + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + _isDropdownOpen = false; + } + + void _handleItemSelection(String category) { + if (widget.onSelectionChanged != null) { + widget.onSelectionChanged?.call(category); + // Update the overlay to reflect the new selection + _overlayEntry?.markNeedsBuild(); + } + } + + OverlayEntry _createOverlayEntry() { + final renderBox = context.findRenderObject()! as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + return OverlayEntry( + builder: (context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _removeOverlay, + child: Stack( + children: [ + Positioned( + left: offset.dx, + top: offset.dy + size.height + 8, + width: 220, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(-220 + size.width, size.height), + child: Material( + color: widget.colors?.filterColors?.categoryFilterColors + ?.filterDropdownBackgroundColor ?? + widget.defaultColors.filterDropdownBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 4, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: NotificationListener( + onNotification: (_) => true, + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.categories.length, + itemBuilder: (context, index) { + final category = widget.categories[index]; + final isSelected = + widget.selectedValues.contains(category); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _handleItemSelection(category), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.filterCheckboxCheckedColor ?? + widget.defaultColors + .filterCheckboxCheckedColor + : Colors.transparent, + border: Border.all( + color: isSelected + ? widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.filterCheckboxCheckedColor ?? + widget.defaultColors + .filterCheckboxCheckedColor + : widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.filterCheckboxUncheckedColor ?? + widget.defaultColors + .filterCheckboxUncheckedColor, + width: 1.5, + ), + borderRadius: + BorderRadius.circular(6), + ), + child: isSelected + ? Center( + child: Icon( + Icons.check, + color: widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.checkIconColor ?? + widget.defaultColors + .checkIconColor, + size: 16, + ), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + category.isEmpty + ? 'Others' + : category, + overflow: TextOverflow.ellipsis, + style: widget + .styles + ?.filterStyles + ?.categoryFilterStyles + ?.dropdownTextStyle + ?.copyWith( + color: widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.filterActionTextColor ?? + widget.defaultColors + .filterActionTextColor, + ) ?? + TextStyle( + fontSize: 14, + color: widget + .colors + ?.filterColors + ?.categoryFilterColors + ?.filterActionTextColor ?? + widget.defaultColors + .filterActionTextColor, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), ), ), ), ), + ), ], ), + ); + }, + ); + } + + @override + void dispose() { + _removeOverlay(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: _toggleDropdown, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + border: Border.all( + color: widget.colors?.filterColors?.categoryFilterColors + ?.filterIconBorderColor ?? + widget.defaultColors.filterIconBorderColor, + ), + borderRadius: BorderRadius.circular(8), + ), + child: widget.filterIconWidget ?? + Icon( + Icons.filter_alt_outlined, + color: widget.colors?.filterColors?.categoryFilterColors + ?.filterIconColor ?? + widget.defaultColors.filterIconColor, + size: 24, + ), + ), + ), + if (widget.selectedValues.isNotEmpty && !widget.hideBadge) + Positioned( + right: -11, + top: -4, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: widget.colors?.filterColors?.categoryFilterColors + ?.filterBadgeColor ?? + widget.defaultColors.filterBadgeColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${widget.selectedValues.length > 99 ? '99+' : widget.selectedValues.length}', + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ), + ), + ], + ), ); } } diff --git a/lib/src/widgets/siren_inbox.dart b/lib/src/widgets/siren_inbox.dart index 78139f1..44f767b 100644 --- a/lib/src/widgets/siren_inbox.dart +++ b/lib/src/widgets/siren_inbox.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:sirenapp_flutter_inbox/sirenapp_flutter_inbox.dart'; import 'package:sirenapp_flutter_inbox/src/api/delete_notification_by_id.dart'; import 'package:sirenapp_flutter_inbox/src/api/fetch_all_notification.dart'; +import 'package:sirenapp_flutter_inbox/src/api/fetch_categories.dart'; import 'package:sirenapp_flutter_inbox/src/api/mark_all_notifications_as_viewed.dart'; import 'package:sirenapp_flutter_inbox/src/api/notifications_bulk_update.dart'; import 'package:sirenapp_flutter_inbox/src/api/read_notification_by_id.dart'; @@ -35,6 +36,7 @@ class SirenInbox extends StatefulWidget { this.theme, this.customStyles, this.customTabIndicator, + this.filterParams, }); /// Flag for enabling dark mode. @@ -81,6 +83,9 @@ class SirenInbox extends StatefulWidget { final BoxDecoration? customTabIndicator; + /// Properties for configuring the category dropdown. + final FilterParams? filterParams; + @override State createState() => _SirenInboxState(); } @@ -98,8 +103,11 @@ class _SirenInboxState extends State bool _enableClearAll = true; List notifications = []; + List allCategories = []; + List selectedCategories = []; late final DeleteNotificationById _deleteNotificationById; late final ReadNotificationById _readNotificationById; + late final FetchCategories _fetchCategories; late Timer? _periodicUpdateRef; late StreamSubscription _subscription; late ScrollController _inboxScrollController; @@ -135,7 +143,10 @@ class _SirenInboxState extends State Future _initialize() async { if (SirenDataProvider.instance.tokenVerificationStatus == Status.SUCCESS) { - await initialFetchNotification(); + await Future.wait([ + initialFetchNotification(), + _fetchAllCategories(), + ]); } else if (SirenDataProvider.instance.tokenVerificationStatus == Status.FAILED || !SirenDataProvider.instance.isProviderInitialized) { @@ -146,6 +157,19 @@ class _SirenInboxState extends State } } + Future _fetchAllCategories() async { + final response = await _fetchCategories.fetchCategories(); + if (response.isSuccess && response.data != null) { + safeSetState(() { + allCategories = (response.data as List) + ..removeWhere((e) => e.isEmpty) + ..addAll(['']); + }); + } else { + widget.onError?.call(response.error ?? SirenErrorType()); + } + } + void _initializeVariables() { pageSize = max(min(widget.itemsPerFetch ?? Generics.PAGE_SIZE, 50), 0); _periodicUpdateRef = Timer(const Duration(days: 1), () {}); @@ -163,6 +187,7 @@ class _SirenInboxState extends State _deleteNotificationById = DeleteNotificationById.instance; _readNotificationById = ReadNotificationById.instance; + _fetchCategories = FetchCategories.instance; _tabController = TabController( length: InboxTabs.values.length, vsync: this, @@ -309,6 +334,7 @@ class _SirenInboxState extends State notifications[0].createdAt, ) : null, + categories: selectedCategories, ); if (fetchedNotifications.isSuccess) { final newNotifications = @@ -343,6 +369,7 @@ class _SirenInboxState extends State end: DateTime.now().toUtc().toIso8601String(), size: pageSize, isRead: getIsRead(), + categories: selectedCategories, ); if (fetchedNotifications.isSuccess) { @@ -447,6 +474,7 @@ class _SirenInboxState extends State ), size: pageSize, isRead: getIsRead(), + categories: selectedCategories, ); if (fetchedNotifications.isSuccess) { final newNotifications = @@ -500,6 +528,19 @@ class _SirenInboxState extends State } } + void _updateSelectedCategories(String category) { + setState(() { + if (selectedCategories.contains(category)) { + selectedCategories.remove(category); + } else { + selectedCategories.add(category); + } + }); + // Reset and fetch notifications with new category selection + _reset(cancelFetch: true); + initialFetchNotification(); + } + Widget _buildInboxBody( ScrollController controller, List data, @@ -547,6 +588,22 @@ class _SirenInboxState extends State isNonEmptyNotifications: _enableClearAll, headerParams: widget.headerParams, styles: widget.customStyles, + categories: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? allCategories + : const [], + selectedValues: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? selectedCategories + : const [], + onCategorySelected: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? _updateSelectedCategories + : null, + filterIconWidget: + widget.filterParams?.categoryFilterParams?.filterIconWidget, + hideBadge: + widget.filterParams?.categoryFilterParams?.hideBadge ?? false, ), body: Column( children: [ @@ -652,6 +709,22 @@ class _SirenInboxState extends State isNonEmptyNotifications: _enableClearAll, headerParams: widget.headerParams, styles: widget.customStyles, + categories: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? allCategories + : const [], + selectedValues: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? selectedCategories + : const [], + onCategorySelected: + widget.filterParams?.categoryFilterParams?.showFilters ?? false + ? _updateSelectedCategories + : null, + filterIconWidget: + widget.filterParams?.categoryFilterParams?.filterIconWidget, + hideBadge: + widget.filterParams?.categoryFilterParams?.hideBadge ?? false, ), body: _buildInboxBody(_inboxScrollController, notifications, false), ); diff --git a/test/models/ui_models_test.dart b/test/models/ui_models_test.dart index 7ab5d81..46dd342 100644 --- a/test/models/ui_models_test.dart +++ b/test/models/ui_models_test.dart @@ -189,4 +189,52 @@ void main() { expect(tabColors.inactiveTabTextColor, Colors.black); }); }); + + group('FilterParams', () { + test('constructor should initialize properties with default values', () { + const filterParams = CategoryFilterParams(); + + expect(filterParams.showFilters, true); + expect(filterParams.filterIconWidget, null); + expect(filterParams.style, null); + expect(filterParams.hideBadge, false); + }); + + test('constructor should initialize properties with provided values', () { + const customIcon = Icon(Icons.tune); + const filterParams = CategoryFilterParams( + showFilters: false, + filterIconWidget: customIcon, + hideBadge: true, + ); + + expect(filterParams.showFilters, false); + expect(filterParams.filterIconWidget, customIcon); + expect(filterParams.hideBadge, true); + }); + }); + + group('FilterColors', () { + test('constructor should initialize properties with provided values', () { + final filterColors = CategoryFilterColors( + filterIconBorderColor: Colors.red, + filterBadgeColor: Colors.blue, + filterDropdownBackgroundColor: Colors.green, + filterCheckboxCheckedColor: Colors.yellow, + filterCheckboxUncheckedColor: Colors.purple, + filterActionTextColor: Colors.orange, + filterIconColor: Colors.pink, + checkIconColor: Colors.brown, + ); + + expect(filterColors.filterIconBorderColor, Colors.red); + expect(filterColors.filterBadgeColor, Colors.blue); + expect(filterColors.filterDropdownBackgroundColor, Colors.green); + expect(filterColors.filterCheckboxCheckedColor, Colors.yellow); + expect(filterColors.filterCheckboxUncheckedColor, Colors.purple); + expect(filterColors.filterActionTextColor, Colors.orange); + expect(filterColors.filterIconColor, Colors.pink); + expect(filterColors.checkIconColor, Colors.brown); + }); + }); } diff --git a/test/widgets/app_bar_test.dart b/test/widgets/app_bar_test.dart index 5e2af73..0cd9a92 100644 --- a/test/widgets/app_bar_test.dart +++ b/test/widgets/app_bar_test.dart @@ -132,4 +132,145 @@ void main() { await tester.tap(find.text('Clear All')); expect(clearAllPressed, true); }); + + // New tests for filter functionality + testWidgets('SirenAppBar displays filter icon when categories are provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + categories: const ['Category 1', 'Category 2'], + ), + ), + ), + ); + + expect(find.byIcon(Icons.filter_alt_outlined), findsOneWidget); + }); + + testWidgets( + 'SirenAppBar does not display filter icon when no categories are provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + ), + ), + ), + ); + + expect(find.byIcon(Icons.filter_alt_outlined), findsNothing); + }); + + testWidgets('SirenAppBar displays filter badge with selected count', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + categories: const ['Category 1', 'Category 2'], + selectedValues: const ['Category 1'], + ), + ), + ), + ); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets( + 'SirenAppBar does not display filter badge when hideBadge is true', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + categories: const ['Category 1', 'Category 2'], + selectedValues: const ['Category 1'], + hideBadge: true, + ), + ), + ), + ); + + expect(find.text('1'), findsNothing); + }); + + testWidgets( + 'SirenAppBar calls onCategorySelected when filter item is selected', + (WidgetTester tester) async { + String? selectedCategory; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + categories: const ['Category 1', 'Category 2'], + onCategorySelected: (category) { + selectedCategory = category; + }, + ), + ), + ), + ); + + // Open filter dropdown + await tester.tap(find.byIcon(Icons.filter_alt_outlined)); + await tester.pumpAndSettle(); + + // Select a category + await tester.tap(find.text('Category 1')); + await tester.pumpAndSettle(); + + expect(selectedCategory, 'Category 1'); + }); + + testWidgets('SirenAppBar uses custom filter icon widget when provided', + (WidgetTester tester) async { + const customIcon = Icon(Icons.tune); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: SirenAppBar( + headerParams: HeaderParams( + title: 'Title', + showBackButton: false, + ), + isNonEmptyNotifications: false, + categories: const ['Category 1', 'Category 2'], + filterIconWidget: customIcon, + ), + ), + ), + ); + + expect(find.byIcon(Icons.tune), findsOneWidget); + expect(find.byIcon(Icons.filter_alt_outlined), findsNothing); + }); }