From 0d87c6ffd3e0b09fcbc696a8c5be99f7b27a0b10 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Wed, 18 Feb 2026 09:42:28 -0800 Subject: [PATCH 1/2] feat: clean up and reorganize project structure, extract common widgets into packages --- .github/workflows/ci.yml | 2 +- .github/workflows/instances.yml | 18 +- .github/workflows/release.yml | 2 +- ios/Flutter/AppFrameworkInfo.plist | 2 - ios/Runner/AppDelegate.swift | 9 +- ios/Runner/Info.plist | 29 +- lib/main.dart | 268 +--- .../ui/src/icons}/thunder_icons.dart | 0 .../content/content_action_handlers.dart | 25 + .../ui/src/models/content/content_media.dart | 61 + .../models/content/content_media_type.dart | 1 + .../src/models/content/content_view_mode.dart | 10 + .../ui/src/models/identity/avatar_data.dart | 13 + .../models/identity/identity_name_data.dart | 9 + .../ui/src/models/identity/name_style.dart | 90 ++ .../src/utils/identity/name_formatting.dart | 79 ++ .../utils/links/link_navigation_utils.dart | 41 + .../src/utils}/markdown/markdown_utils.dart | 10 +- .../ui/src/utils/media/media_utils.dart} | 115 +- .../widgets/actions}/bottom_sheet_action.dart | 0 .../widgets/actions}/thunder_action_chip.dart | 0 .../actions}/thunder_popup_menu_item.dart | 0 .../src/widgets/content/content_renderer.dart | 19 + .../src/widgets/dialogs/thunder_dialog.dart} | 0 .../ui/src/widgets/feedback}/snackbar.dart | 532 ++++--- .../src/widgets/identity/avatar_widgets.dart | 85 ++ .../widgets/identity/community_avatar.dart | 51 + .../widgets/identity/full_name_widgets.dart | 188 +++ .../src/widgets/identity/instance_avatar.dart | 18 + .../src/widgets/identity}/scalable_text.dart | 20 +- .../ui/src/widgets/identity/user_avatar.dart | 18 + .../layout}/conditional_parent_widget.dart | 0 .../src/widgets/layout/thunder_divider.dart} | 52 +- .../markdown/common_markdown_body.dart | 179 ++- .../widgets}/markdown/extended_markdown.dart | 8 +- .../src/widgets/markdown/markdown_body.dart | 3 + .../markdown/markdown_lemmy_link.dart | 0 .../widgets/markdown/markdown_spoiler.dart | 170 +++ .../markdown/markdown_subsuperscript.dart | 96 ++ .../media/compact_thumbnail_preview.dart | 113 ++ .../ui/src/widgets/media}/image_preview.dart | 55 +- .../ui/src/widgets/media}/image_viewer.dart | 28 +- .../src/widgets/media/link_information.dart | 76 + .../ui/src}/widgets/media/media_view.dart | 298 ++-- .../ui/src/widgets/media/media_view_text.dart | 30 + .../pickers}/bottom_sheet_list_picker.dart | 12 +- .../widgets/pickers}/multi_picker_item.dart | 0 .../ui/src/widgets/pickers}/picker_item.dart | 0 lib/packages/ui/ui.dart | 34 + lib/src/app/bloc/thunder_bloc.dart | 109 -- lib/src/app/bootstrap/bootstrap.dart | 53 + .../bootstrap/preferences_migration.dart} | 336 ++--- .../comment_preferences_cubit.dart | 49 - .../fab_preferences_cubit.dart | 71 - .../feed_preferences_cubit.dart | 136 -- .../gesture_preferences_cubit.dart | 66 - .../theme_preferences_cubit.dart | 103 -- .../video_preferences_cubit.dart | 43 - .../share_intent_handler.dart | 37 +- .../navigation/link_navigation_utils.dart} | 294 +--- .../shell/navigation}/loading_page.dart | 31 +- .../app/shell/navigation/navigation_feed.dart | 118 ++ .../shell/navigation/navigation_instance.dart | 121 ++ .../app/shell/navigation/navigation_misc.dart | 56 + .../navigation/navigation_notification.dart | 104 ++ .../app/shell/navigation/navigation_post.dart | 308 ++++ .../shell/navigation/navigation_settings.dart | 111 ++ .../shell/navigation/navigation_utils.dart | 46 + .../navigation}/swipeable_page_route.dart | 0 .../app/{ => shell}/pages/thunder_page.dart | 48 +- .../app/{ => shell}/routing/deep_link.dart | 49 +- .../{ => shell}/routing/deep_link_enums.dart | 0 lib/src/app/shell/thunder_app.dart | 187 +++ .../{ => shell}/widgets/bottom_nav_bar.dart | 9 +- .../deep_links_cubit/deep_links_cubit.dart | 71 +- .../deep_links_cubit/deep_links_state.dart | 19 +- .../network_checker_cubit.dart | 23 +- .../network_checker_state.dart | 2 +- lib/src/app/state/thunder/thunder_bloc.dart | 122 ++ .../thunder}/thunder_event.dart | 8 +- .../thunder}/thunder_state.dart | 23 +- lib/src/app/thunder.dart | 7 - lib/src/app/utils/navigation.dart | 850 ----------- .../wiring/fetch_active_account_provider.dart | 11 + .../nodeinfo_platform_detection_service.dart | 11 + lib/src/app/wiring/state_factories.dart | 164 +++ lib/src/core/enums/enums.dart | 1 - lib/src/core/enums/full_name.dart | 216 --- lib/src/core/models/models.dart | 7 - lib/src/features/account/account.dart | 7 +- .../{data/models/models.dart => api.dart} | 0 .../data/cache}/profile_site_info_cache.dart | 6 +- .../repositories/account_repository_impl.dart | 41 +- .../account/domain/models/account_media.dart | 8 + .../repositories/account_repository.dart | 7 +- .../domain/utils/profile_community_utils.dart | 18 + .../presentation/bloc/profile_bloc.dart | 299 ---- .../presentation/pages/account_page.dart | 2 +- .../presentation/pages/login_page.dart | 25 +- .../presentation/state/profile_bloc.dart | 488 +++++++ .../{bloc => state}/profile_event.dart | 13 +- .../{bloc => state}/profile_state.dart | 18 +- .../{profiles.dart => profile_utils.dart} | 9 +- .../account/presentation/utils/utils.dart | 1 - .../widgets/account_page_app_bar.dart | 13 +- .../widgets/account_placeholder.dart | 10 +- .../widgets/profile_modal_body.dart | 16 +- lib/src/features/comment/api.dart | 1 + .../state/comment_preferences_cubit.dart | 49 + .../state}/comment_preferences_state.dart | 0 lib/src/features/comment/comment.dart | 8 +- .../repositories/comment_repository_impl.dart | 11 +- .../comment/domain/models/comment_page.dart | 2 +- .../repositories/comment_repository.dart | 3 +- .../bloc/create_comment_cubit.dart | 85 -- .../bloc/create_comment_state.dart | 51 - .../presentation/models/comment_list.dart | 51 + .../pages/create_comment_page.dart | 32 +- .../state/create_comment_cubit.dart | 142 ++ .../state/create_comment_state.dart | 59 + .../{comment.dart => comment_utils.dart} | 57 +- .../comment_action_bottom_sheet.dart | 8 +- .../comment_comment_action_bottom_sheet.dart | 12 +- .../general_comment_action_bottom_sheet.dart | 10 +- .../comment_card/additional_comment_card.dart | 11 +- .../widgets/comment_card/comment_card.dart | 10 +- .../comment_card/comment_card_background.dart | 3 +- .../comment_card_button_actions.dart | 6 +- .../comment_card_header.dart | 9 +- .../comment_card_header_date.dart | 8 +- .../comment_card_header_reply_count.dart | 8 +- .../comment_card_header_score.dart | 12 +- .../widgets/comment_card/comment_content.dart | 13 +- .../comment_card/comment_depth_indicator.dart | 4 +- .../widgets/comment_list_entry.dart | 5 +- .../widgets}/comment_reference.dart | 24 +- lib/src/features/community/api.dart | 1 + lib/src/features/community/community.dart | 11 +- .../anonymous_subscriptions_local.dart | 3 +- ...ymous_subscriptions_local_data_source.dart | 2 +- .../favorite_local_data_source.dart | 3 +- .../community_repository_impl.dart | 44 +- .../domain/models/community_details.dart | 15 + .../anonymous_subscriptions_bloc.dart | 34 +- .../anonymous_subscriptions_event.dart | 6 + .../anonymous_subscriptions_state.dart | 14 +- .../widgets/community_drawer.dart | 20 +- .../community_header/community_header.dart | 14 +- .../community_header_actions.dart | 25 +- .../widgets/community_information.dart | 16 +- .../widgets/community_list_entry.dart | 329 ++--- .../presentation/widgets/post_card.dart | 25 +- .../widgets/post_card_actions.dart | 4 +- .../widgets/post_card_metadata.dart | 26 +- .../widgets/post_card_view_comfortable.dart | 15 +- .../widgets/post_card_view_compact.dart | 10 +- .../widgets/common_markdown_body.dart | 84 ++ .../media/compact_thumbnail_preview.dart | 28 +- .../widgets/media/media_utils.dart | 47 + .../widgets/media/media_view.dart | 176 +++ lib/src/features/drafts/api.dart | 1 + .../features/drafts/data/models/draft.dart | 322 +++-- lib/src/features/drafts/drafts.dart | 2 +- lib/src/features/feed/api.dart | 1 + .../state/fab_preferences_cubit.dart | 75 + .../state}/fab_preferences_state.dart | 0 .../feed/application/state}/fab_state.dart | 2 +- .../application/state/fab_state_cubit.dart} | 0 .../state/feed_preferences_cubit.dart | 135 ++ .../state}/feed_preferences_state.dart | 10 +- .../application/state}/feed_ui_cubit.dart | 0 .../application/state}/feed_ui_state.dart | 14 +- .../nav_bar_state_cubit/nav_bar_state.dart} | 8 +- .../nav_bar_state_cubit.dart | 6 +- lib/src/features/feed/domain/enums/enums.dart | 1 + .../feed/domain}/enums/fab_action.dart | 6 +- .../feed/domain/models/feed_result.dart | 28 + .../domain/utils/feed_collection_utils.dart | 39 + lib/src/features/feed/feed.dart | 18 +- .../models/feed_share_options.dart | 3 + .../feed/presentation/pages/feed_page.dart | 34 +- .../{bloc => state}/feed_bloc.dart | 326 +++-- .../{bloc => state}/feed_event.dart | 149 +- .../{bloc => state}/feed_state.dart | 63 +- ...mmunity.dart => community_feed_utils.dart} | 18 +- .../{post.dart => feed_fetch_utils.dart} | 18 +- .../{utils.dart => feed_header_utils.dart} | 0 ...unity_share.dart => feed_share_utils.dart} | 205 ++- .../feed/presentation/utils/user_share.dart | 76 - .../widgets/feed_card_divider.dart | 2 +- .../widgets/feed_comment_card_list.dart | 2 +- .../feed/presentation/widgets/feed_fab.dart | 14 +- .../widgets/feed_page_app_bar.dart | 18 +- .../widgets/feed_post_card_list.dart | 476 +++---- .../feed/presentation/widgets/tagline.dart | 6 +- .../widgets/avatars/community_avatar.dart | 53 + .../widgets/avatars/instance_avatar.dart | 33 + .../widgets/avatars/user_avatar.dart | 45 + .../widgets/full_name_widgets.dart | 133 ++ .../widgets/text/scalable_text.dart | 39 + lib/src/features/inbox/api.dart | 1 + .../inbox/domain/utils/inbox_utils.dart | 56 + lib/src/features/inbox/inbox.dart | 4 +- .../inbox/presentation/bloc/inbox_bloc.dart | 489 ------- .../inbox/presentation/pages/inbox_page.dart | 10 +- .../inbox/presentation/state/inbox_bloc.dart | 593 ++++++++ .../{bloc => state}/inbox_event.dart | 63 +- .../{bloc => state}/inbox_state.dart | 15 +- .../widgets/inbox_mentions_view.dart | 7 +- .../widgets/inbox_private_messages_view.dart | 15 +- .../widgets/inbox_replies_view.dart | 7 +- lib/src/features/instance/api.dart | 1 + .../data/constants/known_instances.dart} | 4 +- .../repositories/instance_repository.dart | 9 +- .../services/instance_discovery_service.dart} | 119 +- .../domain/utils/instance_link_utils.dart | 111 ++ .../instance/domain/utils/instance_utils.dart | 45 + .../presentation/pages/instance_page.dart | 496 +++---- .../{bloc => state}/instance_page_bloc.dart | 762 +++++----- .../{bloc => state}/instance_page_event.dart | 3 +- .../{bloc => state}/instance_page_state.dart | 196 +-- .../widgets/instance_action_bottom_sheet.dart | 23 +- .../widgets/instance_information.dart | 166 +-- .../widgets/instance_list_entry.dart | 114 +- .../widgets/instance_page_app_bar.dart | 15 +- .../presentation/widgets/instance_tabs.dart | 11 +- lib/src/features/moderator/api.dart | 1 + .../moderator/domain/utils/report_utils.dart | 46 + lib/src/features/moderator/moderator.dart | 7 +- .../presentation/bloc/report_bloc.dart | 226 --- .../presentation/pages/report_page.dart | 30 +- .../presentation/state/report_bloc.dart | 343 +++++ .../{bloc => state}/report_event.dart | 39 +- .../{bloc => state}/report_state.dart | 30 +- ...{report.dart => report_actions_utils.dart} | 2 - lib/src/features/modlog/api.dart | 1 + .../modlog/data/models/modlog_event_item.dart | 28 +- .../data/repositories/modlog_repository.dart | 7 +- .../features/modlog/domain/enums/enums.dart | 2 +- lib/src/features/modlog/modlog.dart | 2 +- .../presentation/pages/modlog_page.dart | 11 +- .../{bloc => state}/modlog_cubit.dart | 3 +- .../{bloc => state}/modlog_cubit.freezed.dart | 0 .../{bloc => state}/modlog_state.dart | 8 + .../widgets/modlog_feed_page_app_bar.dart | 4 +- .../widgets/modlog_filter_picker.dart | 5 +- .../widgets/modlog_item_card.dart | 10 +- .../widgets/modlog_item_context_card.dart | 25 +- lib/src/features/notification/api.dart | 3 + .../state}/notifications_cubit.dart | 123 +- .../state}/notifications_state.dart | 104 +- .../repositories/notification_repository.dart | 53 +- .../domain/enums/notification_type.dart | 2 +- .../models}/notification_payload.dart | 0 .../models/unread_notifications_count.dart | 13 + .../features/notification/notification.dart | 17 +- .../features/notification/notifications.dart | 6 +- .../pages/notifications_page.dart} | 276 ++-- ...n.dart => android_notification_utils.dart} | 24 +- ...apns.dart => apns_notification_utils.dart} | 0 ...ons.dart => local_notification_utils.dart} | 11 +- ...s.dart => notification_content_utils.dart} | 0 ...er.dart => notification_server_utils.dart} | 8 +- ....dart => notification_settings_utils.dart} | 11 +- ...fied_push.dart => unified_push_utils.dart} | 9 +- lib/src/features/post/api.dart | 1 + .../data/repositories/post_repository.dart | 24 +- lib/src/features/post/domain/enums/enums.dart | 2 +- .../post/domain/enums/post_status.dart | 4 +- .../domain/utils/comment_state_utils.dart | 22 + lib/src/features/post/post.dart | 12 +- .../presentation/cubit/create_post_cubit.dart | 100 -- .../presentation/pages/create_post_page.dart | 58 +- .../post/presentation/pages/post_page.dart | 23 +- .../presentation/state/create_post_cubit.dart | 176 +++ .../{cubit => state}/create_post_state.dart | 22 +- .../{bloc => state}/post_bloc.dart | 309 +++- .../{bloc => state}/post_event.dart | 74 +- .../post_navigation_cubit.dart | 0 .../post_navigation_state.dart | 14 +- .../{bloc => state}/post_state.dart | 43 +- .../{post.dart => post_media_utils.dart} | 107 +- .../utils/post_optimistic_utils.dart | 68 + ...tils.dart => user_label_dialog_utils.dart} | 147 +- .../post/presentation/utils/utils.dart | 2 - .../presentation/widgets}/cross_posts.dart | 334 ++--- .../widgets/post_body/post_body.dart | 32 +- .../post_body/post_body_action_bar.dart | 11 +- .../widgets/post_body/post_body_preview.dart | 12 +- .../widgets/post_body/post_body_title.dart | 21 +- .../community_post_action_bottom_sheet.dart | 27 +- .../general_post_action_bottom_sheet.dart | 16 +- .../post_action_bottom_sheet.dart | 8 +- .../post_post_action_bottom_sheet.dart | 12 +- .../presentation/widgets/post_card_title.dart | 4 +- .../widgets/post_page_app_bar.dart | 16 +- .../presentation/widgets/post_page_fab.dart | 19 +- .../widgets/post_status_icon.dart | 4 +- lib/src/features/search/api.dart | 1 + .../data/repositories/search_repository.dart | 49 +- .../domain/models/search_resolve_result.dart | 18 + .../search/domain/models/search_results.dart | 17 + .../presentation/pages/search_page.dart | 14 +- .../{bloc => state}/search_bloc.dart | 121 +- .../{bloc => state}/search_event.dart | 0 .../{bloc => state}/search_state.dart | 65 +- .../presentation/utils/search_utils.dart | 47 +- .../presentation/widgets/search_body.dart | 8 +- .../widgets/search_filters_row.dart | 14 +- .../widgets/search_instances_results.dart | 4 +- .../widgets/search_page_app_bar.dart | 4 +- .../widgets/search_posts_results.dart | 5 +- lib/src/features/search/search.dart | 4 +- .../utils/utils.dart => api.dart} | 0 .../gesture_preferences_cubit.dart | 70 + .../gesture_preferences_state.dart | 0 .../theme_preferences_cubit.dart | 104 ++ .../theme_preferences_state.dart | 0 .../video_preferences_cubit.dart | 44 + .../video_preferences_state.dart | 0 .../features/settings/domain/full_name.dart | 145 ++ .../domain/models/language_local.dart} | 0 .../settings/domain}/swipe_action.dart | 2 +- .../pages/about_settings_page.dart | 12 +- .../pages/accessibility_settings_page.dart | 12 +- .../appearance_settings_page.dart | 10 +- .../comment_appearance_settings_page.dart | 22 +- .../post_appearance_settings_page.dart | 25 +- .../{ => appearance}/theme_settings_page.dart | 19 +- .../{ => behavior}/fab_settings_page.dart | 17 +- .../{ => behavior}/filter_settings_page.dart | 17 +- .../{ => behavior}/general_settings_page.dart | 39 +- .../{ => behavior}/gesture_settings_page.dart | 17 +- .../{ => behavior}/video_player_settings.dart | 15 +- .../pages/debug_settings_page.dart | 19 +- .../settings/presentation/pages/pages.dart | 18 +- .../presentation/pages/settings_page.dart | 10 +- .../pages/user_labels_settings_page.dart | 11 +- ...{settings.dart => setting_link_utils.dart} | 4 +- .../widgets/accessibility_profile.dart | 197 +-- .../widgets/action_color_setting_widget.dart | 669 +++++---- .../widgets/discussion_language_selector.dart | 7 +- .../presentation/widgets/list_option.dart | 5 +- .../widgets/settings_list_tile.dart | 2 +- .../presentation/widgets/swipe_picker.dart | 477 ++++--- .../presentation/widgets/toggle_option.dart | 2 +- lib/src/features/settings/settings.dart | 7 +- lib/src/features/user/api.dart | 1 + .../features/user/data/models/user_label.dart | 229 ++- .../data/repositories/user_repository.dart | 11 +- .../user/domain/utils/user_media_utils.dart | 25 + .../pages/media_management_page.dart | 606 ++++---- .../pages/user_settings_block_page.dart | 19 +- .../pages/user_settings_page.dart | 1243 ++++++++--------- .../{bloc => state}/user_settings_bloc.dart | 831 ++++++----- .../{bloc => state}/user_settings_event.dart | 20 +- .../{bloc => state}/user_settings_state.dart | 203 +-- .../user/presentation/utils/restore_user.dart | 15 - ...user_groups.dart => user_group_utils.dart} | 183 ++- ...ut_dialog.dart => user_session_utils.dart} | 80 +- .../widgets/user_action_bottom_sheet.dart | 32 +- .../widgets/user_header/user_header.dart | 14 +- .../user_header/user_header_actions.dart | 39 +- .../presentation/widgets/user_indicator.dart | 6 +- .../widgets/user_information.dart | 14 +- .../presentation/widgets/user_label_chip.dart | 8 +- .../presentation/widgets/user_list_entry.dart | 143 +- .../presentation/widgets/user_selector.dart | 805 +++++------ lib/src/features/user/user.dart | 10 +- .../config/app_config.dart | 0 .../config/app_constants.dart} | 7 +- lib/src/foundation/config/config.dart | 2 + .../config}/global_context.dart | 1 + .../contracts}/account.dart | 5 +- .../contracts/active_account_provider.dart | 5 + .../contracts/connectivity_service.dart | 14 + lib/src/foundation/contracts/contracts.dart | 10 + .../contracts/deep_link_service.dart | 14 + .../contracts/localization_service.dart | 13 + .../contracts/notification_service.dart | 12 + .../contracts/platform_detection_service.dart | 3 + .../contracts/preferences_store.dart | 50 + .../foundation/contracts/version_checker.dart | 15 + .../foundation/contracts/web_controller.dart | 11 + .../errors}/api_exception.dart | 0 .../foundation/errors/app_error_reason.dart | 79 ++ lib/src/foundation/errors/errors.dart | 2 + lib/src/foundation/foundation.dart | 7 + .../networking}/api_client_factory.dart | 14 +- .../networking}/base_api_client.dart | 6 +- .../networking/error_message_utils.dart} | 4 +- .../lemmy/base_lemmy_api_client.dart | 71 +- .../lemmy/lemmy_v3_api_client.dart | 112 +- .../lemmy/lemmy_v4_api_client.dart | 12 +- lib/src/foundation/networking/networking.dart | 4 + .../networking}/piefed/piefed_api_client.dart | 44 +- .../networking}/thunder_api_client.dart | 34 +- .../persistence}/database/database.dart | 8 +- .../persistence}/database/database.g.dart | 0 .../persistence}/database/database.steps.dart | 0 .../persistence}/database/database_utils.dart | 4 +- .../schemas/thunder/drift_schema_v1.json | 0 .../schemas/thunder/drift_schema_v2.json | 0 .../schemas/thunder/drift_schema_v3.json | 0 .../schemas/thunder/drift_schema_v4.json | 0 .../schemas/thunder/drift_schema_v5.json | 0 .../schemas/thunder/drift_schema_v6.json | 0 .../schemas/thunder/drift_schema_v7.json | 0 .../persistence}/database/tables.dart | 2 +- .../database/type_converters.dart | 4 +- .../persistence/database_provider.dart | 7 + .../foundation/persistence/persistence.dart | 6 + .../persistence}/preferences.dart | 2 +- .../primitives}/enums/action_color.dart | 0 .../primitives}/enums/browser_mode.dart | 0 .../primitives}/enums/comment_sort_type.dart | 2 +- .../primitives}/enums/custom_theme_type.dart | 0 .../primitives}/enums/draft_type.dart | 0 .../foundation/primitives/enums/enums.dart | 27 + .../enums/feed_card_divider_thickness.dart | 2 +- .../primitives}/enums/feed_list_type.dart | 2 +- .../primitives}/enums/font_scale.dart | 2 +- .../primitives}/enums/image_caching_mode.dart | 0 .../enums/internet_connection_type.dart | 0 .../primitives}/enums/local_settings.dart | 0 .../primitives}/enums/media_type.dart | 0 .../primitives}/enums/meta_search_type.dart | 4 +- .../primitives}/enums/modlog_action_type.dart | 0 .../enums/nested_comment_indicator.dart | 0 .../enums/post_body_view_type.dart | 0 .../enums/post_card_metadata_item.dart | 0 .../primitives}/enums/post_sort_type.dart | 2 +- .../primitives}/enums/search_sort_type.dart | 2 +- .../enums/subscription_status.dart | 2 +- .../primitives}/enums/theme_type.dart | 0 .../enums/threadiverse_platform.dart | 0 .../primitives}/enums/user_type.dart | 0 .../primitives}/enums/video_auto_play.dart | 0 .../enums/video_playback_speed.dart | 0 .../primitives}/enums/video_player_mode.dart | 0 .../primitives}/enums/view_mode.dart | 0 .../primitives}/models/media.dart | 24 +- .../foundation/primitives/models/models.dart | 18 + .../primitives/models/modlog_event_item.dart | 32 + .../primitives/models/parsed_link.dart | 22 + .../primitives}/models/thunder_comment.dart | 8 +- .../models/thunder_comment_report.dart | 10 +- .../primitives}/models/thunder_community.dart | 2 +- .../models/thunder_instance_info.dart | 2 +- .../primitives}/models/thunder_language.dart | 0 .../models/thunder_local_user.dart | 4 +- .../primitives}/models/thunder_my_user.dart | 6 +- .../primitives}/models/thunder_post.dart | 8 +- .../models/thunder_post_report.dart | 6 +- .../models/thunder_private_message.dart | 2 +- .../primitives}/models/thunder_site.dart | 0 .../models/thunder_site_response.dart | 8 +- .../primitives}/models/thunder_tagline.dart | 0 .../primitives}/models/thunder_user.dart | 0 .../primitives}/models/version.dart | 0 lib/src/foundation/primitives/primitives.dart | 2 + .../utils/cache/image_cache_utils.dart} | 0 .../utils}/cache/image_dimension_cache.dart | 0 .../utils}/cache/platform_version_cache.dart | 0 .../utils}/check_github_update.dart | 6 +- .../utils/debounce_utils.dart} | 0 .../utils/formatting_utils.dart} | 9 + .../threadiverse_link_parser_utils.dart} | 27 +- lib/src/foundation/utils/utils.dart | 4 + lib/src/foundation/utils/utils_internal.dart | 3 + lib/src/shared/full_name_widgets.dart | 198 --- lib/src/shared/gesture_fab.dart | 4 +- .../swipe.dart => gestures/swipe_utils.dart} | 3 +- lib/src/shared/icon_text.dart | 4 +- lib/src/shared/image_preview.dart | 8 +- lib/src/shared/input_dialogs.dart | 1082 +++++++------- lib/src/shared/language_selector.dart | 4 +- lib/src/shared/link_information.dart | 6 +- lib/src/shared/links/links.dart | 1 + .../links/widgets/link_bottom_sheet.dart | 185 +++ lib/src/shared/markdown/markdown_spoiler.dart | 225 --- .../markdown/markdown_subsuperscript.dart | 113 -- .../widgets}/thunder_video_player.dart | 19 +- .../widgets}/thunder_youtube_player.dart | 12 +- .../shared/media/widgets/video_player.dart | 2 + lib/src/shared/persistent_header.dart | 40 - lib/src/shared/reply_to_preview_actions.dart | 153 +- .../shared/share/advanced_share_sheet.dart | 806 ++++++----- .../share/share_action_bottom_sheet.dart | 12 +- lib/src/shared/sort_picker.dart | 11 +- .../colors.dart => theme/color_utils.dart} | 58 +- lib/src/shared/utils/media/video.dart | 68 - lib/src/shared/utils/numbers.dart | 8 - .../shared/utils/text_input_formatter.dart | 11 - .../utils/video_player/video_player.dart | 2 - .../widgets/avatars/community_avatar.dart | 90 -- .../widgets/avatars/instance_avatar.dart | 49 - .../shared/widgets/avatars/user_avatar.dart | 69 - .../shared/widgets/chips/community_chip.dart | 16 +- lib/src/shared/widgets/chips/user_chip.dart | 22 +- .../shared/widgets/comment_navigator_fab.dart | 4 +- .../widgets/media/media_type_badge.dart | 4 +- .../shared/widgets/media/media_view_text.dart | 4 +- .../widgets/multi_action_dismissible.dart | 2 +- .../widgets/text/selectable_text_modal.dart | 346 ++--- lib/src/shared/widgets/webview.dart | 9 +- .../webview/custom_web_view_controller.dart} | 76 +- pubspec.lock | 322 +++-- pubspec.yaml | 4 +- scripts/build.dart | 2 +- test/app/deep_links_cubit_test.dart | 70 + .../app/state_copy_with_nullability_test.dart | 247 ++++ test/app/thunder_bloc_test.dart | 73 + test/drift/thunder/migration_test.dart | 131 +- .../profile_community_usecase_test.dart | 62 + test/features/comment/comment_node_test.dart | 13 +- .../comment/create_comment_cubit_test.dart | 117 ++ test/features/feed/feed_state_test.dart | 12 + .../features/feed/feed_view_usecase_test.dart | 42 + test/features/inbox/inbox_bloc_test.dart | 69 + .../inbox/inbox_cleanup_usecase_test.dart | 66 + .../instance_pagination_usecase_test.dart | 65 + .../instance_resolution_usecase_test.dart | 24 + .../moderator/report_feed_usecase_test.dart | 75 + test/features/modlog/modlog_state_test.dart | 22 + .../post/collapsed_comments_usecase_test.dart | 26 + .../features/post/create_post_cubit_test.dart | 127 ++ .../post/post_navigation_state_test.dart | 15 + .../user/user_media_usecase_test.dart | 54 + test/helpers/fake_preferences_store.dart | 42 + test/utils/link_utils_test.dart | 55 +- test/utils/user_groups_test.dart | 53 +- test/widgets/base_widget.dart | 2 +- 533 files changed, 18386 insertions(+), 13314 deletions(-) rename lib/{src/app/widgets => packages/ui/src/icons}/thunder_icons.dart (100%) create mode 100644 lib/packages/ui/src/models/content/content_action_handlers.dart create mode 100644 lib/packages/ui/src/models/content/content_media.dart create mode 100644 lib/packages/ui/src/models/content/content_media_type.dart create mode 100644 lib/packages/ui/src/models/content/content_view_mode.dart create mode 100644 lib/packages/ui/src/models/identity/avatar_data.dart create mode 100644 lib/packages/ui/src/models/identity/identity_name_data.dart create mode 100644 lib/packages/ui/src/models/identity/name_style.dart create mode 100644 lib/packages/ui/src/utils/identity/name_formatting.dart create mode 100644 lib/packages/ui/src/utils/links/link_navigation_utils.dart rename lib/{src/shared => packages/ui/src/utils}/markdown/markdown_utils.dart (92%) rename lib/{src/shared/utils/media/image.dart => packages/ui/src/utils/media/media_utils.dart} (65%) rename lib/{src/shared => packages/ui/src/widgets/actions}/bottom_sheet_action.dart (100%) rename lib/{src/shared/widgets/chips => packages/ui/src/widgets/actions}/thunder_action_chip.dart (100%) rename lib/{src/shared/widgets => packages/ui/src/widgets/actions}/thunder_popup_menu_item.dart (100%) create mode 100644 lib/packages/ui/src/widgets/content/content_renderer.dart rename lib/{src/shared/dialogs.dart => packages/ui/src/widgets/dialogs/thunder_dialog.dart} (100%) rename lib/{src/shared => packages/ui/src/widgets/feedback}/snackbar.dart (86%) create mode 100644 lib/packages/ui/src/widgets/identity/avatar_widgets.dart create mode 100644 lib/packages/ui/src/widgets/identity/community_avatar.dart create mode 100644 lib/packages/ui/src/widgets/identity/full_name_widgets.dart create mode 100644 lib/packages/ui/src/widgets/identity/instance_avatar.dart rename lib/{src/shared/widgets/text => packages/ui/src/widgets/identity}/scalable_text.dart (54%) create mode 100644 lib/packages/ui/src/widgets/identity/user_avatar.dart rename lib/{src/shared => packages/ui/src/widgets/layout}/conditional_parent_widget.dart (100%) rename lib/{src/shared/divider.dart => packages/ui/src/widgets/layout/thunder_divider.dart} (89%) rename lib/{src/shared => packages/ui/src/widgets}/markdown/common_markdown_body.dart (54%) rename lib/{src/shared => packages/ui/src/widgets}/markdown/extended_markdown.dart (96%) create mode 100644 lib/packages/ui/src/widgets/markdown/markdown_body.dart rename lib/{src/shared => packages/ui/src/widgets}/markdown/markdown_lemmy_link.dart (100%) create mode 100644 lib/packages/ui/src/widgets/markdown/markdown_spoiler.dart create mode 100644 lib/packages/ui/src/widgets/markdown/markdown_subsuperscript.dart create mode 100644 lib/packages/ui/src/widgets/media/compact_thumbnail_preview.dart rename lib/{src/shared/images => packages/ui/src/widgets/media}/image_preview.dart (87%) rename lib/{src/shared/images => packages/ui/src/widgets/media}/image_viewer.dart (96%) create mode 100644 lib/packages/ui/src/widgets/media/link_information.dart rename lib/{src/shared => packages/ui/src}/widgets/media/media_view.dart (51%) create mode 100644 lib/packages/ui/src/widgets/media/media_view_text.dart rename lib/{src/shared/utils => packages/ui/src/widgets/pickers}/bottom_sheet_list_picker.dart (96%) rename lib/{src/shared => packages/ui/src/widgets/pickers}/multi_picker_item.dart (100%) rename lib/{src/shared => packages/ui/src/widgets/pickers}/picker_item.dart (100%) create mode 100644 lib/packages/ui/ui.dart delete mode 100644 lib/src/app/bloc/thunder_bloc.dart create mode 100644 lib/src/app/bootstrap/bootstrap.dart rename lib/src/{shared/utils/preferences.dart => app/bootstrap/preferences_migration.dart} (69%) delete mode 100644 lib/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart delete mode 100644 lib/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart delete mode 100644 lib/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart delete mode 100644 lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart delete mode 100644 lib/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart delete mode 100644 lib/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart rename lib/src/app/{utils => share}/share_intent_handler.dart (54%) rename lib/src/{shared/utils/links.dart => app/shell/navigation/link_navigation_utils.dart} (53%) rename lib/src/{shared/pages => app/shell/navigation}/loading_page.dart (73%) create mode 100644 lib/src/app/shell/navigation/navigation_feed.dart create mode 100644 lib/src/app/shell/navigation/navigation_instance.dart create mode 100644 lib/src/app/shell/navigation/navigation_misc.dart create mode 100644 lib/src/app/shell/navigation/navigation_notification.dart create mode 100644 lib/src/app/shell/navigation/navigation_post.dart create mode 100644 lib/src/app/shell/navigation/navigation_settings.dart create mode 100644 lib/src/app/shell/navigation/navigation_utils.dart rename lib/src/app/{routing => shell/navigation}/swipeable_page_route.dart (100%) rename lib/src/app/{ => shell}/pages/thunder_page.dart (93%) rename lib/src/app/{ => shell}/routing/deep_link.dart (90%) rename lib/src/app/{ => shell}/routing/deep_link_enums.dart (100%) create mode 100644 lib/src/app/shell/thunder_app.dart rename lib/src/app/{ => shell}/widgets/bottom_nav_bar.dart (95%) rename lib/src/app/{cubits => state}/deep_links_cubit/deep_links_cubit.dart (60%) rename lib/src/app/{cubits => state}/deep_links_cubit/deep_links_state.dart (55%) rename lib/src/app/{cubits => state}/network_checker_cubit/network_checker_cubit.dart (58%) rename lib/src/app/{cubits => state}/network_checker_cubit/network_checker_state.dart (85%) create mode 100644 lib/src/app/state/thunder/thunder_bloc.dart rename lib/src/app/{bloc => state/thunder}/thunder_event.dart (78%) rename lib/src/app/{bloc => state/thunder}/thunder_state.dart (78%) delete mode 100644 lib/src/app/thunder.dart delete mode 100644 lib/src/app/utils/navigation.dart create mode 100644 lib/src/app/wiring/fetch_active_account_provider.dart create mode 100644 lib/src/app/wiring/nodeinfo_platform_detection_service.dart create mode 100644 lib/src/app/wiring/state_factories.dart delete mode 100644 lib/src/core/enums/enums.dart delete mode 100644 lib/src/core/enums/full_name.dart delete mode 100644 lib/src/core/models/models.dart rename lib/src/features/account/{data/models/models.dart => api.dart} (100%) rename lib/src/{shared => features/account/data/cache}/profile_site_info_cache.dart (92%) create mode 100644 lib/src/features/account/domain/models/account_media.dart create mode 100644 lib/src/features/account/domain/utils/profile_community_utils.dart delete mode 100644 lib/src/features/account/presentation/bloc/profile_bloc.dart create mode 100644 lib/src/features/account/presentation/state/profile_bloc.dart rename lib/src/features/account/presentation/{bloc => state}/profile_event.dart (91%) rename lib/src/features/account/presentation/{bloc => state}/profile_state.dart (84%) rename lib/src/features/account/presentation/utils/{profiles.dart => profile_utils.dart} (90%) delete mode 100644 lib/src/features/account/presentation/utils/utils.dart create mode 100644 lib/src/features/comment/api.dart create mode 100644 lib/src/features/comment/application/state/comment_preferences_cubit.dart rename lib/src/{app/cubits/comment_preferences_cubit => features/comment/application/state}/comment_preferences_state.dart (100%) delete mode 100644 lib/src/features/comment/presentation/bloc/create_comment_cubit.dart delete mode 100644 lib/src/features/comment/presentation/bloc/create_comment_state.dart create mode 100644 lib/src/features/comment/presentation/models/comment_list.dart create mode 100644 lib/src/features/comment/presentation/state/create_comment_cubit.dart create mode 100644 lib/src/features/comment/presentation/state/create_comment_state.dart rename lib/src/features/comment/presentation/utils/{comment.dart => comment_utils.dart} (80%) rename lib/src/{shared => features/comment/presentation/widgets}/comment_reference.dart (87%) create mode 100644 lib/src/features/community/api.dart rename lib/src/features/community/data/{datasources => data_sources}/anonymous_subscriptions_local.dart (94%) rename lib/src/features/community/data/{datasources => data_sources}/anonymous_subscriptions_local_data_source.dart (94%) rename lib/src/features/community/data/{datasources => data_sources}/favorite_local_data_source.dart (96%) create mode 100644 lib/src/features/community/domain/models/community_details.dart rename lib/src/features/community/presentation/{bloc => state}/anonymous_subscriptions_bloc.dart (72%) rename lib/src/features/community/presentation/{bloc => state}/anonymous_subscriptions_event.dart (89%) rename lib/src/features/community/presentation/{bloc => state}/anonymous_subscriptions_state.dart (67%) create mode 100644 lib/src/features/content/presentation/widgets/common_markdown_body.dart rename lib/src/{shared => features/content/presentation}/widgets/media/compact_thumbnail_preview.dart (67%) create mode 100644 lib/src/features/content/presentation/widgets/media/media_utils.dart create mode 100644 lib/src/features/content/presentation/widgets/media/media_view.dart create mode 100644 lib/src/features/drafts/api.dart create mode 100644 lib/src/features/feed/api.dart create mode 100644 lib/src/features/feed/application/state/fab_preferences_cubit.dart rename lib/src/{app/cubits/fab_preferences_cubit => features/feed/application/state}/fab_preferences_state.dart (100%) rename lib/src/{app/cubits/fab_cubit => features/feed/application/state}/fab_state.dart (97%) rename lib/src/{app/cubits/fab_cubit/fab_cubit.dart => features/feed/application/state/fab_state_cubit.dart} (100%) create mode 100644 lib/src/features/feed/application/state/feed_preferences_cubit.dart rename lib/src/{app/cubits/feed_preferences_cubit => features/feed/application/state}/feed_preferences_state.dart (96%) rename lib/src/{app/cubits/feed_ui_cubit => features/feed/application/state}/feed_ui_cubit.dart (100%) rename lib/src/{app/cubits/feed_ui_cubit => features/feed/application/state}/feed_ui_state.dart (65%) rename lib/src/{app/cubits/nav_bar_state_cubit/nav_bar_state_state.dart => features/feed/application/state/nav_bar_state_cubit/nav_bar_state.dart} (74%) rename lib/src/{app/cubits => features/feed/application/state}/nav_bar_state_cubit/nav_bar_state_cubit.dart (66%) rename lib/src/{core => features/feed/domain}/enums/fab_action.dart (92%) create mode 100644 lib/src/features/feed/domain/models/feed_result.dart create mode 100644 lib/src/features/feed/domain/utils/feed_collection_utils.dart create mode 100644 lib/src/features/feed/presentation/models/feed_share_options.dart rename lib/src/features/feed/presentation/{bloc => state}/feed_bloc.dart (68%) rename lib/src/features/feed/presentation/{bloc => state}/feed_event.dart (56%) rename lib/src/features/feed/presentation/{bloc => state}/feed_state.dart (67%) rename lib/src/features/feed/presentation/utils/{community.dart => community_feed_utils.dart} (86%) rename lib/src/features/feed/presentation/utils/{post.dart => feed_fetch_utils.dart} (91%) rename lib/src/features/feed/presentation/utils/{utils.dart => feed_header_utils.dart} (100%) rename lib/src/features/feed/presentation/utils/{community_share.dart => feed_share_utils.dart} (50%) delete mode 100644 lib/src/features/feed/presentation/utils/user_share.dart create mode 100644 lib/src/features/identity/presentation/widgets/avatars/community_avatar.dart create mode 100644 lib/src/features/identity/presentation/widgets/avatars/instance_avatar.dart create mode 100644 lib/src/features/identity/presentation/widgets/avatars/user_avatar.dart create mode 100644 lib/src/features/identity/presentation/widgets/full_name_widgets.dart create mode 100644 lib/src/features/identity/presentation/widgets/text/scalable_text.dart create mode 100644 lib/src/features/inbox/api.dart create mode 100644 lib/src/features/inbox/domain/utils/inbox_utils.dart delete mode 100644 lib/src/features/inbox/presentation/bloc/inbox_bloc.dart create mode 100644 lib/src/features/inbox/presentation/state/inbox_bloc.dart rename lib/src/features/inbox/presentation/{bloc => state}/inbox_event.dart (51%) rename lib/src/features/inbox/presentation/{bloc => state}/inbox_state.dart (85%) create mode 100644 lib/src/features/instance/api.dart rename lib/{instances.dart => src/features/instance/data/constants/known_instances.dart} (94%) rename lib/src/{shared/utils/instance.dart => features/instance/data/services/instance_discovery_service.dart} (52%) create mode 100644 lib/src/features/instance/domain/utils/instance_link_utils.dart create mode 100644 lib/src/features/instance/domain/utils/instance_utils.dart rename lib/src/features/instance/presentation/{bloc => state}/instance_page_bloc.dart (52%) rename lib/src/features/instance/presentation/{bloc => state}/instance_page_event.dart (92%) rename lib/src/features/instance/presentation/{bloc => state}/instance_page_state.dart (68%) create mode 100644 lib/src/features/moderator/api.dart create mode 100644 lib/src/features/moderator/domain/utils/report_utils.dart delete mode 100644 lib/src/features/moderator/presentation/bloc/report_bloc.dart create mode 100644 lib/src/features/moderator/presentation/state/report_bloc.dart rename lib/src/features/moderator/presentation/{bloc => state}/report_event.dart (67%) rename lib/src/features/moderator/presentation/{bloc => state}/report_state.dart (74%) rename lib/src/features/moderator/presentation/utils/{report.dart => report_actions_utils.dart} (96%) create mode 100644 lib/src/features/modlog/api.dart rename lib/src/features/modlog/presentation/{bloc => state}/modlog_cubit.dart (96%) rename lib/src/features/modlog/presentation/{bloc => state}/modlog_cubit.freezed.dart (100%) rename lib/src/features/modlog/presentation/{bloc => state}/modlog_state.dart (82%) create mode 100644 lib/src/features/notification/api.dart rename lib/src/{app/cubits/notifications_cubit => features/notification/application/state}/notifications_cubit.dart (64%) rename lib/src/{app/cubits/notifications_cubit => features/notification/application/state}/notifications_state.dart (58%) rename lib/src/features/notification/{presentation/utils => domain/models}/notification_payload.dart (100%) create mode 100644 lib/src/features/notification/domain/models/unread_notifications_count.dart rename lib/src/{app/pages/notifications_pages.dart => features/notification/presentation/pages/notifications_page.dart} (53%) rename lib/src/features/notification/presentation/utils/{android_notification.dart => android_notification_utils.dart} (87%) rename lib/src/features/notification/presentation/utils/{apns.dart => apns_notification_utils.dart} (100%) rename lib/src/features/notification/presentation/utils/{local_notifications.dart => local_notification_utils.dart} (97%) rename lib/src/features/notification/presentation/utils/{notification_utils.dart => notification_content_utils.dart} (100%) rename lib/src/features/notification/presentation/utils/{notification_server.dart => notification_server_utils.dart} (93%) rename lib/src/features/notification/presentation/utils/{notification_settings.dart => notification_settings_utils.dart} (94%) rename lib/src/features/notification/presentation/utils/{unified_push.dart => unified_push_utils.dart} (97%) create mode 100644 lib/src/features/post/api.dart create mode 100644 lib/src/features/post/domain/utils/comment_state_utils.dart delete mode 100644 lib/src/features/post/presentation/cubit/create_post_cubit.dart create mode 100644 lib/src/features/post/presentation/state/create_post_cubit.dart rename lib/src/features/post/presentation/{cubit => state}/create_post_state.dart (51%) rename lib/src/features/post/presentation/{bloc => state}/post_bloc.dart (54%) rename lib/src/features/post/presentation/{bloc => state}/post_event.dart (55%) rename lib/src/features/post/presentation/{cubits => state}/post_navigation_cubit/post_navigation_cubit.dart (100%) rename lib/src/features/post/presentation/{cubits => state}/post_navigation_cubit/post_navigation_state.dart (69%) rename lib/src/features/post/presentation/{bloc => state}/post_state.dart (63%) rename lib/src/features/post/presentation/utils/{post.dart => post_media_utils.dart} (61%) create mode 100644 lib/src/features/post/presentation/utils/post_optimistic_utils.dart rename lib/src/features/post/presentation/utils/{user_label_utils.dart => user_label_dialog_utils.dart} (95%) delete mode 100644 lib/src/features/post/presentation/utils/utils.dart rename lib/src/{shared => features/post/presentation/widgets}/cross_posts.dart (92%) create mode 100644 lib/src/features/search/api.dart create mode 100644 lib/src/features/search/domain/models/search_resolve_result.dart create mode 100644 lib/src/features/search/domain/models/search_results.dart rename lib/src/features/search/presentation/{bloc => state}/search_bloc.dart (77%) rename lib/src/features/search/presentation/{bloc => state}/search_event.dart (100%) rename lib/src/features/search/presentation/{bloc => state}/search_state.dart (60%) rename lib/src/features/settings/{presentation/utils/utils.dart => api.dart} (100%) create mode 100644 lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_cubit.dart rename lib/src/{app/cubits => features/settings/application/state}/gesture_preferences_cubit/gesture_preferences_state.dart (100%) create mode 100644 lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_cubit.dart rename lib/src/{app/cubits => features/settings/application/state}/theme_preferences_cubit/theme_preferences_state.dart (100%) create mode 100644 lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_cubit.dart rename lib/src/{app/cubits => features/settings/application/state}/video_preferences_cubit/video_preferences_state.dart (100%) create mode 100644 lib/src/features/settings/domain/full_name.dart rename lib/src/{shared/utils/language/language.dart => features/settings/domain/models/language_local.dart} (100%) rename lib/src/{core/enums => features/settings/domain}/swipe_action.dart (95%) rename lib/src/features/settings/presentation/pages/{ => appearance}/appearance_settings_page.dart (83%) rename lib/src/features/settings/presentation/pages/{ => appearance}/comment_appearance_settings_page.dart (95%) rename lib/src/features/settings/presentation/pages/{ => appearance}/post_appearance_settings_page.dart (98%) rename lib/src/features/settings/presentation/pages/{ => appearance}/theme_settings_page.dart (98%) rename lib/src/features/settings/presentation/pages/{ => behavior}/fab_settings_page.dart (98%) rename lib/src/features/settings/presentation/pages/{ => behavior}/filter_settings_page.dart (94%) rename lib/src/features/settings/presentation/pages/{ => behavior}/general_settings_page.dart (97%) rename lib/src/features/settings/presentation/pages/{ => behavior}/gesture_settings_page.dart (97%) rename lib/src/features/settings/presentation/pages/{ => behavior}/video_player_settings.dart (94%) rename lib/src/features/settings/presentation/utils/{settings.dart => setting_link_utils.dart} (85%) create mode 100644 lib/src/features/user/api.dart create mode 100644 lib/src/features/user/domain/utils/user_media_utils.dart rename lib/src/features/user/presentation/{bloc => state}/user_settings_bloc.dart (57%) rename lib/src/features/user/presentation/{bloc => state}/user_settings_event.dart (79%) rename lib/src/features/user/presentation/{bloc => state}/user_settings_state.dart (63%) delete mode 100644 lib/src/features/user/presentation/utils/restore_user.dart rename lib/src/features/user/presentation/utils/{user_groups.dart => user_group_utils.dart} (90%) rename lib/src/features/user/presentation/utils/{logout_dialog.dart => user_session_utils.dart} (67%) rename lib/src/{core => foundation}/config/app_config.dart (100%) rename lib/src/{shared/utils/constants.dart => foundation/config/app_constants.dart} (88%) create mode 100644 lib/src/foundation/config/config.dart rename lib/src/{app/utils => foundation/config}/global_context.dart (97%) rename lib/src/{features/account/data/models => foundation/contracts}/account.dart (97%) create mode 100644 lib/src/foundation/contracts/active_account_provider.dart create mode 100644 lib/src/foundation/contracts/connectivity_service.dart create mode 100644 lib/src/foundation/contracts/contracts.dart create mode 100644 lib/src/foundation/contracts/deep_link_service.dart create mode 100644 lib/src/foundation/contracts/localization_service.dart create mode 100644 lib/src/foundation/contracts/notification_service.dart create mode 100644 lib/src/foundation/contracts/platform_detection_service.dart create mode 100644 lib/src/foundation/contracts/preferences_store.dart create mode 100644 lib/src/foundation/contracts/version_checker.dart create mode 100644 lib/src/foundation/contracts/web_controller.dart rename lib/src/{core/network => foundation/errors}/api_exception.dart (100%) create mode 100644 lib/src/foundation/errors/app_error_reason.dart create mode 100644 lib/src/foundation/errors/errors.dart create mode 100644 lib/src/foundation/foundation.dart rename lib/src/{core/network => foundation/networking}/api_client_factory.dart (82%) rename lib/src/{core/network => foundation/networking}/base_api_client.dart (96%) rename lib/src/{shared/utils/error_messages.dart => foundation/networking/error_message_utils.dart} (89%) rename lib/src/{core/network => foundation/networking}/lemmy/base_lemmy_api_client.dart (91%) rename lib/src/{core/network => foundation/networking}/lemmy/lemmy_v3_api_client.dart (90%) rename lib/src/{core/network => foundation/networking}/lemmy/lemmy_v4_api_client.dart (86%) create mode 100644 lib/src/foundation/networking/networking.dart rename lib/src/{core/network => foundation/networking}/piefed/piefed_api_client.dart (96%) rename lib/src/{core/network => foundation/networking}/thunder_api_client.dart (90%) rename lib/src/{core => foundation/persistence}/database/database.dart (95%) rename lib/src/{core => foundation/persistence}/database/database.g.dart (100%) rename lib/src/{core => foundation/persistence}/database/database.steps.dart (100%) rename lib/src/{core => foundation/persistence}/database/database_utils.dart (93%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v1.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v2.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v3.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v4.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v5.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v6.json (100%) rename lib/src/{core => foundation/persistence}/database/schemas/thunder/drift_schema_v7.json (100%) rename lib/src/{core => foundation/persistence}/database/tables.dart (95%) rename lib/src/{core => foundation/persistence}/database/type_converters.dart (77%) create mode 100644 lib/src/foundation/persistence/database_provider.dart create mode 100644 lib/src/foundation/persistence/persistence.dart rename lib/src/{core/singletons => foundation/persistence}/preferences.dart (98%) rename lib/src/{core => foundation/primitives}/enums/action_color.dart (100%) rename lib/src/{core => foundation/primitives}/enums/browser_mode.dart (100%) rename lib/src/{core => foundation/primitives}/enums/comment_sort_type.dart (81%) rename lib/src/{core => foundation/primitives}/enums/custom_theme_type.dart (100%) rename lib/src/{features/drafts/domain => foundation/primitives}/enums/draft_type.dart (100%) create mode 100644 lib/src/foundation/primitives/enums/enums.dart rename lib/src/{core => foundation/primitives}/enums/feed_card_divider_thickness.dart (92%) rename lib/src/{core => foundation/primitives}/enums/feed_list_type.dart (86%) rename lib/src/{core => foundation/primitives}/enums/font_scale.dart (94%) rename lib/src/{core => foundation/primitives}/enums/image_caching_mode.dart (100%) rename lib/src/{core => foundation/primitives}/enums/internet_connection_type.dart (100%) rename lib/src/{core => foundation/primitives}/enums/local_settings.dart (100%) rename lib/src/{core => foundation/primitives}/enums/media_type.dart (100%) rename lib/src/{core => foundation/primitives}/enums/meta_search_type.dart (81%) rename lib/src/{features/modlog/domain => foundation/primitives}/enums/modlog_action_type.dart (100%) rename lib/src/{core => foundation/primitives}/enums/nested_comment_indicator.dart (100%) rename lib/src/{core => foundation/primitives}/enums/post_body_view_type.dart (100%) rename lib/src/{features/post/domain => foundation/primitives}/enums/post_card_metadata_item.dart (100%) rename lib/src/{core => foundation/primitives}/enums/post_sort_type.dart (91%) rename lib/src/{core => foundation/primitives}/enums/search_sort_type.dart (88%) rename lib/src/{core => foundation/primitives}/enums/subscription_status.dart (82%) rename lib/src/{core => foundation/primitives}/enums/theme_type.dart (100%) rename lib/src/{core => foundation/primitives}/enums/threadiverse_platform.dart (100%) rename lib/src/{core => foundation/primitives}/enums/user_type.dart (100%) rename lib/src/{core => foundation/primitives}/enums/video_auto_play.dart (100%) rename lib/src/{core => foundation/primitives}/enums/video_playback_speed.dart (100%) rename lib/src/{core => foundation/primitives}/enums/video_player_mode.dart (100%) rename lib/src/{core => foundation/primitives}/enums/view_mode.dart (100%) rename lib/src/{core => foundation/primitives}/models/media.dart (71%) create mode 100644 lib/src/foundation/primitives/models/models.dart create mode 100644 lib/src/foundation/primitives/models/modlog_event_item.dart create mode 100644 lib/src/foundation/primitives/models/parsed_link.dart rename lib/src/{features/comment/data => foundation/primitives}/models/thunder_comment.dart (96%) rename lib/src/{core => foundation/primitives}/models/thunder_comment_report.dart (92%) rename lib/src/{features/community/data => foundation/primitives}/models/thunder_community.dart (99%) rename lib/src/{core => foundation/primitives}/models/thunder_instance_info.dart (92%) rename lib/src/{core => foundation/primitives}/models/thunder_language.dart (100%) rename lib/src/{core => foundation/primitives}/models/thunder_local_user.dart (94%) rename lib/src/{core => foundation/primitives}/models/thunder_my_user.dart (95%) rename lib/src/{features/post/data => foundation/primitives}/models/thunder_post.dart (97%) rename lib/src/{core => foundation/primitives}/models/thunder_post_report.dart (88%) rename lib/src/{core => foundation/primitives}/models/thunder_private_message.dart (96%) rename lib/src/{core => foundation/primitives}/models/thunder_site.dart (100%) rename lib/src/{core => foundation/primitives}/models/thunder_site_response.dart (89%) rename lib/src/{core => foundation/primitives}/models/thunder_tagline.dart (100%) rename lib/src/{features/user/data => foundation/primitives}/models/thunder_user.dart (100%) rename lib/src/{core => foundation/primitives}/models/version.dart (100%) create mode 100644 lib/src/foundation/primitives/primitives.dart rename lib/src/{shared/utils/cache.dart => foundation/utils/cache/image_cache_utils.dart} (100%) rename lib/src/{core => foundation/utils}/cache/image_dimension_cache.dart (100%) rename lib/src/{core => foundation/utils}/cache/platform_version_cache.dart (100%) rename lib/src/{core/update => foundation/utils}/check_github_update.dart (93%) rename lib/src/{shared/utils/debounce.dart => foundation/utils/debounce_utils.dart} (100%) rename lib/src/{shared/utils/date_time.dart => foundation/utils/formatting_utils.dart} (79%) rename lib/src/{shared/utils/link_utils.dart => foundation/utils/threadiverse_link_parser_utils.dart} (92%) create mode 100644 lib/src/foundation/utils/utils.dart create mode 100644 lib/src/foundation/utils/utils_internal.dart delete mode 100644 lib/src/shared/full_name_widgets.dart rename lib/src/shared/{utils/swipe.dart => gestures/swipe_utils.dart} (96%) create mode 100644 lib/src/shared/links/links.dart create mode 100644 lib/src/shared/links/widgets/link_bottom_sheet.dart delete mode 100644 lib/src/shared/markdown/markdown_spoiler.dart delete mode 100644 lib/src/shared/markdown/markdown_subsuperscript.dart rename lib/src/shared/{utils/video_player/src => media/widgets}/thunder_video_player.dart (94%) rename lib/src/shared/{utils/video_player/src => media/widgets}/thunder_youtube_player.dart (92%) create mode 100644 lib/src/shared/media/widgets/video_player.dart delete mode 100644 lib/src/shared/persistent_header.dart rename lib/src/shared/{utils/colors.dart => theme/color_utils.dart} (82%) delete mode 100644 lib/src/shared/utils/media/video.dart delete mode 100644 lib/src/shared/utils/numbers.dart delete mode 100644 lib/src/shared/utils/text_input_formatter.dart delete mode 100644 lib/src/shared/utils/video_player/video_player.dart delete mode 100644 lib/src/shared/widgets/avatars/community_avatar.dart delete mode 100644 lib/src/shared/widgets/avatars/instance_avatar.dart delete mode 100644 lib/src/shared/widgets/avatars/user_avatar.dart rename lib/src/shared/{utils/web_utils.dart => widgets/webview/custom_web_view_controller.dart} (66%) create mode 100644 test/app/deep_links_cubit_test.dart create mode 100644 test/app/state_copy_with_nullability_test.dart create mode 100644 test/app/thunder_bloc_test.dart create mode 100644 test/features/account/profile_community_usecase_test.dart create mode 100644 test/features/comment/create_comment_cubit_test.dart create mode 100644 test/features/feed/feed_state_test.dart create mode 100644 test/features/feed/feed_view_usecase_test.dart create mode 100644 test/features/inbox/inbox_bloc_test.dart create mode 100644 test/features/inbox/inbox_cleanup_usecase_test.dart create mode 100644 test/features/instance/instance_pagination_usecase_test.dart create mode 100644 test/features/instance/instance_resolution_usecase_test.dart create mode 100644 test/features/moderator/report_feed_usecase_test.dart create mode 100644 test/features/modlog/modlog_state_test.dart create mode 100644 test/features/post/collapsed_comments_usecase_test.dart create mode 100644 test/features/post/create_post_cubit_test.dart create mode 100644 test/features/post/post_navigation_state_test.dart create mode 100644 test/features/user/user_media_usecase_test.dart create mode 100644 test/helpers/fake_preferences_store.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a056cf6b..90380f0b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.x' + flutter-version: '3.41.x' channel: "stable" cache: true cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' # optional, change this to force refresh cache diff --git a/.github/workflows/instances.yml b/.github/workflows/instances.yml index 37a7a8c75..05fbe7514 100644 --- a/.github/workflows/instances.yml +++ b/.github/workflows/instances.yml @@ -30,14 +30,14 @@ jobs: cat lemmy-instances.txt piefed-instances.txt | sort | uniq -i > instances.txt # Convert to a dart file with a map of domain -> platform - cat << EOF > lib/instances.dart - import 'package:thunder/src/core/enums/threadiverse_platform.dart'; - - const Map instances = { - $(awk '{ print " \047"$0"\047: ThreadiversePlatform.lemmy," }' lemmy-instances.txt) - $(awk '{ print " \047"$0"\047: ThreadiversePlatform.piefed," }' piefed-instances.txt) - }; - EOF + cat << EOF > lib/src/features/instance/data/constants/known_instances.dart + import 'package:thunder/src/foundation/primitives/primitives.dart'; + + const Map knownInstances = { + $(awk '{ print " \047"$0"\047: ThreadiversePlatform.lemmy," }' lemmy-instances.txt) + $(awk '{ print " \047"$0"\047: ThreadiversePlatform.piefed," }' piefed-instances.txt) + }; + EOF # Put the instances in the Android manifest file manifestInstances="$(awk '{ print " " }' instances.txt)" @@ -140,7 +140,7 @@ jobs: with: commit-message: Update instances title: Update instances - body: This PR is updating `instances.dart`, `AndroidManifest.xml`, `manifest.json` and `content.js` with the latest list of Lemmy and PieFed instances retrieved from fediverse.observer. + body: This PR is updating `known_instances.dart`, `AndroidManifest.xml`, `manifest.json` and `content.js` with the latest list of Lemmy and PieFed instances retrieved from fediverse.observer. branch: update-instances delete-branch: true author: GitHub diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e02768e9a..30b5da6d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.x' # When updating this, also update the corresponding f-droid metadata file + flutter-version: '3.41.x' # When updating this, also update the corresponding f-droid metadata file channel: 'stable' cache: true diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf765..391a902b2 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b63630348..c30b367ec 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 022cc7f56..02f40466c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + com.transistorsoft.fetch + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -47,6 +51,27 @@ We need Photos access to allow you to save media. NSPhotoLibraryUsageDescription We need Photos access to allow you to save media. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UIBackgroundModes @@ -72,10 +97,6 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - BGTaskSchedulerPermittedIdentifiers - - com.transistorsoft.fetch - UIViewControllerBasedStatusBarAppearance diff --git a/lib/main.dart b/lib/main.dart index 184c9eade..8e3294e80 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,267 +1,5 @@ -// Dart imports -import 'dart:async'; -import 'dart:io'; +import 'package:thunder/src/app/bootstrap/bootstrap.dart'; -// Flutter imports -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -// Package imports -import "package:flutter_displaymode/flutter_displaymode.dart"; -import 'package:dart_ping_ios/dart_ping_ios.dart'; -import 'package:dynamic_color/dynamic_color.dart'; -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:l10n_esperanto/l10n_esperanto.dart'; -import 'package:overlay_support/overlay_support.dart'; - -// Project imports -import 'package:thunder/src/core/database/database_utils.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/theme_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/app/cubits/notifications_cubit/notifications_cubit.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/shared/utils/cache.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/preferences.dart'; -import 'package:thunder/src/shared/utils/language/language.dart'; - -late AppDatabase database; - -void initializeDatabase() { - database = AppDatabase(); -} - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // Initializes the UserPreferences singleton - await UserPreferences.instance.initialize(); - - try { - ByteData data = await PlatformAssetBundle().load('assets/ca/isrgrootx1.pem'); - SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List()); - } catch (e) { - // Continue if failed to load certificate - } - - // Setting SystemUIMode - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - - // Initialize the database - initializeDatabase(); - - // Clear image cache - await clearExtendedImageCache(); - - // Register dart_ping on iOS - if (!kIsWeb && Platform.isIOS) { - DartPingIOS.register(); - } - - // Perform preference migrations - await performSharedPreferencesMigration(); - - // Perform database integrity checks - await performDatabaseIntegrityChecks(); - - final account = await fetchActiveProfile(); - - runApp(BlocProvider( - create: (context) => ProfileBloc(account: account)..add(InitializeAuth()), - child: const ThunderApp(), - )); - - if (!kIsWeb && Platform.isAndroid) { - // Set high refresh rate after app initialization - FlutterDisplayMode.setHighRefreshRate(); - } -} - -class ThunderApp extends StatefulWidget { - const ThunderApp({super.key}); - - @override - State createState() => _ThunderAppState(); -} - -class _ThunderAppState extends State { - /// Allows the top-level notification handlers to trigger actions farther down - final StreamController notificationsStreamController = StreamController(); - - PageController thunderPageController = PageController(initialPage: 0); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - String? inboxNotificationType = UserPreferences.getLocalSetting(LocalSettings.inboxNotificationType); - - // If notification type is null, then don't perform any logic - if (inboxNotificationType == null) return; - - if (NotificationType.values.byName(inboxNotificationType) != NotificationType.none) { - // Initialize notification logic - initPushNotificationLogic(controller: notificationsStreamController); - } else { - // Attempt to remove tokens from notification server. When inboxNotificationType == NotificationType.none, - // this indicates that removing token was unsuccessful previously. We will attempt to remove it again. - // When there is a successful removal, the inboxNotificationType will be set to null. - bool success = await deleteAccountFromNotificationServer(); - - if (success) { - UserPreferences.removeSetting(LocalSettings.inboxNotificationType); - debugPrint('Removed tokens from notification server'); - } - } - }); - } - - @override - void dispose() { - super.dispose(); - notificationsStreamController.close(); - } - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => DeepLinksCubit()), - BlocProvider(create: (context) => NotificationsCubit(notificationsStream: notificationsStreamController.stream)), - BlocProvider(create: (context) => ThunderBloc()), - BlocProvider(create: (context) => GesturePreferencesCubit()), - BlocProvider(create: (context) => FeedPreferencesCubit()), - BlocProvider(create: (context) => CommentPreferencesCubit()), - BlocProvider(create: (context) => ThemePreferencesCubit()), - BlocProvider(create: (context) => VideoPreferencesCubit()), - BlocProvider(create: (context) => FabPreferencesCubit()), - BlocProvider(create: (context) => FabStateCubit()), - BlocProvider(create: (context) => NavBarStateCubit()), - BlocProvider(create: (context) => FeedUiCubit()), - BlocProvider(create: (context) => AnonymousSubscriptionsBloc()), - BlocProvider(create: (context) => NetworkCheckerCubit()..getConnectionType()) - ], - child: BlocBuilder( - builder: (context, state) { - final appLanguageCode = context.select((bloc) => bloc.state.appLanguageCode); - - return DynamicColorBuilder( - builder: (lightColorScheme, darkColorScheme) { - FlexScheme scheme = FlexScheme.values.byName(state.selectedTheme.name); - - Color? darkThemeSurfaceColor = state.themeType == ThemeType.pureBlack ? null : Colors.black.lighten(8); - - ThemeData theme = FlexThemeData.light(scheme: scheme); - ThemeData darkTheme = FlexThemeData.dark( - scheme: scheme, - darkIsTrueBlack: state.themeType == ThemeType.pureBlack, - surface: darkThemeSurfaceColor, - scaffoldBackground: darkThemeSurfaceColor, - appBarBackground: darkThemeSurfaceColor, - ); - - // Enable Material You theme - if (state.useMaterialYouTheme == true) { - theme = ThemeData( - colorScheme: lightColorScheme, - ); - - darkTheme = FlexThemeData.dark( - colorScheme: darkColorScheme, - darkIsTrueBlack: state.themeType == ThemeType.pureBlack, - ); - } - - // Set the page transitions - const PageTransitionsTheme pageTransitionsTheme = PageTransitionsTheme(builders: { - TargetPlatform.android: CupertinoPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), - }); - - // Customize our themes with the aforementinoed page transitions, as well as some custom styling - theme = theme.copyWith( - pageTransitionsTheme: pageTransitionsTheme, - inputDecorationTheme: InputDecorationTheme( - hintStyle: TextStyle( - color: lightColorScheme?.onSurface.withValues(alpha: 0.6), - ), - ), - ); - darkTheme = darkTheme.copyWith( - pageTransitionsTheme: pageTransitionsTheme, - inputDecorationTheme: InputDecorationTheme( - hintStyle: TextStyle( - color: darkColorScheme?.onSurface.withValues(alpha: 0.6), - ), - ), - ); - - Locale? locale = LanguageLocal.parseLanguageTag(appLanguageCode ?? 'en'); - - return OverlaySupport.global( - child: AnnotatedRegion( - // Set navigation bar color on Android to be transparent - value: FlexColorScheme.themedSystemNavigationBar(context, systemNavBarStyle: FlexSystemNavBarStyle.transparent), - child: BlocBuilder( - buildWhen: (previous, current) => previous.account.id != current.account.id, - builder: (context, profileState) { - final account = profileState.account; - return MultiBlocProvider( - key: ValueKey('account_${account.id}'), - providers: [ - BlocProvider(create: (context) => InboxBloc(account: account)..add(GetInboxEvent(reset: true))), - BlocProvider(create: (context) => SearchBloc(account: account)), - BlocProvider(create: (context) => FeedBloc(account: account)), - ], - child: MaterialApp( - title: 'Thunder', - locale: locale, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - MaterialLocalizationsEo.delegate, - CupertinoLocalizationsEo.delegate, - ], - supportedLocales: const [ - ...AppLocalizations.supportedLocales, - Locale('eo'), // Additional locale which is not officially supported: Esperanto - ], - themeMode: state.themeType == ThemeType.system ? ThemeMode.system : (state.themeType == ThemeType.light ? ThemeMode.light : ThemeMode.dark), - theme: theme, - darkTheme: darkTheme, - debugShowCheckedModeBanner: false, - scaffoldMessengerKey: GlobalContext.scaffoldMessengerKey, - scrollBehavior: (state.reduceAnimations && Platform.isAndroid) ? const ScrollBehavior().copyWith(overscroll: false) : null, - home: Thunder(pageController: thunderPageController), - ), - ); - }, - ), - ), - ); - }, - ); - }, - ), - ); - } +Future main() async { + await bootstrap(); } diff --git a/lib/src/app/widgets/thunder_icons.dart b/lib/packages/ui/src/icons/thunder_icons.dart similarity index 100% rename from lib/src/app/widgets/thunder_icons.dart rename to lib/packages/ui/src/icons/thunder_icons.dart diff --git a/lib/packages/ui/src/models/content/content_action_handlers.dart b/lib/packages/ui/src/models/content/content_action_handlers.dart new file mode 100644 index 000000000..505e0a4e9 --- /dev/null +++ b/lib/packages/ui/src/models/content/content_action_handlers.dart @@ -0,0 +1,25 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +typedef OpenLinkHandler = void Function(BuildContext context, String url); +typedef OpenImageHandler = void Function(BuildContext context, {String? url, Uint8List? bytes}); +typedef OpenVideoHandler = void Function(BuildContext context, String url); +typedef MarkReadHandler = void Function(int? postId); +typedef LongPressLinkHandler = void Function(BuildContext context, String text, String? url); + +class ContentActionHandlers { + const ContentActionHandlers({ + this.onOpenLink, + this.onLongPressLink, + this.onOpenImage, + this.onOpenVideo, + this.onMarkRead, + }); + + final OpenLinkHandler? onOpenLink; + final LongPressLinkHandler? onLongPressLink; + final OpenImageHandler? onOpenImage; + final OpenVideoHandler? onOpenVideo; + final MarkReadHandler? onMarkRead; +} diff --git a/lib/packages/ui/src/models/content/content_media.dart b/lib/packages/ui/src/models/content/content_media.dart new file mode 100644 index 000000000..197ced85d --- /dev/null +++ b/lib/packages/ui/src/models/content/content_media.dart @@ -0,0 +1,61 @@ +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; + +/// Generic media model used by content widgets. +class ContentMedia { + ContentMedia({ + this.thumbnailUrl, + this.mediaUrl, + this.originalUrl, + this.width, + this.height, + this.nsfw = false, + required this.mediaType, + this.altText, + this.contentType, + }); + + /// The original external URL of the post. + String? originalUrl; + + /// The thumbnail URL of the media. + String? thumbnailUrl; + + /// The actual URL of the media source. + String? mediaUrl; + + /// The width of the media source. + double? width; + + /// The height of the media source. + double? height; + + /// Indicates whether the media is NSFW. + bool nsfw; + + /// Indicates the type of media it holds. + ContentMediaType mediaType; + + /// Includes an alternative text-based description of the image. + String? altText; + + /// The content type of the media. + String? contentType; + + /// Gets the full-size image URL, if any. + String? get imageUrl => _looksLikeImage(mediaUrl) ? mediaUrl : thumbnailUrl; + + bool _looksLikeImage(String? url) { + if (url == null) return false; + if (url.contains('/image_proxy')) return true; + + final lowerPath = (Uri.tryParse(url)?.path ?? url).toLowerCase(); + return lowerPath.endsWith('.png') || + lowerPath.endsWith('.jpg') || + lowerPath.endsWith('.jpeg') || + lowerPath.endsWith('.gif') || + lowerPath.endsWith('.bmp') || + lowerPath.endsWith('.webp') || + lowerPath.endsWith('.avif') || + lowerPath.endsWith('@jpeg'); + } +} diff --git a/lib/packages/ui/src/models/content/content_media_type.dart b/lib/packages/ui/src/models/content/content_media_type.dart new file mode 100644 index 000000000..83875b6e4 --- /dev/null +++ b/lib/packages/ui/src/models/content/content_media_type.dart @@ -0,0 +1 @@ +enum ContentMediaType { image, video, link, text } diff --git a/lib/packages/ui/src/models/content/content_view_mode.dart b/lib/packages/ui/src/models/content/content_view_mode.dart new file mode 100644 index 000000000..666aae203 --- /dev/null +++ b/lib/packages/ui/src/models/content/content_view_mode.dart @@ -0,0 +1,10 @@ +enum ContentViewMode { + comment(150.0), + compact(75.0), + comfortable(150.0); + + /// The height of media previews for the given view mode. + final double height; + + const ContentViewMode(this.height); +} diff --git a/lib/packages/ui/src/models/identity/avatar_data.dart b/lib/packages/ui/src/models/identity/avatar_data.dart new file mode 100644 index 000000000..e17c72771 --- /dev/null +++ b/lib/packages/ui/src/models/identity/avatar_data.dart @@ -0,0 +1,13 @@ +class AvatarData { + const AvatarData({ + required this.fallbackLabel, + this.imageUrl, + this.radius = 16.0, + this.semanticLabel, + }); + + final String fallbackLabel; + final String? imageUrl; + final double radius; + final String? semanticLabel; +} diff --git a/lib/packages/ui/src/models/identity/identity_name_data.dart b/lib/packages/ui/src/models/identity/identity_name_data.dart new file mode 100644 index 000000000..1864a7fae --- /dev/null +++ b/lib/packages/ui/src/models/identity/identity_name_data.dart @@ -0,0 +1,9 @@ +class IdentityNameData { + const IdentityNameData({ + required this.primary, + this.secondary, + }); + + final String primary; + final String? secondary; +} diff --git a/lib/packages/ui/src/models/identity/name_style.dart b/lib/packages/ui/src/models/identity/name_style.dart new file mode 100644 index 000000000..da77df2b1 --- /dev/null +++ b/lib/packages/ui/src/models/identity/name_style.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; + +enum FullNameSeparator { + dot, // name · instance.tld + at, // name@instance.tld + lemmy; // '@name@instance.tld or !name@instance.tld' +} + +enum NameThickness { + light, + normal, + bold; + + FontWeight toWeight() => switch (this) { + NameThickness.light => FontWeight.w300, + NameThickness.normal => FontWeight.w400, + NameThickness.bold => FontWeight.w500, + }; + + double toSliderValue() => switch (this) { + NameThickness.light => 0, + NameThickness.normal => 1, + NameThickness.bold => 2, + }; + + static NameThickness fromSliderValue(double value) { + return switch (value) { + 0 => NameThickness.light, + 1 => NameThickness.normal, + 2 => NameThickness.bold, + _ => NameThickness.normal, + }; + } + + String label(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return switch (this) { + NameThickness.light => l10n.light, + NameThickness.normal => l10n.normal, + NameThickness.bold => l10n.bold, + }; + } +} + +class NameColor { + static const String defaultColor = 'default'; + static const String themePrimary = 'theme_primary'; + static const String themeSecondary = 'theme_secondary'; + static const String themeTertiary = 'theme_tertiary'; + + final String color; + + const NameColor.fromString({this.color = defaultColor}); + + Color? toColor(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return switch (color) { + defaultColor => theme.textTheme.bodyMedium?.color, + themePrimary => theme.colorScheme.primary, + themeSecondary => theme.colorScheme.secondary, + themeTertiary => theme.colorScheme.tertiary, + _ => theme.textTheme.bodyMedium?.color, + }; + } + + String label(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return switch (color) { + defaultColor => l10n.defaultColor, + themePrimary => l10n.themePrimary, + themeSecondary => l10n.themeSecondary, + themeTertiary => l10n.themeTertiary, + _ => l10n.defaultColor, + }; + } + + static List getPossibleValues(NameColor currentValue) { + return [ + currentValue.color == defaultColor ? currentValue : const NameColor.fromString(color: NameColor.defaultColor), + currentValue.color == themePrimary ? currentValue : const NameColor.fromString(color: NameColor.themePrimary), + currentValue.color == themeSecondary ? currentValue : const NameColor.fromString(color: NameColor.themeSecondary), + currentValue.color == themeTertiary ? currentValue : const NameColor.fromString(color: NameColor.themeTertiary), + ]; + } +} diff --git a/lib/packages/ui/src/utils/identity/name_formatting.dart b/lib/packages/ui/src/utils/identity/name_formatting.dart new file mode 100644 index 000000000..a4dd2268c --- /dev/null +++ b/lib/packages/ui/src/utils/identity/name_formatting.dart @@ -0,0 +1,79 @@ +import 'package:thunder/packages/ui/src/models/identity/name_style.dart'; + +String formatUserFullNamePrefix( + String? name, + String? displayName, { + required FullNameSeparator separator, + required bool useDisplayName, +}) { + final resolvedName = (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? ''; + + return switch (separator) { + FullNameSeparator.dot => resolvedName, + FullNameSeparator.at => resolvedName, + FullNameSeparator.lemmy => '@$resolvedName', + }; +} + +String formatUserFullNameSuffix( + String? instance, { + required FullNameSeparator separator, +}) { + return switch (separator) { + FullNameSeparator.dot => ' · $instance', + FullNameSeparator.at => '@$instance', + FullNameSeparator.lemmy => '@$instance', + }; +} + +String formatUserFullName( + String? name, + String? displayName, + String? instance, { + required FullNameSeparator separator, + required bool useDisplayName, +}) { + final prefix = formatUserFullNamePrefix(name, displayName, separator: separator, useDisplayName: useDisplayName); + final suffix = formatUserFullNameSuffix(instance, separator: separator); + + return '$prefix$suffix'; +} + +String formatCommunityFullNamePrefix( + String? name, + String? displayName, { + required FullNameSeparator separator, + required bool useDisplayName, +}) { + final resolvedName = (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? ''; + + return switch (separator) { + FullNameSeparator.dot => resolvedName, + FullNameSeparator.at => resolvedName, + FullNameSeparator.lemmy => '!$resolvedName', + }; +} + +String formatCommunityFullNameSuffix( + String? instance, { + required FullNameSeparator separator, +}) { + return switch (separator) { + FullNameSeparator.dot => ' · $instance', + FullNameSeparator.at => '@$instance', + FullNameSeparator.lemmy => '@$instance', + }; +} + +String formatCommunityFullName( + String? name, + String? displayName, + String? instance, { + required FullNameSeparator separator, + required bool useDisplayName, +}) { + final prefix = formatCommunityFullNamePrefix(name, displayName, separator: separator, useDisplayName: useDisplayName); + final suffix = formatCommunityFullNameSuffix(instance, separator: separator); + + return '$prefix$suffix'; +} diff --git a/lib/packages/ui/src/utils/links/link_navigation_utils.dart b/lib/packages/ui/src/utils/links/link_navigation_utils.dart new file mode 100644 index 000000000..0ac5a8cbb --- /dev/null +++ b/lib/packages/ui/src/utils/links/link_navigation_utils.dart @@ -0,0 +1,41 @@ +import 'package:intl/message_format.dart'; + +/// Resolves the best URL from markdown link text and target. +String resolveMarkdownLink(String text, String? url) { + final parsedUri = Uri.tryParse(url ?? '') ?? Uri.tryParse(text); + + String parsedUrl = text; + + if (parsedUri != null && parsedUri.host.isNotEmpty) { + parsedUrl = parsedUri.toString(); + } else { + parsedUrl = url ?? ''; + } + + // The markdown link processor treats URLs with @ as emails and prepends + // `mailto:`. If the displayed text does not include that prefix, remove it. + if (parsedUrl.startsWith('mailto:') && !text.startsWith('mailto:')) { + parsedUrl = parsedUrl.replaceFirst('mailto:', ''); + } + + return parsedUrl; +} + +List<({String sourceName, String link})> generateAlternateSources(String link) { + return _alternateSources.map((alternateSource) { + return (sourceName: alternateSource.sourceName, link: alternateSource.template.format({'link': link})); + }).toList(); +} + +final List<({String sourceName, MessageFormat template})> _alternateSources = [ + (sourceName: 'Archive Today', template: MessageFormat('https://archive.today/{link}')), + (sourceName: 'Internet Archive', template: MessageFormat('https://web.archive.org/save/{link}')), + (sourceName: 'Ground News', template: MessageFormat('https://ground.news/find?url={link}')), +]; + +/// Determines if a given URL is valid. The URL must have the `http` or +/// `https` scheme. +bool isValidUrl(String url) { + final uri = Uri.tryParse(url); + return uri != null && uri.hasAbsolutePath && uri.scheme.startsWith('http'); +} diff --git a/lib/src/shared/markdown/markdown_utils.dart b/lib/packages/ui/src/utils/markdown/markdown_utils.dart similarity index 92% rename from lib/src/shared/markdown/markdown_utils.dart rename to lib/packages/ui/src/utils/markdown/markdown_utils.dart index cbe62274c..87e48d867 100644 --- a/lib/src/shared/markdown/markdown_utils.dart +++ b/lib/packages/ui/src/utils/markdown/markdown_utils.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; /// Returns a [MarkdownStyleSheet] for a spoiler. /// @@ -64,20 +63,19 @@ MarkdownStyleSheet getSpoilerStyleSheet(BuildContext context) { } /// Returns a [MarkdownStyleSheet] for a normal markdown body. -/// -/// This config is used to display the contents of the markdown body. MarkdownStyleSheet getNormalStyleSheet(BuildContext context) { final theme = Theme.of(context); + final surface = theme.colorScheme.surfaceContainerHighest; return MarkdownStyleSheet.fromTheme(theme).copyWith( blockquoteDecoration: BoxDecoration( - color: getBackgroundColor(context), + color: surface, border: Border(left: BorderSide(color: theme.colorScheme.primary.withValues(alpha: 0.75), width: 4)), borderRadius: BorderRadius.circular(5), ), - codeblockDecoration: BoxDecoration(color: getBackgroundColor(context), borderRadius: BorderRadius.circular(10)), + codeblockDecoration: BoxDecoration(color: surface, borderRadius: BorderRadius.circular(10)), code: theme.textTheme.bodyMedium?.copyWith( - backgroundColor: getBackgroundColor(context), + backgroundColor: surface, fontFamily: 'monospace', fontSize: theme.textTheme.bodyMedium!.fontSize! * 0.85, ), diff --git a/lib/src/shared/utils/media/image.dart b/lib/packages/ui/src/utils/media/media_utils.dart similarity index 65% rename from lib/src/shared/utils/media/image.dart rename to lib/packages/ui/src/utils/media/media_utils.dart index b3ed8bb4a..03be10e2f 100644 --- a/lib/src/shared/utils/media/image.dart +++ b/lib/packages/ui/src/utils/media/media_utils.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart' as http; @@ -13,45 +15,37 @@ import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; import 'package:image_dimension_parser/image_dimension_parser.dart'; -import 'package:thunder/src/core/cache/image_dimension_cache.dart'; -import 'package:thunder/src/shared/images/image_viewer.dart'; +import 'package:thunder/packages/ui/src/widgets/media/image_viewer.dart'; + +final Map _imageDimensionsCache = {}; -/// Given a URL, returns the original URL if it is a proxy URL. Otherwise, returns the original URL unchanged. -/// -/// This is useful for handling thumbnail URLs that are proxied via Lemmy's /image_proxy endpoint. -/// When image proxying is enabled on an instance, thumbnail URLs may be in the format: `https://instance.com/api/v3/image_proxy?url=` -/// -/// This function extracts and returns the original URL so that images can be loaded directly, which helps when the proxy endpoint fails. -/// It handles nested proxy URLs by recursively unwrapping until the original non-proxy URL is found. +/// Given a URL, returns the original URL if it is a proxy URL. String fetchProxyImageUrl(String url) { String currentUrl = url; - // Keep unwrapping proxy URLs until we reach the original while (true) { Uri uri; try { uri = Uri.parse(currentUrl); } catch (e) { - return currentUrl; // Return the current URL if parsing fails + return currentUrl; } - // Handle image proxy URLs if (isImageProxyUrl(currentUrl)) { Uri? parsedUri = Uri.tryParse(uri.queryParameters['url'] ?? ''); if (parsedUri != null) { currentUrl = parsedUri.toString(); - continue; // Check if this URL is also a proxy + continue; } } - // No more proxy found, return the current URL return currentUrl; } } -/// Checks if the given URL is an image proxy URL (contains /image_proxy endpoint) +/// Checks if the given URL is an image proxy URL. bool isImageProxyUrl(String url) { try { final uri = Uri.parse(url); @@ -61,13 +55,10 @@ bool isImageProxyUrl(String url) { } } -/// Determines if the given URL is an image URL +/// Determines if the given URL is an image URL. bool isImageUrl(String url) { - // '@jpeg' is added to support Bluesky's image URLs - // e.g., https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:wf7nfy2us3h5gpa7zfettmzl/bafkreib6k2uwcy52wi654fdfmfqakzqu54m4eq7vi6cwrolwud6yhehihy@jpeg?.jpg final imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '@jpeg']; - // If it's an image proxy URL, it's an image URL. Otherwise, check the file extension of the URL. if (isImageProxyUrl(url)) return true; Uri uri; @@ -87,7 +78,7 @@ bool isImageUrl(String url) { return false; } -/// Determines if the given URL is an SVG +/// Determines if the given URL is an SVG. Future isImageUrlSvg(String imageUrl) async { return isImageUriSvg(Uri.tryParse(imageUrl)); } @@ -96,8 +87,6 @@ Future isImageUriSvg(Uri? imageUri) async { try { final http.Response response = await http.get( imageUri ?? Uri(), - // Get the headers and ask for 0 bytes of the body - // to make this a lightweight request headers: { 'method': 'HEAD', 'Range': 'bytes=0-0', @@ -105,22 +94,20 @@ Future isImageUriSvg(Uri? imageUri) async { ); return response.headers['content-type']?.toLowerCase().contains('svg') == true; } catch (e) { - // If it fails for any reason, it's not an SVG! return false; } } -/// Checks if the given path or URL points to an AVIF image bool _isAvifImage(String path) { return path.toLowerCase().endsWith('.avif'); } -/// Fetches the image dimensions from the given URL using partial content fetch +/// Fetches the image dimensions from the given URL using partial content fetch. Future> processImageDimensions(String imageUrl) async { try { final response = await http.get( Uri.parse(imageUrl), - headers: {'Range': 'bytes=0-10240'}, // 10KB + headers: {'Range': 'bytes=0-10240'}, ); if (response.statusCode == 206 || response.statusCode == 200) { @@ -134,17 +121,18 @@ Future> processImageDimensions(String imageUrl) async { return []; } -/// Retrieves the size of the given image given its bytes. -/// Uses the `image` package which does not support AVIF format. For AVIF images, use [processAvifImage] instead. +/// Retrieves the size of the given image bytes using the image package. Future processImage(String filename) async { final bytes = await File(filename).readAsBytes(); final image = img.decodeImage(bytes); - if (image == null) throw Exception('Failed to retrieve image data from bytes'); + if (image == null) { + throw Exception('Failed to retrieve image data from bytes'); + } return Size(image.width.toDouble(), image.height.toDouble()); } -/// Retrieves the size of an AVIF image using flutter_avif +/// Retrieves the size of an AVIF image using flutter_avif. Future processAvifImage(String filename) async { final bytes = await File(filename).readAsBytes(); final frames = await decodeAvif(bytes); @@ -154,7 +142,6 @@ Future processAvifImage(String filename) async { final firstFrame = frames.first; final size = Size(firstFrame.image.width.toDouble(), firstFrame.image.height.toDouble()); - // Dispose the decoded images to free memory for (final frame in frames) { frame.image.dispose(); } @@ -162,9 +149,7 @@ Future processAvifImage(String filename) async { return size; } -/// Retrieves the size of the given image. Must provide either [imageUrl] or [imageBytes]. -/// -/// For AVIF images, uses flutter_avif to determine dimensions. +/// Retrieves the size of the given image. Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) async { assert(imageUrl != null || imageBytes != null); @@ -172,23 +157,23 @@ Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) Size? size; if (imageUrl != null) { - size = ImageDimensionCache().get(imageUrl); + size = _imageDimensionsCache[imageUrl]; if (size != null) return size; } Uint8List? data = imageBytes; if (data == null && imageUrl != null) { - // Try to get size using partial content fetch try { final dimensions = await compute(processImageDimensions, imageUrl); if (dimensions.isNotEmpty) { size = Size(dimensions[0].toDouble(), dimensions[1].toDouble()); - debugPrint('Retrieved image dimensions using partial content fetch: ${dimensions[0]}x${dimensions[1]}'); + debugPrint( + 'Retrieved image dimensions using partial content fetch: ${dimensions[0]}x${dimensions[1]}', + ); } } catch (e) { - // Fallback to full download if partial fetch fails debugPrint('Failed to retrieve image dimensions using partial content fetch: $e'); } @@ -205,7 +190,7 @@ Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) if (size == null) throw Exception('Failed to retrieve image dimensions'); - if (imageUrl != null) ImageDimensionCache().set(imageUrl, size); + if (imageUrl != null) _imageDimensionsCache[imageUrl] = size; return size; } catch (e) { throw Exception('Failed to retrieve image dimensions: $e'); @@ -214,15 +199,15 @@ Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) Size? getScaledMediaSize({double? width, double? height, double offset = 24.0, bool tabletMode = false}) { if (width == null || height == null) return null; - double mediaRatio = width / height; + final mediaRatio = width / height; final device = PlatformDispatcher.instance.views.first; - double screenWidth = (device.physicalSize.width / device.devicePixelRatio) - device.viewPadding.left - device.viewPadding.right - offset; - double usableScreenWidth = tabletMode ? screenWidth / 2 - (offset + 8.0) : screenWidth; - double widthScale = usableScreenWidth / width; - double mediaMaxWidth = widthScale * width; - double mediaMaxHeight = mediaMaxWidth / mediaRatio; + final screenWidth = (device.physicalSize.width / device.devicePixelRatio) - device.viewPadding.left - device.viewPadding.right - offset; + final usableScreenWidth = tabletMode ? screenWidth / 2 - (offset + 8.0) : screenWidth; + final widthScale = usableScreenWidth / width; + final mediaMaxWidth = widthScale * width; + final mediaMaxHeight = mediaMaxWidth / mediaRatio; return Size(mediaMaxWidth, mediaMaxHeight); } @@ -239,7 +224,7 @@ Future> selectImagesToUpload({bool allowMultiple = false}) async { return [file!.path]; } -void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId, void Function()? navigateToPost, String? altText}) { +void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId, void Function()? navigateToPost, String? altText, bool clearMemoryCacheWhenDispose = false}) { Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -252,6 +237,7 @@ void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId: postId, navigateToPost: navigateToPost, altText: altText, + clearMemoryCacheWhenDispose: clearMemoryCacheWhenDispose, ); }, transitionsBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { @@ -265,3 +251,40 @@ void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? ), ); } + +bool isVideoUrl(String url) { + const videoExtensions = [ + 'mp4', + 'avi', + 'mkv', + 'mov', + 'wmv', + 'flv', + 'webm', + 'ogg', + 'ogv', + '3gp', + 'mpeg', + 'mpg', + 'm4v', + 'ts', + 'vob', + ]; + + final youtubeVideoId = YoutubePlayer.convertUrlToId(url); + final fileExtension = url.split('.').last.toLowerCase(); + + return videoExtensions.contains(fileExtension) || (youtubeVideoId?.isNotEmpty ?? false); +} + +/// Generic video launcher for package consumers. +void showVideoPlayer( + BuildContext context, { + String? url, + int? postId, + void Function(BuildContext context, String url)? onOpenVideo, +}) { + if (url == null) return; + HapticFeedback.selectionClick(); + onOpenVideo?.call(context, url); +} diff --git a/lib/src/shared/bottom_sheet_action.dart b/lib/packages/ui/src/widgets/actions/bottom_sheet_action.dart similarity index 100% rename from lib/src/shared/bottom_sheet_action.dart rename to lib/packages/ui/src/widgets/actions/bottom_sheet_action.dart diff --git a/lib/src/shared/widgets/chips/thunder_action_chip.dart b/lib/packages/ui/src/widgets/actions/thunder_action_chip.dart similarity index 100% rename from lib/src/shared/widgets/chips/thunder_action_chip.dart rename to lib/packages/ui/src/widgets/actions/thunder_action_chip.dart diff --git a/lib/src/shared/widgets/thunder_popup_menu_item.dart b/lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart similarity index 100% rename from lib/src/shared/widgets/thunder_popup_menu_item.dart rename to lib/packages/ui/src/widgets/actions/thunder_popup_menu_item.dart diff --git a/lib/packages/ui/src/widgets/content/content_renderer.dart b/lib/packages/ui/src/widgets/content/content_renderer.dart new file mode 100644 index 000000000..f8608937e --- /dev/null +++ b/lib/packages/ui/src/widgets/content/content_renderer.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; + +import 'package:thunder/packages/ui/src/models/content/content_action_handlers.dart'; + +class ContentRenderer extends StatelessWidget { + const ContentRenderer({ + super.key, + required this.builder, + this.handlers = const ContentActionHandlers(), + }); + + final Widget Function(BuildContext context, ContentActionHandlers handlers) builder; + final ContentActionHandlers handlers; + + @override + Widget build(BuildContext context) { + return builder(context, handlers); + } +} diff --git a/lib/src/shared/dialogs.dart b/lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart similarity index 100% rename from lib/src/shared/dialogs.dart rename to lib/packages/ui/src/widgets/dialogs/thunder_dialog.dart diff --git a/lib/src/shared/snackbar.dart b/lib/packages/ui/src/widgets/feedback/snackbar.dart similarity index 86% rename from lib/src/shared/snackbar.dart rename to lib/packages/ui/src/widgets/feedback/snackbar.dart index 66d382641..80b8ad2d6 100644 --- a/lib/src/shared/snackbar.dart +++ b/lib/packages/ui/src/widgets/feedback/snackbar.dart @@ -1,272 +1,260 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:overlay_support/overlay_support.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; - -const Duration _snackBarTransitionDuration = Duration(milliseconds: 500); - -void showSnackbar( - String text, { - Duration? duration, - Color? backgroundColor, - Color? leadingIconColor, - IconData? leadingIcon, - Color? trailingIconColor, - IconData? trailingIcon, - bool closable = true, - void Function()? trailingAction, -}) { - int wordCount = RegExp(r'[\w-]+').allMatches(text).length; - - // Allows us to clear the previous overlay before showing the next one - const key = TransientKey('transient'); - - WidgetsBinding.instance.addPostFrameCallback((_) { - showOverlay( - (context, progress) { - return SnackbarNotification( - builder: (context) => ThunderSnackbar( - content: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (leadingIcon != null) ...[Icon(leadingIcon, color: leadingIconColor), const SizedBox(width: 8.0)], - Expanded(child: Text(text)), - if (trailingIcon != null) - Padding( - padding: const EdgeInsets.only(left: 12.0), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: trailingAction != null - ? () { - OverlaySupportEntry.of(context)?.dismiss(); - trailingAction(); - } - : null, - child: Icon(trailingIcon, color: trailingIconColor ?? Theme.of(context).colorScheme.inversePrimary), - ), - ), - if (closable) - Padding( - padding: const EdgeInsets.only(left: 12.0), - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: () => OverlaySupportEntry.of(context)?.dismiss(), - child: Icon(Icons.close_rounded, color: Theme.of(context).colorScheme.surface), - ), - ), - ], - ), - closable: closable, - ), - progress: progress, - ); - }, - animationDuration: _snackBarTransitionDuration, - duration: duration ?? Duration(milliseconds: max(kNotificationDuration.inMilliseconds, max(4000, 1000 * wordCount))), // Assuming 60 WPM or 1 WPS - context: GlobalContext.context, - key: key, - ); - }); -} - -/// Builds a custom snackbar which attempts to match the Material 3 spec as closely as possible. -class SnackbarNotification extends StatefulWidget { - final WidgetBuilder builder; - - final double progress; - - const SnackbarNotification({super.key, required this.builder, required this.progress}); - - @override - State createState() => _SnackbarNotificationState(); -} - -class _SnackbarNotificationState extends State with TickerProviderStateMixin { - late AnimationController _controller; - - static const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; - static const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); - static const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: _snackBarTransitionDuration, // Set the duration of the animation. - ); - } - - @override - void didUpdateWidget(SnackbarNotification oldWidget) { - super.didUpdateWidget(oldWidget); - - if ((widget.progress - oldWidget.progress) > 0) { - if (!_controller.isAnimating) _controller.forward(); - } else if ((widget.progress - oldWidget.progress) < 0) { - if (!_controller.isAnimating) _controller.reverse(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final CurvedAnimation fadeInM3Animation = CurvedAnimation(parent: _controller, curve: _snackBarM3FadeInCurve, reverseCurve: _snackBarFadeOutCurve); - - final CurvedAnimation heightM3Animation = CurvedAnimation( - parent: _controller, - curve: _snackBarM3HeightCurve, - reverseCurve: const Threshold(0.0), - ); - - return FadeTransition( - opacity: fadeInM3Animation, - child: AnimatedBuilder( - animation: heightM3Animation, - builder: (BuildContext context, Widget? child) { - return Align( - alignment: AlignmentDirectional.bottomStart, - heightFactor: heightM3Animation.value, - child: child, - ); - }, - child: widget.builder(context), - ), - ); - } -} - -class ThunderSnackbar extends StatefulWidget { - /// The content of the snackbar. - final Widget content; - - /// Whether the snackbar is closable or not. This parameter controls the padding of the snackbar. - /// See https://m3.material.io/components/snackbar/specs#c7b5d52a-24e7-45ca-8db6-7ce7d80a1cea - final bool closable; - - const ThunderSnackbar({super.key, required this.content, this.closable = true}); - - @override - State createState() => _ThunderSnackbarState(); -} - -class _ThunderSnackbarState extends State with WidgetsBindingObserver { - final double horizontalPadding = 16.0; - final double singleLineVerticalPadding = 14.0; - - double snackbarBottomPadding = 0; - Widget child = Container(); - - double calculateBottomPadding() { - final double minimumPadding = MediaQuery.viewPaddingOf(context).bottom + kBottomNavigationBarHeight + singleLineVerticalPadding; - final double bottomViewInsets = MediaQuery.viewInsetsOf(context).bottom; - - return max(minimumPadding, bottomViewInsets); - } - - void rebuildSnackbar() { - final ThemeData theme = Theme.of(context); - final SnackBarThemeData snackBarTheme = theme.snackBarTheme; - - final double elevation = snackBarTheme.elevation ?? 6.0; - final Color backgroundColor = theme.colorScheme.inverseSurface; - final ShapeBorder shape = snackBarTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)); - - child = SafeArea( - child: Container( - padding: EdgeInsets.only(bottom: calculateBottomPadding()), - child: ClipRect( - child: Align( - alignment: AlignmentDirectional.bottomStart, - child: Semantics( - container: true, - liveRegion: true, - child: Padding( - padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 0.0), - child: Material( - shape: shape, - elevation: elevation, - color: backgroundColor, - clipBehavior: Clip.none, - child: Theme( - data: theme, - child: Padding( - padding: EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.closable ? 12.0 : 8.0), - child: Wrap( - children: [ - Row( - children: [ - Expanded( - child: Container( - padding: EdgeInsets.symmetric(vertical: singleLineVerticalPadding), - child: DefaultTextStyle( - style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.onInverseSurface), - child: widget.content, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } - - @override - void initState() { - super.initState(); - - // Initialize the widget here. We do this so that we can change the state of the widget to an empty Container when we dismiss the snackbar. - // Doing so prevents the snackbar from showing back up after it has been dismissed. - WidgetsBinding.instance.addPostFrameCallback((_) => rebuildSnackbar()); - - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeMetrics() { - double newSnackbarBottomPadding = calculateBottomPadding(); - - if (snackbarBottomPadding != newSnackbarBottomPadding) { - snackbarBottomPadding = newSnackbarBottomPadding; - rebuildSnackbar(); - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - return Dismissible( - key: UniqueKey(), - direction: DismissDirection.down, - behavior: HitTestBehavior.deferToChild, - onDismissed: (direction) { - setState(() => child = Container()); - }, - child: child, - ); - } -} +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:overlay_support/overlay_support.dart'; + +const Duration _snackBarTransitionDuration = Duration(milliseconds: 500); + +void showSnackbar( + String text, { + Duration? duration, + Color? backgroundColor, + Color? leadingIconColor, + IconData? leadingIcon, + Color? trailingIconColor, + IconData? trailingIcon, + bool closable = true, + void Function()? trailingAction, +}) { + final int wordCount = RegExp(r'[\w-]+').allMatches(text).length; + + const key = TransientKey('transient'); + + WidgetsBinding.instance.addPostFrameCallback((_) { + showOverlay( + (context, progress) { + return SnackbarNotification( + builder: (context) => ThunderSnackbar( + content: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (leadingIcon != null) ...[Icon(leadingIcon, color: leadingIconColor), const SizedBox(width: 8.0)], + Expanded(child: Text(text)), + if (trailingIcon != null) + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: trailingAction != null + ? () { + OverlaySupportEntry.of(context)?.dismiss(); + trailingAction(); + } + : null, + child: Icon(trailingIcon, color: trailingIconColor ?? Theme.of(context).colorScheme.inversePrimary), + ), + ), + if (closable) + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: () => OverlaySupportEntry.of(context)?.dismiss(), + child: Icon(Icons.close_rounded, color: Theme.of(context).colorScheme.surface), + ), + ), + ], + ), + closable: closable, + ), + progress: progress, + ); + }, + animationDuration: _snackBarTransitionDuration, + duration: duration ?? Duration(milliseconds: max(kNotificationDuration.inMilliseconds, max(4000, 1000 * wordCount))), + key: key, + ); + }); +} + +class SnackbarNotification extends StatefulWidget { + final WidgetBuilder builder; + + final double progress; + + const SnackbarNotification({super.key, required this.builder, required this.progress}); + + @override + State createState() => _SnackbarNotificationState(); +} + +class _SnackbarNotificationState extends State with TickerProviderStateMixin { + late AnimationController _controller; + + static const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; + static const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); + static const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: _snackBarTransitionDuration, + ); + } + + @override + void didUpdateWidget(SnackbarNotification oldWidget) { + super.didUpdateWidget(oldWidget); + + if ((widget.progress - oldWidget.progress) > 0) { + if (!_controller.isAnimating) _controller.forward(); + } else if ((widget.progress - oldWidget.progress) < 0) { + if (!_controller.isAnimating) _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final CurvedAnimation fadeInM3Animation = CurvedAnimation(parent: _controller, curve: _snackBarM3FadeInCurve, reverseCurve: _snackBarFadeOutCurve); + + final CurvedAnimation heightM3Animation = CurvedAnimation( + parent: _controller, + curve: _snackBarM3HeightCurve, + reverseCurve: const Threshold(0.0), + ); + + return FadeTransition( + opacity: fadeInM3Animation, + child: AnimatedBuilder( + animation: heightM3Animation, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: heightM3Animation.value, + child: child, + ); + }, + child: widget.builder(context), + ), + ); + } +} + +class ThunderSnackbar extends StatefulWidget { + final Widget content; + + final bool closable; + + const ThunderSnackbar({super.key, required this.content, this.closable = true}); + + @override + State createState() => _ThunderSnackbarState(); +} + +class _ThunderSnackbarState extends State with WidgetsBindingObserver { + final double horizontalPadding = 16.0; + final double singleLineVerticalPadding = 14.0; + + double snackbarBottomPadding = 0; + Widget child = Container(); + + double calculateBottomPadding() { + final double minimumPadding = MediaQuery.viewPaddingOf(context).bottom + kBottomNavigationBarHeight + singleLineVerticalPadding; + final double bottomViewInsets = MediaQuery.viewInsetsOf(context).bottom; + + return max(minimumPadding, bottomViewInsets); + } + + void rebuildSnackbar() { + final ThemeData theme = Theme.of(context); + final SnackBarThemeData snackBarTheme = theme.snackBarTheme; + + final double elevation = snackBarTheme.elevation ?? 6.0; + final Color backgroundColor = theme.colorScheme.inverseSurface; + final ShapeBorder shape = snackBarTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)); + + child = SafeArea( + child: Container( + padding: EdgeInsets.only(bottom: calculateBottomPadding()), + child: ClipRect( + child: Align( + alignment: AlignmentDirectional.bottomStart, + child: Semantics( + container: true, + liveRegion: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 0.0), + child: Material( + shape: shape, + elevation: elevation, + color: backgroundColor, + clipBehavior: Clip.none, + child: Theme( + data: theme, + child: Padding( + padding: EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.closable ? 12.0 : 8.0), + child: Wrap( + children: [ + Row( + children: [ + Expanded( + child: Container( + padding: EdgeInsets.symmetric(vertical: singleLineVerticalPadding), + child: DefaultTextStyle( + style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.onInverseSurface), + child: widget.content, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => rebuildSnackbar()); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + final double newSnackbarBottomPadding = calculateBottomPadding(); + + if (snackbarBottomPadding != newSnackbarBottomPadding) { + snackbarBottomPadding = newSnackbarBottomPadding; + rebuildSnackbar(); + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Dismissible( + key: UniqueKey(), + direction: DismissDirection.down, + behavior: HitTestBehavior.deferToChild, + onDismissed: (direction) { + setState(() => child = Container()); + }, + child: child, + ); + } +} diff --git a/lib/packages/ui/src/widgets/identity/avatar_widgets.dart b/lib/packages/ui/src/widgets/identity/avatar_widgets.dart new file mode 100644 index 000000000..0966e6410 --- /dev/null +++ b/lib/packages/ui/src/widgets/identity/avatar_widgets.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:thunder/packages/ui/src/models/identity/avatar_data.dart'; + +class UserAvatarWidget extends StatelessWidget { + const UserAvatarWidget({ + super.key, + required this.data, + }); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + return _AvatarWidget(data: data); + } +} + +class CommunityAvatarWidget extends StatelessWidget { + const CommunityAvatarWidget({ + super.key, + required this.data, + }); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + return _AvatarWidget(data: data); + } +} + +class InstanceAvatarWidget extends StatelessWidget { + const InstanceAvatarWidget({ + super.key, + required this.data, + }); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + return _AvatarWidget(data: data); + } +} + +class _AvatarWidget extends StatelessWidget { + const _AvatarWidget({required this.data}); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fallbackText = data.fallbackLabel.isNotEmpty ? data.fallbackLabel[0].toUpperCase() : ''; + + final placeholder = CircleAvatar( + backgroundColor: theme.colorScheme.secondaryContainer, + maxRadius: data.radius, + child: Text( + fallbackText, + semanticsLabel: data.semanticLabel ?? '', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: data.radius), + ), + ); + + final imageUrl = data.imageUrl; + if (imageUrl?.isNotEmpty != true) return placeholder; + + return CachedNetworkImage( + imageUrl: imageUrl!, + imageBuilder: (context, imageProvider) { + return CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: imageProvider, + maxRadius: data.radius, + ); + }, + placeholder: (context, url) => placeholder, + errorWidget: (context, url, error) => placeholder, + ); + } +} diff --git a/lib/packages/ui/src/widgets/identity/community_avatar.dart b/lib/packages/ui/src/widgets/identity/community_avatar.dart new file mode 100644 index 000000000..17adf1943 --- /dev/null +++ b/lib/packages/ui/src/widgets/identity/community_avatar.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/models/identity/avatar_data.dart'; +import 'package:thunder/packages/ui/src/widgets/identity/avatar_widgets.dart'; + +class CommunityAvatar extends StatelessWidget { + const CommunityAvatar({ + super.key, + required this.data, + this.showRestrictedBadge = false, + this.restrictedBadgeTooltip, + this.restrictedBadgeSemanticLabel, + }); + + final AvatarData data; + final bool showRestrictedBadge; + final String? restrictedBadgeTooltip; + final String? restrictedBadgeSemanticLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Stack( + children: [ + CommunityAvatarWidget(data: data), + if (showRestrictedBadge) + Positioned( + bottom: -2.0, + right: -2.0, + child: Tooltip( + message: restrictedBadgeTooltip ?? '', + child: Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock, + color: theme.colorScheme.error, + size: 18.0, + semanticLabel: restrictedBadgeSemanticLabel, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/packages/ui/src/widgets/identity/full_name_widgets.dart b/lib/packages/ui/src/widgets/identity/full_name_widgets.dart new file mode 100644 index 000000000..fb1815248 --- /dev/null +++ b/lib/packages/ui/src/widgets/identity/full_name_widgets.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; + +import 'package:auto_size_text/auto_size_text.dart'; + +import 'package:thunder/packages/ui/src/models/identity/name_style.dart'; +import 'package:thunder/packages/ui/src/utils/identity/name_formatting.dart'; + +/// Package-generic full-name widget for users. +class UserFullNameWidget extends StatelessWidget { + const UserFullNameWidget({ + super.key, + this.name, + this.displayName, + this.instance, + required this.separator, + required this.useDisplayName, + required this.userNameThickness, + required this.userNameColor, + required this.instanceNameThickness, + required this.instanceNameColor, + this.textStyle, + this.includeInstance = true, + this.textScaleFactor = 1.0, + this.autoSize = false, + this.transformColor, + }); + + final String? name; + final String? displayName; + final String? instance; + final FullNameSeparator separator; + final NameThickness userNameThickness; + final NameColor userNameColor; + final NameThickness instanceNameThickness; + final NameColor instanceNameColor; + final TextStyle? textStyle; + final bool includeInstance; + final bool useDisplayName; + final double textScaleFactor; + final bool autoSize; + final Color? Function(Color?)? transformColor; + + @override + Widget build(BuildContext context) { + final prefix = formatUserFullNamePrefix( + name, + displayName, + separator: separator, + useDisplayName: useDisplayName, + ); + final suffix = formatUserFullNameSuffix(instance, separator: separator); + + final resolvedTextStyle = textStyle ?? Theme.of(context).textTheme.bodyMedium!; + final applyColor = transformColor ?? (color) => color; + final textScaler = MediaQuery.textScalerOf(context); + final baseFontSize = resolvedTextStyle.fontSize ?? Theme.of(context).textTheme.bodyMedium?.fontSize ?? 14; + final scaledFontSize = textScaler.scale(baseFontSize * textScaleFactor); + + final textSpan = TextSpan( + children: [ + TextSpan( + text: prefix, + style: resolvedTextStyle.copyWith( + fontWeight: userNameThickness.toWeight(), + color: applyColor(userNameColor.toColor(context)), + fontSize: scaledFontSize, + ), + ), + if (includeInstance) + TextSpan( + text: suffix, + style: resolvedTextStyle.copyWith( + fontWeight: instanceNameThickness.toWeight(), + color: applyColor(instanceNameColor.toColor(context)), + fontSize: scaledFontSize, + ), + ), + ], + ); + + return autoSize + ? AutoSizeText.rich( + softWrap: false, + maxLines: 1, + overflow: TextOverflow.fade, + style: resolvedTextStyle, + textSpan, + ) + : Text.rich( + softWrap: false, + overflow: TextOverflow.fade, + style: resolvedTextStyle, + textScaler: TextScaler.noScaling, + textSpan, + ); + } +} + +/// Package-generic full-name widget for communities. +class CommunityFullNameWidget extends StatelessWidget { + const CommunityFullNameWidget({ + super.key, + this.name, + this.displayName, + this.instance, + required this.separator, + required this.useDisplayName, + required this.communityNameThickness, + required this.communityNameColor, + required this.instanceNameThickness, + required this.instanceNameColor, + this.textStyle, + this.includeInstance = true, + this.textScaleFactor = 1.0, + this.autoSize = false, + this.transformColor, + }); + + final String? name; + final String? displayName; + final String? instance; + final FullNameSeparator separator; + final NameThickness communityNameThickness; + final NameColor communityNameColor; + final NameThickness instanceNameThickness; + final NameColor instanceNameColor; + final TextStyle? textStyle; + final bool includeInstance; + final bool useDisplayName; + final double textScaleFactor; + final bool autoSize; + final Color? Function(Color?)? transformColor; + + @override + Widget build(BuildContext context) { + final prefix = formatCommunityFullNamePrefix( + name, + displayName, + separator: separator, + useDisplayName: useDisplayName, + ); + final suffix = formatCommunityFullNameSuffix(instance, separator: separator); + + final resolvedTextStyle = textStyle ?? Theme.of(context).textTheme.bodyMedium!; + final applyColor = transformColor ?? (color) => color; + final textScaler = MediaQuery.textScalerOf(context); + final baseFontSize = resolvedTextStyle.fontSize ?? Theme.of(context).textTheme.bodyMedium?.fontSize ?? 14; + final scaledFontSize = textScaler.scale(baseFontSize * textScaleFactor); + + final textSpan = TextSpan( + children: [ + TextSpan( + text: prefix, + style: resolvedTextStyle.copyWith( + fontWeight: communityNameThickness.toWeight(), + color: applyColor(communityNameColor.toColor(context)), + fontSize: scaledFontSize, + ), + ), + if (includeInstance) + TextSpan( + text: suffix, + style: resolvedTextStyle.copyWith( + fontWeight: instanceNameThickness.toWeight(), + color: applyColor(instanceNameColor.toColor(context)), + fontSize: scaledFontSize, + ), + ), + ], + ); + + return autoSize + ? AutoSizeText.rich( + softWrap: false, + maxLines: 1, + overflow: TextOverflow.fade, + style: resolvedTextStyle, + textSpan, + ) + : Text.rich( + softWrap: false, + overflow: TextOverflow.fade, + style: resolvedTextStyle, + textScaler: TextScaler.noScaling, + textSpan, + ); + } +} diff --git a/lib/packages/ui/src/widgets/identity/instance_avatar.dart b/lib/packages/ui/src/widgets/identity/instance_avatar.dart new file mode 100644 index 000000000..2723ee867 --- /dev/null +++ b/lib/packages/ui/src/widgets/identity/instance_avatar.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/models/identity/avatar_data.dart'; +import 'package:thunder/packages/ui/src/widgets/identity/avatar_widgets.dart'; + +class InstanceAvatar extends StatelessWidget { + const InstanceAvatar({ + super.key, + required this.data, + }); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + return InstanceAvatarWidget(data: data); + } +} diff --git a/lib/src/shared/widgets/text/scalable_text.dart b/lib/packages/ui/src/widgets/identity/scalable_text.dart similarity index 54% rename from lib/src/shared/widgets/text/scalable_text.dart rename to lib/packages/ui/src/widgets/identity/scalable_text.dart index 69a2d0c82..8c4bd2137 100644 --- a/lib/src/shared/widgets/text/scalable_text.dart +++ b/lib/packages/ui/src/widgets/identity/scalable_text.dart @@ -1,28 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; - -/// Creates a [Text] widget that scales its font size based on the current [fontScale]. class ScalableText extends StatelessWidget { - /// The text to display. final String text; - /// The style to use for the text. If null, defaults to the [ThemeData.textTheme.bodyMedium] style. final TextStyle? style; - /// The alignment of the text. final TextAlign? textAlign; - /// The font scale to use for the text. - final FontScale? fontScale; + final double textScaleFactor; - /// The semantic label to use for the text. final String? semanticsLabel; - /// The type of overflow to use for the text. final TextOverflow? overflow; - /// The maximum number of lines to use for the text. final int? maxLines; const ScalableText( @@ -30,7 +20,7 @@ class ScalableText extends StatelessWidget { super.key, this.style, this.textAlign, - this.fontScale, + this.textScaleFactor = 1.0, this.semanticsLabel, this.overflow, this.maxLines, @@ -44,10 +34,10 @@ class ScalableText extends StatelessWidget { final baseStyle = style ?? defaultStyle; final baseFontSize = baseStyle.fontSize ?? defaultStyle.fontSize!; - // Get the final style by applying the font scale to the base font size. final textScaler = MediaQuery.textScalerOf(context); - final scaleFactor = fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor; - final finalStyle = baseStyle.copyWith(fontSize: textScaler.scale(baseFontSize * scaleFactor)); + final finalStyle = baseStyle.copyWith( + fontSize: textScaler.scale(baseFontSize * textScaleFactor), + ); return Text( text, diff --git a/lib/packages/ui/src/widgets/identity/user_avatar.dart b/lib/packages/ui/src/widgets/identity/user_avatar.dart new file mode 100644 index 000000000..880d8cb81 --- /dev/null +++ b/lib/packages/ui/src/widgets/identity/user_avatar.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/models/identity/avatar_data.dart'; +import 'package:thunder/packages/ui/src/widgets/identity/avatar_widgets.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + super.key, + required this.data, + }); + + final AvatarData data; + + @override + Widget build(BuildContext context) { + return UserAvatarWidget(data: data); + } +} diff --git a/lib/src/shared/conditional_parent_widget.dart b/lib/packages/ui/src/widgets/layout/conditional_parent_widget.dart similarity index 100% rename from lib/src/shared/conditional_parent_widget.dart rename to lib/packages/ui/src/widgets/layout/conditional_parent_widget.dart diff --git a/lib/src/shared/divider.dart b/lib/packages/ui/src/widgets/layout/thunder_divider.dart similarity index 89% rename from lib/src/shared/divider.dart rename to lib/packages/ui/src/widgets/layout/thunder_divider.dart index c363dc1d7..660634738 100644 --- a/lib/src/shared/divider.dart +++ b/lib/packages/ui/src/widgets/layout/thunder_divider.dart @@ -1,26 +1,26 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/shared/conditional_parent_widget.dart'; - -class ThunderDivider extends StatelessWidget { - /// Whether to wrap the returned widget in a [SliverToBoxAdapter] - final bool sliver; - - /// Whether to apply padding around the divider - final bool padding; - - const ThunderDivider({super.key, required this.sliver, this.padding = true}); - - @override - Widget build(BuildContext context) => ConditionalParentWidget( - condition: sliver, - parentBuilder: (Widget child) => SliverToBoxAdapter(child: child), - child: Divider( - indent: padding ? 32.0 : 0, - height: padding ? 32.0 : 16, - endIndent: padding ? 32.0 : 0, - thickness: 2.0, - color: Theme.of(context).dividerColor.withValues(alpha: 0.6), - ), - ); -} +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/widgets/layout/conditional_parent_widget.dart'; + +class ThunderDivider extends StatelessWidget { + /// Whether to wrap the returned widget in a [SliverToBoxAdapter] + final bool sliver; + + /// Whether to apply padding around the divider + final bool padding; + + const ThunderDivider({super.key, required this.sliver, this.padding = true}); + + @override + Widget build(BuildContext context) => ConditionalParentWidget( + condition: sliver, + parentBuilder: (Widget child) => SliverToBoxAdapter(child: child), + child: Divider( + indent: padding ? 32.0 : 0, + height: padding ? 32.0 : 16, + endIndent: padding ? 32.0 : 0, + thickness: 2.0, + color: Theme.of(context).dividerColor.withValues(alpha: 0.6), + ), + ); +} diff --git a/lib/src/shared/markdown/common_markdown_body.dart b/lib/packages/ui/src/widgets/markdown/common_markdown_body.dart similarity index 54% rename from lib/src/shared/markdown/common_markdown_body.dart rename to lib/packages/ui/src/widgets/markdown/common_markdown_body.dart index a7ddb0d6c..5952e1d0f 100644 --- a/lib/src/shared/markdown/common_markdown_body.dart +++ b/lib/packages/ui/src/widgets/markdown/common_markdown_body.dart @@ -3,42 +3,53 @@ import 'package:flutter/rendering.dart'; import 'package:jovial_svg/jovial_svg.dart'; import 'package:markdown/markdown.dart' as md; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/shared/link_information.dart'; -import 'package:thunder/src/shared/markdown/markdown_lemmy_link.dart'; -import 'package:thunder/src/shared/markdown/markdown_spoiler.dart'; -import 'package:thunder/src/shared/markdown/markdown_subsuperscript.dart'; -import 'package:thunder/src/shared/markdown/markdown_utils.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/markdown/extended_markdown.dart'; -import 'package:thunder/src/shared/utils/media/video.dart'; -import 'package:thunder/src/shared/widgets/media/media_view.dart'; +import 'package:thunder/packages/ui/src/utils/media/media_utils.dart'; +import 'package:thunder/packages/ui/src/models/content/content_action_handlers.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; +import 'package:thunder/packages/ui/src/models/content/content_view_mode.dart'; +import 'package:thunder/packages/ui/src/widgets/markdown/extended_markdown.dart'; +import 'package:thunder/packages/ui/src/widgets/markdown/markdown_lemmy_link.dart'; +import 'package:thunder/packages/ui/src/widgets/markdown/markdown_spoiler.dart'; +import 'package:thunder/packages/ui/src/widgets/markdown/markdown_subsuperscript.dart'; +import 'package:thunder/packages/ui/src/utils/markdown/markdown_utils.dart'; +import 'package:thunder/packages/ui/src/widgets/media/link_information.dart'; +import 'package:thunder/packages/ui/src/widgets/media/media_view.dart'; /// A widget that displays markdown content. class CommonMarkdownBody extends StatefulWidget { - /// The markdown content body + /// The markdown content body. final String body; - /// Whether to hide the markdown content. This is mainly used for spoiler markdown + /// Whether to hide the markdown content. final bool hidden; - /// Whether the markdown content is NSFW. This blurs any images within the markdown content. + /// Whether the markdown content is NSFW. final bool nsfw; - /// Indicates if the given markdown is a comment. Depending on the markdown content, different text scaling may be applied + /// Indicates if the given markdown is a comment. final bool? isComment; - /// The maximum width of the image + /// The maximum width of the image. final double? imageMaxWidth; + /// Optional action handlers that decouple media and navigation behavior. + final ContentActionHandlers handlers; + + /// Text scale factor used for comment markdown. + final double commentTextScaleFactor; + + /// Text scale factor used for non-comment markdown. + final double contentTextScaleFactor; + + /// Localized retry tooltip for image fallbacks. + final String retryTooltip; + + /// Localized NSFW warning label. + final String nsfwWarningLabel; + const CommonMarkdownBody({ super.key, required this.body, @@ -46,6 +57,11 @@ class CommonMarkdownBody extends StatefulWidget { this.nsfw = false, this.isComment, this.imageMaxWidth, + this.handlers = const ContentActionHandlers(), + this.commentTextScaleFactor = 1.0, + this.contentTextScaleFactor = 1.0, + this.retryTooltip = 'Retry', + this.nsfwWarningLabel = 'NSFW', }); @override @@ -81,7 +97,11 @@ class _CommonMarkdownBodyState extends State { } static List _getInlineSyntaxes() { - return [LemmyLinkSyntax(), SubscriptInlineSyntax(), SuperscriptInlineSyntax()]; + return [ + LemmyLinkSyntax(), + SubscriptInlineSyntax(), + SuperscriptInlineSyntax(), + ]; } static Map _getBuilders() { @@ -92,22 +112,20 @@ class _CommonMarkdownBodyState extends State { }; } - double _getTextScaleFactor(FontScale commentFontSizeScale, FontScale contentFontSizeScale) { - final baseScale = MediaQuery.of(context).textScaleFactor; - final fontScale = widget.isComment == true ? commentFontSizeScale.textScaleFactor : contentFontSizeScale.textScaleFactor; + double _getTextScaleFactor() { + final baseScale = MediaQuery.textScalerOf(context).scale(1.0); + final fontScale = widget.isComment == true ? widget.commentTextScaleFactor : widget.contentTextScaleFactor; return baseScale * fontScale; } @override Widget build(BuildContext context) { - if (_spoilerMarkdownStyleSheet == null || _normalMarkdownStyleSheet == null) _initializeStyleSheets(); + if (_spoilerMarkdownStyleSheet == null || _normalMarkdownStyleSheet == null) { + _initializeStyleSheets(); + } - final commentFontSizeScale = context.select((cubit) => cubit.state.commentFontSizeScale); - final contentFontSizeScale = context.select((cubit) => cubit.state.contentFontSizeScale); final styleSheet = widget.hidden ? _spoilerMarkdownStyleSheet! : _normalMarkdownStyleSheet!; - // Disable semantics if the accessibility feature is disabled. This allows the widget to be more performant as it doesn't need to compute the semantics tree. - // This is useful especially for complex markdown content. final accessibilityOn = SemanticsBinding.instance.accessibilityFeatures.accessibleNavigation; return ExcludeSemantics( @@ -126,33 +144,51 @@ class _CommonMarkdownBodyState extends State { nsfw: widget.nsfw, isComment: widget.isComment, imageMaxWidth: widget.imageMaxWidth, + handlers: widget.handlers, + retryTooltip: widget.retryTooltip, + nsfwWarningLabel: widget.nsfwWarningLabel, ), - onTapLink: (text, url, title) => handleLinkTap(context, text, url), - onLongPressLink: (text, url, title) => handleLinkLongPress(context, text, url), - styleSheet: styleSheet.copyWith(textScaleFactor: _getTextScaleFactor(commentFontSizeScale, contentFontSizeScale)), + onTapLink: (text, url, title) { + if (url != null) { + widget.handlers.onOpenLink?.call(context, url); + } + }, + onLongPressLink: (text, url, title) { + widget.handlers.onLongPressLink?.call(context, text, url); + }, + styleSheet: styleSheet.copyWith(textScaleFactor: _getTextScaleFactor()), ), ), ); } } -/// Given a markdown image, builds the image widget +/// Given a markdown image, builds the image widget. class MarkdownImageWidget extends StatefulWidget { - /// The URI of the image + /// The URI of the image. final Uri uri; - /// The alt text of the image + /// The alt text of the image. final String? alt; - /// Whether the image is NSFW + /// Whether the image is NSFW. final bool nsfw; - /// Whether the image is a comment + /// Whether the image is a comment. final bool? isComment; - /// The maximum width of the image + /// The maximum width of the image. final double? imageMaxWidth; + /// Optional action handlers that decouple media and navigation behavior. + final ContentActionHandlers handlers; + + /// Localized retry tooltip for image fallback. + final String retryTooltip; + + /// Localized NSFW warning label. + final String nsfwWarningLabel; + const MarkdownImageWidget({ super.key, required this.uri, @@ -160,9 +196,12 @@ class MarkdownImageWidget extends StatefulWidget { required this.nsfw, this.isComment, this.imageMaxWidth, + this.handlers = const ContentActionHandlers(), + this.retryTooltip = 'Retry', + this.nsfwWarningLabel = 'NSFW', }); - /// Holds a cache of previously retrieved SVG results + /// Holds a cache of previously retrieved SVG results. static final Map _svgCache = {}; @override @@ -170,13 +209,13 @@ class MarkdownImageWidget extends StatefulWidget { } class _MarkdownImageWidgetState extends State { - /// The decoded URI of the image + /// The decoded URI of the image. String? uri; - /// The media type of the URL - MediaType? mediaType; + /// The media type of the URL. + ContentMediaType? mediaType; - /// The dimensions of the image + /// The dimensions of the image. Size? dimensions; @override @@ -186,10 +225,10 @@ class _MarkdownImageWidgetState extends State { uri = Uri.decodeFull(widget.uri.toString()); if (isImageUrl(uri!)) { - mediaType = MediaType.image; + mediaType = ContentMediaType.image; _getImageDimensions(); } else if (isVideoUrl(uri!)) { - mediaType = MediaType.video; + mediaType = ContentMediaType.video; } else { _checkSVG(); } @@ -200,7 +239,12 @@ class _MarkdownImageWidgetState extends State { dimensions = await retrieveImageDimensions(imageUrl: uri!); if (dimensions == null) return; - dimensions = getScaledMediaSize(width: dimensions!.width, height: dimensions!.height, offset: 0, tabletMode: true)!; + dimensions = getScaledMediaSize( + width: dimensions!.width, + height: dimensions!.height, + offset: 0, + tabletMode: true, + )!; if (mounted) setState(() {}); } catch (e) { debugPrint('Error getting image dimensions: $uri - $e'); @@ -222,9 +266,21 @@ class _MarkdownImageWidgetState extends State { @override Widget build(BuildContext context) { - if (mediaType == MediaType.video) { - debugPrint('Video link: $uri'); - return LinkInformation(viewMode: ViewMode.comfortable, mediaType: mediaType, url: uri, showEdgeToEdgeImages: false); + if (mediaType == ContentMediaType.video) { + return LinkInformation( + viewMode: ContentViewMode.comfortable, + mediaType: mediaType, + url: uri, + showEdgeToEdgeImages: false, + onTap: () { + if (uri != null) widget.handlers.onOpenLink?.call(context, uri!); + }, + onLongPress: () { + if (uri != null) { + widget.handlers.onLongPressLink?.call(context, uri!, uri); + } + }, + ); } final isSvg = MarkdownImageWidget._svgCache.containsKey(uri); @@ -235,12 +291,19 @@ class _MarkdownImageWidgetState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ isSvg - ? _MarkdownSvgWidget(uri: widget.uri, isComment: widget.isComment, imageMaxWidth: widget.imageMaxWidth) + ? _MarkdownSvgWidget( + uri: widget.uri, + isComment: widget.isComment, + imageMaxWidth: widget.imageMaxWidth, + ) : MediaView( - viewMode: ViewMode.comment, + viewMode: ContentViewMode.comment, hideNsfwPreviews: widget.nsfw, - media: Media( - mediaType: MediaType.image, + handlers: widget.handlers, + retryTooltip: widget.retryTooltip, + nsfwWarningLabel: widget.nsfwWarningLabel, + media: ContentMedia( + mediaType: ContentMediaType.image, mediaUrl: uri, nsfw: widget.nsfw, width: dimensions?.width, @@ -253,15 +316,15 @@ class _MarkdownImageWidgetState extends State { } } -/// Builds an SVG image from markdown +/// Builds an SVG image from markdown. class _MarkdownSvgWidget extends StatelessWidget { - /// The URI of the SVG image + /// The URI of the SVG image. final Uri uri; - /// Whether the image is a comment + /// Whether the image is a comment. final bool? isComment; - /// The maximum width of the image + /// The maximum width of the image. final double? imageMaxWidth; const _MarkdownSvgWidget({required this.uri, this.isComment, this.imageMaxWidth}); diff --git a/lib/src/shared/markdown/extended_markdown.dart b/lib/packages/ui/src/widgets/markdown/extended_markdown.dart similarity index 96% rename from lib/src/shared/markdown/extended_markdown.dart rename to lib/packages/ui/src/widgets/markdown/extended_markdown.dart index 6e7c319a4..05b6596e0 100644 --- a/lib/src/shared/markdown/extended_markdown.dart +++ b/lib/packages/ui/src/widgets/markdown/extended_markdown.dart @@ -107,6 +107,12 @@ abstract class ExtendedMarkdownWidget extends MarkdownWidget { super.softLineBreak = false, }); + // Delegate through a public method so State can invoke the child builder + // without directly accessing MarkdownWidget's protected member. + Widget buildWithChildren(BuildContext context, List? children) { + return build(context, children); + } + @override State createState() => _MarkdownWidgetState(); } @@ -233,7 +239,7 @@ class _MarkdownWidgetState extends State implements Mark } @override - Widget build(BuildContext context) => widget.build(context, _children); + Widget build(BuildContext context) => widget.buildWithChildren(context, _children); } /// A default style sheet generator. diff --git a/lib/packages/ui/src/widgets/markdown/markdown_body.dart b/lib/packages/ui/src/widgets/markdown/markdown_body.dart new file mode 100644 index 000000000..b5ed75ff0 --- /dev/null +++ b/lib/packages/ui/src/widgets/markdown/markdown_body.dart @@ -0,0 +1,3 @@ +import 'package:thunder/packages/ui/src/widgets/markdown/common_markdown_body.dart'; + +typedef MarkdownBody = CommonMarkdownBody; diff --git a/lib/src/shared/markdown/markdown_lemmy_link.dart b/lib/packages/ui/src/widgets/markdown/markdown_lemmy_link.dart similarity index 100% rename from lib/src/shared/markdown/markdown_lemmy_link.dart rename to lib/packages/ui/src/widgets/markdown/markdown_lemmy_link.dart diff --git a/lib/packages/ui/src/widgets/markdown/markdown_spoiler.dart b/lib/packages/ui/src/widgets/markdown/markdown_spoiler.dart new file mode 100644 index 000000000..1cdea220d --- /dev/null +++ b/lib/packages/ui/src/widgets/markdown/markdown_spoiler.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; + +import 'package:expandable/expandable.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; + +import 'package:thunder/packages/ui/src/widgets/markdown/common_markdown_body.dart'; + +/// Markdown inline syntax for spoiler tags. +class SpoilerInlineSyntax extends md.InlineSyntax { + static const String _pattern = r'(:::\s?spoiler\s(.*?)\s?:::)'; + + SpoilerInlineSyntax() : super(_pattern); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final body = match[2]!; + + final md.Node spoiler = md.Element('span', [ + md.Element('spoiler', [ + md.UnparsedContent('_inline:::$body'), + ]), + ]); + + parser.addNode(spoiler); + return true; + } +} + +/// Markdown block syntax for spoiler blocks. +class SpoilerBlockSyntax extends md.BlockSyntax { + RegExp endPattern = RegExp(r'^\s{0,3}:{3,}\s*$'); + + @override + RegExp get pattern => RegExp(r'^\s{0,3}:{3,}\s*spoiler\s+(\S.*)$'); + + @override + bool canParse(md.BlockParser parser) { + return pattern.hasMatch(parser.current.content); + } + + @override + md.Node parse(md.BlockParser parser) { + final Match? match = pattern.firstMatch(parser.current.content); + final String? title = match?.group(1)?.trim(); + + parser.advance(); + + final List body = []; + + while (!parser.isDone) { + if (endPattern.hasMatch(parser.current.content)) { + parser.advance(); + break; + } else { + body.add(parser.current.content); + parser.advance(); + } + } + + final md.Node spoiler = md.Element('p', [ + md.Element('spoiler', [ + md.Text('${title ?? '_block'}:::/-/:::${body.join('\n')}'), + ]), + ]); + + return spoiler; + } +} + +/// Creates a builder that renders spoiler markdown nodes. +class SpoilerElementBuilder extends MarkdownElementBuilder { + SpoilerElementBuilder(); + + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final rawText = element.textContent; + final parts = rawText.split(':::/-/:::'); + + if (parts.length < 2) { + return Container(); + } + + final title = parts[0].trim(); + final body = parts[1].trim(); + return SpoilerWidget(title: title, body: body); + } +} + +/// A widget that toggles the visibility of spoiler content. +class SpoilerWidget extends StatefulWidget { + final String? title; + final String? body; + + const SpoilerWidget({super.key, this.title, this.body}); + + @override + State createState() => _SpoilerWidgetState(); +} + +class _SpoilerWidgetState extends State { + final ExpandableController expandableController = ExpandableController(initialExpanded: false); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.elliptical(5, 5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSpoilerHeader(theme), + _buildSpoilerContent(), + ], + ), + ); + } + + Widget _buildSpoilerHeader(ThemeData theme) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: const BorderRadius.all(Radius.elliptical(5, 5)), + onTap: () { + expandableController.toggle(); + setState(() {}); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + expandableController.expanded ? Icons.expand_more_rounded : Icons.chevron_right_rounded, + semanticLabel: expandableController.expanded ? 'Collapse spoiler' : 'Expand spoiler', + size: 20, + ), + const SizedBox(width: 5), + Expanded( + child: Text( + widget.title ?? 'Spoiler', + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSpoilerContent() { + return Expandable( + controller: expandableController, + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.only(left: 4, right: 4, bottom: 4), + child: CommonMarkdownBody( + body: widget.body ?? '', + isComment: true, + ), + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/markdown/markdown_subsuperscript.dart b/lib/packages/ui/src/widgets/markdown/markdown_subsuperscript.dart new file mode 100644 index 000000000..34b5cc922 --- /dev/null +++ b/lib/packages/ui/src/widgets/markdown/markdown_subsuperscript.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; + +enum CustomMarkdownType { superscript, subscript } + +/// A Markdown extension to handle subscript tags. +class SubscriptInlineSyntax extends md.InlineSyntax { + SubscriptInlineSyntax() : super(r'~([^~\s]+)~'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode(md.Element.text('sub', match[1]!)); + return true; + } +} + +/// A Markdown extension to handle superscript tags. +class SuperscriptInlineSyntax extends md.InlineSyntax { + SuperscriptInlineSyntax() : super(r'\^([^\s^]+)\^'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode(md.Element.text('sup', match[1]!)); + return true; + } +} + +class SubscriptElementBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final textContent = element.textContent; + + return SuperscriptSubscriptWidget( + text: textContent, + type: CustomMarkdownType.subscript, + preferredStyle: preferredStyle, + ); + } +} + +class SuperscriptElementBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final textContent = element.textContent; + + return SuperscriptSubscriptWidget( + text: textContent, + type: CustomMarkdownType.superscript, + preferredStyle: preferredStyle, + ); + } +} + +/// Creates a widget that displays the given [text] in superscript/subscript. +class SuperscriptSubscriptWidget extends StatelessWidget { + final String text; + final CustomMarkdownType type; + final TextStyle? preferredStyle; + + const SuperscriptSubscriptWidget({ + super.key, + required this.text, + required this.type, + this.preferredStyle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final baseStyle = preferredStyle ?? theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14); + + return RichText( + text: TextSpan( + style: baseStyle, + children: [ + WidgetSpan( + child: Transform.translate( + offset: Offset( + 0.0, + type == CustomMarkdownType.subscript ? 3.0 : -5.0, + ), + child: Text( + text, + style: baseStyle.copyWith( + fontSize: (baseStyle.fontSize ?? 14) * 0.8, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/packages/ui/src/widgets/media/compact_thumbnail_preview.dart b/lib/packages/ui/src/widgets/media/compact_thumbnail_preview.dart new file mode 100644 index 000000000..6e9a88ae4 --- /dev/null +++ b/lib/packages/ui/src/widgets/media/compact_thumbnail_preview.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/models/content/content_action_handlers.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; +import 'package:thunder/packages/ui/src/models/content/content_view_mode.dart'; +import 'package:thunder/packages/ui/src/widgets/media/media_view.dart'; + +/// Displays a compact thumbnail preview for a post card. +class CompactThumbnailPreview extends StatelessWidget { + const CompactThumbnailPreview({ + super.key, + required this.media, + this.dim = false, + this.postId, + this.navigateToPost, + this.hideNsfwPreviews = true, + this.markPostReadOnMediaView = false, + this.isUserLoggedIn = false, + this.handlers = const ContentActionHandlers(), + this.nsfwWarningLabel = 'NSFW', + }); + + /// The media to display in the thumbnail. + final ContentMedia media; + + /// Whether or not to dim the thumbnail. + final bool dim; + + /// The post associated with the media. + final int? postId; + + /// The callback function to navigate to the post. + final void Function()? navigateToPost; + + /// Whether to hide NSFW previews. + final bool hideNsfwPreviews; + + /// Whether viewing media marks the post as read. + final bool markPostReadOnMediaView; + + /// Whether the user is currently logged in. + final bool isUserLoggedIn; + + /// Optional action handlers for navigation behavior. + final ContentActionHandlers handlers; + + /// Localized NSFW warning label. + final String nsfwWarningLabel; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: ExcludeSemantics( + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), + child: MediaView( + media: media, + postId: postId, + showFullHeightImages: false, + hideNsfwPreviews: hideNsfwPreviews, + markPostReadOnMediaView: markPostReadOnMediaView, + viewMode: ContentViewMode.compact, + isUserLoggedIn: isUserLoggedIn, + navigateToPost: navigateToPost, + read: dim, + handlers: handlers, + nsfwWarningLabel: nsfwWarningLabel, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: _MediaTypeBadge(mediaType: media.mediaType, dim: dim), + ), + ], + ), + ), + ); + } +} + +class _MediaTypeBadge extends StatelessWidget { + const _MediaTypeBadge({required this.mediaType, required this.dim}); + + final ContentMediaType mediaType; + final bool dim; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final icon = switch (mediaType) { + ContentMediaType.image => Icons.image_outlined, + ContentMediaType.video => Icons.play_arrow_rounded, + ContentMediaType.text => Icons.wysiwyg_rounded, + ContentMediaType.link => Icons.link_rounded, + }; + + final foreground = theme.colorScheme.onSurface.withValues(alpha: dim ? 0.55 : 1); + final background = theme.colorScheme.surface.withValues(alpha: 0.8); + + return Container( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8.0), + ), + padding: const EdgeInsets.all(4.0), + child: Icon(icon, size: 14.0, color: foreground), + ); + } +} diff --git a/lib/src/shared/images/image_preview.dart b/lib/packages/ui/src/widgets/media/image_preview.dart similarity index 87% rename from lib/src/shared/images/image_preview.dart rename to lib/packages/ui/src/widgets/media/image_preview.dart index f3dae2d7d..d1d359d0e 100644 --- a/lib/src/shared/images/image_preview.dart +++ b/lib/packages/ui/src/widgets/media/image_preview.dart @@ -5,9 +5,8 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_avif/flutter_avif.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/packages/ui/src/utils/media/media_utils.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; /// The loading state of an image preview. enum ImagePreviewState { @@ -39,10 +38,7 @@ class ImagePreview extends StatefulWidget { final BoxFit? fit; /// The media type that the underlying image represents. - /// - /// This value dictates the icon that will be displayed if the image fails to load. - /// If none is provided, a generic error icon will be displayed. - final MediaType? mediaType; + final ContentMediaType? mediaType; /// Whether the image has been viewed. This will affect the opacity of the image. final bool? viewed; @@ -51,12 +47,14 @@ class ImagePreview extends StatefulWidget { final bool? blur; /// Whether to allow retrying with the original URL when a proxy URL fails. - /// When false, the error state will show an error icon without retry option. final bool allowRetry; /// Callback invoked when the image loading state changes. final void Function(ImagePreviewState state)? onStateChanged; + /// Localized tooltip shown when retry is available. + final String retryTooltip; + const ImagePreview({ super.key, required this.url, @@ -69,6 +67,7 @@ class ImagePreview extends StatefulWidget { this.blur, this.allowRetry = false, this.onStateChanged, + this.retryTooltip = 'Retry', }); @override @@ -119,7 +118,6 @@ class _ImagePreviewState extends State { @override Widget build(BuildContext context) { - // TODO: Move the logic for determining if the image is valid into data layer so that we don't need to re-evaluate this on every build. final isValidImageUrl = widget.contentType != null || isImageUrl(widget.url); if (!isValidImageUrl) { @@ -127,6 +125,7 @@ class _ImagePreviewState extends State { mediaType: widget.mediaType, blur: widget.blur == true, viewed: widget.viewed == true, + retryTooltip: widget.retryTooltip, ); } @@ -144,6 +143,7 @@ class _ImagePreviewState extends State { onRetry: _retryWithOriginalUrl, onLoaded: _onImageLoaded, onError: _onImageError, + retryTooltip: widget.retryTooltip, ); } } @@ -172,7 +172,7 @@ class _ImageContent extends StatelessWidget { final bool? blur; /// The media type that the underlying image represents. - final MediaType? mediaType; + final ContentMediaType? mediaType; /// Whether the image can be retried with the original URL. final bool canRetry; @@ -186,6 +186,9 @@ class _ImageContent extends StatelessWidget { /// Callback when the image fails to load. final VoidCallback? onError; + /// Localized tooltip shown when retry is available. + final String retryTooltip; + const _ImageContent({ super.key, required this.url, @@ -200,13 +203,14 @@ class _ImageContent extends StatelessWidget { this.onRetry, this.onLoaded, this.onError, + required this.retryTooltip, }); @override Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.devicePixelRatioOf(context).ceil(); - // Calculate cache dimensions based on device pixel ratio + // Calculate cache dimensions based on device pixel ratio. final int? cacheWidth = width != null ? (width! * devicePixelRatio).toInt() : null; final int? cacheHeight = height != null ? (height! * devicePixelRatio).toInt() : null; @@ -215,11 +219,6 @@ class _ImageContent extends StatelessWidget { final filterQuality = (cacheWidth != null && cacheWidth < 200) ? FilterQuality.low : FilterQuality.medium; - // Check if the URL is an AVIF image and use appropriate image loader - // - // Note: we need to check both URL and content type because: - // - Some servers (like lemmy.zip's image_proxy) serve AVIF images through URLs ending in .jpeg/.jpg/.png - // - The API provides content_type: 'image/avif' in imageDetails even when URL doesn't indicate AVIF final isAvifByUrl = url.toLowerCase().endsWith('.avif'); final isAvifByContentType = contentType?.toLowerCase() == 'image/avif'; final isAvif = isAvifByUrl || isAvifByContentType; @@ -245,11 +244,11 @@ class _ImageContent extends StatelessWidget { viewed: viewed == true, canRetry: canRetry, onRetry: onRetry, + retryTooltip: retryTooltip, ); }, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (frame != null) { - // Image has loaded - notify callback Future.microtask(() => onLoaded?.call()); } return child; @@ -296,6 +295,7 @@ class _ImageContent extends StatelessWidget { viewed: viewed == true, canRetry: canRetry, onRetry: onRetry, + retryTooltip: retryTooltip, ); }, ); @@ -327,7 +327,7 @@ class _BlurredImage extends StatelessWidget { /// Displays the fallback widget when an image fails to load. class ImagePreviewError extends StatelessWidget { /// The media type that the underlying image represents. - final MediaType? mediaType; + final ContentMediaType? mediaType; /// Whether the image should be blurred. final bool blur; @@ -341,6 +341,9 @@ class ImagePreviewError extends StatelessWidget { /// Callback to retry loading the image with the original URL. final VoidCallback? onRetry; + /// Localized tooltip shown when retry is available. + final String retryTooltip; + const ImagePreviewError({ super.key, this.mediaType, @@ -348,18 +351,19 @@ class ImagePreviewError extends StatelessWidget { this.viewed = false, this.canRetry = false, this.onRetry, + this.retryTooltip = 'Retry', }); /// Returns the icon to display when the image fails to load. - static IconData _getErrorIcon(MediaType? mediaType) { + static IconData _getErrorIcon(ContentMediaType? mediaType) { switch (mediaType) { - case MediaType.image: + case ContentMediaType.image: return Icons.image_not_supported_outlined; - case MediaType.video: + case ContentMediaType.video: return Icons.video_camera_back_outlined; - case MediaType.link: + case ContentMediaType.link: return Icons.language_rounded; - case MediaType.text: + case ContentMediaType.text: return Icons.text_fields_rounded; default: return Icons.error_outline_rounded; @@ -369,21 +373,18 @@ class ImagePreviewError extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - // Don't display the associated icon if blur is enabled, otherwise there will be two icons displayed at once. if (blur) return const SizedBox.shrink(); final iconColor = theme.colorScheme.onSecondaryContainer.withValues(alpha: viewed ? 0.55 : 1.0); - // If we can retry with the original URL, make the entire area tappable if (canRetry && onRetry != null) { return GestureDetector( onTap: onRetry, behavior: HitTestBehavior.opaque, child: Center( child: Tooltip( - message: l10n.retry, + message: retryTooltip, child: Icon(Icons.refresh_rounded, color: iconColor), ), ), diff --git a/lib/src/shared/images/image_viewer.dart b/lib/packages/ui/src/widgets/media/image_viewer.dart similarity index 96% rename from lib/src/shared/images/image_viewer.dart rename to lib/packages/ui/src/widgets/media/image_viewer.dart index c1fa382f5..59c59ad8d 100644 --- a/lib/src/shared/images/image_viewer.dart +++ b/lib/packages/ui/src/widgets/media/image_viewer.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:extended_image/extended_image.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gal/gal.dart'; import 'package:share_plus/share_plus.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -15,10 +14,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/image_caching_mode.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/packages/ui/src/widgets/feedback/snackbar.dart'; +import 'package:thunder/packages/ui/src/utils/media/media_utils.dart'; class ImageViewer extends StatefulWidget { /// The URL of the image to display @@ -39,6 +36,9 @@ class ImageViewer extends StatefulWidget { /// Whether this image viewer is being shown within the context of a peek. final bool isPeek; + /// Controls whether image cache should be aggressively cleared on dispose. + final bool clearMemoryCacheWhenDispose; + const ImageViewer({ super.key, this.url, @@ -47,6 +47,7 @@ class ImageViewer extends StatefulWidget { this.navigateToPost, this.altText, this.isPeek = false, + this.clearMemoryCacheWhenDispose = false, }) : assert(url != null || bytes != null); @override @@ -85,7 +86,9 @@ class _ImageViewerState extends State with TickerProviderStateMixin void enterFullScreen() { setState(() => fullscreen = true); - if (fullscreen) SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + if (fullscreen) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + } } void exitFullScreen() { @@ -123,7 +126,6 @@ class _ImageViewerState extends State with TickerProviderStateMixin @override Widget build(BuildContext context) { - final thunderState = context.read().state; final l10n = AppLocalizations.of(context)!; AnimationController animationController = AnimationController(duration: const Duration(milliseconds: 140), vsync: this); @@ -256,7 +258,7 @@ class _ImageViewerState extends State with TickerProviderStateMixin mode: ExtendedImageMode.gesture, extendedImageGestureKey: gestureKey, cache: true, - clearMemoryCacheWhenDispose: thunderState.imageCachingMode == ImageCachingMode.relaxed, + clearMemoryCacheWhenDispose: widget.clearMemoryCacheWhenDispose, layoutInsets: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 50, top: MediaQuery.of(context).padding.top + 50), initGestureConfigHandler: (ExtendedImageState state) { return GestureConfig( @@ -318,7 +320,7 @@ class _ImageViewerState extends State with TickerProviderStateMixin enableSlideOutPage: true, mode: ExtendedImageMode.gesture, extendedImageGestureKey: gestureKey, - clearMemoryCacheWhenDispose: true, + clearMemoryCacheWhenDispose: widget.clearMemoryCacheWhenDispose, initGestureConfigHandler: (ExtendedImageState state) { return GestureConfig( minScale: 0.8, @@ -495,7 +497,9 @@ class _ImageViewerState extends State with TickerProviderStateMixin : () async { File file = await DefaultCacheManager().getSingleFile(widget.url!); bool hasPermission = await Gal.hasAccess(toAlbum: true); - if (!hasPermission) await Gal.requestAccess(toAlbum: true); + if (!hasPermission) { + await Gal.requestAccess(toAlbum: true); + } setState(() => isSavingMedia = true); @@ -516,7 +520,9 @@ class _ImageViewerState extends State with TickerProviderStateMixin await Gal.putImage(file.path, album: "Thunder"); setState(() => downloaded = true); } on GalException catch (e) { - if (context.mounted) showSnackbar(e.type.message); + if (context.mounted) { + showSnackbar(e.type.message); + } setState(() => downloaded = false); } } finally { diff --git a/lib/packages/ui/src/widgets/media/link_information.dart b/lib/packages/ui/src/widgets/media/link_information.dart new file mode 100644 index 000000000..2e7975e63 --- /dev/null +++ b/lib/packages/ui/src/widgets/media/link_information.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; +import 'package:thunder/packages/ui/src/models/content/content_view_mode.dart'; + +/// A generic widget that displays information about a media/link URL. +class LinkInformation extends StatelessWidget { + const LinkInformation({ + super.key, + this.url, + this.mediaType, + required this.viewMode, + this.showEdgeToEdgeImages = false, + this.onTap, + this.onLongPress, + }); + + final String? url; + final ContentMediaType? mediaType; + final ContentViewMode viewMode; + final bool showEdgeToEdgeImages; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + IconData _getIconForMediaType() { + return switch (mediaType) { + ContentMediaType.image => Icons.image_outlined, + ContentMediaType.video => Icons.play_arrow_rounded, + ContentMediaType.text => Icons.wysiwyg_rounded, + _ => Icons.link_rounded, + }; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final icon = _getIconForMediaType(); + final borderRadius = BorderRadius.circular(showEdgeToEdgeImages ? 0 : 12); + + return Semantics( + link: true, + child: InkWell( + customBorder: RoundedRectangleBorder(borderRadius: borderRadius), + onTap: onTap, + onLongPress: onLongPress, + child: Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: ElevationOverlay.applySurfaceTint( + theme.colorScheme.surface.withValues(alpha: 0.8), + theme.colorScheme.surfaceTint, + 10, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon(icon, color: theme.colorScheme.onSecondaryContainer), + ), + if (viewMode != ContentViewMode.compact) + Expanded( + child: Text( + url ?? '', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/shared/widgets/media/media_view.dart b/lib/packages/ui/src/widgets/media/media_view.dart similarity index 51% rename from lib/src/shared/widgets/media/media_view.dart rename to lib/packages/ui/src/widgets/media/media_view.dart index b2803c0ee..f9d95f79d 100644 --- a/lib/src/shared/widgets/media/media_view.dart +++ b/lib/packages/ui/src/widgets/media/media_view.dart @@ -1,62 +1,70 @@ -import 'package:flutter/material.dart'; +import 'dart:typed_data'; + import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/shared/images/image_preview.dart'; -import 'package:thunder/src/shared/link_information.dart'; -import 'package:thunder/src/shared/widgets/media/media_view_text.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/images/image_viewer.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; -import 'package:thunder/src/shared/utils/media/video.dart'; +import 'package:thunder/packages/ui/src/widgets/media/image_preview.dart'; +import 'package:thunder/packages/ui/src/widgets/media/image_viewer.dart'; +import 'package:thunder/packages/ui/src/utils/media/media_utils.dart'; +import 'package:thunder/packages/ui/src/models/content/content_action_handlers.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media.dart'; +import 'package:thunder/packages/ui/src/models/content/content_media_type.dart'; +import 'package:thunder/packages/ui/src/models/content/content_view_mode.dart'; +import 'package:thunder/packages/ui/src/widgets/media/link_information.dart'; +import 'package:thunder/packages/ui/src/widgets/media/media_view_text.dart'; class MediaView extends StatefulWidget { - /// The media information - final Media media; + /// The media information. + final ContentMedia media; - /// The associated post ID for the media + /// The associated post ID for the media. final int? postId; - /// Whether to show the full height for images + /// Whether to show the full height for images. final bool showFullHeightImages; /// When enabled, the image height will be unconstrained. final bool allowUnconstrainedImageHeight; - /// Whether to blur NSFW images + /// Whether to blur NSFW images. final bool hideNsfwPreviews; - /// Whether to hide thumbnails + /// Whether to hide thumbnails. final bool hideThumbnails; - /// Whether to extend the image to the edge of the screen (ViewMode.comfortable) + /// Whether to extend the image to the edge of the screen. final bool edgeToEdgeImages; - /// Whether to mark the post as read when the media is viewed + /// Whether to mark the post as read when the media is viewed. final bool markPostReadOnMediaView; - /// Whether the user is logged in + /// Whether the user is logged in. final bool isUserLoggedIn; - /// The view mode of the media - final ViewMode viewMode; + /// The view mode of the media. + final ContentViewMode viewMode; - /// The function to navigate to the post + /// The function to navigate to the post. final void Function()? navigateToPost; - /// Whether the post has been read + /// Whether the post has been read. final bool? read; + /// Optional action handlers that decouple media and navigation behavior. + final ContentActionHandlers handlers; + + /// Duration before peek preview starts when long pressing an image. + final int imagePeekDurationMs; + + /// Whether content should be laid out in tablet mode. + final bool tabletMode; + + /// Localized NSFW warning label shown in comfortable view. + final String nsfwWarningLabel; + + /// Localized retry tooltip used by image fallback UI. + final String retryTooltip; + const MediaView({ super.key, required this.media, @@ -68,9 +76,14 @@ class MediaView extends StatefulWidget { this.hideThumbnails = false, this.markPostReadOnMediaView = false, this.isUserLoggedIn = false, - this.viewMode = ViewMode.comfortable, + this.viewMode = ContentViewMode.comfortable, this.navigateToPost, this.read, + this.handlers = const ContentActionHandlers(), + this.imagePeekDurationMs = 300, + this.tabletMode = false, + this.nsfwWarningLabel = 'NSFW', + this.retryTooltip = 'Retry', }); @override @@ -78,19 +91,22 @@ class MediaView extends StatefulWidget { } class _MediaViewState extends State with TickerProviderStateMixin { - // An overlay entry to display the image overlay for hold to peek + // An overlay entry to display the image overlay for hold to peek. OverlayEntry? _overlayEntry; - // An animation controller to animate the image overlay + // An animation controller to animate the image overlay. late final AnimationController _overlayAnimationController; - // The current state of the image preview + // The current state of the image preview. ImagePreviewState _imagePreviewState = ImagePreviewState.loading; @override void initState() { super.initState(); - _overlayAnimationController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this); + _overlayAnimationController = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: this, + ); } @override @@ -103,16 +119,22 @@ class _MediaViewState extends State with TickerProviderStateMixin { if (mounted) setState(() => _imagePreviewState = state); } - /// Overlays the image as an ImageViewer - void showImage() { - if (widget.isUserLoggedIn && widget.markPostReadOnMediaView) { - try { - // Mark post as read when on the feed page - context.read().add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postId, value: true)); - } catch (e) { - // Do nothing otherwise - } + void _markPostAsRead() { + if (!widget.isUserLoggedIn || !widget.markPostReadOnMediaView) return; + widget.handlers.onMarkRead?.call(widget.postId); + } + + void _openLink(String url) { + widget.handlers.onOpenLink?.call(context, url); + } + + void _openImage({String? url, Uint8List? bytes}) { + final onOpenImage = widget.handlers.onOpenImage; + if (onOpenImage != null) { + onOpenImage(context, url: url, bytes: bytes); + return; } + Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -123,7 +145,8 @@ class _MediaViewState extends State with TickerProviderStateMixin { }, pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { return ImageViewer( - url: widget.media.imageUrl, + url: url, + bytes: bytes, postId: widget.postId, navigateToPost: widget.navigateToPost, altText: widget.media.altText, @@ -133,106 +156,129 @@ class _MediaViewState extends State with TickerProviderStateMixin { ); } + void _openVideo(String url) { + widget.handlers.onOpenVideo?.call(context, url); + } + + /// Overlays the image as an ImageViewer. + void showImage() { + _markPostAsRead(); + _openImage(url: widget.media.imageUrl); + } + double getMinHeight() { - if (!widget.showFullHeightImages && widget.viewMode != ViewMode.comment) return ViewMode.comfortable.height; + if (!widget.showFullHeightImages && widget.viewMode != ContentViewMode.comment) { + return ContentViewMode.comfortable.height; + } if (widget.media.height != null) { - if (MediaQuery.of(context).size.height < widget.media.height!) return MediaQuery.of(context).size.height; + if (MediaQuery.of(context).size.height < widget.media.height!) { + return MediaQuery.of(context).size.height; + } return widget.media.height!; } - return ViewMode.comfortable.height; + return ContentViewMode.comfortable.height; } double getMaxHeight() { - if (widget.viewMode != ViewMode.comment) { - if (widget.allowUnconstrainedImageHeight) return MediaQuery.of(context).size.height; - if (!widget.showFullHeightImages) return ViewMode.comfortable.height; + if (widget.viewMode != ContentViewMode.comment) { + if (widget.allowUnconstrainedImageHeight) { + return MediaQuery.of(context).size.height; + } + if (!widget.showFullHeightImages) { + return ContentViewMode.comfortable.height; + } } if (widget.media.height != null) { - if (MediaQuery.of(context).size.height < widget.media.height!) return MediaQuery.of(context).size.height; + if (MediaQuery.of(context).size.height < widget.media.height!) { + return MediaQuery.of(context).size.height; + } return widget.media.height!; } - return ViewMode.comfortable.height; + return ContentViewMode.comfortable.height; } void handleTap() { - if (widget.isUserLoggedIn && widget.markPostReadOnMediaView) { - try { - final feedBloc = BlocProvider.of(context); - feedBloc.add(FeedItemActionedEvent(postAction: PostAction.read, postId: widget.postId, value: true)); - } catch (e) { - debugPrint('Error marking post as read: $e'); - } - } + _markPostAsRead(); } @override Widget build(BuildContext context) { - bool isImage = isImageUrl(widget.media.imageUrl ?? widget.media.mediaUrl ?? widget.media.originalUrl!); + final imageUrlCandidate = widget.media.imageUrl ?? widget.media.mediaUrl ?? widget.media.originalUrl; + final isImage = isImageUrl(imageUrlCandidate ?? ''); - // If hiding thumbnails is enabled or if the media has no image URL (e.g., text or links with no images), we should display a link preview instead - // This only applies for [ViewMode.comfortable] - if (widget.viewMode == ViewMode.comfortable && (widget.hideThumbnails || !isImage)) { + // If hiding thumbnails is enabled or if the media has no image URL, + // display a link preview instead in comfortable mode. + if (widget.viewMode == ContentViewMode.comfortable && (widget.hideThumbnails || !isImage)) { return LinkInformation( viewMode: widget.viewMode, url: widget.media.originalUrl, mediaType: widget.media.mediaType, onTap: () { handleTap(); - handleLink(context, url: widget.media.originalUrl!); + final url = widget.media.originalUrl; + if (url != null) _openLink(url); + }, + onLongPress: () { + final url = widget.media.originalUrl; + if (url != null) { + widget.handlers.onLongPressLink?.call(context, url, url); + } }, showEdgeToEdgeImages: widget.edgeToEdgeImages, ); } - if (widget.viewMode == ViewMode.compact && widget.media.mediaType == MediaType.text) { + if (widget.viewMode == ContentViewMode.compact && widget.media.mediaType == ContentMediaType.text) { return MediaViewText( text: widget.media.altText, read: widget.read, ); } - // At this point, all other media types should contain images, so we display the image as well as any additional information + // At this point, all other media types should contain images. final theme = Theme.of(context); - final imagePeekDurationMs = context.select((cubit) => cubit.state.imagePeekDuration); - final tabletMode = widget.viewMode == ViewMode.comfortable ? context.select((ThunderBloc bloc) => bloc.state.tabletMode) : false; final blurNSFWPreviews = widget.hideNsfwPreviews && widget.media.nsfw; - late final l10n = GlobalContext.l10n; double? width; double? height; switch (widget.viewMode) { - case ViewMode.comment: + case ContentViewMode.comment: width = widget.media.width; - height = widget.media.height ?? ViewMode.comment.height; // If the height is not set, use the default height + height = widget.media.height ?? ContentViewMode.comment.height; break; - case ViewMode.compact: - width = null; // Setting this to null will use the image's width. This will allow the image to not be stretched or squished. - height = ViewMode.compact.height; + case ContentViewMode.compact: + width = null; + height = ContentViewMode.compact.height; break; - case ViewMode.comfortable: - width = (tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); + case ContentViewMode.comfortable: + width = (widget.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); height = (widget.showFullHeightImages && !widget.allowUnconstrainedImageHeight) ? widget.media.height : null; } Widget? child; - // For links, add inkwell to handle links. For [ViewMode.comfortable], add link information below the image - if (widget.media.mediaType == MediaType.link) { + if (widget.media.mediaType == ContentMediaType.link) { child = InkWell( splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), onTap: () { handleTap(); - handleLink(context, url: widget.media.originalUrl!); + final url = widget.media.originalUrl; + if (url != null) _openLink(url); }, - onLongPress: () => handleLinkLongPress(context, widget.media.originalUrl!, widget.media.originalUrl), - child: widget.viewMode == ViewMode.comfortable + onLongPress: () { + final url = widget.media.originalUrl; + if (url != null) { + widget.handlers.onLongPressLink?.call(context, url, url); + } + }, + child: widget.viewMode == ContentViewMode.comfortable ? SizedBox( height: 70.0, child: Align( @@ -240,18 +286,17 @@ class _MediaViewState extends State with TickerProviderStateMixin { child: LinkInformation( viewMode: widget.viewMode, mediaType: widget.media.mediaType, - url: widget.media.originalUrl ?? '', + url: widget.media.originalUrl, showEdgeToEdgeImages: widget.edgeToEdgeImages, ), ), ) - : SizedBox(), + : const SizedBox.shrink(), ); } - // For images, add hold to peek gesture (only when image is loaded successfully) - if (widget.media.mediaType == MediaType.image && _imagePreviewState == ImagePreviewState.success) { - final imagePeekDuration = Duration(milliseconds: imagePeekDurationMs); + if (widget.media.mediaType == ContentMediaType.image && _imagePreviewState == ImagePreviewState.success) { + final imagePeekDuration = Duration(milliseconds: widget.imagePeekDurationMs); child = InkWell( splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), @@ -295,19 +340,19 @@ class _MediaViewState extends State with TickerProviderStateMixin { ); } - // For videos, add a play icon and tap gesture to play the video - if (widget.media.mediaType == MediaType.video) { + if (widget.media.mediaType == ContentMediaType.video) { child = InkWell( splashColor: theme.colorScheme.primary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), onTap: () { handleTap(); - showVideoPlayer(context, url: widget.media.mediaUrl ?? widget.media.originalUrl, postId: widget.postId); + final url = widget.media.mediaUrl ?? widget.media.originalUrl; + if (url != null) _openVideo(url); }, - child: widget.viewMode == ViewMode.comfortable + child: widget.viewMode == ContentViewMode.comfortable ? Column( children: [ - Expanded(child: Icon(Icons.play_arrow_rounded, size: 55)), + const Expanded(child: Icon(Icons.play_arrow_rounded, size: 55)), SizedBox( height: 70.0, child: Align( @@ -315,14 +360,14 @@ class _MediaViewState extends State with TickerProviderStateMixin { child: LinkInformation( viewMode: widget.viewMode, mediaType: widget.media.mediaType, - url: widget.media.originalUrl ?? '', + url: widget.media.originalUrl, showEdgeToEdgeImages: widget.edgeToEdgeImages, ), ), ), ], ) - : SizedBox(), + : const SizedBox.shrink(), ); } @@ -332,29 +377,30 @@ class _MediaViewState extends State with TickerProviderStateMixin { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: BorderRadius.circular((widget.edgeToEdgeImages ? 0 : 12)), - color: getBackgroundColor(context), + color: Theme.of(context).colorScheme.surfaceContainerHighest, ), constraints: BoxConstraints( - maxHeight: switch (widget.viewMode) { - ViewMode.comment => getMaxHeight(), - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => getMaxHeight(), - }, - minHeight: switch (widget.viewMode) { - ViewMode.comment => getMinHeight(), - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => getMinHeight(), - }, - maxWidth: switch (widget.viewMode) { - ViewMode.comment => MediaQuery.of(context).size.width / 2, - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, - }, - minWidth: switch (widget.viewMode) { - ViewMode.comment => 0, - ViewMode.compact => ViewMode.compact.height, - ViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, - }), + maxHeight: switch (widget.viewMode) { + ContentViewMode.comment => getMaxHeight(), + ContentViewMode.compact => ContentViewMode.compact.height, + ContentViewMode.comfortable => getMaxHeight(), + }, + minHeight: switch (widget.viewMode) { + ContentViewMode.comment => getMinHeight(), + ContentViewMode.compact => ContentViewMode.compact.height, + ContentViewMode.comfortable => getMinHeight(), + }, + maxWidth: switch (widget.viewMode) { + ContentViewMode.comment => MediaQuery.of(context).size.width / 2, + ContentViewMode.compact => ContentViewMode.compact.height, + ContentViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, + }, + minWidth: switch (widget.viewMode) { + ContentViewMode.comment => 0, + ContentViewMode.compact => ContentViewMode.compact.height, + ContentViewMode.comfortable => widget.edgeToEdgeImages ? double.infinity : MediaQuery.of(context).size.width, + }, + ), child: Stack( fit: widget.allowUnconstrainedImageHeight ? StackFit.loose : StackFit.expand, alignment: Alignment.center, @@ -364,12 +410,13 @@ class _MediaViewState extends State with TickerProviderStateMixin { contentType: widget.media.contentType, width: width, height: height, - fit: widget.viewMode == ViewMode.compact ? BoxFit.cover : BoxFit.fitWidth, + fit: widget.viewMode == ContentViewMode.compact ? BoxFit.cover : BoxFit.fitWidth, mediaType: widget.media.mediaType, viewed: widget.read, blur: blurNSFWPreviews, - allowRetry: widget.media.mediaType == MediaType.image, + allowRetry: widget.media.mediaType == ContentMediaType.image, onStateChanged: _onImagePreviewStateChanged, + retryTooltip: widget.retryTooltip, ), if (blurNSFWPreviews) Column( @@ -377,10 +424,13 @@ class _MediaViewState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - widget.media.mediaType == MediaType.image - ? Icon(Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30) - : Icon(widget.viewMode != ViewMode.compact ? Icons.play_arrow_rounded : Icons.warning_rounded, size: widget.viewMode != ViewMode.compact ? 55 : 30), - if (widget.viewMode == ViewMode.comfortable) Text(l10n.nsfwWarning, textScaler: const TextScaler.linear(1.5)), + widget.media.mediaType == ContentMediaType.image + ? Icon(Icons.warning_rounded, size: widget.viewMode != ContentViewMode.compact ? 55 : 30) + : Icon( + widget.viewMode != ContentViewMode.compact ? Icons.play_arrow_rounded : Icons.warning_rounded, + size: widget.viewMode != ContentViewMode.compact ? 55 : 30, + ), + if (widget.viewMode == ContentViewMode.comfortable) Text(widget.nsfwWarningLabel, textScaler: const TextScaler.linear(1.5)), ], ), if (child != null) diff --git a/lib/packages/ui/src/widgets/media/media_view_text.dart b/lib/packages/ui/src/widgets/media/media_view_text.dart new file mode 100644 index 000000000..fdf0f81a2 --- /dev/null +++ b/lib/packages/ui/src/widgets/media/media_view_text.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class MediaViewText extends StatelessWidget { + const MediaViewText({ + super.key, + this.text, + this.read, + }); + + final String? text; + final bool? read; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foreground = theme.colorScheme.onSurface.withValues( + alpha: read == true ? 0.55 : 1.0, + ); + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + text ?? '', + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: foreground), + ), + ); + } +} diff --git a/lib/src/shared/utils/bottom_sheet_list_picker.dart b/lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart similarity index 96% rename from lib/src/shared/utils/bottom_sheet_list_picker.dart rename to lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart index 9f0cef62f..2378bebcb 100644 --- a/lib/src/shared/utils/bottom_sheet_list_picker.dart +++ b/lib/packages/ui/src/widgets/pickers/bottom_sheet_list_picker.dart @@ -1,12 +1,12 @@ import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; -import 'package:thunder/src/shared/picker_item.dart'; +import 'package:thunder/packages/ui/src/widgets/pickers/picker_item.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; class BottomSheetListPicker extends StatefulWidget { final String title; - final List> items; - final Future Function(ListPickerItem)? onSelect; + final List> items; + final Future Function(PickerOption)? onSelect; final T? previouslySelected; final bool closeOnSelect; final Widget? heading; @@ -176,7 +176,7 @@ class _BottomSheetListPickerState extends State> { } } -class ListPickerItem { +class PickerOption { /// Icon shown on the left final IconData? icon; @@ -213,7 +213,7 @@ class ListPickerItem { /// Whether the subtitle should softwrap final bool softWrap; - const ListPickerItem({ + const PickerOption({ this.icon, this.colors, this.label = "", @@ -228,3 +228,5 @@ class ListPickerItem { this.softWrap = false, }); } + +typedef ListPickerItem = PickerOption; diff --git a/lib/src/shared/multi_picker_item.dart b/lib/packages/ui/src/widgets/pickers/multi_picker_item.dart similarity index 100% rename from lib/src/shared/multi_picker_item.dart rename to lib/packages/ui/src/widgets/pickers/multi_picker_item.dart diff --git a/lib/src/shared/picker_item.dart b/lib/packages/ui/src/widgets/pickers/picker_item.dart similarity index 100% rename from lib/src/shared/picker_item.dart rename to lib/packages/ui/src/widgets/pickers/picker_item.dart diff --git a/lib/packages/ui/ui.dart b/lib/packages/ui/ui.dart new file mode 100644 index 000000000..1365c5f10 --- /dev/null +++ b/lib/packages/ui/ui.dart @@ -0,0 +1,34 @@ +export 'src/models/content/content_action_handlers.dart'; +export 'src/models/content/content_media.dart'; +export 'src/models/content/content_media_type.dart'; +export 'src/models/content/content_view_mode.dart'; +export 'src/widgets/markdown/common_markdown_body.dart'; +export 'src/widgets/media/image_preview.dart'; +export 'src/widgets/media/image_viewer.dart'; +export 'src/utils/media/media_utils.dart'; +export 'src/widgets/content/content_renderer.dart'; +export 'src/widgets/markdown/markdown_body.dart'; +export 'src/widgets/media/compact_thumbnail_preview.dart'; +export 'src/widgets/media/media_view.dart'; +export 'src/utils/links/link_navigation_utils.dart'; +export 'src/models/identity/avatar_data.dart'; +export 'src/models/identity/identity_name_data.dart'; +export 'src/models/identity/name_style.dart'; +export 'src/utils/identity/name_formatting.dart'; +export 'src/widgets/identity/avatar_widgets.dart'; +export 'src/widgets/identity/community_avatar.dart'; +export 'src/widgets/identity/instance_avatar.dart'; +export 'src/widgets/identity/user_avatar.dart'; +export 'src/widgets/identity/full_name_widgets.dart'; +export 'src/widgets/identity/scalable_text.dart'; +export 'src/widgets/dialogs/thunder_dialog.dart'; +export 'src/widgets/pickers/multi_picker_item.dart'; +export 'src/widgets/pickers/picker_item.dart'; +export 'src/widgets/pickers/bottom_sheet_list_picker.dart'; +export 'src/widgets/actions/bottom_sheet_action.dart'; +export 'src/widgets/layout/conditional_parent_widget.dart'; +export 'src/widgets/layout/thunder_divider.dart'; +export 'src/widgets/feedback/snackbar.dart'; +export 'src/icons/thunder_icons.dart'; +export 'src/widgets/actions/thunder_action_chip.dart'; +export 'src/widgets/actions/thunder_popup_menu_item.dart'; diff --git a/lib/src/app/bloc/thunder_bloc.dart b/lib/src/app/bloc/thunder_bloc.dart deleted file mode 100644 index 74240dc36..000000000 --- a/lib/src/app/bloc/thunder_bloc.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:stream_transform/stream_transform.dart'; - -import 'package:thunder/src/core/enums/browser_mode.dart'; -import 'package:thunder/src/core/enums/image_caching_mode.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/core/models/version.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; - -part 'thunder_event.dart'; - -part 'thunder_state.dart'; - -const throttleDuration = Duration(milliseconds: 300); - -EventTransformer throttleDroppable(Duration duration) { - return (events, mapper) => droppable().call(events.throttle(duration), mapper); -} - -class ThunderBloc extends Bloc { - ThunderBloc() : super(const ThunderState()) { - on( - _initializeAppEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _userPreferencesChangeEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _onSetCurrentAnonymousInstance, - ); - } - - /// This event should be triggered at the start of the app. - /// - /// It initializes the local database, checks for updates from GitHub, and loads the user's preferences. - Future _initializeAppEvent(InitializeAppEvent event, Emitter emit) async { - try { - // Check for any updates from GitHub - Version version = await fetchVersion(); - - add(UserPreferencesChangeEvent()); - emit(state.copyWith(status: ThunderStatus.success, version: version)); - } catch (e) { - return emit(state.copyWith(status: ThunderStatus.failure, errorMessage: e.toString())); - } - } - - Future _userPreferencesChangeEvent(UserPreferencesChangeEvent event, Emitter emit) async { - try { - emit(state.copyWith(status: ThunderStatus.refreshing)); - - // Tablet Settings - bool tabletMode = UserPreferences.getLocalSetting(LocalSettings.useTabletMode) ?? false; - - // General Settings - BrowserMode browserMode = BrowserMode.values.byName(UserPreferences.getLocalSetting(LocalSettings.browserMode) ?? BrowserMode.customTabs.name); - bool openInReaderMode = UserPreferences.getLocalSetting(LocalSettings.openLinksInReaderMode) ?? false; - bool showInAppUpdateNotification = UserPreferences.getLocalSetting(LocalSettings.showInAppUpdateNotification) ?? false; - bool showUpdateChangelogs = UserPreferences.getLocalSetting(LocalSettings.showUpdateChangelogs) ?? true; - NotificationType inboxNotificationType = NotificationType.values.byName(UserPreferences.getLocalSetting(LocalSettings.inboxNotificationType) ?? NotificationType.none.name); - String? appLanguageCode = UserPreferences.getLocalSetting(LocalSettings.appLanguageCode) ?? 'en'; - bool useProfilePictureForDrawer = UserPreferences.getLocalSetting(LocalSettings.useProfilePictureForDrawer) ?? false; - ImageCachingMode imageCachingMode = ImageCachingMode.values.byName(UserPreferences.getLocalSetting(LocalSettings.imageCachingMode) ?? ImageCachingMode.relaxed.name); - bool showNavigationLabels = UserPreferences.getLocalSetting(LocalSettings.showNavigationLabels) ?? true; - bool hideTopBarOnScroll = UserPreferences.getLocalSetting(LocalSettings.hideTopBarOnScroll) ?? false; - bool hideBottomBarOnScroll = UserPreferences.getLocalSetting(LocalSettings.hideBottomBarOnScroll) ?? false; - bool scoreCounters = UserPreferences.getLocalSetting(LocalSettings.scoreCounters) ?? false; - - String currentAnonymousInstance = UserPreferences.getLocalSetting(LocalSettings.currentAnonymousInstance) ?? DEFAULT_INSTANCE; - - return emit(state.copyWith( - status: ThunderStatus.success, - tabletMode: tabletMode, - browserMode: browserMode, - openInReaderMode: openInReaderMode, - showInAppUpdateNotification: showInAppUpdateNotification, - showUpdateChangelogs: showUpdateChangelogs, - inboxNotificationType: inboxNotificationType, - appLanguageCode: appLanguageCode, - useProfilePictureForDrawer: useProfilePictureForDrawer, - imageCachingMode: imageCachingMode, - showNavigationLabels: showNavigationLabels, - hideTopBarOnScroll: hideTopBarOnScroll, - hideBottomBarOnScroll: hideBottomBarOnScroll, - scoreCounters: scoreCounters, - currentAnonymousInstance: currentAnonymousInstance, - )); - } catch (e) { - return emit(state.copyWith(status: ThunderStatus.failure, errorMessage: e.toString())); - } - } - - void _onSetCurrentAnonymousInstance(OnSetCurrentAnonymousInstance event, Emitter emit) async { - if (event.instance != null) { - UserPreferences.setSetting(LocalSettings.currentAnonymousInstance, event.instance!); - } else { - UserPreferences.removeSetting(LocalSettings.currentAnonymousInstance); - } - - emit(state.copyWith(currentAnonymousInstance: event.instance)); - } -} diff --git a/lib/src/app/bootstrap/bootstrap.dart b/lib/src/app/bootstrap/bootstrap.dart new file mode 100644 index 000000000..f6fb4a74a --- /dev/null +++ b/lib/src/app/bootstrap/bootstrap.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:dart_ping_ios/dart_ping_ios.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/app/shell/thunder_app.dart'; +import 'package:thunder/src/app/bootstrap/preferences_migration.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/account/api.dart'; + +Future bootstrap() async { + WidgetsFlutterBinding.ensureInitialized(); + + try { + // Fixes an issue with older Android devices connecting to instances with LetsEncrypt certificates + // https://github.com/thunder-app/thunder/pull/1675 + final certificate = await PlatformAssetBundle().load('assets/ca/isrgrootx1.pem'); + SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + } catch (_) { + // Continue if failed to load certificate + } + + // Enables edge-to-edge on older Android devices. Android 15 and up automatically enforces it. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + // Initialize preferences and database + await UserPreferences.instance.initialize(); + await performSharedPreferencesMigration(); + initializeDatabase(); + await performDatabaseIntegrityChecks(); + + final account = await fetchActiveProfile(); + + runApp( + BlocProvider( + create: (context) => createProfileBloc(account)..add(InitializeAuth()), + child: const ThunderApp(), + ), + ); + + // Additional platform-specific setup + if (!kIsWeb && Platform.isAndroid) FlutterDisplayMode.setHighRefreshRate(); + if (!kIsWeb && Platform.isIOS) DartPingIOS.register(); + + await clearExtendedImageCache(); +} diff --git a/lib/src/shared/utils/preferences.dart b/lib/src/app/bootstrap/preferences_migration.dart similarity index 69% rename from lib/src/shared/utils/preferences.dart rename to lib/src/app/bootstrap/preferences_migration.dart index 899bf739d..e33fdf278 100644 --- a/lib/src/shared/utils/preferences.dart +++ b/lib/src/app/bootstrap/preferences_migration.dart @@ -1,187 +1,149 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/drafts/drafts.dart'; -import 'package:thunder/src/core/enums/browser_mode.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/theme_type.dart'; -import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; - -@Deprecated('Use Draft model through database instead') -class DraftComment { - String? text; - bool saveAsDraft = true; - - DraftComment({this.text}); - - Map toJson() => {'text': text}; - - static DraftComment fromJson(Map json) => DraftComment(text: json['text']); - - bool get isNotEmpty => text?.isNotEmpty == true; -} - -@Deprecated('Use Draft model through database instead') -class DraftPost { - String? title; - String? url; - String? text; - bool saveAsDraft = true; - - DraftPost({this.title, this.url, this.text}); - - Map toJson() => {'title': title, 'url': url, 'text': text}; - - static DraftPost fromJson(Map json) => DraftPost(title: json['title'], url: json['url'], text: json['text']); - - bool get isNotEmpty => title?.isNotEmpty == true || url?.isNotEmpty == true || text?.isNotEmpty == true; -} - -Future performSharedPreferencesMigration() async { - final prefs = UserPreferences.instance.preferences; - - // Migrate the openInExternalBrowser setting, if found. - bool? legacyOpenInExternalBrowser = prefs.getBool(LocalSettings.openLinksInExternalBrowser.name); - if (legacyOpenInExternalBrowser != null) { - final BrowserMode browserMode = legacyOpenInExternalBrowser ? BrowserMode.external : BrowserMode.customTabs; - await prefs.remove(LocalSettings.openLinksInExternalBrowser.name); - await prefs.setString(LocalSettings.browserMode.name, browserMode.name); - } - - // Check to see if browserMode was set incorrectly - String? browserMode = prefs.getString(LocalSettings.browserMode.name); - if (browserMode != null && browserMode.contains("BrowserMode")) { - await prefs.setString(LocalSettings.browserMode.name, browserMode.replaceAll('BrowserMode.', '')); - } - - // Migrate the commentUseColorizedUsername setting, if found. - bool? legacyCommentUseColorizedUsername = prefs.getBool(LocalSettings.commentUseColorizedUsername.name); - if (legacyCommentUseColorizedUsername != null) { - await prefs.remove(LocalSettings.commentUseColorizedUsername.name); - if (legacyCommentUseColorizedUsername == true) { - await prefs.setString(LocalSettings.userFullNameUserNameColor.name, NameColor.themePrimary); - } - } - - // Migrate the enableInboxNotifications setting, if found. - bool? legacyEnableInboxNotifications = prefs.getBool('setting_enable_inbox_notifications'); - if (legacyEnableInboxNotifications != null) { - await prefs.remove('setting_enable_inbox_notifications'); - await prefs.setString(LocalSettings.inboxNotificationType.name, legacyEnableInboxNotifications ? NotificationType.local.name : NotificationType.none.name); - } - - // Migrate drafts to database - Iterable draftsKeys = prefs.getKeys().where((pref) => pref.startsWith('drafts_cache')); - for (String draftKey in draftsKeys) { - try { - late DraftType draftType; - int? existingId; - int? replyId; - - // ignore: deprecated_member_use_from_same_package - DraftPost? draftPost; - // ignore: deprecated_member_use_from_same_package - DraftComment? draftComment; - - if (draftKey.contains('post-create-general')) { - draftType = DraftType.postCreateGeneral; - // ignore: deprecated_member_use_from_same_package - draftPost = DraftPost.fromJson(jsonDecode(prefs.getString(draftKey)!)); - } else if (draftKey.contains('post-create')) { - draftType = DraftType.postCreate; - replyId = int.parse(draftKey.split('-').last); - // ignore: deprecated_member_use_from_same_package - draftPost = DraftPost.fromJson(jsonDecode(prefs.getString(draftKey)!)); - } else if (draftKey.contains('post-edit')) { - draftType = DraftType.postEdit; - existingId = int.parse(draftKey.split('-').last); - // ignore: deprecated_member_use_from_same_package - draftPost = DraftPost.fromJson(jsonDecode(prefs.getString(draftKey)!)); - } else if (draftKey.contains('comment-create')) { - draftType = DraftType.commentCreate; - replyId = int.parse(draftKey.split('-').last); - // ignore: deprecated_member_use_from_same_package - draftComment = DraftComment.fromJson(jsonDecode(prefs.getString(draftKey)!)); - } else if (draftKey.contains('comment-edit')) { - draftType = DraftType.commentEdit; - existingId = int.parse(draftKey.split('-').last); - // ignore: deprecated_member_use_from_same_package - draftComment = DraftComment.fromJson(jsonDecode(prefs.getString(draftKey)!)); - } else { - // We can't parse the draft type from the shared preferences. - debugPrint('Cannot parse draft type from SharedPreferences key: $draftKey'); - continue; - } - - Draft draft = Draft( - id: '', - draftType: draftType, - existingId: existingId, - replyId: replyId, - title: draftPost?.title, - url: draftPost?.url, - body: draftPost?.text ?? draftComment?.text, - ); - - Draft.upsertDraft(draft); - - // If we've gotten this far without exception, it's safe to delete the shared pref eky - prefs.remove(draftKey); - } catch (e) { - debugPrint('Cannot migrate draft from SharedPreferences: $draftKey'); - } - } - - // Update the default feed type setting - FeedListType defaultFeedListType = FeedListType.values.byName(prefs.getString(LocalSettings.defaultFeedListType.name) ?? DEFAULT_LISTING_TYPE.name); - if (defaultFeedListType == FeedListType.subscribed) { - await prefs.setString(LocalSettings.defaultFeedListType.name, DEFAULT_LISTING_TYPE.name); - } - - // Migrate anonymous instances to database - final List? anonymousInstances = prefs.getStringList('setting_anonymous_instances'); - try { - for (String instance in anonymousInstances ?? []) { - Account anonymousInstance = Account( - id: '', - instance: instance, - index: -1, - anonymous: true, - platform: ThreadiversePlatform.lemmy, - ); - Account.insertAnonymousInstance(anonymousInstance); - } - - // If we've gotten this far without exception, it's safe to delete the shared pref eky - prefs.remove('setting_anonymous_instances'); - } catch (e) { - debugPrint('Cannot migrate anonymous instances from SharedPreferences: $e'); - } - - // Migrate theme settings for pure black to use dark theme + pure black setting - ThemeType themeType = ThemeType.values[prefs.getInt(LocalSettings.appTheme.name) ?? ThemeType.system.index]; - if (themeType == ThemeType.pureBlack) { - await prefs.setInt(LocalSettings.appTheme.name, ThemeType.dark.index); - await prefs.setBool(LocalSettings.usePureBlackTheme.name, true); - } - - // Reset transparent theme to default (transparent theme was removed as it made the app unusable) - String? accentColor = prefs.getString(LocalSettings.appThemeAccentColor.name); - if (accentColor == 'transparent') { - await prefs.remove(LocalSettings.appThemeAccentColor.name); - debugPrint('Reset transparent theme to default'); - } - - // Remove scrapeMissingPreviews setting - bool? scrapeMissingPreviews = prefs.getBool('setting_general_scrape_missing_previews'); - if (scrapeMissingPreviews != null) { - await prefs.remove('setting_general_scrape_missing_previews'); - debugPrint('Removed setting_general_scrape_missing_previews'); - } -} +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/packages/ui/ui.dart' show NameColor; + +/// Performs migrations for shared preferences. +Future performSharedPreferencesMigration() async { + final prefs = UserPreferences.instance.preferences; + + // Migrate the openInExternalBrowser setting, if found. + bool? legacyOpenInExternalBrowser = prefs.getBool(LocalSettings.openLinksInExternalBrowser.name); + if (legacyOpenInExternalBrowser != null) { + final BrowserMode browserMode = legacyOpenInExternalBrowser ? BrowserMode.external : BrowserMode.customTabs; + await prefs.remove(LocalSettings.openLinksInExternalBrowser.name); + await prefs.setString(LocalSettings.browserMode.name, browserMode.name); + } + + // Check to see if browserMode was set incorrectly + String? browserMode = prefs.getString(LocalSettings.browserMode.name); + if (browserMode != null && browserMode.contains("BrowserMode")) { + await prefs.setString(LocalSettings.browserMode.name, browserMode.replaceAll('BrowserMode.', '')); + } + + // Migrate the commentUseColorizedUsername setting, if found. + bool? legacyCommentUseColorizedUsername = prefs.getBool(LocalSettings.commentUseColorizedUsername.name); + if (legacyCommentUseColorizedUsername != null) { + await prefs.remove(LocalSettings.commentUseColorizedUsername.name); + if (legacyCommentUseColorizedUsername == true) { + await prefs.setString(LocalSettings.userFullNameUserNameColor.name, NameColor.themePrimary); + } + } + + // Migrate the enableInboxNotifications setting, if found. + bool? legacyEnableInboxNotifications = prefs.getBool('setting_enable_inbox_notifications'); + if (legacyEnableInboxNotifications != null) { + await prefs.remove('setting_enable_inbox_notifications'); + await prefs.setString(LocalSettings.inboxNotificationType.name, legacyEnableInboxNotifications ? NotificationType.local.name : NotificationType.none.name); + } + + // Migrate drafts to database + Iterable draftsKeys = prefs.getKeys().where((pref) => pref.startsWith('drafts_cache')); + for (String draftKey in draftsKeys) { + try { + late DraftType draftType; + int? existingId; + int? replyId; + + Map? draftPost; + Map? draftComment; + + if (draftKey.contains('post-create-general')) { + draftType = DraftType.postCreateGeneral; + draftPost = (jsonDecode(prefs.getString(draftKey)!) as Map).cast(); + } else if (draftKey.contains('post-create')) { + draftType = DraftType.postCreate; + replyId = int.parse(draftKey.split('-').last); + draftPost = (jsonDecode(prefs.getString(draftKey)!) as Map).cast(); + } else if (draftKey.contains('post-edit')) { + draftType = DraftType.postEdit; + existingId = int.parse(draftKey.split('-').last); + draftPost = (jsonDecode(prefs.getString(draftKey)!) as Map).cast(); + } else if (draftKey.contains('comment-create')) { + draftType = DraftType.commentCreate; + replyId = int.parse(draftKey.split('-').last); + draftComment = (jsonDecode(prefs.getString(draftKey)!) as Map).cast(); + } else if (draftKey.contains('comment-edit')) { + draftType = DraftType.commentEdit; + existingId = int.parse(draftKey.split('-').last); + draftComment = (jsonDecode(prefs.getString(draftKey)!) as Map).cast(); + } else { + // We can't parse the draft type from the shared preferences. + debugPrint('Cannot parse draft type from SharedPreferences key: $draftKey'); + continue; + } + + Draft draft = Draft( + id: '', + draftType: draftType, + existingId: existingId, + replyId: replyId, + title: draftPost?['title'] as String?, + url: draftPost?['url'] as String?, + body: (draftPost?['text'] ?? draftComment?['text']) as String?, + ); + + Draft.upsertDraft(draft); + + // If we've gotten this far without exception, it's safe to delete the shared pref eky + prefs.remove(draftKey); + } catch (e) { + debugPrint('Cannot migrate draft from SharedPreferences: $draftKey'); + } + } + + // Update the default feed type setting + FeedListType defaultFeedListType = FeedListType.values.byName(prefs.getString(LocalSettings.defaultFeedListType.name) ?? DEFAULT_LISTING_TYPE.name); + if (defaultFeedListType == FeedListType.subscribed) { + await prefs.setString(LocalSettings.defaultFeedListType.name, DEFAULT_LISTING_TYPE.name); + } + + // Migrate anonymous instances to database + final List? anonymousInstances = prefs.getStringList('setting_anonymous_instances'); + try { + for (String instance in anonymousInstances ?? []) { + Account anonymousInstance = Account( + id: '', + instance: instance, + index: -1, + anonymous: true, + platform: ThreadiversePlatform.lemmy, + ); + Account.insertAnonymousInstance(anonymousInstance); + } + + // If we've gotten this far without exception, it's safe to delete the shared pref eky + prefs.remove('setting_anonymous_instances'); + } catch (e) { + debugPrint('Cannot migrate anonymous instances from SharedPreferences: $e'); + } + + // Migrate theme settings for pure black to use dark theme + pure black setting + ThemeType themeType = ThemeType.values[prefs.getInt(LocalSettings.appTheme.name) ?? ThemeType.system.index]; + if (themeType == ThemeType.pureBlack) { + await prefs.setInt(LocalSettings.appTheme.name, ThemeType.dark.index); + await prefs.setBool(LocalSettings.usePureBlackTheme.name, true); + } + + // Reset transparent theme to default (transparent theme was removed as it made the app unusable) + String? accentColor = prefs.getString(LocalSettings.appThemeAccentColor.name); + if (accentColor == 'transparent') { + await prefs.remove(LocalSettings.appThemeAccentColor.name); + debugPrint('Reset transparent theme to default'); + } + + // Remove scrapeMissingPreviews setting + bool? scrapeMissingPreviews = prefs.getBool('setting_general_scrape_missing_previews'); + if (scrapeMissingPreviews != null) { + await prefs.remove('setting_general_scrape_missing_previews'); + debugPrint('Removed setting_general_scrape_missing_previews'); + } +} diff --git a/lib/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart b/lib/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart deleted file mode 100644 index b84ecc1c7..000000000 --- a/lib/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; - -part 'comment_preferences_state.dart'; - -/// Cubit for managing comment-related preferences -class CommentPreferencesCubit extends Cubit { - CommentPreferencesCubit() : super(const CommentPreferencesState()) { - load(); - } - - /// Loads comment preferences from UserPreferences - void load() { - final defaultCommentSortType = CommentSortType.values.byName(UserPreferences.getLocalSetting(LocalSettings.defaultCommentSortType) ?? DEFAULT_COMMENT_SORT_TYPE.name); - final collapseParentCommentOnGesture = UserPreferences.getLocalSetting(LocalSettings.collapseParentCommentBodyOnGesture) ?? true; - final showCommentButtonActions = UserPreferences.getLocalSetting(LocalSettings.showCommentActionButtons) ?? false; - final commentShowUserInstance = UserPreferences.getLocalSetting(LocalSettings.commentShowUserInstance) ?? false; - final commentShowUserAvatar = UserPreferences.getLocalSetting(LocalSettings.commentShowUserAvatar) ?? false; - final combineCommentScores = UserPreferences.getLocalSetting(LocalSettings.combineCommentScores) ?? false; - final nestedCommentIndicatorStyle = - NestedCommentIndicatorStyle.values.byName(UserPreferences.getLocalSetting(LocalSettings.nestedCommentIndicatorStyle) ?? DEFAULT_NESTED_COMMENT_INDICATOR_STYLE.name); - final nestedCommentIndicatorColor = - NestedCommentIndicatorColor.values.byName(UserPreferences.getLocalSetting(LocalSettings.nestedCommentIndicatorColor) ?? DEFAULT_NESTED_COMMENT_INDICATOR_COLOR.name); - - emit( - CommentPreferencesState( - defaultCommentSortType: defaultCommentSortType, - collapseParentCommentOnGesture: collapseParentCommentOnGesture, - showCommentButtonActions: showCommentButtonActions, - commentShowUserInstance: commentShowUserInstance, - commentShowUserAvatar: commentShowUserAvatar, - combineCommentScores: combineCommentScores, - nestedCommentIndicatorStyle: nestedCommentIndicatorStyle, - nestedCommentIndicatorColor: nestedCommentIndicatorColor, - ), - ); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart b/lib/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart deleted file mode 100644 index 4537224c0..000000000 --- a/lib/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/fab_action.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; - -part 'fab_preferences_state.dart'; - -/// Cubit for managing floating action button (FAB) preferences -class FabPreferencesCubit extends Cubit { - FabPreferencesCubit() : super(const FabPreferencesState()) { - load(); - } - - /// Loads FAB preferences from UserPreferences - void load() { - final enableFeedsFab = UserPreferences.getLocalSetting(LocalSettings.enableFeedsFab) ?? true; - final enablePostsFab = UserPreferences.getLocalSetting(LocalSettings.enablePostsFab) ?? true; - - final enableBackToTop = UserPreferences.getLocalSetting(LocalSettings.enableBackToTop) ?? true; - final enableSubscriptions = UserPreferences.getLocalSetting(LocalSettings.enableSubscriptions) ?? true; - final enableRefresh = UserPreferences.getLocalSetting(LocalSettings.enableRefresh) ?? true; - final enableDismissRead = UserPreferences.getLocalSetting(LocalSettings.enableDismissRead) ?? true; - final enableChangeSort = UserPreferences.getLocalSetting(LocalSettings.enableChangeSort) ?? true; - final enableNewPost = UserPreferences.getLocalSetting(LocalSettings.enableNewPost) ?? true; - - final postFabEnableBackToTop = UserPreferences.getLocalSetting(LocalSettings.postFabEnableBackToTop) ?? true; - final postFabEnableChangeSort = UserPreferences.getLocalSetting(LocalSettings.postFabEnableChangeSort) ?? true; - final postFabEnableReplyToPost = UserPreferences.getLocalSetting(LocalSettings.postFabEnableReplyToPost) ?? true; - final postFabEnableRefresh = UserPreferences.getLocalSetting(LocalSettings.postFabEnableRefresh) ?? true; - final postFabEnableSearch = UserPreferences.getLocalSetting(LocalSettings.postFabEnableSearch) ?? true; - - final feedFabSinglePressAction = FeedFabAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.feedFabSinglePressAction) ?? FeedFabAction.newPost.name); - final feedFabLongPressAction = FeedFabAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.feedFabLongPressAction) ?? FeedFabAction.openFab.name); - final postFabSinglePressAction = PostFabAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postFabSinglePressAction) ?? PostFabAction.replyToPost.name); - final postFabLongPressAction = PostFabAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postFabLongPressAction) ?? PostFabAction.openFab.name); - - final enableCommentNavigation = UserPreferences.getLocalSetting(LocalSettings.enableCommentNavigation) ?? true; - final combineNavAndFab = UserPreferences.getLocalSetting(LocalSettings.combineNavAndFab) ?? true; - - emit( - FabPreferencesState( - enableFeedsFab: enableFeedsFab, - enablePostsFab: enablePostsFab, - enableBackToTop: enableBackToTop, - enableSubscriptions: enableSubscriptions, - enableRefresh: enableRefresh, - enableDismissRead: enableDismissRead, - enableChangeSort: enableChangeSort, - enableNewPost: enableNewPost, - postFabEnableBackToTop: postFabEnableBackToTop, - postFabEnableChangeSort: postFabEnableChangeSort, - postFabEnableReplyToPost: postFabEnableReplyToPost, - postFabEnableRefresh: postFabEnableRefresh, - postFabEnableSearch: postFabEnableSearch, - feedFabSinglePressAction: feedFabSinglePressAction, - feedFabLongPressAction: feedFabLongPressAction, - postFabSinglePressAction: postFabSinglePressAction, - postFabLongPressAction: postFabLongPressAction, - enableCommentNavigation: enableCommentNavigation, - combineNavAndFab: combineNavAndFab, - ), - ); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart b/lib/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart deleted file mode 100644 index 371c8e1b9..000000000 --- a/lib/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:intl/intl.dart'; - -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_card_divider_thickness.dart'; -import 'package:thunder/src/core/enums/post_body_view_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/features/post/post.dart'; - -part 'feed_preferences_state.dart'; - -/// Cubit for managing feed-related preferences. This includes settings for the feed list, post cards, and post body. -class FeedPreferencesCubit extends Cubit { - FeedPreferencesCubit() : super(const FeedPreferencesState()) { - load(); - } - - /// Loads feed preferences from UserPreferences - void load() { - // Default Listing/Sort Settings - FeedListType defaultFeedListType = DEFAULT_LISTING_TYPE; - PostSortType defaultPostSortType = DEFAULT_POST_SORT_TYPE; - - try { - defaultFeedListType = FeedListType.values.byName(UserPreferences.getLocalSetting(LocalSettings.defaultFeedListType) ?? DEFAULT_LISTING_TYPE.name); - defaultPostSortType = PostSortType.values.byName(UserPreferences.getLocalSetting(LocalSettings.defaultFeedPostSortType) ?? DEFAULT_POST_SORT_TYPE.name); - } catch (e) { - defaultFeedListType = FeedListType.values.byName(DEFAULT_LISTING_TYPE.name); - defaultPostSortType = PostSortType.values.byName(DEFAULT_POST_SORT_TYPE.name); - } - - // NSFW Settings - final hideNsfwPosts = UserPreferences.getLocalSetting(LocalSettings.hideNsfwPosts) ?? false; - final hideNsfwPreviews = UserPreferences.getLocalSetting(LocalSettings.hideNsfwPreviews) ?? true; - - // General Settings - final markPostReadOnMediaView = UserPreferences.getLocalSetting(LocalSettings.markPostAsReadOnMediaView) ?? false; - final markPostReadOnScroll = UserPreferences.getLocalSetting(LocalSettings.markPostAsReadOnScroll) ?? false; - final showHiddenPosts = UserPreferences.getLocalSetting(LocalSettings.showHiddenPosts) ?? false; - final showExpandedTaglines = UserPreferences.getLocalSetting(LocalSettings.showExpandedTaglines) ?? false; - - /// -------------------------- Feed Post Related Settings -------------------------- - // Compact Related Settings - final useCompactView = UserPreferences.getLocalSetting(LocalSettings.useCompactView) ?? false; - final showPostCommunityFirst = UserPreferences.getLocalSetting(LocalSettings.showPostCommunityFirst) ?? false; - final showTitleFirst = UserPreferences.getLocalSetting(LocalSettings.showPostTitleFirst) ?? false; - final hideThumbnails = UserPreferences.getLocalSetting(LocalSettings.hideThumbnails) ?? false; - final showThumbnailPreviewOnRight = UserPreferences.getLocalSetting(LocalSettings.showThumbnailPreviewOnRight) ?? false; - final linkPostsUseCompactView = UserPreferences.getLocalSetting(LocalSettings.linkPostsUseCompactView) ?? false; - final pinnedPostsUseCompactView = UserPreferences.getLocalSetting(LocalSettings.pinnedPostsUseCompactView) ?? true; - final showTextPostIndicator = UserPreferences.getLocalSetting(LocalSettings.showTextPostIndicator) ?? false; - final tappableAuthorCommunity = UserPreferences.getLocalSetting(LocalSettings.tappableAuthorCommunity) ?? false; - - // General Settings - final showVoteActions = UserPreferences.getLocalSetting(LocalSettings.showPostVoteActions) ?? true; - final showSaveAction = UserPreferences.getLocalSetting(LocalSettings.showPostSaveAction) ?? true; - final showCommunityIcons = UserPreferences.getLocalSetting(LocalSettings.showPostCommunityIcons) ?? false; - final showFullHeightImages = UserPreferences.getLocalSetting(LocalSettings.showPostFullHeightImages) ?? true; - final showEdgeToEdgeImages = UserPreferences.getLocalSetting(LocalSettings.showPostEdgeToEdgeImages) ?? false; - final showTextContent = UserPreferences.getLocalSetting(LocalSettings.showPostTextContentPreview) ?? false; - final showPostAuthor = UserPreferences.getLocalSetting(LocalSettings.showPostAuthor) ?? false; - final postShowUserInstance = UserPreferences.getLocalSetting(LocalSettings.postShowUserInstance) ?? false; - final dimReadPosts = UserPreferences.getLocalSetting(LocalSettings.dimReadPosts) ?? true; - final showFullPostDate = UserPreferences.getLocalSetting(LocalSettings.showFullPostDate) ?? false; - final dateFormat = DateFormat(UserPreferences.getLocalSetting(LocalSettings.dateFormat) ?? DateFormat.yMMMMd(Intl.systemLocale).add_jm().pattern); - final feedCardDividerThickness = FeedCardDividerThickness.values.byName(UserPreferences.getLocalSetting(LocalSettings.feedCardDividerThickness) ?? FeedCardDividerThickness.compact.name); - final feedCardDividerColor = UserPreferences.getLocalSetting(LocalSettings.feedCardDividerColor) != null ? Color(UserPreferences.getLocalSetting(LocalSettings.feedCardDividerColor)!) : null; - final compactPostCardMetadataItems = - UserPreferences.getLocalSetting>(LocalSettings.compactPostCardMetadataItems)?.map((e) => PostCardMetadataItem.values.byName(e)).toList() ?? DEFAULT_COMPACT_POST_CARD_METADATA; - final cardPostCardMetadataItems = - UserPreferences.getLocalSetting>(LocalSettings.cardPostCardMetadataItems)?.map((e) => PostCardMetadataItem.values.byName(e)).toList() ?? DEFAULT_CARD_POST_CARD_METADATA; - - // Post body settings - final showCrossPosts = UserPreferences.getLocalSetting(LocalSettings.showCrossPosts) ?? true; - final postBodyViewType = PostBodyViewType.values.byName(UserPreferences.getLocalSetting(LocalSettings.postBodyViewType) ?? PostBodyViewType.expanded.name); - final postBodyShowUserInstance = UserPreferences.getLocalSetting(LocalSettings.postBodyShowUserInstance) ?? false; - final postBodyShowCommunityInstance = UserPreferences.getLocalSetting(LocalSettings.postBodyShowCommunityInstance) ?? false; - final postBodyShowCommunityAvatar = UserPreferences.getLocalSetting(LocalSettings.postBodyShowCommunityAvatar) ?? false; - - final keywordFilters = (UserPreferences.getLocalSetting(LocalSettings.keywordFilters) as List?)?.cast().toList() ?? []; - - emit( - FeedPreferencesState( - defaultFeedListType: defaultFeedListType, - defaultPostSortType: defaultPostSortType, - hideNsfwPosts: hideNsfwPosts, - hideNsfwPreviews: hideNsfwPreviews, - markPostReadOnMediaView: markPostReadOnMediaView, - markPostReadOnScroll: markPostReadOnScroll, - showHiddenPosts: showHiddenPosts, - showExpandedTaglines: showExpandedTaglines, - useCompactView: useCompactView, - showTitleFirst: showTitleFirst, - showPostCommunityFirst: showPostCommunityFirst, - hideThumbnails: hideThumbnails, - showThumbnailPreviewOnRight: showThumbnailPreviewOnRight, - linkPostsUseCompactView: linkPostsUseCompactView, - pinnedPostsUseCompactView: pinnedPostsUseCompactView, - showTextPostIndicator: showTextPostIndicator, - tappableAuthorCommunity: tappableAuthorCommunity, - showVoteActions: showVoteActions, - showSaveAction: showSaveAction, - showCommunityIcons: showCommunityIcons, - showFullHeightImages: showFullHeightImages, - showEdgeToEdgeImages: showEdgeToEdgeImages, - showTextContent: showTextContent, - showPostAuthor: showPostAuthor, - postShowUserInstance: postShowUserInstance, - dimReadPosts: dimReadPosts, - showFullPostDate: showFullPostDate, - dateFormat: dateFormat, - feedCardDividerThickness: feedCardDividerThickness, - feedCardDividerColor: feedCardDividerColor, - compactPostCardMetadataItems: compactPostCardMetadataItems, - cardPostCardMetadataItems: cardPostCardMetadataItems, - showCrossPosts: showCrossPosts, - postBodyViewType: postBodyViewType, - postBodyShowUserInstance: postBodyShowUserInstance, - postBodyShowCommunityInstance: postBodyShowCommunityInstance, - postBodyShowCommunityAvatar: postBodyShowCommunityAvatar, - keywordFilters: keywordFilters, - ), - ); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart b/lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart deleted file mode 100644 index fbcfa172a..000000000 --- a/lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; - -part 'gesture_preferences_state.dart'; - -/// Cubit for managing gesture-related preferences -class GesturePreferencesCubit extends Cubit { - GesturePreferencesCubit() : super(const GesturePreferencesState()) { - load(); - } - - /// Loads gesture preferences from UserPreferences - void load() { - // Sidebar Gesture Settings - final bottomNavBarSwipeGestures = UserPreferences.getLocalSetting(LocalSettings.sidebarBottomNavBarSwipeGesture) ?? true; - final bottomNavBarDoubleTapGestures = UserPreferences.getLocalSetting(LocalSettings.sidebarBottomNavBarDoubleTapGesture) ?? false; - - // Post Gestures - final enablePostGestures = UserPreferences.getLocalSetting(LocalSettings.enablePostGestures) ?? true; - final leftPrimaryPostGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postGestureLeftPrimary) ?? SwipeAction.upvote.name); - final leftSecondaryPostGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postGestureLeftSecondary) ?? SwipeAction.downvote.name); - final rightPrimaryPostGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postGestureRightPrimary) ?? SwipeAction.save.name); - final rightSecondaryPostGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.postGestureRightSecondary) ?? SwipeAction.toggleRead.name); - - // Comment Gestures - final enableCommentGestures = UserPreferences.getLocalSetting(LocalSettings.enableCommentGestures) ?? true; - final leftPrimaryCommentGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.commentGestureLeftPrimary) ?? SwipeAction.upvote.name); - final leftSecondaryCommentGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.commentGestureLeftSecondary) ?? SwipeAction.downvote.name); - final rightPrimaryCommentGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.commentGestureRightPrimary) ?? SwipeAction.reply.name); - final rightSecondaryCommentGesture = SwipeAction.values.byName(UserPreferences.getLocalSetting(LocalSettings.commentGestureRightSecondary) ?? SwipeAction.save.name); - - // Navigation Gestures - final enableFullScreenSwipeNavigationGesture = UserPreferences.getLocalSetting(LocalSettings.enableFullScreenSwipeNavigationGesture) ?? true; - - // Image Peek Settings - final imagePeekDuration = UserPreferences.getLocalSetting(LocalSettings.imagePeekDuration) ?? 300; - - emit( - GesturePreferencesState( - bottomNavBarSwipeGestures: bottomNavBarSwipeGestures, - bottomNavBarDoubleTapGestures: bottomNavBarDoubleTapGestures, - enablePostGestures: enablePostGestures, - leftPrimaryPostGesture: leftPrimaryPostGesture, - leftSecondaryPostGesture: leftSecondaryPostGesture, - rightPrimaryPostGesture: rightPrimaryPostGesture, - rightSecondaryPostGesture: rightSecondaryPostGesture, - enableCommentGestures: enableCommentGestures, - leftPrimaryCommentGesture: leftPrimaryCommentGesture, - leftSecondaryCommentGesture: leftSecondaryCommentGesture, - rightPrimaryCommentGesture: rightPrimaryCommentGesture, - rightSecondaryCommentGesture: rightSecondaryCommentGesture, - enableFullScreenSwipeNavigationGesture: enableFullScreenSwipeNavigationGesture, - imagePeekDuration: imagePeekDuration, - ), - ); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart b/lib/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart deleted file mode 100644 index a38dd216d..000000000 --- a/lib/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/theme_type.dart'; -import 'package:thunder/src/core/enums/custom_theme_type.dart'; -import 'package:thunder/src/core/enums/action_color.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; - -part 'theme_preferences_state.dart'; - -/// Cubit for managing theme-related preferences -class ThemePreferencesCubit extends Cubit { - ThemePreferencesCubit() : super(const ThemePreferencesState()) { - load(); - } - - /// Loads theme preferences from UserPreferences - void load() { - // Theme Settings - ThemeType themeType = ThemeType.values[UserPreferences.getLocalSetting(LocalSettings.appTheme) ?? ThemeType.system.index]; - Brightness brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; - - // Check if the user has selected to use a pure black theme, if so override the themeType to pureBlack - bool usePureBlackTheme = UserPreferences.getLocalSetting(LocalSettings.usePureBlackTheme) ?? false; - if (usePureBlackTheme && (themeType == ThemeType.dark || (themeType == ThemeType.system && brightness == Brightness.dark))) { - themeType = ThemeType.pureBlack; - } - - final selectedTheme = CustomThemeType.values.byName(UserPreferences.getLocalSetting(LocalSettings.appThemeAccentColor) ?? CustomThemeType.deepBlue.name); - final useMaterialYouTheme = UserPreferences.getLocalSetting(LocalSettings.useMaterialYouTheme) ?? false; - - // Fetch reduce animations preferences to remove overscrolling effects - final reduceAnimations = UserPreferences.getLocalSetting(LocalSettings.reduceAnimations) ?? false; - - // Color Settings - final upvoteColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.upvoteColor) ?? ActionColor.orange); - final downvoteColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.downvoteColor) ?? ActionColor.blue); - final saveColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.saveColor) ?? ActionColor.purple); - final markReadColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.markReadColor) ?? ActionColor.teal); - final replyColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.replyColor) ?? ActionColor.green); - final hideColor = ActionColor.fromString(colorRaw: UserPreferences.getLocalSetting(LocalSettings.hideColor) ?? ActionColor.red); - - // Font Settings - final titleFontSizeScale = FontScale.values.byName(UserPreferences.getLocalSetting(LocalSettings.titleFontSizeScale) ?? FontScale.base.name); - final contentFontSizeScale = FontScale.values.byName(UserPreferences.getLocalSetting(LocalSettings.contentFontSizeScale) ?? FontScale.base.name); - final commentFontSizeScale = FontScale.values.byName(UserPreferences.getLocalSetting(LocalSettings.commentFontSizeScale) ?? FontScale.base.name); - final metadataFontSizeScale = FontScale.values.byName(UserPreferences.getLocalSetting(LocalSettings.metadataFontSizeScale) ?? FontScale.base.name); - - // User/Community Display Name Settings - final useDisplayNamesForUsers = UserPreferences.getLocalSetting(LocalSettings.useDisplayNamesForUsers) ?? false; - final useDisplayNamesForCommunities = UserPreferences.getLocalSetting(LocalSettings.useDisplayNamesForCommunities) ?? false; - final userSeparator = FullNameSeparator.values.byName(UserPreferences.getLocalSetting(LocalSettings.userFormat) ?? FullNameSeparator.at.name); - final userFullNameUserNameThickness = NameThickness.values.byName(UserPreferences.getLocalSetting(LocalSettings.userFullNameUserNameThickness) ?? NameThickness.normal.name); - final userFullNameUserNameColor = NameColor.fromString(color: UserPreferences.getLocalSetting(LocalSettings.userFullNameUserNameColor) ?? NameColor.defaultColor); - final userFullNameInstanceNameThickness = NameThickness.values.byName(UserPreferences.getLocalSetting(LocalSettings.userFullNameInstanceNameThickness) ?? NameThickness.light.name); - final userFullNameInstanceNameColor = NameColor.fromString(color: UserPreferences.getLocalSetting(LocalSettings.userFullNameInstanceNameColor) ?? NameColor.defaultColor); - final communitySeparator = FullNameSeparator.values.byName(UserPreferences.getLocalSetting(LocalSettings.communityFormat) ?? FullNameSeparator.dot.name); - final communityFullNameCommunityNameThickness = NameThickness.values.byName(UserPreferences.getLocalSetting(LocalSettings.communityFullNameCommunityNameThickness) ?? NameThickness.normal.name); - final communityFullNameCommunityNameColor = NameColor.fromString(color: UserPreferences.getLocalSetting(LocalSettings.communityFullNameCommunityNameColor) ?? NameColor.defaultColor); - final communityFullNameInstanceNameThickness = NameThickness.values.byName(UserPreferences.getLocalSetting(LocalSettings.communityFullNameInstanceNameThickness) ?? NameThickness.light.name); - final communityFullNameInstanceNameColor = NameColor.fromString(color: UserPreferences.getLocalSetting(LocalSettings.communityFullNameInstanceNameColor) ?? NameColor.defaultColor); - - emit(ThemePreferencesState( - themeType: themeType, - selectedTheme: selectedTheme, - useMaterialYouTheme: useMaterialYouTheme, - reduceAnimations: reduceAnimations, - upvoteColor: upvoteColor, - downvoteColor: downvoteColor, - saveColor: saveColor, - markReadColor: markReadColor, - replyColor: replyColor, - hideColor: hideColor, - titleFontSizeScale: titleFontSizeScale, - contentFontSizeScale: contentFontSizeScale, - commentFontSizeScale: commentFontSizeScale, - metadataFontSizeScale: metadataFontSizeScale, - useDisplayNamesForUsers: useDisplayNamesForUsers, - useDisplayNamesForCommunities: useDisplayNamesForCommunities, - userSeparator: userSeparator, - userFullNameUserNameThickness: userFullNameUserNameThickness, - userFullNameUserNameColor: userFullNameUserNameColor, - userFullNameInstanceNameThickness: userFullNameInstanceNameThickness, - userFullNameInstanceNameColor: userFullNameInstanceNameColor, - communitySeparator: communitySeparator, - communityFullNameCommunityNameThickness: communityFullNameCommunityNameThickness, - communityFullNameCommunityNameColor: communityFullNameCommunityNameColor, - communityFullNameInstanceNameThickness: communityFullNameInstanceNameThickness, - communityFullNameInstanceNameColor: communityFullNameInstanceNameColor, - )); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart b/lib/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart deleted file mode 100644 index de727a214..000000000 --- a/lib/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/video_auto_play.dart'; -import 'package:thunder/src/core/enums/video_playback_speed.dart'; -import 'package:thunder/src/core/enums/video_player_mode.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; - -part 'video_preferences_state.dart'; - -/// Cubit for managing video player preferences -class VideoPreferencesCubit extends Cubit { - VideoPreferencesCubit() : super(const VideoPreferencesState()) { - load(); - } - - /// Loads video preferences from UserPreferences - void load() { - final videoAutoFullscreen = UserPreferences.getLocalSetting(LocalSettings.videoAutoFullscreen) ?? false; - final videoAutoLoop = UserPreferences.getLocalSetting(LocalSettings.videoAutoLoop) ?? false; - final videoAutoMute = UserPreferences.getLocalSetting(LocalSettings.videoAutoMute) ?? true; - final videoAutoPlay = VideoAutoPlay.values.byName(UserPreferences.getLocalSetting(LocalSettings.videoAutoPlay) ?? VideoAutoPlay.never.name); - final videoDefaultPlaybackSpeed = VideoPlayBackSpeed.values.byName(UserPreferences.getLocalSetting(LocalSettings.videoDefaultPlaybackSpeed) ?? VideoPlayBackSpeed.normal.name); - final videoPlayerMode = VideoPlayerMode.values.byName(UserPreferences.getLocalSetting(LocalSettings.videoPlayerMode) ?? VideoPlayerMode.inApp.name); - - emit( - VideoPreferencesState( - videoAutoFullscreen: videoAutoFullscreen, - videoAutoLoop: videoAutoLoop, - videoAutoMute: videoAutoMute, - videoAutoPlay: videoAutoPlay, - videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed, - videoPlayerMode: videoPlayerMode, - ), - ); - } - - /// Reloads preferences from storage. This should be called when preferences are updated elsewhere - void reload() { - load(); - } -} diff --git a/lib/src/app/utils/share_intent_handler.dart b/lib/src/app/share/share_intent_handler.dart similarity index 54% rename from lib/src/app/utils/share_intent_handler.dart rename to lib/src/app/share/share_intent_handler.dart index 437abd16d..6914e777a 100644 --- a/lib/src/app/utils/share_intent_handler.dart +++ b/lib/src/app/share/share_intent_handler.dart @@ -3,18 +3,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// Handles share intents (external content shared to Thunder) from the OS. /// /// This class is responsible for processing shared images, URLs, and text. class ShareIntentHandler { + /// The context of the current build. final BuildContext context; + + /// The stream subscription for the media intent data. StreamSubscription? mediaIntentDataStreamSubscription; ShareIntentHandler(this.context); @@ -25,36 +28,40 @@ class ShareIntentHandler { /// Processes any pending shared content and sets up listeners for new shares. Future handleSharedFilesAndText(String? currentIntent) async { + final l10n = GlobalContext.l10n; + try { // For sharing files from outside the app while the app is closed - List sharedFiles = await FlutterSharingIntent.instance.getInitialSharing(); - if (sharedFiles.isNotEmpty && currentIntent != 'android.intent.action.VIEW') { - handleSharedItems(sharedFiles.first); + final files = await FlutterSharingIntent.instance.getInitialSharing(); + if (files.isNotEmpty && currentIntent != 'android.intent.action.VIEW') { + handleFile(files.first); } // For sharing files while the app is in the memory - mediaIntentDataStreamSubscription = FlutterSharingIntent.instance.getMediaStream().listen((List sharedFiles) { - if (!context.mounted || sharedFiles.isEmpty || currentIntent == 'android.intent.action.VIEW') return; - handleSharedItems(sharedFiles.first); + mediaIntentDataStreamSubscription = FlutterSharingIntent.instance.getMediaStream().listen((List files) { + if (!context.mounted || files.isEmpty || currentIntent == 'android.intent.action.VIEW') { + return; + } + handleFile(files.first); }); } catch (e) { if (context.mounted) { - showSnackbar(AppLocalizations.of(context)!.unexpectedError); + showSnackbar(l10n.unexpectedError); } } } /// Navigates to the post creation page with the shared content based on its type. - void handleSharedItems(SharedFile sharedFile) { - switch (sharedFile.type) { + void handleFile(SharedFile file) { + switch (file.type) { case SharedMediaType.IMAGE: - navigateToCreatePostPage(context, image: File(sharedFile.value!), prePopulated: true); + navigateToCreatePostPage(context, image: File(file.value!), prePopulated: true); break; case SharedMediaType.URL: - navigateToCreatePostPage(context, url: sharedFile.value!, prePopulated: true); + navigateToCreatePostPage(context, url: file.value!, prePopulated: true); break; case SharedMediaType.TEXT: - navigateToCreatePostPage(context, text: sharedFile.value, prePopulated: true); + navigateToCreatePostPage(context, text: file.value, prePopulated: true); break; default: break; diff --git a/lib/src/shared/utils/links.dart b/lib/src/app/shell/navigation/link_navigation_utils.dart similarity index 53% rename from lib/src/shared/utils/links.dart rename to lib/src/app/shell/navigation/link_navigation_utils.dart index 87e719a7f..859898313 100644 --- a/lib/src/shared/utils/links.dart +++ b/lib/src/app/shell/navigation/link_navigation_utils.dart @@ -2,75 +2,28 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:http/http.dart' as http; -import 'package:html/parser.dart' as parser; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import 'package:intl/message_format.dart'; -import 'package:link_preview_generator/link_preview_generator.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/community/api.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/browser_mode.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/video_player_mode.dart'; -import 'package:thunder/instances.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/pages/loading_page.dart'; -import 'package:thunder/src/shared/picker_item.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; -import 'package:thunder/src/shared/utils/media/video.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/features/user/user.dart'; - -class LinkInfo { - String? imageURL; - String? title; - - LinkInfo({this.imageURL, this.title}); -} - -Future getLinkInfo(String url) async { - try { - final response = await http.get(Uri.parse(url)); - - if (response.statusCode == 200) { - final document = parser.parse(response.body); - final metatags = document.getElementsByTagName('meta'); - - String imageURL = ''; - String title = ''; - - for (final metatag in metatags) { - final property = metatag.attributes['property']; - final content = metatag.attributes['content']; - - if (property == 'og:image') { - imageURL = content ?? ''; - } else if (property == 'og:title') { - title = content ?? ''; - } - } - - return LinkInfo(imageURL: imageURL, title: title); - } else { - throw Exception('Unable to fetch link information'); - } - } catch (e) { - return LinkInfo(); - } -} +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/app/shell/navigation/loading_page.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_utils.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/instance/data/constants/known_instances.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/shared/media/widgets/video_player.dart'; +import 'package:thunder/src/features/user/api.dart'; void _openLink(BuildContext context, {required String url, bool isVideo = false}) async { final thunderPreferences = context.read().state; @@ -140,6 +93,37 @@ void _openLink(BuildContext context, {required String url, bool isVideo = false} } } +void _showVideoPlayer(BuildContext context, {required String url, int? postId}) { + final videoId = YoutubePlayer.convertUrlToId(url); + final videoPlayerMode = context.read().state.videoPlayerMode; + + switch (videoPlayerMode) { + case VideoPlayerMode.inApp: + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + transitionDuration: const Duration(milliseconds: 100), + reverseTransitionDuration: const Duration(milliseconds: 50), + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return (videoId != null) ? ThunderYoutubePlayer(videoUrl: url, postId: postId) : ThunderVideoPlayer(videoUrl: url, postId: postId); + }, + transitionsBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return Align( + child: FadeTransition(opacity: animation, child: child), + ); + }, + ), + ); + break; + case VideoPlayerMode.externalPlayer: + _openLink(context, url: url, isVideo: true); + break; + case VideoPlayerMode.customTabs: + _openLink(context, url: url, isVideo: true); + break; + } +} + /// A universal way of handling links in Thunder. /// Attempts to perform in-app navigtion to communities, users, posts, and comments /// Before falling back to opening in the browser (either Custom Tabs or system browser, as specified by the user). @@ -208,7 +192,7 @@ void handleLink(BuildContext context, {required String url, bool forceOpenInBrow // Try navigate to modlog Uri? uri = Uri.tryParse(url); - if (context.mounted && uri != null && instances.keys.contains(uri.host) && url.contains('/modlog')) { + if (context.mounted && uri != null && knownInstances.keys.contains(uri.host) && url.contains('/modlog')) { try { await navigateToModlogPage( context, @@ -240,7 +224,7 @@ void handleLink(BuildContext context, {required String url, bool forceOpenInBrow // try opening as a video try { if (isVideoUrl(url) && context.mounted && !forceOpenInBrowser) { - showVideoPlayer(context, url: url, postId: postId); + _showVideoPlayer(context, url: url, postId: postId); return; } } catch (e) { @@ -281,7 +265,7 @@ Future _testValidCommunity(BuildContext context, String link, String commu return true; } - if (instances.keys.contains(instance)) { + if (knownInstances.keys.contains(instance)) { return true; } @@ -310,7 +294,7 @@ Future _testValidUser(BuildContext context, String link, String userName, return true; } - if (instances.keys.contains(instance)) { + if (knownInstances.keys.contains(instance)) { return true; } @@ -328,182 +312,6 @@ Future _testValidUser(BuildContext context, String link, String userName, return false; } -void handleLinkLongPress(BuildContext context, String text, String? url, {LinkBottomSheetPage initialPage = LinkBottomSheetPage.general, void Function(String)? customNavigation}) { - HapticFeedback.mediumImpact(); - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (ctx) => LinkBottomSheet( - text: text, - url: url, - initialPage: initialPage, - customNavigation: customNavigation, - ), - ); -} - -enum LinkBottomSheetPage { - general, - alternateLinks, -} - -class LinkBottomSheet extends StatefulWidget { - final String? url; - final String text; - final LinkBottomSheetPage initialPage; - final void Function(String)? customNavigation; - - const LinkBottomSheet({ - super.key, - required this.text, - required this.url, - this.initialPage = LinkBottomSheetPage.general, - this.customNavigation, - }); - - @override - State createState() => _LinkBottomSheetState(); -} - -class _LinkBottomSheetState extends State { - LinkBottomSheetPage? page; - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final AppLocalizations l10n = AppLocalizations.of(context)!; - - bool isValidUrl = widget.url?.startsWith('http') ?? false; - - return SingleChildScrollView( - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - alignment: Alignment.bottomCenter, - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Padding( - padding: const EdgeInsets.only(left: 10, right: 10), - child: Material( - borderRadius: BorderRadius.circular(50), - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(50), - onTap: (page ?? widget.initialPage) == LinkBottomSheetPage.general ? null : () => setState(() => page = LinkBottomSheetPage.general), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), - child: Align( - alignment: Alignment.centerLeft, - child: Row( - children: [ - if ((page ?? widget.initialPage) != LinkBottomSheetPage.general) ...[ - const Icon(Icons.chevron_left, size: 30), - const SizedBox(width: 12), - ], - Text( - switch (page ?? widget.initialPage) { - LinkBottomSheetPage.alternateLinks => l10n.alternateSources, - _ => l10n.linkActions, - }, - style: theme.textTheme.titleLarge, - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 10), - if (isValidUrl && (page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ - Padding( - padding: const EdgeInsets.only(left: 24, right: 24), - child: LinkPreviewGenerator( - link: widget.url!, - placeholderWidget: const CircularProgressIndicator(), - linkPreviewStyle: LinkPreviewStyle.large, - cacheDuration: Duration.zero, - onTap: null, - bodyTextOverflow: TextOverflow.fade, - graphicFit: BoxFit.scaleDown, - removeElevation: true, - backgroundColor: theme.dividerColor.withValues(alpha: 0.25), - borderRadius: 10, - useDefaultOnTap: false, - ), - ), - const SizedBox(height: 10), - ], - Padding( - padding: const EdgeInsets.only(left: 24, right: 24), - child: Container( - decoration: BoxDecoration( - color: theme.dividerColor.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(widget.url!), - ), - ), - ), - const SizedBox(height: 10), - if ((page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ - PickerItem( - label: l10n.open, - icon: Icons.language, - onSelected: () => handleLinkTap(context, widget.text, widget.url), - ), - PickerItem( - label: l10n.copy, - icon: Icons.copy_rounded, - onSelected: () => Clipboard.setData(ClipboardData(text: widget.url ?? widget.text)), - ), - PickerItem( - label: l10n.share, - icon: Icons.share_rounded, - onSelected: () => SharePlus.instance.share(ShareParams( - text: widget.url ?? widget.text, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )), - ), - PickerItem( - label: l10n.alternateSources, - icon: Icons.link_rounded, - onSelected: () => setState(() => page = LinkBottomSheetPage.alternateLinks), - trailingIcon: Icons.chevron_right_rounded, - ), - ], - if ((page ?? widget.initialPage) == LinkBottomSheetPage.alternateLinks) - ...generateAlternateSources(widget.url ?? widget.text).map((alternateSource) { - return PickerItem( - label: alternateSource.sourceName, - subtitle: alternateSource.link, - icon: Icons.archive_rounded, - onSelected: () { - if (widget.customNavigation != null) { - widget.customNavigation!.call(alternateSource.link); - } else { - handleLink(context, url: alternateSource.link); - } - - Navigator.of(context).pop(); - }, - trailingIcon: Icons.chevron_right_rounded, - ); - }), - const SizedBox(height: 40.0), - ], - ), - ), - ), - ); - } -} - Future handleLinkTap(BuildContext context, String text, String? url) async { Uri? parsedUri = Uri.tryParse(url ?? '') ?? Uri.tryParse(text); diff --git a/lib/src/shared/pages/loading_page.dart b/lib/src/app/shell/navigation/loading_page.dart similarity index 73% rename from lib/src/shared/pages/loading_page.dart rename to lib/src/app/shell/navigation/loading_page.dart index 2278fd21c..1c5bdf0db 100644 --- a/lib/src/shared/pages/loading_page.dart +++ b/lib/src/app/shell/navigation/loading_page.dart @@ -2,13 +2,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/routing/swipeable_page_route.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/app/shell/navigation/swipeable_page_route.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; bool isLoadingPageShown = false; @@ -27,16 +26,17 @@ class LoadingPage extends StatelessWidget { child: CustomScrollView( slivers: [ SliverAppBar( - toolbarHeight: APP_BAR_HEIGHT, - leading: IconButton( - icon: !kIsWeb && Platform.isIOS - ? Icon( - Icons.arrow_back_ios_new_rounded, - semanticLabel: MaterialLocalizations.of(context).backButtonTooltip, - ) - : Icon(Icons.arrow_back_rounded, semanticLabel: MaterialLocalizations.of(context).backButtonTooltip), - onPressed: null, - )), + toolbarHeight: APP_BAR_HEIGHT, + leading: IconButton( + icon: !kIsWeb && Platform.isIOS + ? Icon( + Icons.arrow_back_ios_new_rounded, + semanticLabel: MaterialLocalizations.of(context).backButtonTooltip, + ) + : Icon(Icons.arrow_back_rounded, semanticLabel: MaterialLocalizations.of(context).backButtonTooltip), + onPressed: null, + ), + ), const SliverFillRemaining( child: Center( child: CircularProgressIndicator(), @@ -58,7 +58,6 @@ void showLoadingPage(BuildContext context) { final enableFullScreenSwipeNavigationGesture = context.read().state.enableFullScreenSwipeNavigationGesture; final reduceAnimations = context.read().state.reduceAnimations; - // Immediately push the loading page. Navigator.of(context).push( SwipeablePageRoute( transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, diff --git a/lib/src/app/shell/navigation/navigation_feed.dart b/lib/src/app/shell/navigation/navigation_feed.dart new file mode 100644 index 000000000..5b5b32223 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_feed.dart @@ -0,0 +1,118 @@ +part of 'navigation_utils.dart'; + +/// Navigates to a [FeedPage] with the given parameters +/// +/// [feedType] must be provided. +/// If [feedType] is [FeedType.general], [feedListType] must be provided +/// If [feedType] is [FeedType.community], one of [communityId] or [communityName] must be provided +/// If [feedType] is [FeedType.user], one of [userId] or [username] must be provided +/// +/// The [context] parameter should contain the following blocs within its widget tree: [AccountBloc], [AuthBloc], [ThunderBloc] +Future navigateToFeedPage( + BuildContext context, { + required FeedType feedType, + FeedListType? feedListType, + PostSortType? postSortType, + String? communityName, + int? communityId, + String? username, + int? userId, +}) async { + // Push navigation + ProfileBloc profileBloc = context.read(); + ThunderBloc thunderBloc = context.read(); + final gestureCubit = context.read(); + final themeCubit = context.read(); + final feedCubit = context.read(); + AnonymousSubscriptionsBloc anonymousSubscriptionsBloc = context.read(); + + final bool reduceAnimations = themeCubit.state.reduceAnimations; + + if (feedType == FeedType.general) { + return context.read().add( + FeedFetchedEvent( + feedType: feedType, + feedListType: feedListType, + postSortType: postSortType ?? + (profileBloc.state.siteResponse?.myUser?.localUserView.localUser.defaultSortType != null + ? profileBloc.state.siteResponse!.myUser!.localUserView.localUser.defaultSortType + : feedCubit.state.defaultPostSortType), + communityId: communityId, + communityName: communityName, + userId: userId, + username: username, + reset: true, + showHidden: feedCubit.state.showHiddenPosts, + ), + ); + } + + SwipeablePageRoute route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + backGestureDetectionWidth: 45, + canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isFeedPage: true) || !gestureCubit.state.enableFullScreenSwipeNavigationGesture, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: anonymousSubscriptionsBloc), + ], + child: Material( + child: FeedPage( + feedType: feedType, + postSortType: postSortType ?? + (profileBloc.state.siteResponse?.myUser?.localUserView.localUser.defaultSortType != null + ? profileBloc.state.siteResponse!.myUser!.localUserView.localUser.defaultSortType + : feedCubit.state.defaultPostSortType), + communityName: communityName, + communityId: communityId, + userId: userId, + username: username, + feedListType: feedListType, + showHidden: feedCubit.state.showHiddenPosts, + ), + ), + ), + ); + + pushOnTopOfLoadingPage(context, route); +} + +/// Navigates to the search page +/// +/// The [context] parameter should contain the following blocs within its widget tree: [FeedBloc], [ThunderBloc] +void navigateToSearchPage(BuildContext context) { + final hasFeedBloc = context.findAncestorWidgetOfExactType>() != null; + assert(hasFeedBloc == true); + + final feedBloc = context.read(); + final thunderBloc = context.read(); + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final account = context.read().state.account; + + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => createSearchBloc(account)), + BlocProvider.value(value: thunderBloc), + ], + child: SearchPage(community: feedBloc.state.community), + ), + ), + ); +} diff --git a/lib/src/app/shell/navigation/navigation_instance.dart b/lib/src/app/shell/navigation/navigation_instance.dart new file mode 100644 index 000000000..4ebdf0bd8 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_instance.dart @@ -0,0 +1,121 @@ +part of 'navigation_utils.dart'; + +/// Navigates to the instance page for the given [instanceHost]. +/// +/// When [instanceId] is provided, the instance page will allow the option to block that given instance. This value represents +/// the id of the navigated instance from the original instance (e.g., lemmy.ml's instance id from lemmy.world). +Future navigateToInstancePage( + BuildContext context, { + required String instanceHost, + required int? instanceId, +}) async { + showLoadingPage(context); + + final reduceAnimations = context.read().state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = context.read().state.enableFullScreenSwipeNavigationGesture; + + final platformInfo = await detectPlatformFromNodeInfo(instanceHost); + final platform = platformInfo?['platform'] ?? ThreadiversePlatform.lemmy; // Fallback to Lemmy if we can't detect the platform + + ThunderSiteResponse? site; + + try { + // Get the site information by connecting to the given instance + final account = Account(id: '', index: -1, instance: instanceHost, platform: platform); + site = await InstanceRepositoryImpl(account: account).info().timeout(const Duration(seconds: 5)); + } catch (e) { + // Continue if we can't get the site + } + + final route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => BlocProvider.value( + value: context.read(), + child: InstancePage( + instance: ThunderInstanceInfo( + id: instanceId, + domain: site!.site.actorId, + name: site.site.name, + description: site.site.description, + sidebar: site.site.sidebar, + icon: site.site.icon, + users: site.site.users, + version: site.version, + platform: platform, + contentWarning: site.site.contentWarning, + ), + ), + ), + ); + + if (site != null) { + pushOnTopOfLoadingPage(context, route); + } else { + final l10n = GlobalContext.l10n; + + showSnackbar( + l10n.unableToNavigateToInstance(instanceHost), + trailingAction: () => handleLink(context, url: "https://$instanceHost", forceOpenInBrowser: true), + trailingIcon: Icons.open_in_browser_rounded, + ); + + hideLoadingPage(context); + } +} + +/// Navigates to the modlog page with the given parameters. +Future navigateToModlogPage( + BuildContext context, { + ModlogActionType? modlogActionType, + int? communityId, + int? userId, + int? moderatorId, + int? commentId, + required String subtitle, +}) async { + final thunderBloc = context.read(); + final account = context.read().state.account; + + // Optional blocs + final hasFeedBloc = context.findAncestorWidgetOfExactType>(); + final feedBloc = hasFeedBloc != null ? context.read() : createFeedBloc(account); + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final SwipeablePageRoute route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: thunderBloc), + ], + child: ModlogFeedPage( + modlogActionType: modlogActionType, + communityId: communityId, + userId: userId, + moderatorId: moderatorId, + commentId: commentId, + subtitle: subtitle, + ), + ), + ); + + pushOnTopOfLoadingPage(context, route); +} diff --git a/lib/src/app/shell/navigation/navigation_misc.dart b/lib/src/app/shell/navigation/navigation_misc.dart new file mode 100644 index 000000000..8bfc994d0 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_misc.dart @@ -0,0 +1,56 @@ +part of 'navigation_utils.dart'; + +/// Navigates to the [ReportFeedPage] page. +/// +/// The [context] parameter should contain the following blocs within its widget tree: [FeedBloc], [ThunderBloc] +void navigateToReportPage(BuildContext context) { + final hasFeedBloc = context.findAncestorWidgetOfExactType>() != null; + assert(hasFeedBloc == true); + + final feedBloc = context.read(); + final thunderBloc = context.read(); + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (_) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: thunderBloc), + ], + child: const ReportFeedPage(), + ); + }, + ), + ); +} + +/// Navigates to the given [url] in a webview. +void navigateToWebView(BuildContext context, String url) { + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + SwipeablePageRoute route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => WebView(url: url), + ); + + pushOnTopOfLoadingPage(context, route); +} diff --git a/lib/src/app/shell/navigation/navigation_notification.dart b/lib/src/app/shell/navigation/navigation_notification.dart new file mode 100644 index 000000000..6ecf501ea --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_notification.dart @@ -0,0 +1,104 @@ +part of 'navigation_utils.dart'; + +/// Navigates to a notifications page for the given [inboxType]. +/// +/// The [notificationId] is used to find and display a specific notification. +/// If [notificationId] is null, all unread notifications of the given type will be shown. +void navigateToNotificationPage( + BuildContext context, { + required InboxType inboxType, + required int? notificationId, + required String? accountId, +}) async { + assert(inboxType == InboxType.replies || inboxType == InboxType.mentions || inboxType == InboxType.messages); + + // It can take a little while to set up notifications, so show a loading page + showLoadingPage(context); + + final thunderBloc = context.read(); + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + + if (accountId == null) { + hideLoadingPage(context); + return; // No account ID provided, so we can't do anything. + } + + final account = await Account.fetchAccount(accountId); + if (account == null) { + if (context.mounted) hideLoadingPage(context); + return; // No account found, so we can't do anything. + } + + final notificationRepository = NotificationRepositoryImpl(account: account); + + late final NotificationsPage notificationsPage; + + if (inboxType == InboxType.messages) { + final messages = []; + ThunderPrivateMessage? message; + + bool doneFetching = false; + int currentPage = 1; + + while (!doneFetching) { + final response = await notificationRepository.messages( + unread: notificationId == null, + limit: 50, + page: currentPage, + ); + + messages.addAll(response); + message ??= response.firstWhereOrNull((m) => m.id == notificationId); + + doneFetching = message != null || response.isEmpty; + ++currentPage; + } + + notificationsPage = NotificationsPage.messages(messages: message == null ? messages : [message]); + } else { + final comments = []; + ThunderComment? comment; + + bool doneFetching = false; + int currentPage = 1; + + while (!doneFetching) { + final response = inboxType == InboxType.replies + ? await notificationRepository.replies(unread: notificationId == null, limit: 50, sort: CommentSortType.new_, page: currentPage) + : await notificationRepository.mentions(unread: notificationId == null, limit: 50, sort: CommentSortType.new_, page: currentPage); + + comments.addAll(response); + comment ??= response.firstWhereOrNull((c) => c.id == notificationId); + + doneFetching = comment != null || response.isEmpty; + ++currentPage; + } + + notificationsPage = + inboxType == InboxType.replies ? NotificationsPage.replies(replies: comment == null ? comments : [comment]) : NotificationsPage.mentions(mentions: comment == null ? comments : [comment]); + } + + if (context.mounted) { + final route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + backGestureDetectionWidth: 45, + canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: !gestureCubit.state.enableFullScreenSwipeNavigationGesture, + builder: (context) => MultiBlocProvider( + providers: [BlocProvider.value(value: thunderBloc)], + child: notificationsPage, + ), + ); + + pushOnTopOfLoadingPage(context, route).then((_) { + context.read().add(const GetInboxEvent(reset: true, inboxType: InboxType.all)); + }); + } +} diff --git a/lib/src/app/shell/navigation/navigation_post.dart b/lib/src/app/shell/navigation/navigation_post.dart new file mode 100644 index 000000000..c26adfcc3 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_post.dart @@ -0,0 +1,308 @@ +part of 'navigation_utils.dart'; + +({String postApId, post_bloc.PostBloc postBloc})? _cachedPostBloc; + +/// Navigates to the post page with the given [post] or [postId]. +/// +/// One of [post] or [postId] must be provided. If [post] is provided, the post page will use that data to display the post. +/// Otherwise, the post page will fetch the post with the given [postId]. +Future navigateToPost( + BuildContext context, { + int? postId, + ThunderPost? post, + int? highlightedCommentId, + Function(ThunderPost post)? onPostUpdated, +}) async { + assert((postId != null || post != null), 'One of the parameters must be provided'); + + // Required blocs + final profileBloc = context.read(); + final thunderBloc = context.read(); + + // Optional blocs + final hasFeedBloc = context.findAncestorWidgetOfExactType>(); + final feedBloc = hasFeedBloc != null ? context.read() : null; + + ThunderPost? pvm = post; + + final account = context.read().state.account; + + if (pvm == null) { + final response = await PostRepositoryImpl(account: account).getPost(postId!); + pvm = response?['post']; + } + + // Mark post as read when tapped + if (profileBloc.state.isLoggedIn) { + feedBloc?.add(FeedItemActionedEvent(postId: pvm!.id, postAction: PostAction.read, actionInput: const ReadPostInput(true))); + } + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final post_bloc.PostBloc postBloc = _cachedPostBloc?.postApId == pvm!.apId + ? _cachedPostBloc!.postBloc + : (_cachedPostBloc = ( + postApId: pvm.apId, + postBloc: createPostBloc(account), + )) + .postBloc; + + final route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + backGestureDetectionStartOffset: !kIsWeb && Platform.isAndroid ? 45 : 0, + backGestureDetectionWidth: 45, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isPostPage: true) || !enableFullScreenSwipeNavigationGesture, + builder: (_) { + final postNavigationCubit = PostNavigationCubit(); + if (highlightedCommentId != null) { + postNavigationCubit.setHighlightedCommentId(highlightedCommentId); + } + + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: postBloc), + BlocProvider.value(value: postNavigationCubit), + BlocProvider(create: (context) => AnonymousSubscriptionsBloc()), + ], + child: PostPage( + initialPost: postBloc.state.post ?? pvm!, + highlightedCommentId: highlightedCommentId, + onPostUpdated: (ThunderPost post) { + // Manually marking the read attribute as true when navigating to post since there is a case where the API call to mark the post as read from the feed page is not completed in time + feedBloc?.add(FeedItemUpdatedEvent(post: post.copyWith(read: true))); + }, + ), + ); + }, + ); + + pushOnTopOfLoadingPage(context, route); +} + +Future getPostFromComment(ThunderComment comment, Account account) async { + if (comment.post != null) return comment.post!; + + final response = await PostRepositoryImpl(account: account).getPost(comment.postId, commentId: comment.id); + return response!['post'] as ThunderPost; +} + +Future navigateToComment(BuildContext context, ThunderComment comment) async { + final profileBloc = context.read(); + final thunderBloc = context.read(); + final gestureCubit = context.read(); + + final account = context.read().state.account; + + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + + final route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + backGestureDetectionWidth: 45, + canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isPostPage: true) || !gestureCubit.state.enableFullScreenSwipeNavigationGesture, + builder: (context) { + final postNavigationCubit = PostNavigationCubit(); + postNavigationCubit.setHighlightedCommentId(comment.id); + + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: thunderBloc), + BlocProvider(create: (context) => createPostBloc(account)), + BlocProvider.value(value: postNavigationCubit), + ], + child: FutureBuilder( + future: getPostFromComment(comment, account), + builder: (context, snapshot) { + if (snapshot.hasData) { + return PostPage( + initialPost: snapshot.data!, + highlightedCommentId: comment.id, + commentPath: comment.path, + onPostUpdated: (ThunderPost post) {}, + ); + } + + return LoadingPage(); + }, + ), + ); + }, + ); + + pushOnTopOfLoadingPage(context, route); +} + +Future navigateToCreateCommentPage( + BuildContext context, { + ThunderPost? post, + ThunderComment? comment, + ThunderComment? parentComment, + Function(ThunderComment comment, bool userChanged)? onCommentSuccess, +}) async { + assert(!(post == null && parentComment == null && comment == null)); + assert(!(post != null && (parentComment != null || comment != null))); + + final profileBloc = context.read(); + final thunderBloc = context.read(); + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final SwipeablePageRoute route = SwipeablePageRoute( + transitionDuration: isLoadingPageShown + ? Duration.zero + : reduceAnimations + ? const Duration(milliseconds: 100) + : null, + reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: profileBloc), + ], + child: CreateCommentPage( + post: post, + comment: comment, + parentComment: parentComment, + onCommentSuccess: onCommentSuccess, + ), + ), + ); + + final result = await pushOnTopOfLoadingPage(context, route); + if (result is ThunderComment) return result; + return null; +} + +Future navigateToCreatePostPage( + BuildContext context, { + String? title, + String? text, + File? image, + String? url, + bool? prePopulated, + int? communityId, + ThunderCommunity? community, + ThunderPost? post, + bool isCrossPost = false, + Function(ThunderPost post, bool)? onPostSuccess, +}) async { + try { + final l10n = AppLocalizations.of(context)!; + final account = context.read().state.account; + + FeedBloc? feedBloc; + PostBloc? postBloc; + ThunderBloc thunderBloc = context.read(); + ProfileBloc profileBloc = context.read(); + CreatePostCubit createPostCubit = createCreatePostCubit(account); + + final themeCubit = context.read(); + final bool reduceAnimations = themeCubit.state.reduceAnimations; + final gestureCubit = context.read(); + final bool enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + try { + feedBloc = context.read(); + } catch (e) { + // Don't need feed block if we're not opening post in the context of a feed. + } + + try { + postBloc = context.read(); + } catch (e) { + // It's ok if we don't get the PostBloc + } + + ThunderCommunity? pvmCommunity; + + if (post != null) { + pvmCommunity = post.community; + } + + await Navigator.of(context).push(SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + backGestureDetectionWidth: 45, + builder: (navigatorContext) { + return MultiBlocProvider( + providers: [ + feedBloc != null ? BlocProvider.value(value: feedBloc) : BlocProvider(create: (context) => createFeedBloc(account)), + if (postBloc != null) BlocProvider.value(value: postBloc), + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: createPostCubit), + ], + child: CreatePostPage( + title: title, + text: text, + image: image, + url: url, + prePopulated: prePopulated, + communityId: communityId ?? post?.community!.id, + community: community ?? (post != null ? pvmCommunity : null), + post: post, + isCrossPost: isCrossPost, + onPostSuccess: (ThunderPost updatedPost, bool userChanged) { + // Update the existing post view media if it exists + if (feedBloc != null) { + feedBloc.add(FeedItemUpdatedEvent(post: updatedPost)); + } + if (postBloc != null) { + postBloc.add(PostUpdatedEvent(post: updatedPost)); + } + + // Show snackbar message if the post was just created + if (!userChanged && post == null) { + try { + showSnackbar( + l10n.postCreatedSuccessfully, + trailingIcon: Icons.remove_red_eye_rounded, + trailingAction: () { + navigateToPost(context, post: updatedPost); + }, + ); + } catch (e) { + if (context.mounted) { + showSnackbar("${AppLocalizations.of(context)!.unexpectedError}: $e"); + } + } + } + + if (onPostSuccess != null) { + onPostSuccess(updatedPost, userChanged); + } + }, + ), + ); + }, + )); + } catch (e) { + if (context.mounted) { + showSnackbar(AppLocalizations.of(context)!.unexpectedError); + } + } +} diff --git a/lib/src/app/shell/navigation/navigation_settings.dart b/lib/src/app/shell/navigation/navigation_settings.dart new file mode 100644 index 000000000..c790e6796 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_settings.dart @@ -0,0 +1,111 @@ +part of 'navigation_utils.dart'; + +/// Navigates to a given Setting page. This includes sub-pages (e.g., Account -> Blocklist, Appearance -> Posts, etc.) +/// +/// Additionally, the [settingToHighlight] parameter can be used to highlight a specific setting when the page is opened. +void navigateToSettingPage(BuildContext context, LocalSettings setting, {LocalSettings? settingToHighlight}) { + final thunderBloc = context.read(); + final profileBloc = context.read(); + + final gestureCubit = context.read(); + final themeCubit = context.read(); + final reduceAnimations = themeCubit.state.reduceAnimations; + final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; + + final account = context.read().state.account; + + String pageToNav = { + LocalSettingsCategories.posts: SETTINGS_APPEARANCE_POSTS_PAGE, + LocalSettingsCategories.comments: SETTINGS_APPEARANCE_COMMENTS_PAGE, + LocalSettingsCategories.general: SETTINGS_GENERAL_PAGE, + LocalSettingsCategories.gestures: SETTINGS_GESTURES_PAGE, + LocalSettingsCategories.floatingActionButton: SETTINGS_FAB_PAGE, + LocalSettingsCategories.filters: SETTINGS_FILTERS_PAGE, + LocalSettingsCategories.accessibility: SETTINGS_ACCESSIBILITY_PAGE, + LocalSettingsCategories.account: SETTINGS_ACCOUNT_PAGE, + LocalSettingsCategories.accountBlocklist: SETTINGS_ACCOUNT_BLOCKLIST_PAGE, + LocalSettingsCategories.accountLanguages: SETTINGS_ACCOUNT_LANGUAGES_PAGE, + LocalSettingsCategories.accountMediaManagement: SETTINGS_ACCOUNT_MEDIA_PAGE, + LocalSettingsCategories.userLabels: SETTINGS_USER_LABELS_PAGE, + LocalSettingsCategories.theming: SETTINGS_APPEARANCE_THEMES_PAGE, + LocalSettingsCategories.debug: SETTINGS_DEBUG_PAGE, + LocalSettingsCategories.about: SETTINGS_ABOUT_PAGE, + LocalSettingsCategories.videoPlayer: SETTINGS_VIDEO_PAGE, + LocalSettingsCategories.appearance: SETTINGS_APPEARANCE_PAGE, + }[setting.category] ?? + SETTINGS_GENERAL_PAGE; + + if (pageToNav == SETTINGS_ABOUT_PAGE) { + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: thunderBloc), + ], + child: AboutSettingsPage(settingToHighlight: settingToHighlight ?? setting), + ), + ), + ); + } else if (pageToNav == SETTINGS_ACCOUNT_MEDIA_PAGE) { + final hasUserSettingsBloc = context.findAncestorWidgetOfExactType>() != null; + + final userSettingsBloc = hasUserSettingsBloc ? context.read() : createUserSettingsBloc(account); + + userSettingsBloc.add(const ListMediaEvent()); + + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: userSettingsBloc), + ], + child: MediaManagementPage(), + ), + ), + ); + } else { + final hasUserSettingsBloc = context.findAncestorWidgetOfExactType>() != null; + final userSettingsBloc = hasUserSettingsBloc ? context.read() : createUserSettingsBloc(account); + + Navigator.of(context).push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, + canOnlySwipeFromEdge: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: thunderBloc), + BlocProvider.value(value: userSettingsBloc), + ], + child: switch (pageToNav) { + SETTINGS_GENERAL_PAGE => GeneralSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_APPEARANCE_POSTS_PAGE => PostAppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_APPEARANCE_COMMENTS_PAGE => CommentAppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_GESTURES_PAGE => GestureSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_FAB_PAGE => FabSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_FILTERS_PAGE => FilterSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_ACCOUNT_PAGE => UserSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_ACCOUNT_LANGUAGES_PAGE => DiscussionLanguageSelector(), + SETTINGS_ACCOUNT_BLOCKLIST_PAGE => UserSettingsBlockPage(), + SETTINGS_APPEARANCE_THEMES_PAGE => ThemeSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_DEBUG_PAGE => DebugSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_VIDEO_PAGE => VideoPlayerSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_USER_LABELS_PAGE => UserLabelSettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_ACCESSIBILITY_PAGE => AccessibilitySettingsPage(settingToHighlight: settingToHighlight ?? setting), + SETTINGS_APPEARANCE_PAGE => AppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), + _ => Container(), + }, + ), + ), + ); + } +} diff --git a/lib/src/app/shell/navigation/navigation_utils.dart b/lib/src/app/shell/navigation/navigation_utils.dart new file mode 100644 index 000000000..ffe481ee6 --- /dev/null +++ b/lib/src/app/shell/navigation/navigation_utils.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/app/shell/navigation/swipeable_page_route.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/inbox/inbox.dart'; +import 'package:thunder/src/features/moderator/moderator.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; +import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/settings/settings.dart'; +import 'package:thunder/src/app/shell/navigation/loading_page.dart'; + +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/shared/gestures/swipe_utils.dart'; +import 'package:thunder/src/shared/widgets/webview.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/notification/presentation/pages/notifications_page.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; +import 'package:thunder/src/features/post/presentation/state/post_bloc.dart' as post_bloc; + +part 'navigation_feed.dart'; +part 'navigation_instance.dart'; +part 'navigation_misc.dart'; +part 'navigation_notification.dart'; +part 'navigation_post.dart'; +part 'navigation_settings.dart'; diff --git a/lib/src/app/routing/swipeable_page_route.dart b/lib/src/app/shell/navigation/swipeable_page_route.dart similarity index 100% rename from lib/src/app/routing/swipeable_page_route.dart rename to lib/src/app/shell/navigation/swipeable_page_route.dart diff --git a/lib/src/app/pages/thunder_page.dart b/lib/src/app/shell/pages/thunder_page.dart similarity index 93% rename from lib/src/app/pages/thunder_page.dart rename to lib/src/app/shell/pages/thunder_page.dart index fbc5dee7b..b72506686 100644 --- a/lib/src/app/pages/thunder_page.dart +++ b/lib/src/app/shell/pages/thunder_page.dart @@ -16,34 +16,28 @@ import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/foundation/utils/check_github_update.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/routing/deep_link.dart'; -import 'package:thunder/src/app/utils/share_intent_handler.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/cubits/deep_links_cubit/deep_links_cubit.dart'; -import 'package:thunder/src/app/cubits/notifications_cubit/notifications_cubit.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart'; -import 'package:thunder/src/app/widgets/bottom_nav_bar.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/app/shell/routing/deep_link.dart'; +import 'package:thunder/src/app/share/share_intent_handler.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/app/state/deep_links_cubit/deep_links_cubit.dart'; +import 'package:thunder/src/features/notification/application/state/notifications_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/app/shell/widgets/bottom_nav_bar.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/core/models/version.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/settings/settings.dart'; import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; String? currentIntent; @@ -326,7 +320,9 @@ class _ThunderState extends State { ), body: BlocConsumer( listenWhen: (ProfileState previous, ProfileState current) { - if (previous.isLoggedIn != current.isLoggedIn || previous.status == ProfileStatus.initial) return true; + if (previous.isLoggedIn != current.isLoggedIn || previous.status == ProfileStatus.initial) { + return true; + } return false; }, buildWhen: (previous, current) { @@ -346,7 +342,9 @@ class _ThunderState extends State { if (!state.reload) return; // Add a bit of artificial delay to allow preferences to set the proper active profile - if (context.mounted) context.read().add(const GetInboxEvent(reset: true)); + if (context.mounted) { + context.read().add(const GetInboxEvent(reset: true)); + } if (context.read().state.status != FeedStatus.initial) { final feedCubit = context.read(); diff --git a/lib/src/app/routing/deep_link.dart b/lib/src/app/shell/routing/deep_link.dart similarity index 90% rename from lib/src/app/routing/deep_link.dart rename to lib/src/app/shell/routing/deep_link.dart index 0cde329bd..36bfd3a56 100644 --- a/lib/src/app/routing/deep_link.dart +++ b/lib/src/app/shell/routing/deep_link.dart @@ -1,20 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/routing/deep_link_enums.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/shell/routing/deep_link_enums.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// Custom exception for deep link related errors class DeepLinkException implements Exception { @@ -165,7 +164,9 @@ String _normalizeLink(String? link) { /// Routes the navigation request to the appropriate specific handler based on [linkType]. /// Returns a [DeepLinkResult] indicating success or failure of the navigation attempt. Future _handleNavigation(BuildContext context, LinkType linkType, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } switch (linkType) { case LinkType.comment: @@ -222,7 +223,9 @@ Future _navigateToInstance(BuildContext context, String link) as /// Navigates to a post page. Future _navigateToPost(BuildContext context, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } final postId = await getLemmyPostId(context, link); if (postId == null) { @@ -233,7 +236,9 @@ Future _navigateToPost(BuildContext context, String link) async final account = context.read().state.account; final post = await PostRepositoryImpl(account: account).getPost(postId); - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } navigateToPost(context, post: post?['post']); return DeepLinkResult.successful(); @@ -244,7 +249,9 @@ Future _navigateToPost(BuildContext context, String link) async /// Navigates to a community page. Future _navigateToCommunity(BuildContext context, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } final communityName = await getLemmyCommunity(link); if (communityName == null) { @@ -261,7 +268,9 @@ Future _navigateToCommunity(BuildContext context, String link) a /// Navigates to the modlog page with optional filter parameters for action type, community, user, and moderator. Future _navigateToModlog(BuildContext context, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } try { final uri = Uri.tryParse(link); @@ -301,7 +310,9 @@ Future _navigateToModlog(BuildContext context, String link) asyn /// Navigates to a specific comment. Future _navigateToComment(BuildContext context, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } final commentId = await getLemmyCommentId(context, link); if (commentId == null) { @@ -309,7 +320,9 @@ Future _navigateToComment(BuildContext context, String link) asy } try { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } final account = context.read().state.account; final comment = await CommentRepositoryImpl(account: account).getComment(commentId); @@ -322,7 +335,9 @@ Future _navigateToComment(BuildContext context, String link) asy /// Navigates to a user profile page. Future _navigateToUser(BuildContext context, String link) async { - if (!context.mounted) return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + if (!context.mounted) { + return DeepLinkResult.failure(GlobalContext.l10n.unexpectedError); + } final username = await getLemmyUser(link); if (username == null) { diff --git a/lib/src/app/routing/deep_link_enums.dart b/lib/src/app/shell/routing/deep_link_enums.dart similarity index 100% rename from lib/src/app/routing/deep_link_enums.dart rename to lib/src/app/shell/routing/deep_link_enums.dart diff --git a/lib/src/app/shell/thunder_app.dart b/lib/src/app/shell/thunder_app.dart new file mode 100644 index 000000000..6aa5f893b --- /dev/null +++ b/lib/src/app/shell/thunder_app.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:l10n_esperanto/l10n_esperanto.dart'; +import 'package:overlay_support/overlay_support.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/app/shell/pages/thunder_page.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/community/api.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/inbox/api.dart'; +import 'package:thunder/src/features/notification/api.dart'; +import 'package:thunder/src/features/settings/domain/models/language_local.dart'; + +class ThunderApp extends StatefulWidget { + const ThunderApp({super.key}); + + @override + State createState() => _ThunderAppState(); +} + +class _ThunderAppState extends State { + final StreamController notificationsStreamController = StreamController(); + + PageController thunderPageController = PageController(initialPage: 0); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final String? inboxNotificationType = UserPreferences.getLocalSetting(LocalSettings.inboxNotificationType); + + if (inboxNotificationType == null) return; + + if (NotificationType.values.byName(inboxNotificationType) != NotificationType.none) { + initPushNotificationLogic(controller: notificationsStreamController); + } else { + final bool success = await deleteAccountFromNotificationServer(); + + if (success) { + UserPreferences.removeSetting(LocalSettings.inboxNotificationType); + } + } + }); + } + + @override + void dispose() { + super.dispose(); + notificationsStreamController.close(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => createDeepLinksCubit()), + BlocProvider(create: (context) => NotificationsCubit(notificationsStream: notificationsStreamController.stream)), + BlocProvider(create: (context) => createThunderBloc()), + BlocProvider(create: (context) => GesturePreferencesCubit(preferencesStore: const UserPreferencesStore())), + BlocProvider(create: (context) => FeedPreferencesCubit(preferencesStore: const UserPreferencesStore())), + BlocProvider(create: (context) => CommentPreferencesCubit(preferences: const UserPreferencesStore())), + BlocProvider(create: (context) => ThemePreferencesCubit(preferencesStore: const UserPreferencesStore())), + BlocProvider(create: (context) => VideoPreferencesCubit(preferencesStore: const UserPreferencesStore())), + BlocProvider(create: (context) => FabPreferencesCubit(preferencesStore: const UserPreferencesStore())), + BlocProvider(create: (context) => FabStateCubit()), + BlocProvider(create: (context) => NavBarStateCubit()), + BlocProvider(create: (context) => FeedUiCubit()), + BlocProvider(create: (context) => AnonymousSubscriptionsBloc()), + BlocProvider(create: (context) => createNetworkCheckerCubit()..getConnectionType()), + ], + child: BlocBuilder( + builder: (context, state) { + final appLanguageCode = context.select((bloc) => bloc.state.appLanguageCode); + + return DynamicColorBuilder( + builder: (lightColorScheme, darkColorScheme) { + final FlexScheme scheme = FlexScheme.values.byName(state.selectedTheme.name); + + final Color? darkThemeSurfaceColor = state.themeType == ThemeType.pureBlack ? null : Colors.black.lighten(8); + + ThemeData theme = FlexThemeData.light(scheme: scheme); + ThemeData darkTheme = FlexThemeData.dark( + scheme: scheme, + darkIsTrueBlack: state.themeType == ThemeType.pureBlack, + surface: darkThemeSurfaceColor, + scaffoldBackground: darkThemeSurfaceColor, + appBarBackground: darkThemeSurfaceColor, + ); + + if (state.useMaterialYouTheme == true) { + theme = ThemeData( + colorScheme: lightColorScheme, + ); + + darkTheme = FlexThemeData.dark( + colorScheme: darkColorScheme, + darkIsTrueBlack: state.themeType == ThemeType.pureBlack, + ); + } + + const PageTransitionsTheme pageTransitionsTheme = PageTransitionsTheme(builders: { + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + }); + + theme = theme.copyWith( + pageTransitionsTheme: pageTransitionsTheme, + inputDecorationTheme: InputDecorationTheme( + hintStyle: TextStyle( + color: lightColorScheme?.onSurface.withValues(alpha: 0.6), + ), + ), + ); + darkTheme = darkTheme.copyWith( + pageTransitionsTheme: pageTransitionsTheme, + inputDecorationTheme: InputDecorationTheme( + hintStyle: TextStyle( + color: darkColorScheme?.onSurface.withValues(alpha: 0.6), + ), + ), + ); + + final Locale? locale = LanguageLocal.parseLanguageTag(appLanguageCode ?? 'en'); + + return OverlaySupport.global( + child: AnnotatedRegion( + value: FlexColorScheme.themedSystemNavigationBar(context, systemNavBarStyle: FlexSystemNavBarStyle.transparent), + child: BlocBuilder( + buildWhen: (previous, current) => previous.account.id != current.account.id, + builder: (context, profileState) { + final account = profileState.account; + return MultiBlocProvider( + key: ValueKey('account_${account.id}'), + providers: [ + BlocProvider(create: (context) => createInboxBloc(account)..add(GetInboxEvent(reset: true))), + BlocProvider(create: (context) => createSearchBloc(account)), + BlocProvider(create: (context) => createFeedBloc(account)), + ], + child: MaterialApp( + title: 'Thunder', + locale: locale, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + MaterialLocalizationsEo.delegate, + CupertinoLocalizationsEo.delegate, + ], + supportedLocales: const [ + ...AppLocalizations.supportedLocales, + Locale('eo'), + ], + themeMode: state.themeType == ThemeType.system ? ThemeMode.system : (state.themeType == ThemeType.light ? ThemeMode.light : ThemeMode.dark), + theme: theme, + darkTheme: darkTheme, + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: GlobalContext.scaffoldMessengerKey, + scrollBehavior: (state.reduceAnimations && Platform.isAndroid) ? const ScrollBehavior().copyWith(overscroll: false) : null, + home: Thunder(pageController: thunderPageController), + ), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/src/app/widgets/bottom_nav_bar.dart b/lib/src/app/shell/widgets/bottom_nav_bar.dart similarity index 95% rename from lib/src/app/widgets/bottom_nav_bar.dart rename to lib/src/app/shell/widgets/bottom_nav_bar.dart index 5fcc01ad5..a884ce8c6 100644 --- a/lib/src/app/widgets/bottom_nav_bar.dart +++ b/lib/src/app/shell/widgets/bottom_nav_bar.dart @@ -2,15 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/settings/api.dart'; /// A custom bottom navigation bar that enables tap/swipe gestures class CustomBottomNavigationBar extends StatefulWidget { diff --git a/lib/src/app/cubits/deep_links_cubit/deep_links_cubit.dart b/lib/src/app/state/deep_links_cubit/deep_links_cubit.dart similarity index 60% rename from lib/src/app/cubits/deep_links_cubit/deep_links_cubit.dart rename to lib/src/app/state/deep_links_cubit/deep_links_cubit.dart index d83052a96..10dcc266c 100644 --- a/lib/src/app/cubits/deep_links_cubit/deep_links_cubit.dart +++ b/lib/src/app/state/deep_links_cubit/deep_links_cubit.dart @@ -1,21 +1,26 @@ import 'dart:async'; -import 'package:app_links/app_links.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/routing/deep_link_enums.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/app/shell/routing/deep_link_enums.dart'; part 'deep_links_state.dart'; /// A Cubit for handling deep links and determining their types. class DeepLinksCubit extends Cubit { - DeepLinksCubit() : super(const DeepLinksState()); - StreamSubscription? _appLinksStreamSubscription; + DeepLinksCubit({ + required DeepLinkService deepLinkService, + required LocalizationService localizationService, + }) : _deepLinkService = deepLinkService, + _localizationService = localizationService, + super(const DeepLinksState()); + StreamSubscription? _appLinksStreamSubscription; - AppLinks appLinks = AppLinks(); + final DeepLinkService _deepLinkService; + final LocalizationService _localizationService; /// Analyzes a given link to determine its type and updates the state accordingly. /// @@ -30,6 +35,8 @@ class DeepLinksCubit extends Cubit { deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.thunder, + error: null, + errorReason: null, )); } @@ -38,6 +45,8 @@ class DeepLinksCubit extends Cubit { deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.user, + error: null, + errorReason: null, )); } else if (link.contains("/post/")) { LinkType linkType = LinkType.post; @@ -52,43 +61,57 @@ class DeepLinksCubit extends Cubit { deepLinkStatus: DeepLinkStatus.success, link: link, linkType: linkType, + error: null, + errorReason: null, )); } else if (link.contains("/comment/")) { emit(state.copyWith( deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.comment, + error: null, + errorReason: null, )); } else if (link.contains("/c/")) { emit(state.copyWith( deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.community, + error: null, + errorReason: null, )); } else if (link.contains("/modlog")) { emit(state.copyWith( deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.modlog, + error: null, + errorReason: null, )); } else if (Uri.tryParse(link)?.pathSegments.isEmpty == true) { emit(state.copyWith( deepLinkStatus: DeepLinkStatus.success, link: link, linkType: LinkType.instance, + error: null, + errorReason: null, )); } else { + final message = _localizationService.l10n.uriNotSupported; emit(state.copyWith( deepLinkStatus: DeepLinkStatus.unknown, link: link, - error: AppLocalizations.of(GlobalContext.context)!.uriNotSupported, + error: message, + errorReason: AppErrorReason.validation(message: message), )); } } catch (e) { + final message = e.toString(); emit( state.copyWith( deepLinkStatus: DeepLinkStatus.error, - error: e.toString(), + error: message, + errorReason: AppErrorReason.unexpected(message: message), link: link, ), ); @@ -97,29 +120,43 @@ class DeepLinksCubit extends Cubit { /// Handles deep link navigation. Future initialize() async { - emit(state.copyWith(deepLinkStatus: DeepLinkStatus.loading)); + emit(state.copyWith( + deepLinkStatus: DeepLinkStatus.loading, + error: null, + errorReason: null, + )); - _appLinksStreamSubscription = appLinks.uriLinkStream.listen((Uri? uri) { - emit(state.copyWith(deepLinkStatus: DeepLinkStatus.loading)); + _appLinksStreamSubscription = _deepLinkService.uriLinkStream.listen((Uri? uri) { + emit(state.copyWith( + deepLinkStatus: DeepLinkStatus.loading, + error: null, + errorReason: null, + )); getLinkType(uri.toString()); }, onError: (Object err) { if (err is FormatException) { + final message = _localizationService.l10n.malformedUri; emit( state.copyWith( deepLinkStatus: DeepLinkStatus.error, - error: AppLocalizations.of(GlobalContext.context)!.malformedUri, + error: message, + errorReason: AppErrorReason.validation(message: message), ), ); } else { - state.copyWith( + final message = err.toString(); + emit(state.copyWith( deepLinkStatus: DeepLinkStatus.error, - error: err.toString(), - ); + error: message, + errorReason: AppErrorReason.unexpected(message: message), + )); } }); } - void dispose() { + @override + Future close() async { _appLinksStreamSubscription?.cancel(); + await super.close(); } } diff --git a/lib/src/app/cubits/deep_links_cubit/deep_links_state.dart b/lib/src/app/state/deep_links_cubit/deep_links_state.dart similarity index 55% rename from lib/src/app/cubits/deep_links_cubit/deep_links_state.dart rename to lib/src/app/state/deep_links_cubit/deep_links_state.dart index 00d2cfee4..4d19e4a23 100644 --- a/lib/src/app/cubits/deep_links_cubit/deep_links_state.dart +++ b/lib/src/app/state/deep_links_cubit/deep_links_state.dart @@ -2,35 +2,42 @@ part of 'deep_links_cubit.dart'; enum DeepLinkStatus { empty, loading, error, success, unknown } +const _deepLinksUnset = Object(); + class DeepLinksState extends Equatable { const DeepLinksState({ this.deepLinkStatus = DeepLinkStatus.empty, this.linkType = LinkType.unknown, this.link, this.error, + this.errorReason, }); final String? error; + final AppErrorReason? errorReason; final LinkType linkType; final String? link; final DeepLinkStatus deepLinkStatus; DeepLinksState copyWith({ - required DeepLinkStatus? deepLinkStatus, - String? error, - String? link, + DeepLinkStatus? deepLinkStatus, + Object? error = _deepLinksUnset, + Object? errorReason = _deepLinksUnset, + Object? link = _deepLinksUnset, LinkType? linkType, }) { return DeepLinksState( deepLinkStatus: deepLinkStatus ?? this.deepLinkStatus, - error: error ?? this.error, + error: identical(error, _deepLinksUnset) ? this.error : error as String?, + errorReason: identical(errorReason, _deepLinksUnset) ? this.errorReason : errorReason as AppErrorReason?, linkType: linkType ?? this.linkType, - link: link ?? this.link, + link: identical(link, _deepLinksUnset) ? this.link : link as String?, ); } @override - List get props => [ + List get props => [ error, + errorReason, linkType, link, deepLinkStatus, diff --git a/lib/src/app/cubits/network_checker_cubit/network_checker_cubit.dart b/lib/src/app/state/network_checker_cubit/network_checker_cubit.dart similarity index 58% rename from lib/src/app/cubits/network_checker_cubit/network_checker_cubit.dart rename to lib/src/app/state/network_checker_cubit/network_checker_cubit.dart index e364880d2..ac5161759 100644 --- a/lib/src/app/cubits/network_checker_cubit/network_checker_cubit.dart +++ b/lib/src/app/state/network_checker_cubit/network_checker_cubit.dart @@ -3,16 +3,23 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:equatable/equatable.dart'; -import 'package:thunder/src/core/enums/internet_connection_type.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; part 'network_checker_state.dart'; class NetworkCheckerCubit extends Cubit { - NetworkCheckerCubit() : super(const NetworkCheckerState()); + NetworkCheckerCubit({required ConnectivityService connectivityService}) + : _connectivityService = connectivityService, + super(const NetworkCheckerState()); + + final ConnectivityService _connectivityService; + StreamSubscription>? _connectivitySubscription; Future getConnectionType() async { emit(const NetworkCheckerState(status: NetworkCheckerStatus.loading)); - Connectivity().onConnectivityChanged.listen((List result) { + await _connectivitySubscription?.cancel(); + _connectivitySubscription = _connectivityService.onConnectivityChanged.listen((List result) { // Received changes in available connectivity types! switch (result) { case [ConnectivityResult.wifi]: @@ -32,8 +39,18 @@ class NetworkCheckerCubit extends Cubit { status: NetworkCheckerStatus.success, internetConnectionType: InternetConnectionType.unknown, )); + break; default: + emit(const NetworkCheckerState( + status: NetworkCheckerStatus.error, + )); } }); } + + @override + Future close() async { + await _connectivitySubscription?.cancel(); + await super.close(); + } } diff --git a/lib/src/app/cubits/network_checker_cubit/network_checker_state.dart b/lib/src/app/state/network_checker_cubit/network_checker_state.dart similarity index 85% rename from lib/src/app/cubits/network_checker_cubit/network_checker_state.dart rename to lib/src/app/state/network_checker_cubit/network_checker_state.dart index 88f913caf..46d0cc60a 100644 --- a/lib/src/app/cubits/network_checker_cubit/network_checker_state.dart +++ b/lib/src/app/state/network_checker_cubit/network_checker_state.dart @@ -8,7 +8,7 @@ class NetworkCheckerState extends Equatable { final InternetConnectionType? internetConnectionType; final NetworkCheckerStatus status; @override - List get props => [internetConnectionType, status]; + List get props => [internetConnectionType, status]; } enum NetworkCheckerStatus { initial, loading, success, error } diff --git a/lib/src/app/state/thunder/thunder_bloc.dart b/lib/src/app/state/thunder/thunder_bloc.dart new file mode 100644 index 000000000..ba418d76a --- /dev/null +++ b/lib/src/app/state/thunder/thunder_bloc.dart @@ -0,0 +1,122 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/config/config.dart'; + +part 'thunder_event.dart'; +part 'thunder_state.dart'; + +const throttleDuration = Duration(milliseconds: 300); + +EventTransformer throttleDroppable(Duration duration) { + return (events, mapper) => droppable().call(events.throttle(duration), mapper); +} + +class ThunderBloc extends Bloc { + ThunderBloc({ + required PreferencesStore preferencesStore, + required VersionChecker versionChecker, + }) : _preferencesStore = preferencesStore, + _versionChecker = versionChecker, + super(const ThunderState()) { + on(_initializeAppEvent, transformer: throttleDroppable(throttleDuration)); + on(_userPreferencesChangeEvent, transformer: throttleDroppable(throttleDuration)); + on(_onSetCurrentAnonymousInstance); + } + + final PreferencesStore _preferencesStore; + final VersionChecker _versionChecker; + + /// This event should be triggered at the start of the app. + /// + /// It initializes the local database, checks for updates from GitHub, and loads the user's preferences. + Future _initializeAppEvent(InitializeAppEvent event, Emitter emit) async { + try { + // Check for any updates from GitHub + final version = await _versionChecker.fetchLatestVersion(); + + add(UserPreferencesChangeEvent()); + emit(state.copyWith(status: ThunderStatus.success, version: version)); + } catch (e) { + final message = e.toString(); + + return emit( + state.copyWith( + status: ThunderStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected(message: message, details: message), + ), + ); + } + } + + Future _userPreferencesChangeEvent(UserPreferencesChangeEvent event, Emitter emit) async { + try { + emit(state.copyWith(status: ThunderStatus.refreshing)); + + // Tablet Settings + bool tabletMode = _preferencesStore.getLocalSetting(LocalSettings.useTabletMode) ?? false; + + // General Settings + BrowserMode browserMode = BrowserMode.values.byName(_preferencesStore.getLocalSetting(LocalSettings.browserMode) ?? BrowserMode.customTabs.name); + bool openInReaderMode = _preferencesStore.getLocalSetting(LocalSettings.openLinksInReaderMode) ?? false; + bool showInAppUpdateNotification = _preferencesStore.getLocalSetting(LocalSettings.showInAppUpdateNotification) ?? false; + bool showUpdateChangelogs = _preferencesStore.getLocalSetting(LocalSettings.showUpdateChangelogs) ?? true; + NotificationType inboxNotificationType = NotificationType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.inboxNotificationType) ?? NotificationType.none.name); + String? appLanguageCode = _preferencesStore.getLocalSetting(LocalSettings.appLanguageCode) ?? 'en'; + bool useProfilePictureForDrawer = _preferencesStore.getLocalSetting(LocalSettings.useProfilePictureForDrawer) ?? false; + ImageCachingMode imageCachingMode = ImageCachingMode.values.byName(_preferencesStore.getLocalSetting(LocalSettings.imageCachingMode) ?? ImageCachingMode.relaxed.name); + bool showNavigationLabels = _preferencesStore.getLocalSetting(LocalSettings.showNavigationLabels) ?? true; + bool hideTopBarOnScroll = _preferencesStore.getLocalSetting(LocalSettings.hideTopBarOnScroll) ?? false; + bool hideBottomBarOnScroll = _preferencesStore.getLocalSetting(LocalSettings.hideBottomBarOnScroll) ?? false; + bool scoreCounters = _preferencesStore.getLocalSetting(LocalSettings.scoreCounters) ?? false; + + String currentAnonymousInstance = _preferencesStore.getLocalSetting(LocalSettings.currentAnonymousInstance) ?? DEFAULT_INSTANCE; + + return emit(state.copyWith( + status: ThunderStatus.success, + tabletMode: tabletMode, + browserMode: browserMode, + openInReaderMode: openInReaderMode, + showInAppUpdateNotification: showInAppUpdateNotification, + showUpdateChangelogs: showUpdateChangelogs, + inboxNotificationType: inboxNotificationType, + appLanguageCode: appLanguageCode, + useProfilePictureForDrawer: useProfilePictureForDrawer, + imageCachingMode: imageCachingMode, + showNavigationLabels: showNavigationLabels, + hideTopBarOnScroll: hideTopBarOnScroll, + hideBottomBarOnScroll: hideBottomBarOnScroll, + scoreCounters: scoreCounters, + currentAnonymousInstance: currentAnonymousInstance, + errorReason: null, + )); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: ThunderStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _onSetCurrentAnonymousInstance(OnSetCurrentAnonymousInstance event, Emitter emit) async { + if (event.instance != null) { + _preferencesStore.setSetting(LocalSettings.currentAnonymousInstance, event.instance!); + } else { + _preferencesStore.removeSetting(LocalSettings.currentAnonymousInstance); + } + + return emit(state.copyWith(currentAnonymousInstance: event.instance)); + } +} diff --git a/lib/src/app/bloc/thunder_event.dart b/lib/src/app/state/thunder/thunder_event.dart similarity index 78% rename from lib/src/app/bloc/thunder_event.dart rename to lib/src/app/state/thunder/thunder_event.dart index cf7805596..2a0e3cf04 100644 --- a/lib/src/app/bloc/thunder_event.dart +++ b/lib/src/app/state/thunder/thunder_event.dart @@ -4,7 +4,7 @@ abstract class ThunderEvent extends Equatable { const ThunderEvent(); @override - List get props => []; + List get props => []; } class UserPreferencesChangeEvent extends ThunderEvent {} @@ -16,9 +16,15 @@ class OnScrollToTopEvent extends ThunderEvent {} class OnDismissEvent extends ThunderEvent { final bool isBeingDismissed; const OnDismissEvent(this.isBeingDismissed); + + @override + List get props => [isBeingDismissed]; } class OnSetCurrentAnonymousInstance extends ThunderEvent { final String? instance; const OnSetCurrentAnonymousInstance(this.instance); + + @override + List get props => [instance]; } diff --git a/lib/src/app/bloc/thunder_state.dart b/lib/src/app/state/thunder/thunder_state.dart similarity index 78% rename from lib/src/app/bloc/thunder_state.dart rename to lib/src/app/state/thunder/thunder_state.dart index ac633aa04..299d5d816 100644 --- a/lib/src/app/bloc/thunder_state.dart +++ b/lib/src/app/state/thunder/thunder_state.dart @@ -2,6 +2,8 @@ part of 'thunder_bloc.dart'; enum ThunderStatus { initial, loading, refreshing, success, failure } +const _thunderStateUnset = Object(); + class ThunderState extends Equatable { const ThunderState({ this.status = ThunderStatus.initial, @@ -9,6 +11,7 @@ class ThunderState extends Equatable { // General this.version, this.errorMessage, + this.errorReason, // Tablet Settings this.tabletMode = false, @@ -32,6 +35,7 @@ class ThunderState extends Equatable { final ThunderStatus status; final Version? version; final String? errorMessage; + final AppErrorReason? errorReason; // Tablet Settings final bool tabletMode; @@ -53,8 +57,9 @@ class ThunderState extends Equatable { ThunderState copyWith({ ThunderStatus? status, - Version? version, - String? errorMessage, + Object? version = _thunderStateUnset, + Object? errorMessage = _thunderStateUnset, + Object? errorReason = _thunderStateUnset, bool? tabletMode, BrowserMode? browserMode, bool? openInReaderMode, @@ -67,13 +72,14 @@ class ThunderState extends Equatable { bool? showNavigationLabels, bool? hideTopBarOnScroll, bool? hideBottomBarOnScroll, - String? appLanguageCode, - String? currentAnonymousInstance, + Object? appLanguageCode = _thunderStateUnset, + Object? currentAnonymousInstance = _thunderStateUnset, }) { return ThunderState( status: status ?? this.status, - version: version ?? this.version, - errorMessage: errorMessage, + version: identical(version, _thunderStateUnset) ? this.version : version as Version?, + errorMessage: identical(errorMessage, _thunderStateUnset) ? this.errorMessage : errorMessage as String?, + errorReason: identical(errorReason, _thunderStateUnset) ? this.errorReason : errorReason as AppErrorReason?, tabletMode: tabletMode ?? this.tabletMode, browserMode: browserMode ?? this.browserMode, openInReaderMode: openInReaderMode ?? this.openInReaderMode, @@ -86,8 +92,8 @@ class ThunderState extends Equatable { showNavigationLabels: showNavigationLabels ?? this.showNavigationLabels, hideTopBarOnScroll: hideTopBarOnScroll ?? this.hideTopBarOnScroll, hideBottomBarOnScroll: hideBottomBarOnScroll ?? this.hideBottomBarOnScroll, - appLanguageCode: appLanguageCode ?? this.appLanguageCode, - currentAnonymousInstance: currentAnonymousInstance, + appLanguageCode: identical(appLanguageCode, _thunderStateUnset) ? this.appLanguageCode : appLanguageCode as String?, + currentAnonymousInstance: identical(currentAnonymousInstance, _thunderStateUnset) ? this.currentAnonymousInstance : currentAnonymousInstance as String?, ); } @@ -96,6 +102,7 @@ class ThunderState extends Equatable { status, version, errorMessage, + errorReason, tabletMode, browserMode, openInReaderMode, diff --git a/lib/src/app/thunder.dart b/lib/src/app/thunder.dart deleted file mode 100644 index ab41d40b9..000000000 --- a/lib/src/app/thunder.dart +++ /dev/null @@ -1,7 +0,0 @@ -export 'pages/thunder_page.dart'; -export 'cubits/deep_links_cubit/deep_links_cubit.dart'; -export 'cubits/network_checker_cubit/network_checker_cubit.dart'; -export 'cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -export 'cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -export 'routing/deep_link_enums.dart'; -export 'bloc/thunder_bloc.dart'; diff --git a/lib/src/app/utils/navigation.dart b/lib/src/app/utils/navigation.dart deleted file mode 100644 index 5697e1011..000000000 --- a/lib/src/app/utils/navigation.dart +++ /dev/null @@ -1,850 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/routing/swipeable_page_route.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/features/moderator/moderator.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/pages/loading_page.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/swipe.dart'; -import 'package:thunder/src/shared/widgets/webview.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/pages/notifications_pages.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/features/post/presentation/bloc/post_bloc.dart' as post_bloc; - -({String postApId, post_bloc.PostBloc postBloc})? _cachedPostBloc; - -/// Navigates to the instance page for the given [instanceHost]. -/// -/// When [instanceId] is provided, the instance page will allow the option to block that given instance. This value represents -/// the id of the navigated instance from the original instance (e.g., lemmy.ml's instance id from lemmy.world). -Future navigateToInstancePage( - BuildContext context, { - required String instanceHost, - required int? instanceId, -}) async { - showLoadingPage(context); - - final reduceAnimations = context.read().state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = context.read().state.enableFullScreenSwipeNavigationGesture; - - final platformInfo = await detectPlatformFromNodeInfo(instanceHost); - final platform = platformInfo?['platform'] ?? ThreadiversePlatform.lemmy; // Fallback to Lemmy if we can't detect the platform - - ThunderSiteResponse? site; - - try { - // Get the site information by connecting to the given instance - final account = Account(id: '', index: -1, instance: instanceHost, platform: platform); - site = await InstanceRepositoryImpl(account: account).info().timeout(const Duration(seconds: 5)); - } catch (e) { - // Continue if we can't get the site - } - - final route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => BlocProvider.value( - value: context.read(), - child: InstancePage( - instance: ThunderInstanceInfo( - id: instanceId, - domain: site!.site.actorId, - name: site.site.name, - description: site.site.description, - sidebar: site.site.sidebar, - icon: site.site.icon, - users: site.site.users, - version: site.version, - platform: platform, - contentWarning: site.site.contentWarning, - ), - ), - ), - ); - - if (site != null) { - pushOnTopOfLoadingPage(context, route); - } else { - final l10n = GlobalContext.l10n; - - showSnackbar( - l10n.unableToNavigateToInstance(instanceHost), - trailingAction: () => handleLink(context, url: "https://$instanceHost", forceOpenInBrowser: true), - trailingIcon: Icons.open_in_browser_rounded, - ); - - hideLoadingPage(context); - } -} - -/// Navigates to the post page with the given [post] or [postId]. -/// -/// One of [post] or [postId] must be provided. If [post] is provided, the post page will use that data to display the post. -/// Otherwise, the post page will fetch the post with the given [postId]. -Future navigateToPost( - BuildContext context, { - int? postId, - ThunderPost? post, - int? highlightedCommentId, - Function(ThunderPost post)? onPostUpdated, -}) async { - assert((postId != null || post != null), 'One of the parameters must be provided'); - - // Required blocs - final profileBloc = context.read(); - final thunderBloc = context.read(); - - // Optional blocs - final hasFeedBloc = context.findAncestorWidgetOfExactType>(); - final feedBloc = hasFeedBloc != null ? context.read() : null; - - ThunderPost? pvm = post; - - final account = context.read().state.account; - - if (pvm == null) { - final response = await PostRepositoryImpl(account: account).getPost(postId!); - pvm = response?['post']; - } - - // Mark post as read when tapped - if (profileBloc.state.isLoggedIn) { - feedBloc?.add(FeedItemActionedEvent(postId: pvm!.id, postAction: PostAction.read, value: true)); - } - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - final post_bloc.PostBloc postBloc = _cachedPostBloc?.postApId == pvm!.apId - ? _cachedPostBloc!.postBloc - : (_cachedPostBloc = ( - postApId: pvm.apId, - postBloc: post_bloc.PostBloc(account: account), - )) - .postBloc; - - final route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - backGestureDetectionStartOffset: !kIsWeb && Platform.isAndroid ? 45 : 0, - backGestureDetectionWidth: 45, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isPostPage: true) || !enableFullScreenSwipeNavigationGesture, - builder: (_) { - final postNavigationCubit = PostNavigationCubit(); - if (highlightedCommentId != null) { - postNavigationCubit.setHighlightedCommentId(highlightedCommentId); - } - - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: postBloc), - BlocProvider.value(value: postNavigationCubit), - BlocProvider(create: (context) => AnonymousSubscriptionsBloc()), - ], - child: PostPage( - initialPost: postBloc.state.post ?? pvm!, - highlightedCommentId: highlightedCommentId, - onPostUpdated: (ThunderPost post) { - // Manually marking the read attribute as true when navigating to post since there is a case where the API call to mark the post as read from the feed page is not completed in time - feedBloc?.add(FeedItemUpdatedEvent(post: post.copyWith(read: true))); - }, - ), - ); - }, - ); - - pushOnTopOfLoadingPage(context, route); -} - -/// Navigates to the modlog page with the given parameters. -Future navigateToModlogPage( - BuildContext context, { - ModlogActionType? modlogActionType, - int? communityId, - int? userId, - int? moderatorId, - int? commentId, - required String subtitle, -}) async { - final thunderBloc = context.read(); - final account = context.read().state.account; - - // Optional blocs - final hasFeedBloc = context.findAncestorWidgetOfExactType>(); - final feedBloc = hasFeedBloc != null ? context.read() : FeedBloc(account: account); - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - final SwipeablePageRoute route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: feedBloc), - BlocProvider.value(value: thunderBloc), - ], - child: ModlogFeedPage( - modlogActionType: modlogActionType, - communityId: communityId, - userId: userId, - moderatorId: moderatorId, - commentId: commentId, - subtitle: subtitle, - ), - ), - ); - - pushOnTopOfLoadingPage(context, route); -} - -Future getPostFromComment(ThunderComment comment, Account account) async { - if (comment.post != null) return comment.post!; - - final response = await PostRepositoryImpl(account: account).getPost(comment.postId, commentId: comment.id); - return response!['post'] as ThunderPost; -} - -Future navigateToComment(BuildContext context, ThunderComment comment) async { - final profileBloc = context.read(); - final thunderBloc = context.read(); - final gestureCubit = context.read(); - - final account = context.read().state.account; - - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - - final route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - backGestureDetectionWidth: 45, - canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isPostPage: true) || !gestureCubit.state.enableFullScreenSwipeNavigationGesture, - builder: (context) { - final postNavigationCubit = PostNavigationCubit(); - postNavigationCubit.setHighlightedCommentId(comment.id); - - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: thunderBloc), - BlocProvider(create: (context) => PostBloc(account: account)), - BlocProvider.value(value: postNavigationCubit), - ], - child: FutureBuilder( - future: getPostFromComment(comment, account), - builder: (context, snapshot) { - if (snapshot.hasData) { - return PostPage( - initialPost: snapshot.data!, - highlightedCommentId: comment.id, - commentPath: comment.path, - onPostUpdated: (ThunderPost post) {}, - ); - } - - return LoadingPage(); - }, - ), - ); - }, - ); - - pushOnTopOfLoadingPage(context, route); -} - -Future navigateToCreateCommentPage( - BuildContext context, { - ThunderPost? post, - ThunderComment? comment, - ThunderComment? parentComment, - Function(ThunderComment comment, bool userChanged)? onCommentSuccess, -}) async { - assert(!(post == null && parentComment == null && comment == null)); - assert(!(post != null && (parentComment != null || comment != null))); - - final profileBloc = context.read(); - final thunderBloc = context.read(); - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - final SwipeablePageRoute route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: profileBloc), - ], - child: CreateCommentPage( - post: post, - comment: comment, - parentComment: parentComment, - onCommentSuccess: onCommentSuccess, - ), - ), - ); - - final result = await pushOnTopOfLoadingPage(context, route); - if (result is ThunderComment) return result; - return null; -} - -Future navigateToCreatePostPage( - BuildContext context, { - String? title, - String? text, - File? image, - String? url, - bool? prePopulated, - int? communityId, - ThunderCommunity? community, - ThunderPost? post, - bool isCrossPost = false, - Function(ThunderPost post, bool)? onPostSuccess, -}) async { - try { - final l10n = AppLocalizations.of(context)!; - final account = context.read().state.account; - - FeedBloc? feedBloc; - PostBloc? postBloc; - ThunderBloc thunderBloc = context.read(); - ProfileBloc profileBloc = context.read(); - CreatePostCubit createPostCubit = CreatePostCubit(account: account); - - final themeCubit = context.read(); - final bool reduceAnimations = themeCubit.state.reduceAnimations; - final gestureCubit = context.read(); - final bool enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - try { - feedBloc = context.read(); - } catch (e) { - // Don't need feed block if we're not opening post in the context of a feed. - } - - try { - postBloc = context.read(); - } catch (e) { - // It's ok if we don't get the PostBloc - } - - ThunderCommunity? pvmCommunity; - - if (post != null) { - pvmCommunity = post.community; - } - - await Navigator.of(context).push(SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - backGestureDetectionWidth: 45, - builder: (navigatorContext) { - return MultiBlocProvider( - providers: [ - feedBloc != null ? BlocProvider.value(value: feedBloc) : BlocProvider(create: (context) => FeedBloc(account: account)), - if (postBloc != null) BlocProvider.value(value: postBloc), - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: createPostCubit), - ], - child: CreatePostPage( - title: title, - text: text, - image: image, - url: url, - prePopulated: prePopulated, - communityId: communityId ?? post?.community!.id, - community: community ?? (post != null ? pvmCommunity : null), - post: post, - isCrossPost: isCrossPost, - onPostSuccess: (ThunderPost updatedPost, bool userChanged) { - // Update the existing post view media if it exists - if (feedBloc != null) { - feedBloc.add(FeedItemUpdatedEvent(post: updatedPost)); - } - if (postBloc != null) { - postBloc.add(PostUpdatedEvent(post: updatedPost)); - } - - // Show snackbar message if the post was just created - if (!userChanged && post == null) { - try { - showSnackbar( - l10n.postCreatedSuccessfully, - trailingIcon: Icons.remove_red_eye_rounded, - trailingAction: () { - navigateToPost(context, post: updatedPost); - }, - ); - } catch (e) { - if (context.mounted) showSnackbar("${AppLocalizations.of(context)!.unexpectedError}: $e"); - } - } - - if (onPostSuccess != null) onPostSuccess(updatedPost, userChanged); - }, - ), - ); - }, - )); - } catch (e) { - if (context.mounted) showSnackbar(AppLocalizations.of(context)!.unexpectedError); - } -} - -/// Navigates to a notifications page for the given [inboxType]. -/// -/// The [notificationId] is used to find and display a specific notification. -/// If [notificationId] is null, all unread notifications of the given type will be shown. -void navigateToNotificationPage( - BuildContext context, { - required InboxType inboxType, - required int? notificationId, - required String? accountId, -}) async { - assert(inboxType == InboxType.replies || inboxType == InboxType.mentions || inboxType == InboxType.messages); - - // It can take a little while to set up notifications, so show a loading page - showLoadingPage(context); - - final thunderBloc = context.read(); - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - - if (accountId == null) { - hideLoadingPage(context); - return; // No account ID provided, so we can't do anything. - } - - final account = await Account.fetchAccount(accountId); - if (account == null) { - if (context.mounted) hideLoadingPage(context); - return; // No account found, so we can't do anything. - } - - final notificationRepository = NotificationRepositoryImpl(account: account); - - late final NotificationsPage notificationsPage; - - if (inboxType == InboxType.messages) { - final messages = []; - ThunderPrivateMessage? message; - - bool doneFetching = false; - int currentPage = 1; - - while (!doneFetching) { - final response = await notificationRepository.messages( - unread: notificationId == null, - limit: 50, - page: currentPage, - ); - - messages.addAll(response); - message ??= response.firstWhereOrNull((m) => m.id == notificationId); - - doneFetching = message != null || response.isEmpty; - ++currentPage; - } - - notificationsPage = NotificationsPage.messages(messages: message == null ? messages : [message]); - } else { - final comments = []; - ThunderComment? comment; - - bool doneFetching = false; - int currentPage = 1; - - while (!doneFetching) { - final response = inboxType == InboxType.replies - ? await notificationRepository.replies(unread: notificationId == null, limit: 50, sort: CommentSortType.new_, page: currentPage) - : await notificationRepository.mentions(unread: notificationId == null, limit: 50, sort: CommentSortType.new_, page: currentPage); - - comments.addAll(response); - comment ??= response.firstWhereOrNull((c) => c.id == notificationId); - - doneFetching = comment != null || response.isEmpty; - ++currentPage; - } - - notificationsPage = - inboxType == InboxType.replies ? NotificationsPage.replies(replies: comment == null ? comments : [comment]) : NotificationsPage.mentions(mentions: comment == null ? comments : [comment]); - } - - if (context.mounted) { - final route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - backGestureDetectionWidth: 45, - canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: !gestureCubit.state.enableFullScreenSwipeNavigationGesture, - builder: (context) => MultiBlocProvider( - providers: [BlocProvider.value(value: thunderBloc)], - child: notificationsPage, - ), - ); - - pushOnTopOfLoadingPage(context, route).then((_) { - context.read().add(const GetInboxEvent(reset: true, inboxType: InboxType.all)); - }); - } -} - -/// Navigates to the [ReportFeedPage] page. -/// -/// The [context] parameter should contain the following blocs within its widget tree: [FeedBloc], [ThunderBloc] -void navigateToReportPage(BuildContext context) { - final hasFeedBloc = context.findAncestorWidgetOfExactType>() != null; - assert(hasFeedBloc == true); - - final feedBloc = context.read(); - final thunderBloc = context.read(); - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - Navigator.of(context).push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (_) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: feedBloc), - BlocProvider.value(value: thunderBloc), - ], - child: const ReportFeedPage(), - ); - }, - ), - ); -} - -/// Navigates to a [FeedPage] with the given parameters -/// -/// [feedType] must be provided. -/// If [feedType] is [FeedType.general], [feedListType] must be provided -/// If [feedType] is [FeedType.community], one of [communityId] or [communityName] must be provided -/// If [feedType] is [FeedType.user], one of [userId] or [username] must be provided -/// -/// The [context] parameter should contain the following blocs within its widget tree: [AccountBloc], [AuthBloc], [ThunderBloc] -Future navigateToFeedPage( - BuildContext context, { - required FeedType feedType, - FeedListType? feedListType, - PostSortType? postSortType, - String? communityName, - int? communityId, - String? username, - int? userId, -}) async { - // Push navigation - ProfileBloc profileBloc = context.read(); - ThunderBloc thunderBloc = context.read(); - final gestureCubit = context.read(); - final themeCubit = context.read(); - final feedCubit = context.read(); - AnonymousSubscriptionsBloc anonymousSubscriptionsBloc = context.read(); - - final bool reduceAnimations = themeCubit.state.reduceAnimations; - - if (feedType == FeedType.general) { - return context.read().add( - FeedFetchedEvent( - feedType: feedType, - feedListType: feedListType, - postSortType: postSortType ?? - (profileBloc.state.siteResponse?.myUser?.localUserView.localUser.defaultSortType != null - ? profileBloc.state.siteResponse!.myUser!.localUserView.localUser.defaultSortType - : feedCubit.state.defaultPostSortType), - communityId: communityId, - communityName: communityName, - userId: userId, - username: username, - reset: true, - showHidden: feedCubit.state.showHiddenPosts, - ), - ); - } - - SwipeablePageRoute route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - backGestureDetectionWidth: 45, - canSwipe: !kIsWeb && Platform.isIOS || gestureCubit.state.enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: disableFullPageSwipe(isUserLoggedIn: profileBloc.state.isLoggedIn, state: gestureCubit.state, isFeedPage: true) || !gestureCubit.state.enableFullScreenSwipeNavigationGesture, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: anonymousSubscriptionsBloc), - ], - child: Material( - child: FeedPage( - feedType: feedType, - postSortType: postSortType ?? - (profileBloc.state.siteResponse?.myUser?.localUserView.localUser.defaultSortType != null - ? profileBloc.state.siteResponse!.myUser!.localUserView.localUser.defaultSortType - : feedCubit.state.defaultPostSortType), - communityName: communityName, - communityId: communityId, - userId: userId, - username: username, - feedListType: feedListType, - showHidden: feedCubit.state.showHiddenPosts, - ), - ), - ), - ); - - pushOnTopOfLoadingPage(context, route); -} - -/// Navigates to the search page -/// -/// The [context] parameter should contain the following blocs within its widget tree: [FeedBloc], [ThunderBloc] -void navigateToSearchPage(BuildContext context) { - final hasFeedBloc = context.findAncestorWidgetOfExactType>() != null; - assert(hasFeedBloc == true); - - final feedBloc = context.read(); - final thunderBloc = context.read(); - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - final account = context.read().state.account; - - Navigator.of(context).push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => SearchBloc(account: account)), - BlocProvider.value(value: thunderBloc), - ], - child: SearchPage(community: feedBloc.state.community), - ), - ), - ); -} - -/// Navigates to a given Setting page. This includes sub-pages (e.g., Account -> Blocklist, Appearance -> Posts, etc.) -/// -/// Additionally, the [settingToHighlight] parameter can be used to highlight a specific setting when the page is opened. -void navigateToSettingPage(BuildContext context, LocalSettings setting, {LocalSettings? settingToHighlight}) { - final thunderBloc = context.read(); - final profileBloc = context.read(); - - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - final account = context.read().state.account; - - String pageToNav = { - LocalSettingsCategories.posts: SETTINGS_APPEARANCE_POSTS_PAGE, - LocalSettingsCategories.comments: SETTINGS_APPEARANCE_COMMENTS_PAGE, - LocalSettingsCategories.general: SETTINGS_GENERAL_PAGE, - LocalSettingsCategories.gestures: SETTINGS_GESTURES_PAGE, - LocalSettingsCategories.floatingActionButton: SETTINGS_FAB_PAGE, - LocalSettingsCategories.filters: SETTINGS_FILTERS_PAGE, - LocalSettingsCategories.accessibility: SETTINGS_ACCESSIBILITY_PAGE, - LocalSettingsCategories.account: SETTINGS_ACCOUNT_PAGE, - LocalSettingsCategories.accountBlocklist: SETTINGS_ACCOUNT_BLOCKLIST_PAGE, - LocalSettingsCategories.accountLanguages: SETTINGS_ACCOUNT_LANGUAGES_PAGE, - LocalSettingsCategories.accountMediaManagement: SETTINGS_ACCOUNT_MEDIA_PAGE, - LocalSettingsCategories.userLabels: SETTINGS_USER_LABELS_PAGE, - LocalSettingsCategories.theming: SETTINGS_APPEARANCE_THEMES_PAGE, - LocalSettingsCategories.debug: SETTINGS_DEBUG_PAGE, - LocalSettingsCategories.about: SETTINGS_ABOUT_PAGE, - LocalSettingsCategories.videoPlayer: SETTINGS_VIDEO_PAGE, - LocalSettingsCategories.appearance: SETTINGS_APPEARANCE_PAGE, - }[setting.category] ?? - SETTINGS_GENERAL_PAGE; - - if (pageToNav == SETTINGS_ABOUT_PAGE) { - Navigator.of(context).push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: thunderBloc), - ], - child: AboutSettingsPage(settingToHighlight: settingToHighlight ?? setting), - ), - ), - ); - } else if (pageToNav == SETTINGS_ACCOUNT_MEDIA_PAGE) { - final hasUserSettingsBloc = context.findAncestorWidgetOfExactType>() != null; - - final userSettingsBloc = hasUserSettingsBloc ? context.read() : UserSettingsBloc(account: account); - - userSettingsBloc.add(const ListMediaEvent()); - - Navigator.of(context).push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: userSettingsBloc), - ], - child: MediaManagementPage(), - ), - ), - ); - } else { - final hasUserSettingsBloc = context.findAncestorWidgetOfExactType>() != null; - final userSettingsBloc = hasUserSettingsBloc ? context.read() : UserSettingsBloc(account: account); - - Navigator.of(context).push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: thunderBloc), - BlocProvider.value(value: userSettingsBloc), - ], - child: switch (pageToNav) { - SETTINGS_GENERAL_PAGE => GeneralSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_APPEARANCE_POSTS_PAGE => PostAppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_APPEARANCE_COMMENTS_PAGE => CommentAppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_GESTURES_PAGE => GestureSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_FAB_PAGE => FabSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_FILTERS_PAGE => FilterSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_ACCOUNT_PAGE => UserSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_ACCOUNT_LANGUAGES_PAGE => DiscussionLanguageSelector(), - SETTINGS_ACCOUNT_BLOCKLIST_PAGE => UserSettingsBlockPage(), - SETTINGS_APPEARANCE_THEMES_PAGE => ThemeSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_DEBUG_PAGE => DebugSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_VIDEO_PAGE => VideoPlayerSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_USER_LABELS_PAGE => UserLabelSettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_ACCESSIBILITY_PAGE => AccessibilitySettingsPage(settingToHighlight: settingToHighlight ?? setting), - SETTINGS_APPEARANCE_PAGE => AppearanceSettingsPage(settingToHighlight: settingToHighlight ?? setting), - _ => Container(), - }, - ), - ), - ); - } -} - -/// Navigates to the given [url] in a webview. -void navigateToWebView(BuildContext context, String url) { - final gestureCubit = context.read(); - final themeCubit = context.read(); - final reduceAnimations = themeCubit.state.reduceAnimations; - final enableFullScreenSwipeNavigationGesture = gestureCubit.state.enableFullScreenSwipeNavigationGesture; - - SwipeablePageRoute route = SwipeablePageRoute( - transitionDuration: isLoadingPageShown - ? Duration.zero - : reduceAnimations - ? const Duration(milliseconds: 100) - : null, - reverseTransitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : const Duration(milliseconds: 500), - canSwipe: !kIsWeb && Platform.isIOS || enableFullScreenSwipeNavigationGesture, - canOnlySwipeFromEdge: true, - builder: (context) => WebView(url: url), - ); - - pushOnTopOfLoadingPage(context, route); -} diff --git a/lib/src/app/wiring/fetch_active_account_provider.dart b/lib/src/app/wiring/fetch_active_account_provider.dart new file mode 100644 index 000000000..4682f419a --- /dev/null +++ b/lib/src/app/wiring/fetch_active_account_provider.dart @@ -0,0 +1,11 @@ +import 'package:thunder/src/foundation/contracts/active_account_provider.dart'; +import 'package:thunder/src/features/account/account.dart'; + +class FetchActiveAccountProvider implements ActiveAccountProvider { + const FetchActiveAccountProvider(); + + @override + Future getActiveAccount() { + return fetchActiveProfile(); + } +} diff --git a/lib/src/app/wiring/nodeinfo_platform_detection_service.dart b/lib/src/app/wiring/nodeinfo_platform_detection_service.dart new file mode 100644 index 000000000..19b3281d9 --- /dev/null +++ b/lib/src/app/wiring/nodeinfo_platform_detection_service.dart @@ -0,0 +1,11 @@ +import 'package:thunder/src/foundation/contracts/platform_detection_service.dart'; +import 'package:thunder/src/features/instance/data/services/instance_discovery_service.dart'; + +class NodeInfoPlatformDetectionService implements PlatformDetectionService { + const NodeInfoPlatformDetectionService(); + + @override + Future?> detectPlatform(String instance) { + return detectPlatformFromNodeInfo(instance); + } +} diff --git a/lib/src/app/wiring/state_factories.dart b/lib/src/app/wiring/state_factories.dart new file mode 100644 index 000000000..c71653f46 --- /dev/null +++ b/lib/src/app/wiring/state_factories.dart @@ -0,0 +1,164 @@ +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/app/wiring/fetch_active_account_provider.dart'; +import 'package:thunder/src/app/wiring/nodeinfo_platform_detection_service.dart'; +import 'package:thunder/src/app/state/deep_links_cubit/deep_links_cubit.dart'; +import 'package:thunder/src/app/state/network_checker_cubit/network_checker_cubit.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/community/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/inbox/api.dart'; +import 'package:thunder/src/features/instance/api.dart'; +import 'package:thunder/src/features/moderator/api.dart'; +import 'package:thunder/src/features/notification/api.dart'; +import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/features/search/api.dart'; +import 'package:thunder/src/features/user/api.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_bloc.dart'; + +ThunderBloc createThunderBloc() { + return ThunderBloc( + preferencesStore: const UserPreferencesStore(), + versionChecker: const GithubVersionChecker(), + ); +} + +DeepLinksCubit createDeepLinksCubit() { + return DeepLinksCubit( + deepLinkService: AppLinksDeepLinkService(), + localizationService: const GlobalContextLocalizationService(), + ); +} + +NetworkCheckerCubit createNetworkCheckerCubit() { + return NetworkCheckerCubit( + connectivityService: DefaultConnectivityService(), + ); +} + +ProfileBloc createProfileBloc(Account account) { + return ProfileBloc( + account: account, + instanceRepositoryFactory: (account) => InstanceRepositoryImpl(account: account), + accountRepositoryFactory: (account) => AccountRepositoryImpl(account: account), + userRepositoryFactory: (account) => UserRepositoryImpl(account: account), + platformDetectionService: const NodeInfoPlatformDetectionService(), + activeAccountProvider: const FetchActiveAccountProvider(), + localizationService: const GlobalContextLocalizationService(), + preferencesStore: const UserPreferencesStore(), + ); +} + +FeedBloc createFeedBloc(Account account) { + return FeedBloc( + account: account, + postRepository: PostRepositoryImpl(account: account), + communityRepository: CommunityRepositoryImpl(account: account), + userRepository: UserRepositoryImpl(account: account), + ); +} + +InstancePageBloc createInstancePageBloc({ + required Account account, + required ThunderInstanceInfo instanceInfo, +}) { + final uri = Uri.parse(instanceInfo.domain); + final remoteAccount = Account( + instance: uri.host, + id: '', + index: -1, + platform: instanceInfo.platform, + ); + + return InstancePageBloc( + account: account, + instanceInfo: instanceInfo, + repository: SearchRepositoryImpl(account: remoteAccount), + localRepository: SearchRepositoryImpl(account: account), + ); +} + +SearchBloc createSearchBloc(Account account) { + return SearchBloc( + account: account, + commentRepository: CommentRepositoryImpl(account: account), + searchRepository: SearchRepositoryImpl(account: account), + communityRepository: CommunityRepositoryImpl(account: account), + userRepository: UserRepositoryImpl(account: account), + instanceRepository: InstanceRepositoryImpl(account: account), + ); +} + +InboxBloc createInboxBloc(Account account) { + return InboxBloc( + account: account, + commentRepository: CommentRepositoryImpl(account: account), + notificationRepository: NotificationRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ); +} + +InboxBloc createInboxBlocWithInitial({ + required Account account, + required List replies, + required bool showUnreadOnly, +}) { + return InboxBloc.initWith( + account: account, + replies: replies, + showUnreadOnly: showUnreadOnly, + commentRepository: CommentRepositoryImpl(account: account), + notificationRepository: NotificationRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ); +} + +PostBloc createPostBloc(Account account) { + return PostBloc( + account: account, + postRepository: PostRepositoryImpl(account: account), + commentRepository: CommentRepositoryImpl(account: account), + communityRepository: CommunityRepositoryImpl(account: account), + preferencesStore: const UserPreferencesStore(), + localizationService: const GlobalContextLocalizationService(), + ); +} + +CreatePostCubit createCreatePostCubit(Account account) { + return CreatePostCubit( + account: account, + postRepositoryFactory: (account) => PostRepositoryImpl(account: account), + accountRepositoryFactory: (account) => AccountRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ); +} + +CreateCommentCubit createCreateCommentCubit(Account account) { + return CreateCommentCubit( + account: account, + commentRepositoryFactory: (account) => CommentRepositoryImpl(account: account), + accountRepositoryFactory: (account) => AccountRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ); +} + +UserSettingsBloc createUserSettingsBloc(Account account) { + return UserSettingsBloc( + account: account, + instanceRepository: InstanceRepositoryImpl(account: account), + searchRepository: SearchRepositoryImpl(account: account), + communityRepository: CommunityRepositoryImpl(account: account), + accountRepository: AccountRepositoryImpl(account: account), + userRepository: UserRepositoryImpl(account: account), + activeAccountProvider: const FetchActiveAccountProvider(), + localizationService: const GlobalContextLocalizationService(), + ); +} + +ReportBloc createReportBloc() { + return ReportBloc( + localizationService: const GlobalContextLocalizationService(), + ); +} diff --git a/lib/src/core/enums/enums.dart b/lib/src/core/enums/enums.dart deleted file mode 100644 index 5cfa0ebab..000000000 --- a/lib/src/core/enums/enums.dart +++ /dev/null @@ -1 +0,0 @@ -export 'feed_list_type.dart'; diff --git a/lib/src/core/enums/full_name.dart b/lib/src/core/enums/full_name.dart deleted file mode 100644 index 025099150..000000000 --- a/lib/src/core/enums/full_name.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -enum FullNameSeparator { - dot, // name · instance.tld - at, // name@instance.tld - lemmy; // '@name@instance.tld or !name@instance.tld' -} - -enum NameThickness { - light, - normal, - bold; - - FontWeight toWeight() => switch (this) { - NameThickness.light => FontWeight.w300, - NameThickness.normal => FontWeight.w400, - NameThickness.bold => FontWeight.w500, - }; - - double toSliderValue() => switch (this) { - NameThickness.light => 0, - NameThickness.normal => 1, - NameThickness.bold => 2, - }; - - static NameThickness fromSliderValue(double value) { - return switch (value) { - 0 => NameThickness.light, - 1 => NameThickness.normal, - 2 => NameThickness.bold, - _ => NameThickness.normal, - }; - } - - String label(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - - return switch (this) { - NameThickness.light => l10n.light, - NameThickness.normal => l10n.normal, - NameThickness.bold => l10n.bold, - }; - } -} - -class NameColor { - static const String defaultColor = 'default'; - static const String themePrimary = 'theme_primary'; - static const String themeSecondary = 'theme_secondary'; - static const String themeTertiary = 'theme_tertiary'; - - final String color; - - const NameColor.fromString({this.color = defaultColor}); - - Color? toColor(BuildContext context) { - final ThemeData theme = Theme.of(context); - - return switch (color) { - defaultColor => theme.textTheme.bodyMedium?.color, - themePrimary => theme.colorScheme.primary, - themeSecondary => theme.colorScheme.secondary, - themeTertiary => theme.colorScheme.tertiary, - _ => theme.textTheme.bodyMedium?.color, - }; - } - - String label(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - - return switch (color) { - defaultColor => l10n.defaultColor, - themePrimary => l10n.themePrimary, - themeSecondary => l10n.themeSecondary, - themeTertiary => l10n.themeTertiary, - _ => l10n.defaultColor, - }; - } - - static List getPossibleValues(NameColor currentValue) { - return [ - currentValue.color == defaultColor ? currentValue : const NameColor.fromString(color: NameColor.defaultColor), - currentValue.color == themePrimary ? currentValue : const NameColor.fromString(color: NameColor.themePrimary), - currentValue.color == themeSecondary ? currentValue : const NameColor.fromString(color: NameColor.themeSecondary), - currentValue.color == themeTertiary ? currentValue : const NameColor.fromString(color: NameColor.themeTertiary), - ]; - } -} - -/// --- SAMPLES --- - -String generateSampleUserFullName(FullNameSeparator separator, bool useDisplayName) => generateUserFullName( - null, - 'name', - 'name', - 'instance.tld', - userSeparator: separator, - useDisplayName: useDisplayName, - ); - -Widget generateSampleUserFullNameWidget( - FullNameSeparator separator, { - NameThickness? userNameThickness, - NameColor? userNameColor, - NameThickness? instanceNameThickness, - NameColor? instanceNameColor, - TextStyle? textStyle, - bool? useDisplayName, -}) => - UserFullNameWidget( - null, - 'name', - 'name', - 'instance.tld', - userSeparator: separator, - userNameThickness: userNameThickness, - userNameColor: userNameColor, - instanceNameThickness: instanceNameThickness, - instanceNameColor: instanceNameColor, - textStyle: textStyle, - useDisplayName: useDisplayName, - ); - -String generateSampleCommunityFullName(FullNameSeparator separator, bool useDisplayName) => generateCommunityFullName( - null, - 'name', - 'name', - 'instance.tld', - communitySeparator: separator, - useDisplayName: useDisplayName, - ); - -Widget generateSampleCommunityFullNameWidget( - FullNameSeparator separator, { - NameThickness? communityNameThickness, - NameColor? communityNameColor, - NameThickness? instanceNameThickness, - NameColor? instanceNameColor, - TextStyle? textStyle, - bool? useDisplayName, -}) => - CommunityFullNameWidget( - null, - 'name', - 'name', - 'instance.tld', - communitySeparator: separator, - communityNameThickness: communityNameThickness, - communityNameColor: communityNameColor, - instanceNameThickness: instanceNameThickness, - instanceNameColor: instanceNameColor, - textStyle: textStyle, - useDisplayName: useDisplayName, - ); - -/// --- USERS --- - -String generateUserFullNamePrefix(BuildContext? context, String? name, String? displayName, {FullNameSeparator? userSeparator, bool? useDisplayName}) { - assert(context != null || (userSeparator != null && useDisplayName != null)); - userSeparator ??= context!.read().state.userSeparator; - useDisplayName ??= context!.read().state.useDisplayNamesForUsers; - return switch (userSeparator) { - FullNameSeparator.dot => (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? '', - FullNameSeparator.at => (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? '', - FullNameSeparator.lemmy => '@${(useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? ''}', - }; -} - -String generateUserFullNameSuffix(BuildContext? context, String? instance, {FullNameSeparator? userSeparator}) { - assert(context != null || userSeparator != null); - userSeparator ??= context!.read().state.userSeparator; - return switch (userSeparator) { - FullNameSeparator.dot => ' · $instance', - FullNameSeparator.at => '@$instance', - FullNameSeparator.lemmy => '@$instance', - }; -} - -String generateUserFullName(BuildContext? context, String? name, String? displayName, instance, {FullNameSeparator? userSeparator, bool? useDisplayName}) { - String prefix = generateUserFullNamePrefix(context, name, displayName, userSeparator: userSeparator, useDisplayName: useDisplayName); - String suffix = generateUserFullNameSuffix(context, instance, userSeparator: userSeparator); - return '$prefix$suffix'; -} - -/// --- COMMUNITIES --- - -String generateCommunityFullNamePrefix(BuildContext? context, String? name, String? displayName, {FullNameSeparator? communitySeparator, bool? useDisplayName}) { - assert(context != null || (communitySeparator != null && useDisplayName != null)); - communitySeparator ??= context!.read().state.communitySeparator; - useDisplayName ??= context!.read().state.useDisplayNamesForCommunities; - return switch (communitySeparator) { - FullNameSeparator.dot => (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? '', - FullNameSeparator.at => (useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? '', - FullNameSeparator.lemmy => '!${(useDisplayName && displayName?.isNotEmpty == true ? displayName : name) ?? ''}', - }; -} - -String generateCommunityFullNameSuffix(BuildContext? context, String? instance, {FullNameSeparator? communitySeparator}) { - assert(context != null || communitySeparator != null); - communitySeparator ??= context!.read().state.communitySeparator; - return switch (communitySeparator) { - FullNameSeparator.dot => ' · $instance', - FullNameSeparator.at => '@$instance', - FullNameSeparator.lemmy => '@$instance', - }; -} - -String generateCommunityFullName(BuildContext? context, String? name, String? displayName, instance, {FullNameSeparator? communitySeparator, bool? useDisplayName}) { - String prefix = generateCommunityFullNamePrefix(context, name, displayName, communitySeparator: communitySeparator, useDisplayName: useDisplayName); - String suffix = generateCommunityFullNameSuffix(context, instance, communitySeparator: communitySeparator); - return '$prefix$suffix'; -} diff --git a/lib/src/core/models/models.dart b/lib/src/core/models/models.dart deleted file mode 100644 index 539ccaf31..000000000 --- a/lib/src/core/models/models.dart +++ /dev/null @@ -1,7 +0,0 @@ -export 'thunder_site.dart'; -export 'thunder_instance_info.dart'; -export 'thunder_language.dart'; -export 'thunder_tagline.dart'; -export 'thunder_local_user.dart'; -export 'thunder_my_user.dart'; -export 'thunder_site_response.dart'; diff --git a/lib/src/features/account/account.dart b/lib/src/features/account/account.dart index f1f34d5fc..7b7911353 100644 --- a/lib/src/features/account/account.dart +++ b/lib/src/features/account/account.dart @@ -1,7 +1,8 @@ -export 'presentation/bloc/profile_bloc.dart'; +export 'presentation/state/profile_bloc.dart'; export 'presentation/pages/pages.dart'; export 'presentation/widgets/widgets.dart'; -export 'presentation/utils/utils.dart'; -export 'data/models/models.dart'; +export 'presentation/utils/profile_utils.dart'; +export 'package:thunder/src/foundation/contracts/account.dart'; +export 'domain/models/account_media.dart'; export 'domain/repositories/account_repository.dart'; export 'data/repositories/account_repository_impl.dart'; diff --git a/lib/src/features/account/data/models/models.dart b/lib/src/features/account/api.dart similarity index 100% rename from lib/src/features/account/data/models/models.dart rename to lib/src/features/account/api.dart diff --git a/lib/src/shared/profile_site_info_cache.dart b/lib/src/features/account/data/cache/profile_site_info_cache.dart similarity index 92% rename from lib/src/shared/profile_site_info_cache.dart rename to lib/src/features/account/data/cache/profile_site_info_cache.dart index df6e36f4f..53fd68b61 100644 --- a/lib/src/shared/profile_site_info_cache.dart +++ b/lib/src/features/account/data/cache/profile_site_info_cache.dart @@ -1,9 +1,9 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/instance/api.dart'; /// A cache that holds a given [account]'s site info. class ProfileSiteInfoCache { diff --git a/lib/src/features/account/data/repositories/account_repository_impl.dart b/lib/src/features/account/data/repositories/account_repository_impl.dart index 36eea687c..1eadbd219 100644 --- a/lib/src/features/account/data/repositories/account_repository_impl.dart +++ b/lib/src/features/account/data/repositories/account_repository_impl.dart @@ -2,14 +2,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; /// Implementation of [AccountRepository] class AccountRepositoryImpl implements AccountRepository { @@ -19,10 +16,18 @@ class AccountRepositoryImpl implements AccountRepository { /// The API client to use for the repository final ThunderApiClient _api; + /// The localization service to use for user-facing errors + final LocalizationService _localizationService; + /// Creates a new AccountRepositoryImpl. /// /// An optional [api] client can be provided for testing. - AccountRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); + AccountRepositoryImpl({ + required this.account, + ThunderApiClient? api, + LocalizationService localizationService = const GlobalContextLocalizationService(), + }) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode), + _localizationService = localizationService; @override Future login({required String username, required String password, String? totp}) async { @@ -31,7 +36,7 @@ class AccountRepositoryImpl implements AccountRepository { @override Future> subscriptions() async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); final response = await _api.site(); @@ -39,15 +44,17 @@ class AccountRepositoryImpl implements AccountRepository { } @override - Future> media({int? page, int? limit}) async { - final l10n = GlobalContext.l10n; + Future media({int? page, int? limit}) async { + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); if (!_api.supportsMedia) { throw UnsupportedFeatureException('Media management', platformName: _api.platformName); } - return await _api.media(page: page, limit: limit); + final response = await _api.media(page: page, limit: limit); + final images = (response['images'] as List? ?? []).whereType>().toList(); + return AccountMedia(images: images); } @override @@ -65,7 +72,7 @@ class AccountRepositoryImpl implements AccountRepository { bool? showBotAccounts, List? discussionLanguages, }) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); await _api.saveUserSettings( @@ -86,7 +93,7 @@ class AccountRepositoryImpl implements AccountRepository { @override Future importSettings(String settings) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); if (!_api.supportsSettingsImportExport) { @@ -98,7 +105,7 @@ class AccountRepositoryImpl implements AccountRepository { @override Future exportSettings() async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); if (!_api.supportsSettingsImportExport) { @@ -110,7 +117,7 @@ class AccountRepositoryImpl implements AccountRepository { @override Future uploadImage(String filePath) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); final response = await _api.uploadImage(filePath); @@ -132,7 +139,7 @@ class AccountRepositoryImpl implements AccountRepository { @override Future deleteImage({required String file, required String token}) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); if (!_api.supportsMedia) { diff --git a/lib/src/features/account/domain/models/account_media.dart b/lib/src/features/account/domain/models/account_media.dart new file mode 100644 index 000000000..063613e78 --- /dev/null +++ b/lib/src/features/account/domain/models/account_media.dart @@ -0,0 +1,8 @@ +class AccountMedia { + /// The images uploaded by the user. + final List> images; + + const AccountMedia({required this.images}); + + bool get isEmpty => images.isEmpty; +} diff --git a/lib/src/features/account/domain/repositories/account_repository.dart b/lib/src/features/account/domain/repositories/account_repository.dart index f715b7982..7e121030e 100644 --- a/lib/src/features/account/domain/repositories/account_repository.dart +++ b/lib/src/features/account/domain/repositories/account_repository.dart @@ -1,6 +1,5 @@ -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/domain/models/account_media.dart'; //// Interface for an account repository abstract class AccountRepository { @@ -11,7 +10,7 @@ abstract class AccountRepository { Future> subscriptions(); /// Fetches the user's media. - Future> media({int? page, int? limit}); + Future media({int? page, int? limit}); /// Saves the user's settings. Future saveSettings({ diff --git a/lib/src/features/account/domain/utils/profile_community_utils.dart b/lib/src/features/account/domain/utils/profile_community_utils.dart new file mode 100644 index 000000000..50ac5f10a --- /dev/null +++ b/lib/src/features/account/domain/utils/profile_community_utils.dart @@ -0,0 +1,18 @@ +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/user/user.dart'; + +bool isSameUser({ + required ThunderUser user, + required Account account, +}) { + return user.id == account.userId; +} + +List filterFavorites({ + required List subscriptions, + required List favorites, +}) { + final favoriteCommunityIds = favorites.map((favorite) => favorite.communityId).toSet(); + return subscriptions.where((community) => favoriteCommunityIds.contains(community.id)).toList(); +} diff --git a/lib/src/features/account/presentation/bloc/profile_bloc.dart b/lib/src/features/account/presentation/bloc/profile_bloc.dart deleted file mode 100644 index 8d1103313..000000000 --- a/lib/src/features/account/presentation/bloc/profile_bloc.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:stream_transform/stream_transform.dart'; - -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; - -part 'profile_event.dart'; -part 'profile_state.dart'; - -const throttleDuration = Duration(milliseconds: 100); - -EventTransformer throttleDroppable(Duration duration) { - return (events, mapper) { - return droppable().call(events.throttle(duration), mapper); - }; -} - -class ProfileBloc extends Bloc { - Account account; - - InstanceRepository? instanceRepository; - AccountRepository? accountRepository; - UserRepository? userRepository; - - ProfileBloc({required this.account}) : super(ProfileState(account: account)) { - // This event should be triggered during the start of the app, or when there is a change in the active account - on(_initializeAuth, transformer: restartable()); - - /// This event should be triggered whenever the user removes a profile - /// This could be either a log out event, or a removal of a profile - on(_removeProfile); - - /// This event occurs whenever you switch to a different profile - on(_switchProfile); - - /// This event should be triggered whenever the user adds a profile. - /// This could be addition of a anonymous or non-anonymous account. - on(_addProfile); - - /// This event handles fetching a given profile's information. - /// For non-anonymous accounts, this includes user information, subscriptions, and favourites. - /// For anonymous accounts, this will not do anything. - on(_fetchProfileInformation, transformer: restartable()); - - /// This event should be triggered when the user cancels a login attempt - on(_cancelLoginAttempt); - - /// When any account setting synced with Lemmy is updated, re-fetch the instance information and preferences. - on(_fetchProfileSettings); - - /// Fetches the current profile's subscribed communities. This is only applicable for non-anonymous profiles. - on(_fetchProfileSubscriptions, transformer: restartable()); - - /// Fetches the current profile's favourited communities. This is only applicable for non-anonymous profiles. - on(_fetchProfileFavorites, transformer: restartable()); - } - - Future _initializeAuth(InitializeAuth event, Emitter emit) async { - final platformInfo = await detectPlatformFromNodeInfo(account.instance); - if (platformInfo == null) return emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, error: () => GlobalContext.l10n.unableToLoadInstance(account.instance))); - - PlatformVersionCache().set(account.instance, platformInfo['version'] ?? ''); - - // Initialize the repositories with the current account - instanceRepository = InstanceRepositoryImpl(account: account); - accountRepository = AccountRepositoryImpl(account: account); - userRepository = UserRepositoryImpl(account: account); - - // Check to see the instance settings (for checking if downvotes are enabled) - bool downvotesEnabled = true; - ThunderSiteResponse? siteResponse; - - try { - siteResponse = await instanceRepository!.info().timeout(const Duration(seconds: 15)); - downvotesEnabled = siteResponse.site.enableDownvotes ?? true; - } catch (e) { - return emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, error: () => getExceptionErrorMessage(e))); - } - - emit( - state.copyWith( - status: ProfileStatus.success, - account: () => account, - isLoggedIn: !account.anonymous, - downvotesEnabled: downvotesEnabled, - siteResponse: () => siteResponse!, - moderates: [], - subscriptions: [], - favorites: [], - ), - ); - - // Do not use add(BlocEvent) here, as we want all these to happen sequentially. - await _fetchProfileInformation(FetchProfileInformation(reload: false), emit); - await _fetchProfileSettings(FetchProfileSettings(), emit); - await _fetchProfileSubscriptions(FetchProfileSubscriptions(reload: false), emit); - - return; - } - - Future _addProfile(AddProfile event, Emitter emit) async { - try { - emit(state.copyWith(status: ProfileStatus.loading)); - - final instanceUrl = event.instance.replaceAll('https://', ''); - - // Detect the platform before attempting to log in - final platformInfo = await detectPlatformFromNodeInfo(instanceUrl) ?? {'platform': ThreadiversePlatform.lemmy}; - final platform = platformInfo['platform']; - - // Create a temporary Account to attempt to log in - Account tempAccount = Account(id: '', index: -1, instance: instanceUrl, platform: platform); - - // Create a temporary account repository to use for the login - final jwt = await AccountRepositoryImpl(account: tempAccount).login(username: event.username, password: event.password, totp: event.totp); - if (jwt == null) return emit(state.copyWith(status: ProfileStatus.failure)); - - // Create a temporary instance repository to use for the site information - tempAccount = Account(id: '', index: -1, jwt: jwt, instance: tempAccount.instance, platform: platform); - final siteResponse = await InstanceRepositoryImpl(account: tempAccount).info(); - - if (event.showContentWarning && siteResponse.site.contentWarning?.isNotEmpty == true) { - return emit(state.copyWith(status: ProfileStatus.contentWarning, contentWarning: () => siteResponse.site.contentWarning!)); - } - - // Create a new account in the database - Account? account = Account( - id: '', - username: siteResponse.myUser?.localUserView.person.name, - jwt: jwt, - instance: tempAccount.instance, - userId: siteResponse.myUser?.localUserView.person.id, - index: -1, - platform: platform, - ); - - account = await Account.insertAccount(account); - if (account == null) return emit(state.copyWith(status: ProfileStatus.failure)); - - // Set this account as the active account - this.account = account; - final prefs = UserPreferences.instance.preferences; - prefs.setString('active_profile_id', account.id); - - // Run the CheckAuth event to reset everything - return await _initializeAuth(InitializeAuth(), emit); - } catch (e) { - debugPrint('Error adding profile: ${e.toString()}'); - return emit(state.copyWith(status: ProfileStatus.failure, error: () => getExceptionErrorMessage(e))); - } - } - - Future _switchProfile(SwitchProfile event, Emitter emit) async { - emit(state.copyWith(status: ProfileStatus.loading, reload: event.reload)); - - Account? account = await Account.fetchAccount(event.accountId); - final prefs = UserPreferences.instance.preferences; - - if (account != null) { - // Set this account as the active account - prefs.setString('active_profile_id', event.accountId); - } else { - // Account was not found - this indicates is an anonymous account. Find the corresponding account - final anonymousAccounts = await Account.anonymousInstances(); - final anonymousAccount = anonymousAccounts.firstWhereOrNull((element) => element.instance == event.accountId); - account = anonymousAccount; - - await prefs.remove('active_profile_id'); - } - - if (account == null) { - return emit(state.copyWith(status: ProfileStatus.failure, error: () => AppLocalizations.of(GlobalContext.context)!.unexpectedError)); - } - - this.account = account; - add(InitializeAuth()); - } - - Future _removeProfile(RemoveProfile event, Emitter emit) async { - emit(state.copyWith(status: ProfileStatus.loading)); - - final prefs = UserPreferences.instance.preferences; - - final account = await fetchActiveProfile(); - await Account.deleteAccount(event.accountId); - - if (!account.anonymous && account.id == event.accountId) { - // The removed profile is the currently active profile. Remove this. - prefs.remove('active_profile_id'); - add(InitializeAuth()); - } else if (account.anonymous && account.instance == event.accountId) { - // The removed profile is the current anonymous profile. - add(InitializeAuth()); - } - - return emit(state.copyWith(status: ProfileStatus.success)); - } - - Future _cancelLoginAttempt(CancelLoginAttempt event, Emitter emit) async { - return emit(state.copyWith(status: ProfileStatus.failure, error: () => AppLocalizations.of(GlobalContext.context)!.loginAttemptCanceled)); - } - - /// Fetches the current profile's information, including the user's information and moderated communities. - /// This is only applicable for non-anonymous profiles. - Future _fetchProfileInformation(FetchProfileInformation event, Emitter emit) async { - final account = await fetchActiveProfile(); - if (account.anonymous) return emit(state.copyWith(status: ProfileStatus.success, reload: event.reload, user: null, subscriptions: [], favorites: [], moderates: [])); - - try { - emit(state.copyWith(status: ProfileStatus.loading, user: null, moderates: [], reload: event.reload)); - - final response = await userRepository!.getUser(username: account.username, sort: PostSortType.new_, page: 1); - final ThunderUser user = response!['user']; - final List moderates = response['moderates']; - - // This eliminates an issue which has plagued me a lot which is that there's a race condition - // with so many calls to GetAccountInformation, we can return success for the new and old account. - if (user.id == account.userId) { - return emit(state.copyWith(status: ProfileStatus.success, user: () => user, moderates: moderates, reload: event.reload)); - } else { - return emit(state.copyWith(status: ProfileStatus.success, user: null, moderates: [], reload: event.reload)); - } - } catch (e) { - debugPrint('Error fetching profile information: ${e.toString()}'); - emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, error: () => getExceptionErrorMessage(e), reload: event.reload)); - } - } - - /// Fetches the current profile's account settings. This is only applicable for non-anonymous profiles. - Future _fetchProfileSettings(FetchProfileSettings event, Emitter emit) async { - final account = await fetchActiveProfile(); - if (account.anonymous) return emit(state.copyWith(status: ProfileStatus.success)); - - try { - emit(state.copyWith(status: ProfileStatus.loading)); - - // Refresh the site information, which includes the user's settings - final response = await instanceRepository!.info(); - - return emit(state.copyWith(status: ProfileStatus.success, siteResponse: () => response)); - } catch (e) { - debugPrint('Error fetching profile settings: ${e.toString()}'); - emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, error: () => getExceptionErrorMessage(e), reload: event.reload)); - } - } - - /// Fetches the current profile's subscribed communities. This is only applicable for non-anonymous profiles. - Future _fetchProfileSubscriptions(FetchProfileSubscriptions event, Emitter emit) async { - final account = await fetchActiveProfile(); - if (account.anonymous) return emit(state.copyWith(status: ProfileStatus.success, reload: event.reload, subscriptions: [], favorites: [])); - - try { - emit(state.copyWith(status: ProfileStatus.loading, reload: event.reload)); - final subscriptions = await accountRepository!.subscriptions(); - emit(state.copyWith(status: ProfileStatus.success, reload: event.reload, subscriptions: subscriptions)); - - // Refresh the favourited communities as it might've changed. - add(FetchProfileFavorites(reload: event.reload)); - } catch (e) { - debugPrint('Error fetching profile subscriptions: ${e.toString()}'); - emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, reload: event.reload, error: () => getExceptionErrorMessage(e))); - } - } - - /// Fetches the current profile's favourited communities. This is only applicable for non-anonymous profiles. - Future _fetchProfileFavorites(FetchProfileFavorites event, Emitter emit) async { - final account = await fetchActiveProfile(); - if (account.anonymous) return emit(state.copyWith(status: ProfileStatus.success, reload: event.reload, favorites: [])); - - try { - emit(state.copyWith(status: ProfileStatus.loading, reload: event.reload)); - - final favorites = await Favorite.favorites(account.id); - final communities = state.subscriptions.where((community) => favorites.any((favorite) => favorite.communityId == community.id)).toList(); - - return emit(state.copyWith(status: ProfileStatus.success, reload: event.reload, favorites: communities)); - } catch (e) { - debugPrint('Error fetching profile favorites: ${e.toString()}'); - emit(state.copyWith(status: ProfileStatus.failureCheckingInstance, reload: event.reload, error: () => getExceptionErrorMessage(e))); - } - } -} diff --git a/lib/src/features/account/presentation/pages/account_page.dart b/lib/src/features/account/presentation/pages/account_page.dart index 901ef0689..b61bd1481 100644 --- a/lib/src/features/account/presentation/pages/account_page.dart +++ b/lib/src/features/account/presentation/pages/account_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; class AccountPage extends StatefulWidget { diff --git a/lib/src/features/account/presentation/pages/login_page.dart b/lib/src/features/account/presentation/pages/login_page.dart index af3df5225..52c38537b 100644 --- a/lib/src/features/account/presentation/pages/login_page.dart +++ b/lib/src/features/account/presentation/pages/login_page.dart @@ -8,17 +8,15 @@ import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/thunder_instance_info.dart'; -import 'package:thunder/instances.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/shared/utils/text_input_formatter.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/instance/data/constants/known_instances.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; class LoginPage extends StatefulWidget { /// The callback to pop the register page. @@ -76,7 +74,9 @@ class _LoginPageState extends State with SingleTickerProviderStateMix // Fetches the instance information and updates the icon _instanceTextEditingController.addListener(() async { - if (instanceTextDebounceTimer?.isActive == true) instanceTextDebounceTimer!.cancel(); + if (instanceTextDebounceTimer?.isActive == true) { + instanceTextDebounceTimer!.cancel(); + } instanceTextDebounceTimer = Timer(const Duration(milliseconds: 300), () async { if (_instanceTextEditingController.text.isEmpty) return; final instanceInfo = await getInstanceInfo(_instanceTextEditingController.text); @@ -286,7 +286,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix autocorrect: false, controller: controller, focusNode: focusNode, - inputFormatters: [LowerCaseTextFormatter()], decoration: InputDecoration( border: const OutlineInputBorder(), labelText: AppLocalizations.of(context)!.instance(1), @@ -302,7 +301,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix ), suggestionsCallback: (String pattern) { if (pattern.isNotEmpty != true) return []; - return instances.keys.where((instance) => instance.contains(pattern)).toList(); + return knownInstances.keys.where((instance) => instance.contains(pattern)).toList(); }, itemBuilder: (BuildContext context, String itemData) { return ListTile(title: Text(itemData)); diff --git a/lib/src/features/account/presentation/state/profile_bloc.dart b/lib/src/features/account/presentation/state/profile_bloc.dart new file mode 100644 index 000000000..d48cc3bf7 --- /dev/null +++ b/lib/src/features/account/presentation/state/profile_bloc.dart @@ -0,0 +1,488 @@ +import 'package:flutter/foundation.dart'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/features/account/domain/utils/profile_community_utils.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; + +part 'profile_event.dart'; +part 'profile_state.dart'; + +const throttleDuration = Duration(milliseconds: 100); + +EventTransformer throttleDroppable(Duration duration) { + return (events, mapper) { + return droppable().call(events.throttle(duration), mapper); + }; +} + +class ProfileBloc extends Bloc { + Account account; + + InstanceRepository? instanceRepository; + AccountRepository? accountRepository; + UserRepository? userRepository; + + final InstanceRepository Function(Account) _instanceRepositoryFactory; + final AccountRepository Function(Account) _accountRepositoryFactory; + final UserRepository Function(Account) _userRepositoryFactory; + final PlatformDetectionService _platformDetectionService; + final ActiveAccountProvider _activeAccountProvider; + final LocalizationService _localizationService; + final PreferencesStore _preferencesStore; + + ProfileBloc({ + required this.account, + required InstanceRepository Function(Account) instanceRepositoryFactory, + required AccountRepository Function(Account) accountRepositoryFactory, + required UserRepository Function(Account) userRepositoryFactory, + required PlatformDetectionService platformDetectionService, + required ActiveAccountProvider activeAccountProvider, + required LocalizationService localizationService, + required PreferencesStore preferencesStore, + }) : _instanceRepositoryFactory = instanceRepositoryFactory, + _accountRepositoryFactory = accountRepositoryFactory, + _userRepositoryFactory = userRepositoryFactory, + _platformDetectionService = platformDetectionService, + _activeAccountProvider = activeAccountProvider, + _localizationService = localizationService, + _preferencesStore = preferencesStore, + super(ProfileState(account: account)) { + // This event should be triggered during the start of the app, or when there is a change in the active account + on(_initializeAuth, transformer: restartable()); + + /// This event should be triggered whenever the user removes a profile + /// This could be either a log out event, or a removal of a profile + on(_removeProfile); + + /// This event occurs whenever you switch to a different profile + on(_switchProfile); + + /// This event should be triggered whenever the user adds a profile. + /// This could be addition of a anonymous or non-anonymous account. + on(_addProfile); + + /// This event handles fetching a given profile's information. + /// For non-anonymous accounts, this includes user information, subscriptions, and favourites. + /// For anonymous accounts, this will not do anything. + on(_fetchProfileInformation, transformer: restartable()); + + /// This event should be triggered when the user cancels a login attempt + on(_cancelLoginAttempt); + + /// When any account setting synced with Lemmy is updated, re-fetch the instance information and preferences. + on(_fetchProfileSettings); + + /// Fetches the current profile's subscribed communities. This is only applicable for non-anonymous profiles. + on(_fetchProfileSubscriptions, transformer: restartable()); + + /// Fetches the current profile's favourited communities. This is only applicable for non-anonymous profiles. + on(_fetchProfileFavorites, transformer: restartable()); + } + + Future _initializeAuth(InitializeAuth event, Emitter emit) async { + final platformInfo = await _platformDetectionService.detectPlatform(account.instance); + if (platformInfo == null) { + final message = _localizationService.l10n.unableToLoadInstance(account.instance); + return emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + error: () => message, + errorReason: () => AppErrorReason.network(message: message), + )); + } + + PlatformVersionCache().set(account.instance, platformInfo['version'] ?? ''); + + // Initialize the repositories with the current account + instanceRepository = _instanceRepositoryFactory(account); + accountRepository = _accountRepositoryFactory(account); + userRepository = _userRepositoryFactory(account); + + // Check to see the instance settings (for checking if downvotes are enabled) + bool downvotesEnabled = true; + ThunderSiteResponse? siteResponse; + + try { + siteResponse = await instanceRepository!.info().timeout(const Duration(seconds: 15)); + downvotesEnabled = siteResponse.site.enableDownvotes ?? true; + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + + emit( + state.copyWith( + status: ProfileStatus.success, + account: () => account, + isLoggedIn: !account.anonymous, + downvotesEnabled: downvotesEnabled, + siteResponse: () => siteResponse!, + moderates: [], + subscriptions: [], + favorites: [], + error: () => null, + errorReason: () => null, + ), + ); + + // Do not use add(BlocEvent) here, as we want all these to happen sequentially. + await _fetchProfileInformation(FetchProfileInformation(reload: false), emit); + await _fetchProfileSettings(FetchProfileSettings(), emit); + await _fetchProfileSubscriptions(FetchProfileSubscriptions(reload: false), emit); + + return; + } + + Future _addProfile(AddProfile event, Emitter emit) async { + try { + emit(state.copyWith( + status: ProfileStatus.loading, + error: () => null, + errorReason: () => null, + )); + + final instanceUrl = event.instance.replaceAll('https://', ''); + + // Detect the platform before attempting to log in + final platformInfo = await _platformDetectionService.detectPlatform(instanceUrl) ?? {'platform': ThreadiversePlatform.lemmy}; + final platform = platformInfo['platform']; + + // Create a temporary Account to attempt to log in + Account tempAccount = Account(id: '', index: -1, instance: instanceUrl, platform: platform); + + // Create a temporary account repository to use for the login + final jwt = await _accountRepositoryFactory(tempAccount).login(username: event.username, password: event.password, totp: event.totp); + if (jwt == null) { + final message = _localizationService.l10n.unexpectedError; + return emit(state.copyWith( + status: ProfileStatus.failure, + error: () => message, + errorReason: () => AppErrorReason.actionFailed(message: message), + )); + } + + // Create a temporary instance repository to use for the site information + tempAccount = Account(id: '', index: -1, jwt: jwt, instance: tempAccount.instance, platform: platform); + final siteResponse = await _instanceRepositoryFactory(tempAccount).info(); + + if (event.showContentWarning && siteResponse.site.contentWarning?.isNotEmpty == true) { + return emit(state.copyWith(status: ProfileStatus.contentWarning, contentWarning: () => siteResponse.site.contentWarning!)); + } + + // Create a new account in the database + Account? account = Account( + id: '', + username: siteResponse.myUser?.localUserView.person.name, + jwt: jwt, + instance: tempAccount.instance, + userId: siteResponse.myUser?.localUserView.person.id, + index: -1, + platform: platform, + ); + + account = await Account.insertAccount(account); + if (account == null) { + final message = _localizationService.l10n.unexpectedError; + return emit(state.copyWith( + status: ProfileStatus.failure, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + + // Set this account as the active account + this.account = account; + await _preferencesStore.setString('active_profile_id', account.id); + + // Run the CheckAuth event to reset everything + return await _initializeAuth(InitializeAuth(), emit); + } catch (e) { + debugPrint('Error adding profile: ${e.toString()}'); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: ProfileStatus.failure, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + } + + Future _switchProfile(SwitchProfile event, Emitter emit) async { + emit(state.copyWith( + status: ProfileStatus.loading, + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + + Account? account = await Account.fetchAccount(event.accountId); + + if (account != null) { + // Set this account as the active account + await _preferencesStore.setString('active_profile_id', event.accountId); + } else { + // Account was not found - this indicates is an anonymous account. Find the corresponding account + final anonymousAccounts = await Account.anonymousInstances(); + final anonymousAccount = anonymousAccounts.firstWhereOrNull((element) => element.instance == event.accountId); + account = anonymousAccount; + + await _preferencesStore.remove('active_profile_id'); + } + + if (account == null) { + final message = _localizationService.l10n.unexpectedError; + return emit(state.copyWith( + status: ProfileStatus.failure, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + + this.account = account; + add(InitializeAuth()); + } + + Future _removeProfile(RemoveProfile event, Emitter emit) async { + emit(state.copyWith( + status: ProfileStatus.loading, + error: () => null, + errorReason: () => null, + )); + + final account = await _activeAccountProvider.getActiveAccount(); + await Account.deleteAccount(event.accountId); + + if (!account.anonymous && account.id == event.accountId) { + // The removed profile is the currently active profile. Remove this. + await _preferencesStore.remove('active_profile_id'); + add(InitializeAuth()); + } else if (account.anonymous && account.instance == event.accountId) { + // The removed profile is the current anonymous profile. + add(InitializeAuth()); + } + + return emit(state.copyWith( + status: ProfileStatus.success, + error: () => null, + errorReason: () => null, + )); + } + + Future _cancelLoginAttempt(CancelLoginAttempt event, Emitter emit) async { + final message = _localizationService.l10n.loginAttemptCanceled; + return emit(state.copyWith( + status: ProfileStatus.failure, + error: () => message, + errorReason: () => AppErrorReason.actionFailed(message: message), + )); + } + + /// Fetches the current profile's information, including the user's information and moderated communities. + /// This is only applicable for non-anonymous profiles. + Future _fetchProfileInformation(FetchProfileInformation event, Emitter emit) async { + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: ProfileStatus.success, + reload: event.reload, + user: null, + subscriptions: [], + favorites: [], + moderates: [], + error: () => null, + errorReason: () => null, + )); + } + + try { + emit(state.copyWith( + status: ProfileStatus.loading, + user: null, + moderates: [], + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + + final response = await userRepository!.getUser(username: account.username, sort: PostSortType.new_, page: 1); + final ThunderUser user = response!['user']; + final List moderates = response['moderates']; + + // This eliminates an issue which has plagued me a lot which is that there's a race condition + // with so many calls to GetAccountInformation, we can return success for the new and old account. + if (isSameUser(user: user, account: account)) { + return emit(state.copyWith( + status: ProfileStatus.success, + user: () => user, + moderates: moderates, + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + } + + return emit(state.copyWith( + status: ProfileStatus.success, + user: null, + moderates: [], + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + } catch (e) { + debugPrint('Error fetching profile information: ${e.toString()}'); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + reload: event.reload, + )); + } + } + + /// Fetches the current profile's account settings. This is only applicable for non-anonymous profiles. + Future _fetchProfileSettings(FetchProfileSettings event, Emitter emit) async { + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: ProfileStatus.success, + error: () => null, + errorReason: () => null, + )); + } + + try { + emit(state.copyWith( + status: ProfileStatus.loading, + error: () => null, + errorReason: () => null, + )); + + // Refresh the site information, which includes the user's settings + final response = await instanceRepository!.info(); + + return emit(state.copyWith( + status: ProfileStatus.success, + siteResponse: () => response, + error: () => null, + errorReason: () => null, + )); + } catch (e) { + debugPrint('Error fetching profile settings: ${e.toString()}'); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + reload: event.reload, + )); + } + } + + /// Fetches the current profile's subscribed communities. This is only applicable for non-anonymous profiles. + Future _fetchProfileSubscriptions(FetchProfileSubscriptions event, Emitter emit) async { + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: ProfileStatus.success, + reload: event.reload, + subscriptions: [], + favorites: [], + error: () => null, + errorReason: () => null, + )); + } + + try { + emit(state.copyWith( + status: ProfileStatus.loading, + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + final subscriptions = await accountRepository!.subscriptions(); + emit(state.copyWith( + status: ProfileStatus.success, + reload: event.reload, + subscriptions: subscriptions, + error: () => null, + errorReason: () => null, + )); + + // Refresh the favourited communities as it might've changed. + add(FetchProfileFavorites(reload: event.reload)); + } catch (e) { + debugPrint('Error fetching profile subscriptions: ${e.toString()}'); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + reload: event.reload, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + } + + /// Fetches the current profile's favourited communities. This is only applicable for non-anonymous profiles. + Future _fetchProfileFavorites(FetchProfileFavorites event, Emitter emit) async { + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: ProfileStatus.success, + reload: event.reload, + favorites: [], + error: () => null, + errorReason: () => null, + )); + } + + try { + emit(state.copyWith( + status: ProfileStatus.loading, + reload: event.reload, + error: () => null, + errorReason: () => null, + )); + + final favorites = await Favorite.favorites(account.id); + final communities = filterFavorites( + subscriptions: state.subscriptions, + favorites: favorites, + ); + + return emit(state.copyWith( + status: ProfileStatus.success, + reload: event.reload, + favorites: communities, + error: () => null, + errorReason: () => null, + )); + } catch (e) { + debugPrint('Error fetching profile favorites: ${e.toString()}'); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: ProfileStatus.failureCheckingInstance, + reload: event.reload, + error: () => message, + errorReason: () => AppErrorReason.unexpected(message: message), + )); + } + } +} diff --git a/lib/src/features/account/presentation/bloc/profile_event.dart b/lib/src/features/account/presentation/state/profile_event.dart similarity index 91% rename from lib/src/features/account/presentation/bloc/profile_event.dart rename to lib/src/features/account/presentation/state/profile_event.dart index f95d5c91e..81a6aa843 100644 --- a/lib/src/features/account/presentation/bloc/profile_event.dart +++ b/lib/src/features/account/presentation/state/profile_event.dart @@ -24,6 +24,9 @@ class AddProfile extends ProfileEvent { final bool showContentWarning; const AddProfile({required this.username, required this.password, required this.instance, this.totp = "", this.showContentWarning = true}); + + @override + List get props => [reload, username, password, instance, totp, showContentWarning]; } /// Cancels a login attempt by emitting the `failure` state. @@ -42,6 +45,9 @@ class RemoveProfile extends ProfileEvent { final String accountId; const RemoveProfile({required this.accountId}); + + @override + List get props => [reload, accountId]; } /// TODO: Consolidate logic to have removing accounts (for both authenticated and anonymous accounts) placed here @@ -55,10 +61,11 @@ class RemoveAllAccounts extends ProfileEvent { /// TODO: Consolidate logic so that anonymous accounts are also handled here. class SwitchProfile extends ProfileEvent { final String accountId; - @override - final bool reload; - const SwitchProfile({required this.accountId, this.reload = true}); + const SwitchProfile({required this.accountId, super.reload = true}); + + @override + List get props => [reload, accountId]; } /// The [FetchProfileSettings] event should be triggered whenever the any user Lemmy account setting is updated. diff --git a/lib/src/features/account/presentation/bloc/profile_state.dart b/lib/src/features/account/presentation/state/profile_state.dart similarity index 84% rename from lib/src/features/account/presentation/bloc/profile_state.dart rename to lib/src/features/account/presentation/state/profile_state.dart index 893b1b46a..1f4a3070f 100644 --- a/lib/src/features/account/presentation/bloc/profile_state.dart +++ b/lib/src/features/account/presentation/state/profile_state.dart @@ -40,6 +40,9 @@ class ProfileState extends Equatable { /// The error message if the account failed to load final String? error; + /// Typed error details for deterministic failure handling in bloc tests. + final AppErrorReason? errorReason; + const ProfileState({ this.status = ProfileStatus.initial, this.isLoggedIn = false, @@ -52,6 +55,7 @@ class ProfileState extends Equatable { this.moderates = const [], this.user, this.error, + this.errorReason, this.reload = true, }); @@ -60,14 +64,15 @@ class ProfileState extends Equatable { bool? isLoggedIn, ValueGetter? account, bool? downvotesEnabled, - ValueGetter? siteResponse, - ValueGetter? contentWarning, - ValueGetter? user, + ValueGetter? siteResponse, + ValueGetter? contentWarning, + ValueGetter? user, List? subscriptions, List? favorites, List? moderates, bool? reload, - ValueGetter? error, + ValueGetter? error, + ValueGetter? errorReason, }) { return ProfileState( status: status ?? this.status, @@ -82,6 +87,7 @@ class ProfileState extends Equatable { moderates: moderates ?? this.moderates, reload: reload ?? this.reload, error: error != null ? error() : this.error, + errorReason: errorReason != null ? errorReason() : this.errorReason, ); } @@ -92,13 +98,13 @@ class ProfileState extends Equatable { account, downvotesEnabled, siteResponse, - reload, - status, + contentWarning, user, subscriptions, favorites, moderates, reload, error, + errorReason, ]; } diff --git a/lib/src/features/account/presentation/utils/profiles.dart b/lib/src/features/account/presentation/utils/profile_utils.dart similarity index 90% rename from lib/src/features/account/presentation/utils/profiles.dart rename to lib/src/features/account/presentation/utils/profile_utils.dart index 848b6095f..a68b2039b 100644 --- a/lib/src/features/account/presentation/utils/profiles.dart +++ b/lib/src/features/account/presentation/utils/profile_utils.dart @@ -3,12 +3,11 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/foundation/config/config.dart'; /// Fetches the currently active profile. This includes logged in and anonymous accounts. /// diff --git a/lib/src/features/account/presentation/utils/utils.dart b/lib/src/features/account/presentation/utils/utils.dart deleted file mode 100644 index 38034e2b7..000000000 --- a/lib/src/features/account/presentation/utils/utils.dart +++ /dev/null @@ -1 +0,0 @@ -export 'profiles.dart'; diff --git a/lib/src/features/account/presentation/widgets/account_page_app_bar.dart b/lib/src/features/account/presentation/widgets/account_page_app_bar.dart index 3305f7421..203db30d1 100644 --- a/lib/src/features/account/presentation/widgets/account_page_app_bar.dart +++ b/lib/src/features/account/presentation/widgets/account_page_app_bar.dart @@ -5,14 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; /// Holds the app bar for the account page class AccountPageAppBar extends StatefulWidget { diff --git a/lib/src/features/account/presentation/widgets/account_placeholder.dart b/lib/src/features/account/presentation/widgets/account_placeholder.dart index 53286589b..b2fbfae82 100644 --- a/lib/src/features/account/presentation/widgets/account_placeholder.dart +++ b/lib/src/features/account/presentation/widgets/account_placeholder.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// A widget that displays a placeholder when no user account is logged in. /// @@ -15,8 +14,9 @@ class AccountPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - final instance = context.select((bloc) => bloc.state.currentAnonymousInstance) ?? ''; + final l10n = GlobalContext.l10n; + + final account = context.select((bloc) => bloc.state.account); return Center( child: Padding( @@ -27,7 +27,7 @@ class AccountPlaceholder extends StatelessWidget { children: [ Icon(Icons.people_rounded, size: 100.0, color: theme.dividerColor), const SizedBox(height: 16.0), - Text(l10n.browsingAnonymously(instance), textAlign: TextAlign.center), + Text(l10n.browsingAnonymously(account.instance), textAlign: TextAlign.center), Text(l10n.addAccountToSeeProfile, textAlign: TextAlign.center), const SizedBox(height: 24.0), ElevatedButton( diff --git a/lib/src/features/account/presentation/widgets/profile_modal_body.dart b/lib/src/features/account/presentation/widgets/profile_modal_body.dart index e0f96e0c1..7446cbb96 100644 --- a/lib/src/features/account/presentation/widgets/profile_modal_body.dart +++ b/lib/src/features/account/presentation/widgets/profile_modal_body.dart @@ -7,17 +7,15 @@ import 'package:dart_ping/dart_ping.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:thunder/src/app/routing/swipeable_page_route.dart'; +import 'package:thunder/src/app/shell/navigation/swipeable_page_route.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; /// Creates a widget which can display a list of accounts and anonymous instances. @@ -783,7 +781,7 @@ class _ProfileSelectState extends State { try { final unread = await NotificationRepositoryImpl(account: account.account).unreadNotificationsCount(); - int? totalUnreadCount = unread['replies'] + unread['mentions'] + unread['private_messages']; + int? totalUnreadCount = unread.total; if (totalUnreadCount == 0) totalUnreadCount = null; setState(() => account.totalUnreadCount = totalUnreadCount); } catch (e) { diff --git a/lib/src/features/comment/api.dart b/lib/src/features/comment/api.dart new file mode 100644 index 000000000..0726ef935 --- /dev/null +++ b/lib/src/features/comment/api.dart @@ -0,0 +1 @@ +export 'comment.dart'; diff --git a/lib/src/features/comment/application/state/comment_preferences_cubit.dart b/lib/src/features/comment/application/state/comment_preferences_cubit.dart new file mode 100644 index 000000000..609759205 --- /dev/null +++ b/lib/src/features/comment/application/state/comment_preferences_cubit.dart @@ -0,0 +1,49 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/config.dart'; + +part 'comment_preferences_state.dart'; + +/// Cubit for managing comment-related preferences +class CommentPreferencesCubit extends Cubit { + CommentPreferencesCubit({required PreferencesStore preferences}) + : _preferences = preferences, + super(const CommentPreferencesState()) { + load(); + } + + final PreferencesStore _preferences; + + /// Loads comment preferences from UserPreferences + void load() { + final defaultCommentSortType = _preferences.getLocalSetting(LocalSettings.defaultCommentSortType) ?? DEFAULT_COMMENT_SORT_TYPE.name; + final collapseParentCommentOnGesture = _preferences.getLocalSetting(LocalSettings.collapseParentCommentBodyOnGesture) ?? true; + final showCommentButtonActions = _preferences.getLocalSetting(LocalSettings.showCommentActionButtons) ?? false; + final commentShowUserInstance = _preferences.getLocalSetting(LocalSettings.commentShowUserInstance) ?? false; + final commentShowUserAvatar = _preferences.getLocalSetting(LocalSettings.commentShowUserAvatar) ?? false; + final combineCommentScores = _preferences.getLocalSetting(LocalSettings.combineCommentScores) ?? false; + final nestedCommentIndicatorStyle = _preferences.getLocalSetting(LocalSettings.nestedCommentIndicatorStyle) ?? DEFAULT_NESTED_COMMENT_INDICATOR_STYLE.name; + final nestedCommentIndicatorColor = _preferences.getLocalSetting(LocalSettings.nestedCommentIndicatorColor) ?? DEFAULT_NESTED_COMMENT_INDICATOR_COLOR.name; + + emit( + CommentPreferencesState( + defaultCommentSortType: CommentSortType.values.byName(defaultCommentSortType), + collapseParentCommentOnGesture: collapseParentCommentOnGesture, + showCommentButtonActions: showCommentButtonActions, + commentShowUserInstance: commentShowUserInstance, + commentShowUserAvatar: commentShowUserAvatar, + combineCommentScores: combineCommentScores, + nestedCommentIndicatorStyle: NestedCommentIndicatorStyle.values.byName(nestedCommentIndicatorStyle), + nestedCommentIndicatorColor: NestedCommentIndicatorColor.values.byName(nestedCommentIndicatorColor), + ), + ); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/comment_preferences_cubit/comment_preferences_state.dart b/lib/src/features/comment/application/state/comment_preferences_state.dart similarity index 100% rename from lib/src/app/cubits/comment_preferences_cubit/comment_preferences_state.dart rename to lib/src/features/comment/application/state/comment_preferences_state.dart diff --git a/lib/src/features/comment/comment.dart b/lib/src/features/comment/comment.dart index 36145445a..152221427 100644 --- a/lib/src/features/comment/comment.dart +++ b/lib/src/features/comment/comment.dart @@ -1,10 +1,12 @@ export 'presentation/widgets/widgets.dart'; export 'presentation/pages/pages.dart'; -export 'presentation/utils/comment.dart'; -export 'presentation/bloc/create_comment_cubit.dart'; +export 'application/state/comment_preferences_cubit.dart'; +export 'presentation/utils/comment_utils.dart'; +export 'presentation/models/comment_list.dart'; +export 'presentation/state/create_comment_cubit.dart'; export 'data/models/comment_node.dart'; export 'domain/enums/comment_action.dart'; -export 'data/models/thunder_comment.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; export 'domain/models/comment_page.dart'; export 'domain/repositories/comment_repository.dart'; export 'data/repositories/comment_repository_impl.dart'; diff --git a/lib/src/features/comment/data/repositories/comment_repository_impl.dart b/lib/src/features/comment/data/repositories/comment_repository_impl.dart index 8bb814fae..7f26d6415 100644 --- a/lib/src/features/comment/data/repositories/comment_repository_impl.dart +++ b/lib/src/features/comment/data/repositories/comment_repository_impl.dart @@ -2,15 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/user/user.dart'; /// Implementation of [CommentRepository] class CommentRepositoryImpl implements CommentRepository { diff --git a/lib/src/features/comment/domain/models/comment_page.dart b/lib/src/features/comment/domain/models/comment_page.dart index 4b1c43ec7..66548a125 100644 --- a/lib/src/features/comment/domain/models/comment_page.dart +++ b/lib/src/features/comment/domain/models/comment_page.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import 'package:thunder/src/features/comment/data/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; /// Represents a page of comments returned from an API request. class CommentPage extends Equatable { diff --git a/lib/src/features/comment/domain/repositories/comment_repository.dart b/lib/src/features/comment/domain/repositories/comment_repository.dart index 8dd8fb09f..6e8727d99 100644 --- a/lib/src/features/comment/domain/repositories/comment_repository.dart +++ b/lib/src/features/comment/domain/repositories/comment_repository.dart @@ -1,6 +1,5 @@ -import 'package:thunder/src/core/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; /// Interface for a comment repository abstract class CommentRepository { diff --git a/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart b/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart deleted file mode 100644 index db9d6680f..000000000 --- a/lib/src/features/comment/presentation/bloc/create_comment_cubit.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -part 'create_comment_state.dart'; - -class CreateCommentCubit extends Cubit { - /// The current account. - Account account; - - /// The repository for the comment. - late CommentRepository repository; - - CreateCommentCubit({required this.account}) : super(const CreateCommentState(status: CreateCommentStatus.initial)) { - repository = CommentRepositoryImpl(account: account); - } - - Future clearMessage() async { - emit(state.copyWith(status: CreateCommentStatus.initial, message: null)); - } - - Future switchAccount(Account newAccount) async { - account = newAccount; - repository = CommentRepositoryImpl(account: account); - - debugPrint('Account switched to ${account.username}@${account.instance}'); - emit(state.copyWith(status: CreateCommentStatus.success)); - } - - Future uploadImages(List imageFiles) async { - final l10n = GlobalContext.l10n; - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - List urls = []; - - emit(state.copyWith(status: CreateCommentStatus.imageUploadInProgress)); - - try { - final accountRepository = AccountRepositoryImpl(account: account); - - for (String imageFile in imageFiles) { - final url = await accountRepository.uploadImage(imageFile); - urls.add(url); - - // Add a delay between each upload to avoid possible rate limiting - await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); - } - - emit(state.copyWith(status: CreateCommentStatus.imageUploadSuccess, imageUrls: urls)); - } catch (e) { - emit(state.copyWith(status: CreateCommentStatus.imageUploadFailure, message: e.toString())); - } - } - - /// Creates or edits a comment. When successful, it emits the newly created/updated comment - /// in the form of a [ThunderComment] and returns the newly created comment id. - Future createOrEditComment({int? postId, int? parentCommentId, required String content, int? commentIdBeingEdited, int? languageId}) async { - assert(!(postId == null && commentIdBeingEdited == null)); - - try { - emit(state.copyWith(status: CreateCommentStatus.submitting)); - - ThunderComment comment; - - if (commentIdBeingEdited != null) { - comment = await repository.edit(commentId: commentIdBeingEdited, content: content, languageId: languageId); - } else { - comment = await repository.create(postId: postId!, content: content, parentId: parentCommentId, languageId: languageId); - } - - emit(state.copyWith(status: CreateCommentStatus.success, comment: comment)); - return comment.id; - } catch (e) { - emit(state.copyWith(status: CreateCommentStatus.error, message: getExceptionErrorMessage(e))); - } - - return null; - } -} diff --git a/lib/src/features/comment/presentation/bloc/create_comment_state.dart b/lib/src/features/comment/presentation/bloc/create_comment_state.dart deleted file mode 100644 index 6d56ef8fb..000000000 --- a/lib/src/features/comment/presentation/bloc/create_comment_state.dart +++ /dev/null @@ -1,51 +0,0 @@ -part of 'create_comment_cubit.dart'; - -enum CreateCommentStatus { - initial, - loading, - submitting, - error, - success, - imageUploadInProgress, - imageUploadSuccess, - imageUploadFailure, - unknown, -} - -class CreateCommentState extends Equatable { - const CreateCommentState({ - this.status = CreateCommentStatus.initial, - this.comment, - this.imageUrls, - this.message, - }); - - /// The status of the current cubit - final CreateCommentStatus status; - - /// The result of the created or edited comment - final ThunderComment? comment; - - /// The urls of the uploaded images - final List? imageUrls; - - /// The info or error message to be displayed as a snackbar - final String? message; - - CreateCommentState copyWith({ - required CreateCommentStatus status, - ThunderComment? comment, - List? imageUrls, - String? message, - }) { - return CreateCommentState( - status: status, - comment: comment ?? this.comment, - imageUrls: imageUrls ?? this.imageUrls, - message: message ?? this.message, - ); - } - - @override - List get props => [status, comment, imageUrls, message]; -} diff --git a/lib/src/features/comment/presentation/models/comment_list.dart b/lib/src/features/comment/presentation/models/comment_list.dart new file mode 100644 index 000000000..8ba2847db --- /dev/null +++ b/lib/src/features/comment/presentation/models/comment_list.dart @@ -0,0 +1,51 @@ +import 'package:thunder/src/features/comment/data/models/comment_node.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/features/comment/presentation/utils/comment_utils.dart'; + +/// Deterministic representation of API and UI comment ordering. +/// +/// `api` represents the merged API order (first-seen order, latest values). +/// `uiComments` is the DFS-flattened tree order shown in the UI. +class CommentList { + /// The comments returned by the API in API order for this page. + final List api; + + /// The tree of comments in the UI. + final CommentNode tree; + + const CommentList._({ + required this.api, + required this.tree, + }); + + factory CommentList.empty() { + return CommentList._( + api: const [], + tree: CommentNode(comment: null, replies: []), + ); + } + + factory CommentList.fromApi(List comments) { + final mergedComments = mergeComments(const [], comments); + return CommentList._( + api: mergedComments, + tree: buildCommentTree(mergedComments), + ); + } + + CommentList merge(List incomingComments) { + if (incomingComments.isEmpty) return this; + + final mergedComments = mergeComments(api, incomingComments); + return CommentList._( + api: mergedComments, + tree: buildCommentTree(mergedComments), + ); + } + + List get comments => tree.flatten(); + + List get apiCommentIds => List.unmodifiable(api.map((comment) => comment.id)); + + List get uiCommentIds => List.unmodifiable(comments.map((node) => node.comment?.id).whereType()); +} diff --git a/lib/src/features/comment/presentation/pages/create_comment_page.dart b/lib/src/features/comment/presentation/pages/create_comment_page.dart index 5d22cf8e7..421a24ba8 100644 --- a/lib/src/features/comment/presentation/pages/create_comment_page.dart +++ b/lib/src/features/comment/presentation/pages/create_comment_page.dart @@ -11,22 +11,22 @@ import 'package:markdown_editor/markdown_editor.dart'; // Project imports import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/drafts/drafts.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/shared/language_selector.dart'; -import 'package:thunder/src/shared/snackbar.dart'; + import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show selectImagesToUpload, showSnackbar; class CreateCommentPage extends StatefulWidget { /// [post] is passed in when replying to a post. [comment] and [parentComment] must be null if this is passed in. @@ -243,7 +243,7 @@ class _CreateCommentPageState extends State { return PopScope( onPopInvokedWithResult: (didPop, result) {}, child: BlocProvider( - create: (context) => CreateCommentCubit(account: account!), + create: (context) => createCreateCommentCubit(account!), child: BlocConsumer( listener: (ctx, state) { if (state.status == CreateCommentStatus.success && state.comment != null) { @@ -461,10 +461,14 @@ class _CreateCommentPageState extends State { }, imageIsLoading: state.status == CreateCommentStatus.imageUploadInProgress, customImageButtonAction: () async { - if (state.status == CreateCommentStatus.imageUploadInProgress) return; + if (state.status == CreateCommentStatus.imageUploadInProgress) { + return; + } List imagesPath = await selectImagesToUpload(allowMultiple: true); - if (context.mounted) context.read().uploadImages(imagesPath); + if (context.mounted) { + context.read().uploadImages(imagesPath); + } }, getAlternativeSelection: () => replyViewSelection, ), @@ -480,7 +484,9 @@ class _CreateCommentPageState extends State { } setState(() => showPreview = !showPreview); - if (!showPreview && wasKeyboardVisible) _bodyFocusNode.requestFocus(); + if (!showPreview && wasKeyboardVisible) { + _bodyFocusNode.requestFocus(); + } }, icon: Icon( showPreview ? Icons.visibility_off_rounded : Icons.visibility, diff --git a/lib/src/features/comment/presentation/state/create_comment_cubit.dart b/lib/src/features/comment/presentation/state/create_comment_cubit.dart new file mode 100644 index 000000000..550c80899 --- /dev/null +++ b/lib/src/features/comment/presentation/state/create_comment_cubit.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; + +part 'create_comment_state.dart'; + +class CreateCommentCubit extends Cubit { + /// The current account. + Account account; + + /// The repository for the comment. + late CommentRepository repository; + + /// Factories for creating repositories when the account changes. + final CommentRepository Function(Account) _commentRepositoryFactory; + final AccountRepository Function(Account) _accountRepositoryFactory; + final LocalizationService _localizationService; + + CreateCommentCubit({ + required this.account, + required CommentRepository Function(Account) commentRepositoryFactory, + required AccountRepository Function(Account) accountRepositoryFactory, + required LocalizationService localizationService, + }) : _commentRepositoryFactory = commentRepositoryFactory, + _accountRepositoryFactory = accountRepositoryFactory, + _localizationService = localizationService, + super(const CreateCommentState(status: CreateCommentStatus.initial)) { + repository = _commentRepositoryFactory(account); + } + + Future clearMessage() async { + emit(state.copyWith( + status: CreateCommentStatus.initial, + message: null, + errorReason: null, + )); + } + + Future switchAccount(Account newAccount) async { + account = newAccount; + repository = _commentRepositoryFactory(account); + + debugPrint('Account switched to ${account.username}@${account.instance}'); + emit(state.copyWith( + status: CreateCommentStatus.success, + message: null, + errorReason: null, + )); + } + + Future uploadImages(List imageFiles) async { + final l10n = _localizationService.l10n; + if (account.anonymous) { + emit(state.copyWith( + status: CreateCommentStatus.imageUploadFailure, + message: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + return; + } + + List urls = []; + + emit(state.copyWith( + status: CreateCommentStatus.imageUploadInProgress, + message: null, + errorReason: null, + )); + + try { + final accountRepository = _accountRepositoryFactory(account); + + for (String imageFile in imageFiles) { + final url = await accountRepository.uploadImage(imageFile); + urls.add(url); + + // Add a delay between each upload to avoid possible rate limiting + await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); + } + + emit(state.copyWith( + status: CreateCommentStatus.imageUploadSuccess, + imageUrls: urls, + message: null, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: CreateCommentStatus.imageUploadFailure, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + } + + /// Creates or edits a comment. When successful, it emits the newly created/updated comment + /// in the form of a [ThunderComment] and returns the newly created comment id. + Future createOrEditComment({int? postId, int? parentCommentId, required String content, int? commentIdBeingEdited, int? languageId}) async { + assert(!(postId == null && commentIdBeingEdited == null)); + + try { + emit(state.copyWith( + status: CreateCommentStatus.submitting, + message: null, + errorReason: null, + )); + + ThunderComment comment; + + if (commentIdBeingEdited != null) { + comment = await repository.edit(commentId: commentIdBeingEdited, content: content, languageId: languageId); + } else { + comment = await repository.create(postId: postId!, content: content, parentId: parentCommentId, languageId: languageId); + } + + emit(state.copyWith( + status: CreateCommentStatus.success, + comment: comment, + message: null, + errorReason: null, + )); + return comment.id; + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: CreateCommentStatus.error, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + + return null; + } +} diff --git a/lib/src/features/comment/presentation/state/create_comment_state.dart b/lib/src/features/comment/presentation/state/create_comment_state.dart new file mode 100644 index 000000000..7f9227d30 --- /dev/null +++ b/lib/src/features/comment/presentation/state/create_comment_state.dart @@ -0,0 +1,59 @@ +part of 'create_comment_cubit.dart'; + +const _createCommentStateUnset = Object(); + +enum CreateCommentStatus { + initial, + loading, + submitting, + error, + success, + imageUploadInProgress, + imageUploadSuccess, + imageUploadFailure, + unknown, +} + +class CreateCommentState extends Equatable { + const CreateCommentState({ + this.status = CreateCommentStatus.initial, + this.comment, + this.imageUrls, + this.message, + this.errorReason, + }); + + /// The status of the current cubit + final CreateCommentStatus status; + + /// The result of the created or edited comment + final ThunderComment? comment; + + /// The urls of the uploaded images + final List? imageUrls; + + /// The info or error message to be displayed as a snackbar + final String? message; + + /// Typed error details for deterministic failure handling. + final AppErrorReason? errorReason; + + CreateCommentState copyWith({ + required CreateCommentStatus status, + Object? comment = _createCommentStateUnset, + Object? imageUrls = _createCommentStateUnset, + Object? message = _createCommentStateUnset, + Object? errorReason = _createCommentStateUnset, + }) { + return CreateCommentState( + status: status, + comment: identical(comment, _createCommentStateUnset) ? this.comment : comment as ThunderComment?, + imageUrls: identical(imageUrls, _createCommentStateUnset) ? this.imageUrls : imageUrls as List?, + message: identical(message, _createCommentStateUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _createCommentStateUnset) ? this.errorReason : errorReason as AppErrorReason?, + ); + } + + @override + List get props => [status, comment, imageUrls, message, errorReason]; +} diff --git a/lib/src/features/comment/presentation/utils/comment.dart b/lib/src/features/comment/presentation/utils/comment_utils.dart similarity index 80% rename from lib/src/features/comment/presentation/utils/comment.dart rename to lib/src/features/comment/presentation/utils/comment_utils.dart index 156ac98cd..e9112baa9 100644 --- a/lib/src/features/comment/presentation/utils/comment.dart +++ b/lib/src/features/comment/presentation/utils/comment_utils.dart @@ -1,57 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; - -/// Deterministic representation of API and UI comment ordering. -/// -/// `api` represents the merged API order (first-seen order, latest values). -/// `uiComments` is the DFS-flattened tree order shown in the UI. -class CommentList { - /// The comments returned by the API in API order for this page. - final List api; - - /// The tree of comments in the UI. - final CommentNode tree; - - const CommentList._({ - required this.api, - required this.tree, - }); - - factory CommentList.empty() { - return CommentList._( - api: const [], - tree: CommentNode(comment: null, replies: []), - ); - } - - factory CommentList.fromApi(List comments) { - final mergedComments = mergeComments(const [], comments); - return CommentList._( - api: mergedComments, - tree: buildCommentTree(mergedComments), - ); - } - - CommentList merge(List incomingComments) { - if (incomingComments.isEmpty) return this; - - final mergedComments = mergeComments(api, incomingComments); - return CommentList._( - api: mergedComments, - tree: buildCommentTree(mergedComments), - ); - } - - List get comments => tree.flatten(); - - List get apiCommentIds => List.unmodifiable(api.map((comment) => comment.id)); - - List get uiCommentIds => List.unmodifiable(comments.map((node) => node.comment?.id).whereType()); -} +import 'package:thunder/src/features/comment/data/models/comment_node.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/features/comment/data/repositories/comment_repository_impl.dart'; +import 'package:thunder/src/features/comment/domain/enums/comment_action.dart'; // Optimistically updates a comment ThunderComment optimisticallyVoteComment(ThunderComment comment, int voteType) { diff --git a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_action_bottom_sheet.dart b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_action_bottom_sheet.dart index 987a8af9b..479035506 100644 --- a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_action_bottom_sheet.dart +++ b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_action_bottom_sheet.dart @@ -8,13 +8,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/models/thunder_my_user.dart'; -import 'package:thunder/src/shared/profile_site_info_cache.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/account/data/cache/profile_site_info_cache.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/shared/share/share_action_bottom_sheet.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; /// Programatically show the comment action bottom sheet void showCommentActionBottomModalSheet( diff --git a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart index 6190178b2..3dfc1fd18 100644 --- a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart +++ b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/comment_comment_action_bottom_sheet.dart @@ -5,15 +5,11 @@ import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/snackbar.dart'; import 'package:thunder/src/shared/widgets/text/selectable_text_modal.dart'; -import 'package:thunder/src/app/widgets/thunder_icons.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; /// Defines the actions that can be taken on a comment enum CommentBottomSheetAction { diff --git a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart index 3ed34e138..f2d436a9f 100644 --- a/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart +++ b/lib/src/features/comment/presentation/widgets/comment_bottom_sheet/general_comment_action_bottom_sheet.dart @@ -4,13 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/shared/multi_picker_item.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, MultiPickerItem, PickerItemData; /// Defines the general actions that can be taken on a comment enum GeneralCommentAction { diff --git a/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart b/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart index 428917db5..b6b534bda 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/additional_comment_card.dart @@ -2,13 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; class AdditionalCommentCard extends StatefulWidget { /// The function to call when tapped diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart index cb27e912b..e6212fcea 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card.dart @@ -5,14 +5,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/comment/api.dart'; import 'package:thunder/src/shared/widgets/multi_action_dismissible.dart'; -import 'package:thunder/src/shared/utils/swipe.dart'; +import 'package:thunder/src/shared/gestures/swipe_utils.dart'; /// A widget that displays a given comment. /// diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart index 4bc07f9e0..d9682929e 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_background.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; +import 'package:thunder/src/features/settings/api.dart'; /// A widget that displays the proper background when a swipe action is performed on a comment. class CommentCardBackground extends StatelessWidget { diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_button_actions.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_button_actions.dart index 36fea6228..4eb21239f 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_button_actions.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_button_actions.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Displays a row of actions that can be performed on a comment. /// diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header.dart index d9e9f57b4..4f622c154 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header.dart @@ -7,12 +7,11 @@ import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart'; import 'package:thunder/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart'; import 'package:thunder/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; import 'package:thunder/src/shared/widgets/chips/user_chip.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/action_color.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; /// A widget that displays the header of a comment, including user information, score, and metadata diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart index 3151cd2b4..23f3b4e9e 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_date.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; /// A widget that displays the timestamp for a comment, with special styling for recent comments. /// diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart index 91cd56f72..10dcd0093 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_reply_count.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; /// A widget that displays the number of replies to a comment. /// diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart index 03438f485..89a152916 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_card_header/comment_card_header_score.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; /// A widget that displays voting scores for comments with upvote/downvote indicators /// diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart index 34631f523..8c3d89245 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_content.dart @@ -5,16 +5,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/conditional_parent_widget.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; import 'package:thunder/src/shared/reply_to_preview_actions.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' show ConditionalParentWidget; /// A widget that displays the content of a comment. class CommentContent extends StatefulWidget { diff --git a/lib/src/features/comment/presentation/widgets/comment_card/comment_depth_indicator.dart b/lib/src/features/comment/presentation/widgets/comment_card/comment_depth_indicator.dart index da8139c0d..1a441778e 100644 --- a/lib/src/features/comment/presentation/widgets/comment_card/comment_depth_indicator.dart +++ b/lib/src/features/comment/presentation/widgets/comment_card/comment_depth_indicator.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; class CommentDepthIndicatorDecoration extends Decoration { /// The [BuildContext] used to access the theme and colors for rendering. diff --git a/lib/src/features/comment/presentation/widgets/comment_list_entry.dart b/lib/src/features/comment/presentation/widgets/comment_list_entry.dart index 1822ae04a..feb6af815 100644 --- a/lib/src/features/comment/presentation/widgets/comment_list_entry.dart +++ b/lib/src/features/comment/presentation/widgets/comment_list_entry.dart @@ -2,10 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/comment_reference.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; /// A widget that can display a single comment entry for use within a list (e.g., search page, instance explorer) class CommentListEntry extends StatelessWidget { @@ -19,7 +20,7 @@ class CommentListEntry extends StatelessWidget { final account = context.select((bloc) => bloc.state.account); return BlocProvider( - create: (BuildContext context) => PostBloc(account: account), + create: (BuildContext context) => createPostBloc(account), child: CommentReference(comment: comment), ); } diff --git a/lib/src/shared/comment_reference.dart b/lib/src/features/comment/presentation/widgets/comment_reference.dart similarity index 87% rename from lib/src/shared/comment_reference.dart rename to lib/src/features/comment/presentation/widgets/comment_reference.dart index b9a719001..32d16eb88 100644 --- a/lib/src/shared/comment_reference.dart +++ b/lib/src/features/comment/presentation/widgets/comment_reference.dart @@ -2,18 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// A widget that displays a reference to a comment with additional post and community information. /// diff --git a/lib/src/features/community/api.dart b/lib/src/features/community/api.dart new file mode 100644 index 000000000..695a8d3bd --- /dev/null +++ b/lib/src/features/community/api.dart @@ -0,0 +1 @@ +export 'community.dart'; diff --git a/lib/src/features/community/community.dart b/lib/src/features/community/community.dart index 603ec7a22..041bd1046 100644 --- a/lib/src/features/community/community.dart +++ b/lib/src/features/community/community.dart @@ -1,7 +1,8 @@ export 'domain/enums/community_action.dart'; -export 'data/models/thunder_community.dart'; +export 'domain/models/community_details.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; export 'data/repositories/community_repository_impl.dart'; -export 'presentation/bloc/anonymous_subscriptions_bloc.dart'; +export 'presentation/state/anonymous_subscriptions_bloc.dart'; export 'presentation/widgets/community_drawer.dart'; export 'presentation/widgets/community_header/community_header.dart'; export 'presentation/widgets/community_header/community_header_actions.dart'; @@ -12,6 +13,6 @@ export 'presentation/widgets/post_card_actions.dart'; export 'presentation/widgets/post_card_metadata.dart'; export 'presentation/widgets/post_card_view_comfortable.dart'; export 'presentation/widgets/post_card_view_compact.dart'; -export 'data/datasources/favorite_local_data_source.dart'; -export 'data/datasources/anonymous_subscriptions_local.dart'; -export 'data/datasources/anonymous_subscriptions_local_data_source.dart'; +export 'data/data_sources/favorite_local_data_source.dart'; +export 'data/data_sources/anonymous_subscriptions_local.dart'; +export 'data/data_sources/anonymous_subscriptions_local_data_source.dart'; diff --git a/lib/src/features/community/data/datasources/anonymous_subscriptions_local.dart b/lib/src/features/community/data/data_sources/anonymous_subscriptions_local.dart similarity index 94% rename from lib/src/features/community/data/datasources/anonymous_subscriptions_local.dart rename to lib/src/features/community/data/data_sources/anonymous_subscriptions_local.dart index 6e69aa850..ea8b5a3d1 100644 --- a/lib/src/features/community/data/datasources/anonymous_subscriptions_local.dart +++ b/lib/src/features/community/data/data_sources/anonymous_subscriptions_local.dart @@ -2,8 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/main.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; class LocalCommunity { final int id; diff --git a/lib/src/features/community/data/datasources/anonymous_subscriptions_local_data_source.dart b/lib/src/features/community/data/data_sources/anonymous_subscriptions_local_data_source.dart similarity index 94% rename from lib/src/features/community/data/datasources/anonymous_subscriptions_local_data_source.dart rename to lib/src/features/community/data/data_sources/anonymous_subscriptions_local_data_source.dart index deaae2cc3..1374930fa 100644 --- a/lib/src/features/community/data/datasources/anonymous_subscriptions_local_data_source.dart +++ b/lib/src/features/community/data/data_sources/anonymous_subscriptions_local_data_source.dart @@ -1,5 +1,5 @@ import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; Future> getSubscriptions() async { List subscribedCommunities = await AnonymousSubscriptions.getSubscribedCommunities(); diff --git a/lib/src/features/community/data/datasources/favorite_local_data_source.dart b/lib/src/features/community/data/data_sources/favorite_local_data_source.dart similarity index 96% rename from lib/src/features/community/data/datasources/favorite_local_data_source.dart rename to lib/src/features/community/data/data_sources/favorite_local_data_source.dart index ff52eb0ce..8ccdad9bb 100644 --- a/lib/src/features/community/data/datasources/favorite_local_data_source.dart +++ b/lib/src/features/community/data/data_sources/favorite_local_data_source.dart @@ -1,8 +1,7 @@ import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/main.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; class Favorite { final String id; diff --git a/lib/src/features/community/data/repositories/community_repository_impl.dart b/lib/src/features/community/data/repositories/community_repository_impl.dart index 3833718a4..3ba2a2aa6 100644 --- a/lib/src/features/community/data/repositories/community_repository_impl.dart +++ b/lib/src/features/community/data/repositories/community_repository_impl.dart @@ -2,19 +2,15 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/user/user.dart'; /// Interface for a community repository abstract class CommunityRepository { /// Fetches community information by ID or name - Future> getCommunity({int? id, String? name}); + Future getCommunity({int? id, String? name}); /// Lists trending communities Future> trending(); @@ -43,25 +39,33 @@ class CommunityRepositoryImpl implements CommunityRepository { /// The API client to use for the repository final ThunderApiClient _api; + /// The localization service to use for user-facing errors + final LocalizationService _localizationService; + /// Creates a new CommunityRepositoryImpl. /// /// An optional [api] client can be provided for testing. - CommunityRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); + CommunityRepositoryImpl({ + required this.account, + ThunderApiClient? api, + LocalizationService localizationService = const GlobalContextLocalizationService(), + }) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode), + _localizationService = localizationService; @override - Future> getCommunity({int? id, String? name}) async { + Future getCommunity({int? id, String? name}) async { final response = await _api.getCommunity(id: id, name: name); - return { - 'community': response.community, - 'site': response.site, - 'moderators': response.moderators, - 'discussion_languages': response.discussionLanguages, - }; + return CommunityDetails( + community: response.community, + site: response.site, + moderators: response.moderators, + discussionLanguages: response.discussionLanguages, + ); } @override Future subscribe(int communityId, bool follow) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.subscribeToCommunity(communityId: communityId, follow: follow); @@ -69,7 +73,7 @@ class CommunityRepositoryImpl implements CommunityRepository { @override Future block(int communityId, bool block) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.blockCommunity(communityId: communityId, block: block); @@ -84,7 +88,7 @@ class CommunityRepositoryImpl implements CommunityRepository { int? expires, bool removeData = false, }) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.banUserFromCommunity( @@ -103,7 +107,7 @@ class CommunityRepositoryImpl implements CommunityRepository { required bool added, required int communityId, }) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.addModerator( diff --git a/lib/src/features/community/domain/models/community_details.dart b/lib/src/features/community/domain/models/community_details.dart new file mode 100644 index 000000000..18eaca647 --- /dev/null +++ b/lib/src/features/community/domain/models/community_details.dart @@ -0,0 +1,15 @@ +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +class CommunityDetails { + final ThunderCommunity community; + final ThunderSite? site; + final List moderators; + final List discussionLanguages; + + const CommunityDetails({ + required this.community, + required this.site, + required this.moderators, + required this.discussionLanguages, + }); +} diff --git a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart b/lib/src/features/community/presentation/state/anonymous_subscriptions_bloc.dart similarity index 72% rename from lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart rename to lib/src/features/community/presentation/state/anonymous_subscriptions_bloc.dart index ac0725670..249105f6b 100644 --- a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_bloc.dart +++ b/lib/src/features/community/presentation/state/anonymous_subscriptions_bloc.dart @@ -5,9 +5,10 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:stream_transform/stream_transform.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; part 'anonymous_subscriptions_event.dart'; part 'anonymous_subscriptions_state.dart'; @@ -37,7 +38,12 @@ class AnonymousSubscriptionsBloc extends Bloc e.actorId)), + message: null, + errorReason: null, ), ); } catch (e) { - emit(state.copyWith(status: AnonymousSubscriptionsStatus.failure, message: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: AnonymousSubscriptionsStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected(message: message), + )); } } Future _getSubscribedCommunities(GetSubscribedCommunitiesEvent event, Emitter emit) async { - emit(state.copyWith(status: AnonymousSubscriptionsStatus.loading)); + emit(state.copyWith( + status: AnonymousSubscriptionsStatus.loading, + message: null, + errorReason: null, + )); try { List subscribedCommunities = await getSubscriptions(); @@ -79,10 +96,17 @@ class AnonymousSubscriptionsBloc extends Bloc communities; const AddSubscriptionsEvent({required this.communities}); + + @override + List get props => [communities]; } /// Deletes a given set of subscriptions by their actor ids @@ -24,4 +27,7 @@ class DeleteSubscriptionsEvent extends AnonymousSubscriptionsEvent { final Set urls; const DeleteSubscriptionsEvent({required this.urls}); + + @override + List get props => [urls]; } diff --git a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_state.dart b/lib/src/features/community/presentation/state/anonymous_subscriptions_state.dart similarity index 67% rename from lib/src/features/community/presentation/bloc/anonymous_subscriptions_state.dart rename to lib/src/features/community/presentation/state/anonymous_subscriptions_state.dart index 7ded76a4f..d672501cb 100644 --- a/lib/src/features/community/presentation/bloc/anonymous_subscriptions_state.dart +++ b/lib/src/features/community/presentation/state/anonymous_subscriptions_state.dart @@ -2,12 +2,15 @@ part of 'anonymous_subscriptions_bloc.dart'; enum AnonymousSubscriptionsStatus { initial, loading, refreshing, success, empty, failure } +const _anonymousSubscriptionsUnset = Object(); + class AnonymousSubscriptionsState extends Equatable { const AnonymousSubscriptionsState({ this.status = AnonymousSubscriptionsStatus.initial, this.subscriptions = const [], this.urls = const {}, this.message, + this.errorReason, }); /// Status of the bloc @@ -16,6 +19,9 @@ class AnonymousSubscriptionsState extends Equatable { /// Error message final String? message; + /// Typed error reason for deterministic failure handling. + final AppErrorReason? errorReason; + /// List of subscribed communities final List subscriptions; @@ -26,16 +32,18 @@ class AnonymousSubscriptionsState extends Equatable { AnonymousSubscriptionsStatus? status, List? subscriptions, Set? urls, - String? message, + Object? message = _anonymousSubscriptionsUnset, + Object? errorReason = _anonymousSubscriptionsUnset, }) { return AnonymousSubscriptionsState( status: status ?? this.status, urls: urls ?? this.urls, subscriptions: subscriptions ?? this.subscriptions, - message: message ?? this.message, + message: identical(message, _anonymousSubscriptionsUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _anonymousSubscriptionsUnset) ? this.errorReason : errorReason as AppErrorReason?, ); } @override - List get props => [status, subscriptions, urls, message]; + List get props => [status, subscriptions, urls, message, errorReason]; } diff --git a/lib/src/features/community/presentation/widgets/community_drawer.dart b/lib/src/features/community/presentation/widgets/community_drawer.dart index 465a97686..a9b0516d5 100644 --- a/lib/src/features/community/presentation/widgets/community_drawer.dart +++ b/lib/src/features/community/presentation/widgets/community_drawer.dart @@ -8,16 +8,15 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/enums.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; class CommunityDrawer extends StatefulWidget { const CommunityDrawer({super.key, this.navigateToAccount}); @@ -143,7 +142,6 @@ class UserDrawerItem extends StatelessWidget { ProfileState profileState = context.watch().state; bool isLoggedIn = context.watch().state.isLoggedIn; - String? anonymousInstance = context.select((bloc) => bloc.state.currentAnonymousInstance); return Container( color: theme.colorScheme.surfaceContainerLow, @@ -180,7 +178,7 @@ class UserDrawerItem extends StatelessWidget { ], ), Text( - isLoggedIn ? profileState.account.instance : anonymousInstance ?? '', + profileState.account.instance, style: theme.textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header.dart b/lib/src/features/community/presentation/widgets/community_header/community_header.dart index 849e0be9d..c61bc02f9 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header.dart @@ -1,15 +1,13 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/shared/images/image_preview.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/icon_text.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ImagePreview; /// A widget that displays a community's header information and related actions. /// diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart index bae610825..75d6da387 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart @@ -3,21 +3,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; -import 'package:thunder/src/shared/snackbar.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip, showSnackbar; /// A widget that displays relevant actions for a community in a scrollable chip list. class CommunityHeaderActions extends StatelessWidget { @@ -184,8 +179,12 @@ class _SubscriptionActionChip extends StatelessWidget { HapticFeedback.mediumImpact(); final updatedCommunity = await handleSubscription(context, community); - if (community.subscribed != updatedCommunity?.subscribed) context.read().add(FetchProfileSubscriptions()); - if (updatedCommunity != null) context.read().add(FeedCommunityUpdatedEvent(community: updatedCommunity)); + if (community.subscribed != updatedCommunity?.subscribed) { + context.read().add(FetchProfileSubscriptions()); + } + if (updatedCommunity != null) { + context.read().add(FeedCommunityUpdatedEvent(community: updatedCommunity)); + } }, ); } diff --git a/lib/src/features/community/presentation/widgets/community_information.dart b/lib/src/features/community/presentation/widgets/community_information.dart index 8f864de35..0e821b92f 100644 --- a/lib/src/features/community/presentation/widgets/community_information.dart +++ b/lib/src/features/community/presentation/widgets/community_information.dart @@ -3,16 +3,16 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; /// A widget that displays information about a community. class CommunityInformation extends StatelessWidget { diff --git a/lib/src/features/community/presentation/widgets/community_list_entry.dart b/lib/src/features/community/presentation/widgets/community_list_entry.dart index 515c1ba60..23e6ca54b 100644 --- a/lib/src/features/community/presentation/widgets/community_list_entry.dart +++ b/lib/src/features/community/presentation/widgets/community_list_entry.dart @@ -1,164 +1,165 @@ -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; - -/// A widget that displays a given community's information. This widget is generally used in a list. -class CommunityListEntry extends StatefulWidget { - /// The community to display. - final ThunderCommunity community; - - /// Whether to indicate that the community is a favorite. - final bool indicateFavorites; - - /// The account to use for resolving the community to a different instance - final Account? resolutionAccount; - - const CommunityListEntry({ - super.key, - required this.community, - this.indicateFavorites = true, - this.resolutionAccount, - }); - - @override - State createState() => _CommunityListEntryState(); -} - -class _CommunityListEntryState extends State { - void onSubscribe(bool subscribed, bool isUserLoggedIn) async { - if (isUserLoggedIn) { - final account = context.read().state.account; - final repository = CommunityRepositoryImpl(account: account); - - await repository.subscribe(widget.community.id, !subscribed); - context.read().add(const FetchProfileSubscriptions()); - } else { - if (!subscribed) { - context.read().add(AddSubscriptionsEvent(communities: {widget.community})); - context.read().add(GetSubscribedCommunitiesEvent()); - } else { - context.read().add(DeleteSubscriptionsEvent(urls: {widget.community.actorId})); - } - } - } - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - final isUserLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); - - ThunderCommunity community; - - // Fetch the community from the user's subscriptions or anonymous subscriptions if possible - if (isUserLoggedIn) { - final subscriptions = context.select>((bloc) => bloc.state.subscriptions); - community = subscriptions.firstWhereOrNull((c) => c.actorId == widget.community.actorId) ?? widget.community; - } else { - final subscriptions = context.select>((bloc) => bloc.state.subscriptions); - community = subscriptions.firstWhereOrNull((c) => c.actorId == widget.community.actorId) ?? widget.community; - } - - final favourited = context.select((bloc) => bloc.state.favorites.any((c) => c.actorId == community.actorId)); - - String subscriptionButtonLabel = switch (community.subscribed) { - SubscriptionStatus.notSubscribed => l10n.subscribe, - SubscriptionStatus.pending => l10n.unsubscribePending, - SubscriptionStatus.subscribed => l10n.unsubscribe, - _ => '', - }; - - return Tooltip( - excludeFromSemantics: true, - message: '${widget.community.title}\n${generateCommunityFullName( - context, - widget.community.name, - widget.community.title, - fetchInstanceNameFromUrl(widget.community.actorId), - )}', - preferBelow: false, - child: ListTile( - leading: CommunityAvatar(community: widget.community, radius: 25), - title: Text(widget.community.title, overflow: TextOverflow.ellipsis), - subtitle: Row( - children: [ - Flexible( - child: CommunityFullNameWidget( - context, - widget.community.name, - widget.community.title, - fetchInstanceNameFromUrl(widget.community.actorId), - // Override because we're showing display name above - useDisplayName: false, - ), - ), - if (widget.community.subscribers != null) ...[ - Text( - ' · ${formatLongNumber(widget.community.subscribers!)}', - semanticsLabel: l10n.countSubscribers(widget.community.subscribers!), - ), - const SizedBox(width: 4), - const Icon(Icons.people_rounded, size: 16.0), - ], - if (widget.indicateFavorites && favourited) ...const [ - Text(' · '), - Icon(Icons.star_rounded, size: 15), - ] - ], - ), - trailing: widget.resolutionAccount == null - ? IconButton( - onPressed: () { - onSubscribe(community.subscribed != SubscriptionStatus.notSubscribed, isUserLoggedIn); - showSnackbar(community.subscribed == SubscriptionStatus.notSubscribed ? l10n.addedCommunityToSubscriptions : l10n.removedCommunityFromSubscriptions); - }, - icon: Semantics( - label: subscriptionButtonLabel, - child: Icon( - switch (community.subscribed) { - SubscriptionStatus.notSubscribed => Icons.add_circle_outline_rounded, - SubscriptionStatus.pending => Icons.pending_outlined, - SubscriptionStatus.subscribed => Icons.remove_circle_outline_rounded, - _ => null, - }, - ), - ), - tooltip: subscriptionButtonLabel, - visualDensity: VisualDensity.compact, - ) - : null, - onTap: () async { - int? communityId = widget.community.id; - - if (widget.resolutionAccount != null) { - try { - final response = await SearchRepositoryImpl(account: widget.resolutionAccount!).resolve(query: widget.community.actorId); - - communityId = response['community']?.id; - } catch (e) { - // If we can't find it, then we'll get a standard error message about communityId being un-navigable - } - } - - if (context.mounted) { - navigateToFeedPage(context, feedType: FeedType.community, communityId: communityId); - } - }, - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; + +/// A widget that displays a given community's information. This widget is generally used in a list. +class CommunityListEntry extends StatefulWidget { + /// The community to display. + final ThunderCommunity community; + + /// Whether to indicate that the community is a favorite. + final bool indicateFavorites; + + /// The account to use for resolving the community to a different instance + final Account? resolutionAccount; + + const CommunityListEntry({ + super.key, + required this.community, + this.indicateFavorites = true, + this.resolutionAccount, + }); + + @override + State createState() => _CommunityListEntryState(); +} + +class _CommunityListEntryState extends State { + void onSubscribe(bool subscribed, bool isUserLoggedIn) async { + if (isUserLoggedIn) { + final account = context.read().state.account; + final repository = CommunityRepositoryImpl(account: account); + + await repository.subscribe(widget.community.id, !subscribed); + context.read().add(const FetchProfileSubscriptions()); + } else { + if (!subscribed) { + context.read().add(AddSubscriptionsEvent(communities: {widget.community})); + context.read().add(GetSubscribedCommunitiesEvent()); + } else { + context.read().add(DeleteSubscriptionsEvent(urls: {widget.community.actorId})); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final isUserLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); + + ThunderCommunity community; + + // Fetch the community from the user's subscriptions or anonymous subscriptions if possible + if (isUserLoggedIn) { + final subscriptions = context.select>((bloc) => bloc.state.subscriptions); + community = subscriptions.firstWhereOrNull((c) => c.actorId == widget.community.actorId) ?? widget.community; + } else { + final subscriptions = context.select>((bloc) => bloc.state.subscriptions); + community = subscriptions.firstWhereOrNull((c) => c.actorId == widget.community.actorId) ?? widget.community; + } + + final favourited = context.select((bloc) => bloc.state.favorites.any((c) => c.actorId == community.actorId)); + + String subscriptionButtonLabel = switch (community.subscribed) { + SubscriptionStatus.notSubscribed => l10n.subscribe, + SubscriptionStatus.pending => l10n.unsubscribePending, + SubscriptionStatus.subscribed => l10n.unsubscribe, + _ => '', + }; + + return Tooltip( + excludeFromSemantics: true, + message: '${widget.community.title}\n${generateCommunityFullName( + context, + widget.community.name, + widget.community.title, + fetchInstanceNameFromUrl(widget.community.actorId), + )}', + preferBelow: false, + child: ListTile( + leading: CommunityAvatar(community: widget.community, radius: 25), + title: Text(widget.community.title, overflow: TextOverflow.ellipsis), + subtitle: Row( + children: [ + Flexible( + child: CommunityFullNameWidget( + context, + widget.community.name, + widget.community.title, + fetchInstanceNameFromUrl(widget.community.actorId), + // Override because we're showing display name above + useDisplayName: false, + ), + ), + if (widget.community.subscribers != null) ...[ + Text( + ' · ${formatLongNumber(widget.community.subscribers!)}', + semanticsLabel: l10n.countSubscribers(widget.community.subscribers!), + ), + const SizedBox(width: 4), + const Icon(Icons.people_rounded, size: 16.0), + ], + if (widget.indicateFavorites && favourited) ...const [ + Text(' · '), + Icon(Icons.star_rounded, size: 15), + ] + ], + ), + trailing: widget.resolutionAccount == null + ? IconButton( + onPressed: () { + onSubscribe(community.subscribed != SubscriptionStatus.notSubscribed, isUserLoggedIn); + showSnackbar(community.subscribed == SubscriptionStatus.notSubscribed ? l10n.addedCommunityToSubscriptions : l10n.removedCommunityFromSubscriptions); + }, + icon: Semantics( + label: subscriptionButtonLabel, + child: Icon( + switch (community.subscribed) { + SubscriptionStatus.notSubscribed => Icons.add_circle_outline_rounded, + SubscriptionStatus.pending => Icons.pending_outlined, + SubscriptionStatus.subscribed => Icons.remove_circle_outline_rounded, + _ => null, + }, + ), + ), + tooltip: subscriptionButtonLabel, + visualDensity: VisualDensity.compact, + ) + : null, + onTap: () async { + int? communityId = widget.community.id; + + if (widget.resolutionAccount != null) { + try { + final response = await SearchRepositoryImpl(account: widget.resolutionAccount!).resolve(query: widget.community.actorId); + + communityId = response.community?.id; + } catch (e) { + // If we can't find it, then we'll get a standard error message about communityId being un-navigable + } + } + + if (context.mounted) { + navigateToFeedPage(context, feedType: FeedType.community, communityId: communityId); + } + }, + ), + ); + } +} diff --git a/lib/src/features/community/presentation/widgets/post_card.dart b/lib/src/features/community/presentation/widgets/post_card.dart index 7715d58c0..963bde923 100644 --- a/lib/src/features/community/presentation/widgets/post_card.dart +++ b/lib/src/features/community/presentation/widgets/post_card.dart @@ -1,23 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/widgets/multi_action_dismissible.dart'; -import 'package:thunder/src/shared/utils/swipe.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; +import 'package:thunder/src/shared/gestures/swipe_utils.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; class PostCard extends StatefulWidget { /// The associated post information to display in the card. @@ -228,8 +227,12 @@ class _PostCardState extends State { context, widget.post, onAction: ({postAction, userAction, communityAction, post}) { - if (postAction == null && userAction == null && communityAction == null) return; - if (post != null) context.read().add(FeedItemUpdatedEvent(post: post)); + if (postAction == null && userAction == null && communityAction == null) { + return; + } + if (post != null) { + context.read().add(FeedItemUpdatedEvent(post: post)); + } if (postAction == PostAction.hide) { context.read().add(FeedDismissHiddenPostEvent(postId: post!.id)); diff --git a/lib/src/features/community/presentation/widgets/post_card_actions.dart b/lib/src/features/community/presentation/widgets/post_card_actions.dart index 5f2354f36..de4be80ae 100644 --- a/lib/src/features/community/presentation/widgets/post_card_actions.dart +++ b/lib/src/features/community/presentation/widgets/post_card_actions.dart @@ -5,8 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; /// Represents the actions that can be performed on a post when using the card view. class PostCardActions extends StatelessWidget { diff --git a/lib/src/features/community/presentation/widgets/post_card_metadata.dart b/lib/src/features/community/presentation/widgets/post_card_metadata.dart index 2d8272ac3..64d1f72e1 100644 --- a/lib/src/features/community/presentation/widgets/post_card_metadata.dart +++ b/lib/src/features/community/presentation/widgets/post_card_metadata.dart @@ -3,28 +3,20 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/icon_text.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; /// Contains metadata related to a given post. This is generally displayed as part of the post card. /// diff --git a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart index c81ed1e39..c91fdd801 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart @@ -3,19 +3,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; +import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/media/media_view.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_view.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Displays a card view of a post card. This view is used in the feed related pages. class PostCardViewComfortable extends StatelessWidget { diff --git a/lib/src/features/community/presentation/widgets/post_card_view_compact.dart b/lib/src/features/community/presentation/widgets/post_card_view_compact.dart index ab4fbf9be..3521f0f62 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_compact.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_compact.dart @@ -3,13 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/widgets/media/compact_thumbnail_preview.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/compact_thumbnail_preview.dart'; +import 'package:thunder/src/features/feed/api.dart'; /// Displays a compact view of a post card. This view is used in the feed related pages. class PostCardViewCompact extends StatelessWidget { diff --git a/lib/src/features/content/presentation/widgets/common_markdown_body.dart b/lib/src/features/content/presentation/widgets/common_markdown_body.dart new file mode 100644 index 000000000..ff6eb9e1f --- /dev/null +++ b/lib/src/features/content/presentation/widgets/common_markdown_body.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' as content; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/shared/links/widgets/link_bottom_sheet.dart'; + +/// App adapter for package-generic markdown renderer. +class CommonMarkdownBody extends StatelessWidget { + /// The markdown content body. + final String body; + + /// Whether to hide the markdown content. + final bool hidden; + + /// Whether the markdown content is NSFW. + final bool nsfw; + + /// Indicates if the given markdown is a comment. + final bool? isComment; + + /// The maximum width of the image. + final double? imageMaxWidth; + + /// Optional action handlers that decouple media and navigation behavior. + final content.ContentActionHandlers handlers; + + const CommonMarkdownBody({ + super.key, + required this.body, + this.hidden = false, + this.nsfw = false, + this.isComment, + this.imageMaxWidth, + this.handlers = const content.ContentActionHandlers(), + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final commentFontSizeScale = context.select( + (cubit) => cubit.state.commentFontSizeScale, + ); + final contentFontSizeScale = context.select( + (cubit) => cubit.state.contentFontSizeScale, + ); + + final effectiveHandlers = content.ContentActionHandlers( + onOpenLink: handlers.onOpenLink ?? + (context, url) { + handleLink(context, url: url); + }, + onLongPressLink: handlers.onLongPressLink ?? + (context, text, url) { + if (url != null) { + handleLinkLongPress(context, text, url); + } + }, + onOpenImage: handlers.onOpenImage, + onOpenVideo: handlers.onOpenVideo ?? + (context, url) { + handleVideoLink(context, url: url); + }, + onMarkRead: handlers.onMarkRead, + ); + + return content.CommonMarkdownBody( + body: body, + hidden: hidden, + nsfw: nsfw, + isComment: isComment, + imageMaxWidth: imageMaxWidth, + handlers: effectiveHandlers, + commentTextScaleFactor: commentFontSizeScale.textScaleFactor, + contentTextScaleFactor: contentFontSizeScale.textScaleFactor, + retryTooltip: l10n.retry, + nsfwWarningLabel: l10n.nsfwWarning, + ); + } +} diff --git a/lib/src/shared/widgets/media/compact_thumbnail_preview.dart b/lib/src/features/content/presentation/widgets/media/compact_thumbnail_preview.dart similarity index 67% rename from lib/src/shared/widgets/media/compact_thumbnail_preview.dart rename to lib/src/features/content/presentation/widgets/media/compact_thumbnail_preview.dart index eb6e49776..9fad9e1c7 100644 --- a/lib/src/shared/widgets/media/compact_thumbnail_preview.dart +++ b/lib/src/features/content/presentation/widgets/media/compact_thumbnail_preview.dart @@ -2,26 +2,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/shared/widgets/media/media_type_badge.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/shared/widgets/media/media_view.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_view.dart'; -/// Displays a compact thumbnail preview for a post card. +/// App adapter for compact media thumbnail previews. class CompactThumbnailPreview extends StatelessWidget { - /// The media to display in the thumbnail + /// The media to display in the thumbnail. final Media media; - /// Whether or not to dim the thumbnail. This is used when a post has been read. - /// This value can be overridden for special cases (e.g., viewing user account) + /// Whether or not to dim the thumbnail. final bool dim; - /// The post associated with the media + /// The post associated with the media. final int? postId; - /// The callback function to navigate to the post + /// The callback function to navigate to the post. final void Function()? navigateToPost; const CompactThumbnailPreview({ @@ -34,8 +32,12 @@ class CompactThumbnailPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final hideNsfwPreviews = context.select((cubit) => cubit.state.hideNsfwPreviews); - final markPostReadOnMediaView = context.select((cubit) => cubit.state.markPostReadOnMediaView); + final hideNsfwPreviews = context.select( + (cubit) => cubit.state.hideNsfwPreviews, + ); + final markPostReadOnMediaView = context.select( + (cubit) => cubit.state.markPostReadOnMediaView, + ); final isUserLoggedIn = context.select((ProfileBloc bloc) => bloc.state.isLoggedIn); diff --git a/lib/src/features/content/presentation/widgets/media/media_utils.dart b/lib/src/features/content/presentation/widgets/media/media_utils.dart new file mode 100644 index 000000000..b6511b7a5 --- /dev/null +++ b/lib/src/features/content/presentation/widgets/media/media_utils.dart @@ -0,0 +1,47 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart' as content; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; + +export 'package:thunder/packages/ui/ui.dart' + show + fetchProxyImageUrl, + getScaledMediaSize, + isImageProxyUrl, + isImageUriSvg, + isImageUrl, + isImageUrlSvg, + isVideoUrl, + processAvifImage, + processImage, + processImageDimensions, + retrieveImageDimensions, + selectImagesToUpload, + showVideoPlayer; + +/// App adapter for content package image viewer opening. +void showImageViewer( + BuildContext context, { + String? url, + Uint8List? bytes, + int? postId, + void Function()? navigateToPost, + String? altText, +}) { + final clearMemoryCacheWhenDispose = context.read().state.imageCachingMode == ImageCachingMode.relaxed; + + content.showImageViewer( + context, + url: url, + bytes: bytes, + postId: postId, + navigateToPost: navigateToPost, + altText: altText, + clearMemoryCacheWhenDispose: clearMemoryCacheWhenDispose, + ); +} diff --git a/lib/src/features/content/presentation/widgets/media/media_view.dart b/lib/src/features/content/presentation/widgets/media/media_view.dart new file mode 100644 index 000000000..06b072b21 --- /dev/null +++ b/lib/src/features/content/presentation/widgets/media/media_view.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' as content; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/shared/links/widgets/link_bottom_sheet.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_utils.dart'; + +/// App adapter for package-generic content media view. +class MediaView extends StatelessWidget { + const MediaView({ + super.key, + required this.media, + this.postId, + this.showFullHeightImages = true, + this.allowUnconstrainedImageHeight = false, + this.edgeToEdgeImages = false, + this.hideNsfwPreviews = true, + this.hideThumbnails = false, + this.markPostReadOnMediaView = false, + this.isUserLoggedIn = false, + this.viewMode = ViewMode.comfortable, + this.navigateToPost, + this.read, + this.handlers = const content.ContentActionHandlers(), + }); + + /// The media information. + final Media media; + + /// The associated post ID for the media. + final int? postId; + + /// Whether to show the full height for images. + final bool showFullHeightImages; + + /// When enabled, the image height will be unconstrained. + final bool allowUnconstrainedImageHeight; + + /// Whether to blur NSFW images. + final bool hideNsfwPreviews; + + /// Whether to hide thumbnails. + final bool hideThumbnails; + + /// Whether to extend the image to the edge of the screen. + final bool edgeToEdgeImages; + + /// Whether to mark the post as read when the media is viewed. + final bool markPostReadOnMediaView; + + /// Whether the user is logged in. + final bool isUserLoggedIn; + + /// The view mode of the media. + final ViewMode viewMode; + + /// The function to navigate to the post. + final void Function()? navigateToPost; + + /// Whether the post has been read. + final bool? read; + + /// Optional action handlers that decouple media and navigation behavior. + final content.ContentActionHandlers handlers; + + @override + Widget build(BuildContext context) { + final imagePeekDurationMs = context.select( + (cubit) => cubit.state.imagePeekDuration, + ); + final tabletMode = viewMode == ViewMode.comfortable ? context.select((ThunderBloc bloc) => bloc.state.tabletMode) : false; + final l10n = AppLocalizations.of(context)!; + + final effectiveHandlers = content.ContentActionHandlers( + onOpenLink: handlers.onOpenLink ?? + (context, url) { + handleLink(context, url: url); + }, + onLongPressLink: handlers.onLongPressLink ?? + (context, text, url) { + if (url != null) { + handleLinkLongPress(context, text, url); + } + }, + onOpenImage: handlers.onOpenImage ?? + (context, {url, bytes}) { + showImageViewer( + context, + url: url, + bytes: bytes, + postId: postId, + navigateToPost: navigateToPost, + altText: media.altText, + ); + }, + onOpenVideo: handlers.onOpenVideo ?? + (context, url) { + handleVideoLink(context, url: url); + }, + onMarkRead: handlers.onMarkRead ?? + (postId) { + try { + final feedBloc = BlocProvider.of(context); + feedBloc.add( + FeedItemActionedEvent( + postAction: PostAction.read, + postId: postId, + actionInput: const ReadPostInput(true), + ), + ); + } catch (e) { + debugPrint('Error marking post as read: $e'); + } + }, + ); + + return content.MediaView( + media: _mapMedia(media), + postId: postId, + showFullHeightImages: showFullHeightImages, + allowUnconstrainedImageHeight: allowUnconstrainedImageHeight, + edgeToEdgeImages: edgeToEdgeImages, + hideNsfwPreviews: hideNsfwPreviews, + hideThumbnails: hideThumbnails, + markPostReadOnMediaView: markPostReadOnMediaView, + isUserLoggedIn: isUserLoggedIn, + viewMode: _mapViewMode(viewMode), + navigateToPost: navigateToPost, + read: read, + handlers: effectiveHandlers, + imagePeekDurationMs: imagePeekDurationMs, + tabletMode: tabletMode, + nsfwWarningLabel: l10n.nsfwWarning, + retryTooltip: l10n.retry, + ); + } +} + +content.ContentMedia _mapMedia(Media media) { + return content.ContentMedia( + thumbnailUrl: media.thumbnailUrl, + mediaUrl: media.mediaUrl, + originalUrl: media.originalUrl, + width: media.width, + height: media.height, + nsfw: media.nsfw, + mediaType: _mapMediaType(media.mediaType), + altText: media.altText, + contentType: media.contentType, + ); +} + +content.ContentViewMode _mapViewMode(ViewMode viewMode) { + return switch (viewMode) { + ViewMode.comment => content.ContentViewMode.comment, + ViewMode.compact => content.ContentViewMode.compact, + ViewMode.comfortable => content.ContentViewMode.comfortable, + }; +} + +content.ContentMediaType _mapMediaType(MediaType mediaType) { + return switch (mediaType) { + MediaType.image => content.ContentMediaType.image, + MediaType.video => content.ContentMediaType.video, + MediaType.link => content.ContentMediaType.link, + MediaType.text => content.ContentMediaType.text, + }; +} diff --git a/lib/src/features/drafts/api.dart b/lib/src/features/drafts/api.dart new file mode 100644 index 000000000..64e83dd13 --- /dev/null +++ b/lib/src/features/drafts/api.dart @@ -0,0 +1 @@ +export 'drafts.dart'; diff --git a/lib/src/features/drafts/data/models/draft.dart b/lib/src/features/drafts/data/models/draft.dart index 34f831d02..037793a46 100644 --- a/lib/src/features/drafts/data/models/draft.dart +++ b/lib/src/features/drafts/data/models/draft.dart @@ -1,162 +1,160 @@ -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/src/core/database/type_converters.dart'; -import 'package:thunder/src/features/drafts/drafts.dart'; -import 'package:thunder/main.dart'; - -class Draft { - /// The database identifier for this object - final String id; - - /// The type of draft - final DraftType draftType; - - /// Existing id, if we're editing - final int? existingId; - - /// The community/post/comment we're replying to - final int? replyId; - - /// The title of the post - final String? title; - - /// The URL of the post - final String? url; - - /// The custom thumbnail of the post - final String? customThumbnail; - - /// Alternative text for the image - final String? altText; - - /// The body of the post/comment - final String? body; - - const Draft({ - required this.id, - required this.draftType, - this.existingId, - this.replyId, - this.title, - this.url, - this.customThumbnail, - this.altText, - this.body, - }); - - Draft copyWith({ - String? id, - DraftType? draftType, - int? existingId, - int? replyId, - String? title, - String? url, - String? customThumbnail, - String? altText, - String? body, - }) => - Draft( - id: id ?? this.id, - draftType: draftType ?? this.draftType, - existingId: existingId ?? this.existingId, - replyId: replyId ?? this.replyId, - title: title ?? this.title, - url: url ?? this.url, - customThumbnail: customThumbnail ?? this.customThumbnail, - altText: altText ?? this.altText, - body: body ?? this.body, - ); - - /// See whether this draft contains enough info to save for a post - bool get isPostNotEmpty => title?.isNotEmpty == true || url?.isNotEmpty == true || customThumbnail?.isNotEmpty == true || altText?.isNotEmpty == true || body?.isNotEmpty == true; - - /// See whether this draft contains enough info to save for a comment - bool get isCommentNotEmpty => body?.isNotEmpty == true; - - /// Create or update a draft in the db - static Future upsertDraft(Draft draft) async { - try { - final existingDraft = await (database.select(database.drafts) - ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draft.draftType))) - ..where((t) => draft.existingId == null ? t.existingId.isNull() : t.existingId.equals(draft.existingId!)) - ..where((t) => draft.replyId == null ? t.replyId.isNull() : t.replyId.equals(draft.replyId!))) - .getSingleOrNull(); - - if (existingDraft == null) { - final id = await database.into(database.drafts).insert( - DraftsCompanion.insert( - draftType: draft.draftType, - existingId: Value(draft.existingId), - replyId: Value(draft.replyId), - title: Value(draft.title), - url: Value(draft.url), - customThumbnail: Value(draft.customThumbnail), - altText: Value(draft.altText), - body: Value(draft.body), - ), - ); - return draft.copyWith(id: id.toString()); - } else { - await database.update(database.drafts).replace( - DraftsCompanion( - id: Value(existingDraft.id), - draftType: Value(draft.draftType), - existingId: Value(draft.existingId), - replyId: Value(draft.replyId), - title: Value(draft.title), - url: Value(draft.url), - customThumbnail: Value(draft.customThumbnail), - altText: Value(draft.altText), - body: Value(draft.body), - ), - ); - return draft.copyWith(id: existingDraft.id.toString()); - } - } catch (e) { - debugPrint(e.toString()); - return null; - } - } - - /// Retrieve a draft from the db - static Future fetchDraft(DraftType draftType, int? existingId, int? replyId) async { - try { - final draft = await (database.select(database.drafts) - ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType))) - ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) - ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) - .getSingleOrNull(); - - if (draft == null) return null; - - return Draft( - id: draft.id.toString(), - draftType: draft.draftType, - existingId: draft.existingId, - replyId: draft.replyId, - title: draft.title, - url: draft.url, - customThumbnail: draft.customThumbnail, - altText: draft.altText, - body: draft.body, - ); - } catch (e) { - debugPrint(e.toString()); - return null; - } - } - - /// Delete a draft from the db - static Future deleteDraft(DraftType draftType, int? existingId, int? replyId) async { - try { - await (database.delete(database.drafts) - ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType))) - ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) - ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) - .go(); - } catch (e) { - debugPrint(e.toString()); - } - } -} +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/drafts/drafts.dart'; + +class Draft { + /// The database identifier for this object + final String id; + + /// The type of draft + final DraftType draftType; + + /// Existing id, if we're editing + final int? existingId; + + /// The community/post/comment we're replying to + final int? replyId; + + /// The title of the post + final String? title; + + /// The URL of the post + final String? url; + + /// The custom thumbnail of the post + final String? customThumbnail; + + /// Alternative text for the image + final String? altText; + + /// The body of the post/comment + final String? body; + + const Draft({ + required this.id, + required this.draftType, + this.existingId, + this.replyId, + this.title, + this.url, + this.customThumbnail, + this.altText, + this.body, + }); + + Draft copyWith({ + String? id, + DraftType? draftType, + int? existingId, + int? replyId, + String? title, + String? url, + String? customThumbnail, + String? altText, + String? body, + }) => + Draft( + id: id ?? this.id, + draftType: draftType ?? this.draftType, + existingId: existingId ?? this.existingId, + replyId: replyId ?? this.replyId, + title: title ?? this.title, + url: url ?? this.url, + customThumbnail: customThumbnail ?? this.customThumbnail, + altText: altText ?? this.altText, + body: body ?? this.body, + ); + + /// See whether this draft contains enough info to save for a post + bool get isPostNotEmpty => title?.isNotEmpty == true || url?.isNotEmpty == true || customThumbnail?.isNotEmpty == true || altText?.isNotEmpty == true || body?.isNotEmpty == true; + + /// See whether this draft contains enough info to save for a comment + bool get isCommentNotEmpty => body?.isNotEmpty == true; + + /// Create or update a draft in the db + static Future upsertDraft(Draft draft) async { + try { + final existingDraft = await (database.select(database.drafts) + ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draft.draftType))) + ..where((t) => draft.existingId == null ? t.existingId.isNull() : t.existingId.equals(draft.existingId!)) + ..where((t) => draft.replyId == null ? t.replyId.isNull() : t.replyId.equals(draft.replyId!))) + .getSingleOrNull(); + + if (existingDraft == null) { + final id = await database.into(database.drafts).insert( + DraftsCompanion.insert( + draftType: draft.draftType, + existingId: Value(draft.existingId), + replyId: Value(draft.replyId), + title: Value(draft.title), + url: Value(draft.url), + customThumbnail: Value(draft.customThumbnail), + altText: Value(draft.altText), + body: Value(draft.body), + ), + ); + return draft.copyWith(id: id.toString()); + } else { + await database.update(database.drafts).replace( + DraftsCompanion( + id: Value(existingDraft.id), + draftType: Value(draft.draftType), + existingId: Value(draft.existingId), + replyId: Value(draft.replyId), + title: Value(draft.title), + url: Value(draft.url), + customThumbnail: Value(draft.customThumbnail), + altText: Value(draft.altText), + body: Value(draft.body), + ), + ); + return draft.copyWith(id: existingDraft.id.toString()); + } + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + /// Retrieve a draft from the db + static Future fetchDraft(DraftType draftType, int? existingId, int? replyId) async { + try { + final draft = await (database.select(database.drafts) + ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType))) + ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) + ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) + .getSingleOrNull(); + + if (draft == null) return null; + + return Draft( + id: draft.id.toString(), + draftType: draft.draftType, + existingId: draft.existingId, + replyId: draft.replyId, + title: draft.title, + url: draft.url, + customThumbnail: draft.customThumbnail, + altText: draft.altText, + body: draft.body, + ); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + /// Delete a draft from the db + static Future deleteDraft(DraftType draftType, int? existingId, int? replyId) async { + try { + await (database.delete(database.drafts) + ..where((t) => t.draftType.equals(const DraftTypeConverter().toSql(draftType))) + ..where((t) => existingId == null ? t.existingId.isNull() : t.existingId.equals(existingId)) + ..where((t) => replyId == null ? t.replyId.isNull() : t.replyId.equals(replyId))) + .go(); + } catch (e) { + debugPrint(e.toString()); + } + } +} diff --git a/lib/src/features/drafts/drafts.dart b/lib/src/features/drafts/drafts.dart index f250bf268..377a8a8e3 100644 --- a/lib/src/features/drafts/drafts.dart +++ b/lib/src/features/drafts/drafts.dart @@ -1,2 +1,2 @@ export 'data/models/draft.dart'; -export 'domain/enums/draft_type.dart'; +export 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; diff --git a/lib/src/features/feed/api.dart b/lib/src/features/feed/api.dart new file mode 100644 index 000000000..c92a6fcc8 --- /dev/null +++ b/lib/src/features/feed/api.dart @@ -0,0 +1 @@ +export 'feed.dart'; diff --git a/lib/src/features/feed/application/state/fab_preferences_cubit.dart b/lib/src/features/feed/application/state/fab_preferences_cubit.dart new file mode 100644 index 000000000..fc1c573d3 --- /dev/null +++ b/lib/src/features/feed/application/state/fab_preferences_cubit.dart @@ -0,0 +1,75 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/feed/domain/enums/fab_action.dart'; + +part 'fab_preferences_state.dart'; + +/// Cubit for managing floating action button (FAB) preferences +class FabPreferencesCubit extends Cubit { + FabPreferencesCubit({required PreferencesStore preferencesStore}) + : _preferencesStore = preferencesStore, + super(const FabPreferencesState()) { + load(); + } + + final PreferencesStore _preferencesStore; + + /// Loads FAB preferences from UserPreferences + void load() { + final enableFeedsFab = _preferencesStore.getLocalSetting(LocalSettings.enableFeedsFab) ?? true; + final enablePostsFab = _preferencesStore.getLocalSetting(LocalSettings.enablePostsFab) ?? true; + + final enableBackToTop = _preferencesStore.getLocalSetting(LocalSettings.enableBackToTop) ?? true; + final enableSubscriptions = _preferencesStore.getLocalSetting(LocalSettings.enableSubscriptions) ?? true; + final enableRefresh = _preferencesStore.getLocalSetting(LocalSettings.enableRefresh) ?? true; + final enableDismissRead = _preferencesStore.getLocalSetting(LocalSettings.enableDismissRead) ?? true; + final enableChangeSort = _preferencesStore.getLocalSetting(LocalSettings.enableChangeSort) ?? true; + final enableNewPost = _preferencesStore.getLocalSetting(LocalSettings.enableNewPost) ?? true; + + final postFabEnableBackToTop = _preferencesStore.getLocalSetting(LocalSettings.postFabEnableBackToTop) ?? true; + final postFabEnableChangeSort = _preferencesStore.getLocalSetting(LocalSettings.postFabEnableChangeSort) ?? true; + final postFabEnableReplyToPost = _preferencesStore.getLocalSetting(LocalSettings.postFabEnableReplyToPost) ?? true; + final postFabEnableRefresh = _preferencesStore.getLocalSetting(LocalSettings.postFabEnableRefresh) ?? true; + final postFabEnableSearch = _preferencesStore.getLocalSetting(LocalSettings.postFabEnableSearch) ?? true; + + final feedFabSinglePressAction = FeedFabAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.feedFabSinglePressAction) ?? FeedFabAction.newPost.name); + final feedFabLongPressAction = FeedFabAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.feedFabLongPressAction) ?? FeedFabAction.openFab.name); + final postFabSinglePressAction = PostFabAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postFabSinglePressAction) ?? PostFabAction.replyToPost.name); + final postFabLongPressAction = PostFabAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postFabLongPressAction) ?? PostFabAction.openFab.name); + + final enableCommentNavigation = _preferencesStore.getLocalSetting(LocalSettings.enableCommentNavigation) ?? true; + final combineNavAndFab = _preferencesStore.getLocalSetting(LocalSettings.combineNavAndFab) ?? true; + + emit( + FabPreferencesState( + enableFeedsFab: enableFeedsFab, + enablePostsFab: enablePostsFab, + enableBackToTop: enableBackToTop, + enableSubscriptions: enableSubscriptions, + enableRefresh: enableRefresh, + enableDismissRead: enableDismissRead, + enableChangeSort: enableChangeSort, + enableNewPost: enableNewPost, + postFabEnableBackToTop: postFabEnableBackToTop, + postFabEnableChangeSort: postFabEnableChangeSort, + postFabEnableReplyToPost: postFabEnableReplyToPost, + postFabEnableRefresh: postFabEnableRefresh, + postFabEnableSearch: postFabEnableSearch, + feedFabSinglePressAction: feedFabSinglePressAction, + feedFabLongPressAction: feedFabLongPressAction, + postFabSinglePressAction: postFabSinglePressAction, + postFabLongPressAction: postFabLongPressAction, + enableCommentNavigation: enableCommentNavigation, + combineNavAndFab: combineNavAndFab, + ), + ); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/fab_preferences_cubit/fab_preferences_state.dart b/lib/src/features/feed/application/state/fab_preferences_state.dart similarity index 100% rename from lib/src/app/cubits/fab_preferences_cubit/fab_preferences_state.dart rename to lib/src/features/feed/application/state/fab_preferences_state.dart diff --git a/lib/src/app/cubits/fab_cubit/fab_state.dart b/lib/src/features/feed/application/state/fab_state.dart similarity index 97% rename from lib/src/app/cubits/fab_cubit/fab_state.dart rename to lib/src/features/feed/application/state/fab_state.dart index 986495529..4a1e652cf 100644 --- a/lib/src/app/cubits/fab_cubit/fab_state.dart +++ b/lib/src/features/feed/application/state/fab_state.dart @@ -1,4 +1,4 @@ -part of 'fab_cubit.dart'; +part of 'fab_state_cubit.dart'; class FabStateState extends Equatable { const FabStateState({ diff --git a/lib/src/app/cubits/fab_cubit/fab_cubit.dart b/lib/src/features/feed/application/state/fab_state_cubit.dart similarity index 100% rename from lib/src/app/cubits/fab_cubit/fab_cubit.dart rename to lib/src/features/feed/application/state/fab_state_cubit.dart diff --git a/lib/src/features/feed/application/state/feed_preferences_cubit.dart b/lib/src/features/feed/application/state/feed_preferences_cubit.dart new file mode 100644 index 000000000..040ff470c --- /dev/null +++ b/lib/src/features/feed/application/state/feed_preferences_cubit.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:intl/intl.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/config.dart'; + +part 'feed_preferences_state.dart'; + +/// Cubit for managing feed-related preferences. This includes settings for the feed list, post cards, and post body. +class FeedPreferencesCubit extends Cubit { + FeedPreferencesCubit({required PreferencesStore preferencesStore}) + : _preferencesStore = preferencesStore, + super(const FeedPreferencesState()) { + load(); + } + + final PreferencesStore _preferencesStore; + + /// Loads feed preferences from UserPreferences + void load() { + // Default Listing/Sort Settings + FeedListType defaultFeedListType = DEFAULT_LISTING_TYPE; + PostSortType defaultPostSortType = DEFAULT_POST_SORT_TYPE; + + try { + defaultFeedListType = FeedListType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.defaultFeedListType) ?? DEFAULT_LISTING_TYPE.name); + defaultPostSortType = PostSortType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.defaultFeedPostSortType) ?? DEFAULT_POST_SORT_TYPE.name); + } catch (e) { + defaultFeedListType = FeedListType.values.byName(DEFAULT_LISTING_TYPE.name); + defaultPostSortType = PostSortType.values.byName(DEFAULT_POST_SORT_TYPE.name); + } + + // NSFW Settings + final hideNsfwPosts = _preferencesStore.getLocalSetting(LocalSettings.hideNsfwPosts) ?? false; + final hideNsfwPreviews = _preferencesStore.getLocalSetting(LocalSettings.hideNsfwPreviews) ?? true; + + // General Settings + final markPostReadOnMediaView = _preferencesStore.getLocalSetting(LocalSettings.markPostAsReadOnMediaView) ?? false; + final markPostReadOnScroll = _preferencesStore.getLocalSetting(LocalSettings.markPostAsReadOnScroll) ?? false; + final showHiddenPosts = _preferencesStore.getLocalSetting(LocalSettings.showHiddenPosts) ?? false; + final showExpandedTaglines = _preferencesStore.getLocalSetting(LocalSettings.showExpandedTaglines) ?? false; + + /// -------------------------- Feed Post Related Settings -------------------------- + // Compact Related Settings + final useCompactView = _preferencesStore.getLocalSetting(LocalSettings.useCompactView) ?? false; + final showPostCommunityFirst = _preferencesStore.getLocalSetting(LocalSettings.showPostCommunityFirst) ?? false; + final showTitleFirst = _preferencesStore.getLocalSetting(LocalSettings.showPostTitleFirst) ?? false; + final hideThumbnails = _preferencesStore.getLocalSetting(LocalSettings.hideThumbnails) ?? false; + final showThumbnailPreviewOnRight = _preferencesStore.getLocalSetting(LocalSettings.showThumbnailPreviewOnRight) ?? false; + final linkPostsUseCompactView = _preferencesStore.getLocalSetting(LocalSettings.linkPostsUseCompactView) ?? false; + final pinnedPostsUseCompactView = _preferencesStore.getLocalSetting(LocalSettings.pinnedPostsUseCompactView) ?? true; + final showTextPostIndicator = _preferencesStore.getLocalSetting(LocalSettings.showTextPostIndicator) ?? false; + final tappableAuthorCommunity = _preferencesStore.getLocalSetting(LocalSettings.tappableAuthorCommunity) ?? false; + + // General Settings + final showVoteActions = _preferencesStore.getLocalSetting(LocalSettings.showPostVoteActions) ?? true; + final showSaveAction = _preferencesStore.getLocalSetting(LocalSettings.showPostSaveAction) ?? true; + final showCommunityIcons = _preferencesStore.getLocalSetting(LocalSettings.showPostCommunityIcons) ?? false; + final showFullHeightImages = _preferencesStore.getLocalSetting(LocalSettings.showPostFullHeightImages) ?? true; + final showEdgeToEdgeImages = _preferencesStore.getLocalSetting(LocalSettings.showPostEdgeToEdgeImages) ?? false; + final showTextContent = _preferencesStore.getLocalSetting(LocalSettings.showPostTextContentPreview) ?? false; + final showPostAuthor = _preferencesStore.getLocalSetting(LocalSettings.showPostAuthor) ?? false; + final postShowUserInstance = _preferencesStore.getLocalSetting(LocalSettings.postShowUserInstance) ?? false; + final dimReadPosts = _preferencesStore.getLocalSetting(LocalSettings.dimReadPosts) ?? true; + final showFullPostDate = _preferencesStore.getLocalSetting(LocalSettings.showFullPostDate) ?? false; + final dateFormat = DateFormat(_preferencesStore.getLocalSetting(LocalSettings.dateFormat) ?? DateFormat.yMMMMd(Intl.systemLocale).add_jm().pattern); + final feedCardDividerThickness = FeedCardDividerThickness.values.byName(_preferencesStore.getLocalSetting(LocalSettings.feedCardDividerThickness) ?? FeedCardDividerThickness.compact.name); + final feedCardDividerColor = _preferencesStore.getLocalSetting(LocalSettings.feedCardDividerColor) != null ? Color(_preferencesStore.getLocalSetting(LocalSettings.feedCardDividerColor)!) : null; + final compactPostCardMetadataItems = + _preferencesStore.getLocalSetting>(LocalSettings.compactPostCardMetadataItems)?.map((e) => PostCardMetadataItem.values.byName(e)).toList() ?? DEFAULT_COMPACT_POST_CARD_METADATA; + final cardPostCardMetadataItems = + _preferencesStore.getLocalSetting>(LocalSettings.cardPostCardMetadataItems)?.map((e) => PostCardMetadataItem.values.byName(e)).toList() ?? DEFAULT_CARD_POST_CARD_METADATA; + + // Post body settings + final showCrossPosts = _preferencesStore.getLocalSetting(LocalSettings.showCrossPosts) ?? true; + final postBodyViewType = PostBodyViewType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postBodyViewType) ?? PostBodyViewType.expanded.name); + final postBodyShowUserInstance = _preferencesStore.getLocalSetting(LocalSettings.postBodyShowUserInstance) ?? false; + final postBodyShowCommunityInstance = _preferencesStore.getLocalSetting(LocalSettings.postBodyShowCommunityInstance) ?? false; + final postBodyShowCommunityAvatar = _preferencesStore.getLocalSetting(LocalSettings.postBodyShowCommunityAvatar) ?? false; + + final keywordFilters = (_preferencesStore.getLocalSetting(LocalSettings.keywordFilters) as List?)?.cast().toList() ?? []; + + emit( + FeedPreferencesState( + defaultFeedListType: defaultFeedListType, + defaultPostSortType: defaultPostSortType, + hideNsfwPosts: hideNsfwPosts, + hideNsfwPreviews: hideNsfwPreviews, + markPostReadOnMediaView: markPostReadOnMediaView, + markPostReadOnScroll: markPostReadOnScroll, + showHiddenPosts: showHiddenPosts, + showExpandedTaglines: showExpandedTaglines, + useCompactView: useCompactView, + showTitleFirst: showTitleFirst, + showPostCommunityFirst: showPostCommunityFirst, + hideThumbnails: hideThumbnails, + showThumbnailPreviewOnRight: showThumbnailPreviewOnRight, + linkPostsUseCompactView: linkPostsUseCompactView, + pinnedPostsUseCompactView: pinnedPostsUseCompactView, + showTextPostIndicator: showTextPostIndicator, + tappableAuthorCommunity: tappableAuthorCommunity, + showVoteActions: showVoteActions, + showSaveAction: showSaveAction, + showCommunityIcons: showCommunityIcons, + showFullHeightImages: showFullHeightImages, + showEdgeToEdgeImages: showEdgeToEdgeImages, + showTextContent: showTextContent, + showPostAuthor: showPostAuthor, + postShowUserInstance: postShowUserInstance, + dimReadPosts: dimReadPosts, + showFullPostDate: showFullPostDate, + dateFormat: dateFormat, + feedCardDividerThickness: feedCardDividerThickness, + feedCardDividerColor: feedCardDividerColor, + compactPostCardMetadataItems: compactPostCardMetadataItems, + cardPostCardMetadataItems: cardPostCardMetadataItems, + showCrossPosts: showCrossPosts, + postBodyViewType: postBodyViewType, + postBodyShowUserInstance: postBodyShowUserInstance, + postBodyShowCommunityInstance: postBodyShowCommunityInstance, + postBodyShowCommunityAvatar: postBodyShowCommunityAvatar, + keywordFilters: keywordFilters, + ), + ); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/feed_preferences_cubit/feed_preferences_state.dart b/lib/src/features/feed/application/state/feed_preferences_state.dart similarity index 96% rename from lib/src/app/cubits/feed_preferences_cubit/feed_preferences_state.dart rename to lib/src/features/feed/application/state/feed_preferences_state.dart index 075619b9f..fcc3a61a2 100644 --- a/lib/src/app/cubits/feed_preferences_cubit/feed_preferences_state.dart +++ b/lib/src/features/feed/application/state/feed_preferences_state.dart @@ -1,5 +1,7 @@ part of 'feed_preferences_cubit.dart'; +const _feedPreferencesUnset = Object(); + class FeedPreferencesState extends Equatable { const FeedPreferencesState({ this.defaultFeedListType = DEFAULT_LISTING_TYPE, @@ -185,9 +187,9 @@ class FeedPreferencesState extends Equatable { bool? postShowUserInstance, bool? dimReadPosts, bool? showFullPostDate, - DateFormat? dateFormat, + Object? dateFormat = _feedPreferencesUnset, FeedCardDividerThickness? feedCardDividerThickness, - Color? feedCardDividerColor, + Object? feedCardDividerColor = _feedPreferencesUnset, List? compactPostCardMetadataItems, List? cardPostCardMetadataItems, bool? showCrossPosts, @@ -225,9 +227,9 @@ class FeedPreferencesState extends Equatable { postShowUserInstance: postShowUserInstance ?? this.postShowUserInstance, dimReadPosts: dimReadPosts ?? this.dimReadPosts, showFullPostDate: showFullPostDate ?? this.showFullPostDate, - dateFormat: dateFormat ?? this.dateFormat, + dateFormat: identical(dateFormat, _feedPreferencesUnset) ? this.dateFormat : dateFormat as DateFormat?, feedCardDividerThickness: feedCardDividerThickness ?? this.feedCardDividerThickness, - feedCardDividerColor: feedCardDividerColor, + feedCardDividerColor: identical(feedCardDividerColor, _feedPreferencesUnset) ? this.feedCardDividerColor : feedCardDividerColor as Color?, compactPostCardMetadataItems: compactPostCardMetadataItems ?? this.compactPostCardMetadataItems, cardPostCardMetadataItems: cardPostCardMetadataItems ?? this.cardPostCardMetadataItems, showCrossPosts: showCrossPosts ?? this.showCrossPosts, diff --git a/lib/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart b/lib/src/features/feed/application/state/feed_ui_cubit.dart similarity index 100% rename from lib/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart rename to lib/src/features/feed/application/state/feed_ui_cubit.dart diff --git a/lib/src/app/cubits/feed_ui_cubit/feed_ui_state.dart b/lib/src/features/feed/application/state/feed_ui_state.dart similarity index 65% rename from lib/src/app/cubits/feed_ui_cubit/feed_ui_state.dart rename to lib/src/features/feed/application/state/feed_ui_state.dart index 188ac860b..eae3c2301 100644 --- a/lib/src/app/cubits/feed_ui_cubit/feed_ui_state.dart +++ b/lib/src/features/feed/application/state/feed_ui_state.dart @@ -1,5 +1,7 @@ part of 'feed_ui_cubit.dart'; +const _feedUiUnset = Object(); + class FeedUiState extends Equatable { const FeedUiState({ this.scrollId = 0, @@ -27,16 +29,16 @@ class FeedUiState extends Equatable { FeedUiState copyWith({ int? scrollId, int? dismissReadId, - int? dismissBlockedUserId, - int? dismissBlockedCommunityId, - int? dismissHiddenPostId, + Object? dismissBlockedUserId = _feedUiUnset, + Object? dismissBlockedCommunityId = _feedUiUnset, + Object? dismissHiddenPostId = _feedUiUnset, }) { return FeedUiState( scrollId: scrollId ?? this.scrollId, dismissReadId: dismissReadId ?? this.dismissReadId, - dismissBlockedUserId: dismissBlockedUserId, - dismissBlockedCommunityId: dismissBlockedCommunityId, - dismissHiddenPostId: dismissHiddenPostId, + dismissBlockedUserId: identical(dismissBlockedUserId, _feedUiUnset) ? this.dismissBlockedUserId : dismissBlockedUserId as int?, + dismissBlockedCommunityId: identical(dismissBlockedCommunityId, _feedUiUnset) ? this.dismissBlockedCommunityId : dismissBlockedCommunityId as int?, + dismissHiddenPostId: identical(dismissHiddenPostId, _feedUiUnset) ? this.dismissHiddenPostId : dismissHiddenPostId as int?, ); } diff --git a/lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_state.dart b/lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state.dart similarity index 74% rename from lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_state.dart rename to lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state.dart index 0fc309417..bbf330287 100644 --- a/lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_state.dart +++ b/lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state.dart @@ -1,17 +1,17 @@ part of 'nav_bar_state_cubit.dart'; -class NavBarStateState extends Equatable { - const NavBarStateState({ +class NavBarState extends Equatable { + const NavBarState({ this.isBottomNavBarVisible = true, }); /// Whether the bottom navigation bar is currently visible final bool isBottomNavBarVisible; - NavBarStateState copyWith({ + NavBarState copyWith({ bool? isBottomNavBarVisible, }) { - return NavBarStateState( + return NavBarState( isBottomNavBarVisible: isBottomNavBarVisible ?? this.isBottomNavBarVisible, ); } diff --git a/lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart b/lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state_cubit.dart similarity index 66% rename from lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart rename to lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state_cubit.dart index a925d3841..e35f61326 100644 --- a/lib/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart +++ b/lib/src/features/feed/application/state/nav_bar_state_cubit/nav_bar_state_cubit.dart @@ -1,11 +1,11 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -part 'nav_bar_state_state.dart'; +part 'nav_bar_state.dart'; /// Cubit for managing bottom navigation bar state -class NavBarStateCubit extends Cubit { - NavBarStateCubit() : super(const NavBarStateState()); +class NavBarStateCubit extends Cubit { + NavBarStateCubit() : super(const NavBarState()); /// Sets the bottom navigation bar visibility void setBottomNavBarVisible(bool isVisible) { diff --git a/lib/src/features/feed/domain/enums/enums.dart b/lib/src/features/feed/domain/enums/enums.dart index 6ed309b7e..098fd3dd0 100644 --- a/lib/src/features/feed/domain/enums/enums.dart +++ b/lib/src/features/feed/domain/enums/enums.dart @@ -1 +1,2 @@ export 'feed_type_subview.dart'; +export 'fab_action.dart'; diff --git a/lib/src/core/enums/fab_action.dart b/lib/src/features/feed/domain/enums/fab_action.dart similarity index 92% rename from lib/src/core/enums/fab_action.dart rename to lib/src/features/feed/domain/enums/fab_action.dart index e235ba6a7..f353a936e 100644 --- a/lib/src/core/enums/fab_action.dart +++ b/lib/src/features/feed/domain/enums/fab_action.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; enum FeedFabAction { openFab(), diff --git a/lib/src/features/feed/domain/models/feed_result.dart b/lib/src/features/feed/domain/models/feed_result.dart new file mode 100644 index 000000000..5c96f4d59 --- /dev/null +++ b/lib/src/features/feed/domain/models/feed_result.dart @@ -0,0 +1,28 @@ +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/post/api.dart'; + +/// A class representing the result of a feed fetch. +class FeedResult { + /// The posts in the feed. + final List posts; + + /// The comments in the feed. + final List comments; + + /// Whether the feed has reached the end of the posts. + final bool hasReachedPostsEnd; + + /// Whether the feed has reached the end of the comments. + final bool hasReachedCommentsEnd; + + /// The cursor for the next page of the feed. + final String? cursor; + + const FeedResult({ + required this.posts, + required this.comments, + required this.hasReachedPostsEnd, + required this.hasReachedCommentsEnd, + required this.cursor, + }); +} diff --git a/lib/src/features/feed/domain/utils/feed_collection_utils.dart b/lib/src/features/feed/domain/utils/feed_collection_utils.dart new file mode 100644 index 000000000..c2fdde820 --- /dev/null +++ b/lib/src/features/feed/domain/utils/feed_collection_utils.dart @@ -0,0 +1,39 @@ +import 'package:thunder/src/features/post/post.dart'; + +List hidePostsByIds({ + required List posts, + required Set postIds, +}) { + final updatedPosts = List.from(posts); + updatedPosts.removeWhere((post) => postIds.contains(post.id)); + return updatedPosts; +} + +List replaceAt({ + required List source, + required int index, + required ThunderPost value, +}) { + final updated = List.from(source); + updated[index] = value; + return updated; +} + +({List indexes, List ids, List posts}) collectByIds({ + required List source, + required List ids, +}) { + final indexes = []; + final postIds = []; + final posts = []; + + for (var i = 0; i < source.length; i++) { + if (ids.contains(source[i].id)) { + indexes.add(i); + postIds.add(source[i].id); + posts.add(source[i]); + } + } + + return (indexes: indexes, ids: postIds, posts: posts); +} diff --git a/lib/src/features/feed/feed.dart b/lib/src/features/feed/feed.dart index a5da782a0..904d5b904 100644 --- a/lib/src/features/feed/feed.dart +++ b/lib/src/features/feed/feed.dart @@ -1,11 +1,17 @@ -export 'presentation/bloc/feed_bloc.dart'; +export 'presentation/state/feed_bloc.dart'; +export 'application/state/fab_preferences_cubit.dart'; +export 'application/state/fab_state_cubit.dart'; +export 'application/state/feed_preferences_cubit.dart'; +export 'application/state/feed_ui_cubit.dart'; +export 'application/state/nav_bar_state_cubit/nav_bar_state_cubit.dart'; export 'domain/enums/enums.dart'; -export 'presentation/utils/utils.dart'; +export 'domain/models/feed_result.dart'; export 'presentation/pages/pages.dart'; export 'presentation/widgets/widgets.dart'; export 'presentation/widgets/tagline.dart'; export 'presentation/widgets/feed_card_divider.dart'; -export 'presentation/utils/community.dart'; -export 'presentation/utils/user_share.dart'; -export 'presentation/utils/community_share.dart'; -export 'presentation/utils/post.dart'; +export 'presentation/models/feed_share_options.dart'; +export 'presentation/utils/community_feed_utils.dart'; +export 'presentation/utils/feed_fetch_utils.dart'; +export 'presentation/utils/feed_header_utils.dart'; +export 'presentation/utils/feed_share_utils.dart'; diff --git a/lib/src/features/feed/presentation/models/feed_share_options.dart b/lib/src/features/feed/presentation/models/feed_share_options.dart new file mode 100644 index 000000000..2f7511109 --- /dev/null +++ b/lib/src/features/feed/presentation/models/feed_share_options.dart @@ -0,0 +1,3 @@ +enum CommunityShareOptions { link, localLink, lemmy } + +enum UserShareOptions { link, localLink, lemmy } diff --git a/lib/src/features/feed/presentation/pages/feed_page.dart b/lib/src/features/feed/presentation/pages/feed_page.dart index 40d54b730..16fa6d260 100644 --- a/lib/src/features/feed/presentation/pages/feed_page.dart +++ b/lib/src/features/feed/presentation/pages/feed_page.dart @@ -5,29 +5,23 @@ import 'package:flutter/services.dart'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/nav_bar_state_cubit/nav_bar_state_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; enum FeedType { community, user, general, account } @@ -146,7 +140,7 @@ class _FeedPageState extends State with AutomaticKeepAliveClientMixin< final account = context.select((bloc) => bloc.state.account); return BlocProvider( - create: (_) => FeedBloc(account: account) + create: (_) => createFeedBloc(account) ..add(FeedFetchedEvent( feedType: widget.feedType, feedListType: widget.feedListType, @@ -376,7 +370,9 @@ class _FeedViewState extends State { Future.delayed(const Duration(milliseconds: 1000), () { if (!mounted) return; bool isScrollable = _scrollController.position.maxScrollExtent > _scrollController.position.viewportDimension; - if (!isScrollable) context.read().add(const FeedFetchedEvent()); + if (!isScrollable) { + context.read().add(const FeedFetchedEvent()); + } }); } diff --git a/lib/src/features/feed/presentation/bloc/feed_bloc.dart b/lib/src/features/feed/presentation/state/feed_bloc.dart similarity index 68% rename from lib/src/features/feed/presentation/bloc/feed_bloc.dart rename to lib/src/features/feed/presentation/state/feed_bloc.dart index 12a6e527c..a7e0eae06 100644 --- a/lib/src/features/feed/presentation/bloc/feed_bloc.dart +++ b/lib/src/features/feed/presentation/state/feed_bloc.dart @@ -6,15 +6,14 @@ import 'package:equatable/equatable.dart'; import 'package:stream_transform/stream_transform.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/feed/domain/utils/feed_collection_utils.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; part 'feed_event.dart'; part 'feed_state.dart'; @@ -28,17 +27,18 @@ EventTransformer throttleDroppable(Duration duration) { } class FeedBloc extends Bloc { - Account account; - - late PostRepository postRepository; - late CommunityRepository communityRepository; - late UserRepository userRepository; - - FeedBloc({required this.account}) : super(const FeedState()) { - postRepository = PostRepositoryImpl(account: account); - communityRepository = CommunityRepositoryImpl(account: account); - userRepository = UserRepositoryImpl(account: account); - + final Account account; + + final PostRepository postRepository; + final CommunityRepository communityRepository; + final UserRepository userRepository; + + FeedBloc({ + required this.account, + required this.postRepository, + required this.communityRepository, + required this.userRepository, + }) : super(const FeedState()) { /// Handles resetting the feed to its initial state on( _onResetFeed, @@ -102,15 +102,25 @@ class FeedBloc extends Bloc { Future _onFeedHidePostsFromView(FeedHidePostsFromViewEvent event, Emitter emit) async { emit(state.copyWith(status: FeedStatus.fetching)); - List posts = List.from(state.posts); - posts.removeWhere((ThunderPost post) => event.postIds.contains(post.id)); + final posts = hidePostsByIds( + posts: state.posts, + postIds: event.postIds.toSet(), + ); - emit(state.copyWith(status: FeedStatus.success, posts: posts)); + emit(state.copyWith( + status: FeedStatus.success, + posts: posts, + errorReason: null, + )); } /// Handles clearing any messages from the state Future _onFeedClearMessage(FeedClearMessageEvent event, Emitter emit) async { - emit(state.copyWith(status: state.status == FeedStatus.failureLoadingCommunity || state.status == FeedStatus.failureLoadingUser ? state.status : FeedStatus.success, message: null)); + emit(state.copyWith( + status: state.status == FeedStatus.failureLoadingCommunity || state.status == FeedStatus.failureLoadingUser ? state.status : FeedStatus.success, + message: null, + errorReason: null, + )); } /// Handles post related actions on a given item within the feed @@ -120,77 +130,90 @@ class FeedBloc extends Bloc { switch (event.postAction) { case PostAction.vote: + final input = event.actionInput; + if (input is! VotePostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically update the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.score; try { - ThunderPost updatedPost = optimisticallyVotePost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyVotePost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - updatedPost = await postRepository.vote(post, event.value); - updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + updatedPost = await postRepository.vote(post, value); + updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } catch (e) { // Restore the original post contents - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.save: + final input = event.actionInput; + if (input is! SavePostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically save the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.save; try { - ThunderPost updatedPost = optimisticallySavePost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallySavePost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - updatedPost = await postRepository.save(post, event.value); - updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + updatedPost = await postRepository.save(post, value); + updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } catch (e) { // Restore the original post contents - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.read: + final input = event.actionInput; + if (input is! ReadPostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically read the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); if (existingPostIndex == -1) return; // Unable to find the post final post = state.posts[existingPostIndex]; + final value = input.read; // Give a slight delay to have the UI perform any navigation first await Future.delayed(const Duration(milliseconds: 250)); try { - ThunderPost updatedPost = optimisticallyReadPost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyReadPost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.read(post.id, event.value); + bool success = await postRepository.read(post.id, value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents @@ -199,36 +222,35 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.multiRead: + final input = event.actionInput; + if (input is! MultiReadPostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } List eventPostIds = event.postIds ?? []; + final value = input.read; if (eventPostIds.isNotEmpty) { // Optimistically read the posts - List existingPostIndexes = []; - List postIds = []; - List posts = []; - List originalPosts = []; - - for (int i = 0; i < state.posts.length; i++) { - if (eventPostIds.contains(state.posts[i].id)) { - existingPostIndexes.add(i); - postIds.add(state.posts[i].id); - posts.add(state.posts[i]); - originalPosts.add(state.posts[i]); - } - } + final collected = collectByIds(source: state.posts, ids: eventPostIds); + List existingPostIndexes = collected.indexes; + List postIds = collected.ids; + List posts = collected.posts; + List originalPosts = List.from(posts); try { List updatedPosts = List.from(state.posts); for (int i = 0; i < existingPostIndexes.length; i++) { - ThunderPost updatedPost = optimisticallyReadPost(posts[i], event.value); + ThunderPost updatedPost = optimisticallyReadPost(posts[i], value); updatedPosts[existingPostIndexes[i]] = updatedPost; } // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - List failed = await postRepository.readMultiple(postIds, event.value); - if (failed.isEmpty) return emit(state.copyWith(status: FeedStatus.success)); + List failed = await postRepository.readMultiple(postIds, value); + if (failed.isEmpty) { + return emit(state.copyWith(status: FeedStatus.success)); + } // Restore the original post contents if not successful List restoredPosts = List.from(state.posts); @@ -247,24 +269,30 @@ class FeedBloc extends Bloc { } } case PostAction.hide: + final input = event.actionInput; + if (input is! HidePostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically hide the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.hide; try { - ThunderPost updatedPost = optimisticallyHidePost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyHidePost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.hide(post.id, event.value); + bool success = await postRepository.hide(post.id, value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents @@ -273,24 +301,30 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.delete: + final input = event.actionInput; + if (input is! DeletePostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically delete the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.delete; try { - ThunderPost updatedPost = optimisticallyDeletePost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyDeletePost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.delete(post.id, event.value); + bool success = await postRepository.delete(post.id, value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents @@ -299,34 +333,48 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.report: + final input = event.actionInput; + if (input is! ReportPostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.reason; try { - await postRepository.report(post.id, event.value); + await postRepository.report(post.id, value); return emit(state.copyWith(status: FeedStatus.success)); } catch (e) { return emit(state.copyWith(status: FeedStatus.failure)); } case PostAction.lock: + final input = event.actionInput; + if (input is! LockPostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically lock the post int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.lock; try { - ThunderPost updatedPost = optimisticallyLockPost(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyLockPost(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.lock(post.id, event.value); + bool success = await postRepository.lock(post.id, value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents @@ -335,24 +383,30 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.pinCommunity: + final input = event.actionInput; + if (input is! PinCommunityPostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically pin the post to the community int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final value = input.pin; try { - ThunderPost updatedPost = optimisticallyPinPostToCommunity(post, event.value); - List updatedPosts = List.from(state.posts); - updatedPosts[existingPostIndex] = updatedPost; + ThunderPost updatedPost = optimisticallyPinPostToCommunity(post, value); + List updatedPosts = replaceAt(source: state.posts, index: existingPostIndex, value: updatedPost); // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.pinCommunity(post.id, event.value); + bool success = await postRepository.pinCommunity(post.id, value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - List restoredPosts = List.from(state.posts); - restoredPosts[existingPostIndex] = post; + List restoredPosts = replaceAt(source: state.posts, index: existingPostIndex, value: post); return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents @@ -361,19 +415,28 @@ class FeedBloc extends Bloc { return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.remove: + final input = event.actionInput; + if (input is! RemovePostInput) { + return emit(state.copyWith(status: FeedStatus.failure)); + } // Optimistically remove the post from the community int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); + if (existingPostIndex == -1) { + return emit(state.copyWith(status: FeedStatus.failure)); + } final post = state.posts[existingPostIndex]; + final remove = input.remove; + final reason = input.reason; try { - ThunderPost updatedPost = optimisticallyRemovePost(post, event.value['remove']); + ThunderPost updatedPost = optimisticallyRemovePost(post, remove); List updatedPosts = List.from(state.posts); updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); - bool success = await postRepository.remove(post.id, event.value['remove'], event.value['reason']); + bool success = await postRepository.remove(post.id, remove, reason); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful @@ -398,7 +461,8 @@ class FeedBloc extends Bloc { List updatedPosts = List.from(state.posts); for (final (index, post) in state.posts.indexed) { if (post.id == event.post.id) { - updatedPosts[index] = event.post; + final preserveMedia = event.post.media.isEmpty && post.media.isNotEmpty; + updatedPosts[index] = preserveMedia ? event.post.copyWith(media: post.media) : event.post; } } @@ -474,13 +538,21 @@ class FeedBloc extends Bloc { try { // Assert any requirements if (event.reset) assert(event.feedType != null); - if (event.reset && event.feedType == FeedType.community) assert(!(event.communityId == null && event.communityName == null)); - if (event.reset && event.feedType == FeedType.user) assert(!(event.userId != null && event.username != null)); - if (event.reset && event.feedType == FeedType.general) assert(event.feedListType != null); + if (event.reset && event.feedType == FeedType.community) { + assert(!(event.communityId == null && event.communityName == null)); + } + if (event.reset && event.feedType == FeedType.user) { + assert(!(event.userId != null && event.username != null)); + } + if (event.reset && event.feedType == FeedType.general) { + assert(event.feedListType != null); + } // Handle the initial fetch or reload of a feed if (event.reset) { - if (state.status != FeedStatus.initial) add(ResetFeedEvent(softReset: event.feedType == FeedType.account)); + if (state.status != FeedStatus.initial) { + add(ResetFeedEvent(softReset: event.feedType == FeedType.account)); + } ThunderCommunity? community; ThunderSite? communityInstance; @@ -494,15 +566,22 @@ class FeedBloc extends Bloc { // Fetch community information try { final result = await communityRepository.getCommunity(id: event.communityId, name: event.communityName); - community = result['community']; - communityInstance = result['instance']; - communityModerators = result['moderators']; + community = result.community; + communityInstance = result.site; + communityModerators = result.moderators; } catch (e) { // If we are given a community feed, but we can't load the community, that's a problem! Emit an error. return emit(state.copyWith( status: FeedStatus.failureLoadingCommunity, message: getExceptionErrorMessage(e, additionalInfo: event.communityName), feedType: event.feedType, + errorReason: AppErrorReason.unexpected( + message: getExceptionErrorMessage( + e, + additionalInfo: event.communityName, + ), + details: e.toString(), + ), )); } break; @@ -519,6 +598,13 @@ class FeedBloc extends Bloc { status: FeedStatus.failureLoadingUser, message: getExceptionErrorMessage(e, additionalInfo: event.username), feedType: event.feedType, + errorReason: AppErrorReason.unexpected( + message: getExceptionErrorMessage( + e, + additionalInfo: event.username, + ), + details: e.toString(), + ), )); } break; @@ -528,7 +614,7 @@ class FeedBloc extends Bloc { break; } - Map feedItemResult = await fetchFeedItems( + FeedResult feedItemResult = await fetchFeedItems( cursor: null, feedListType: event.feedListType, postSortType: event.postSortType, @@ -539,15 +625,15 @@ class FeedBloc extends Bloc { feedTypeSubview: event.feedTypeSubview, showHidden: event.showHidden, showSaved: event.showSaved, - notifyExcessiveApiCalls: () => emit(state.copyWith(excessivesApiCalls: true)), + notifyExcessiveApiCalls: () => emit(state.copyWith(excessiveApiCalls: true)), ); // Extract information from the response - List posts = feedItemResult['posts']; - List comments = feedItemResult['comments']; - bool hasReachedPostsEnd = feedItemResult['hasReachedPostsEnd']; - bool hasReachedCommentsEnd = feedItemResult['hasReachedCommentsEnd']; - String? cursor = feedItemResult['cursor']; + List posts = feedItemResult.posts; + List comments = feedItemResult.comments; + bool hasReachedPostsEnd = feedItemResult.hasReachedPostsEnd; + bool hasReachedCommentsEnd = feedItemResult.hasReachedCommentsEnd; + String? cursor = feedItemResult.cursor; return emit(state.copyWith( status: FeedStatus.success, @@ -570,6 +656,7 @@ class FeedBloc extends Bloc { cursor: cursor, showHidden: event.showHidden, showSaved: event.showSaved, + errorReason: null, )); } @@ -583,7 +670,7 @@ class FeedBloc extends Bloc { List posts = List.from(state.posts); List comments = List.from(state.comments); - Map feedItemResult = await fetchFeedItems( + FeedResult feedItemResult = await fetchFeedItems( cursor: state.cursor, feedListType: state.feedListType, postSortType: state.postSortType, @@ -597,11 +684,11 @@ class FeedBloc extends Bloc { ); // Extract information from the response - List newPosts = feedItemResult['posts']; - List newComments = feedItemResult['comments']; - bool hasReachedPostsEnd = feedItemResult['hasReachedPostsEnd']; - bool hasReachedCommentsEnd = feedItemResult['hasReachedCommentsEnd']; - String? cursor = feedItemResult['cursor']; + List newPosts = feedItemResult.posts; + List newComments = feedItemResult.comments; + bool hasReachedPostsEnd = feedItemResult.hasReachedPostsEnd; + bool hasReachedCommentsEnd = feedItemResult.hasReachedCommentsEnd; + String? cursor = feedItemResult.cursor; Set newInsertedPostIds = Set.from(state.insertedPostIds); List filteredPosts = []; @@ -626,10 +713,19 @@ class FeedBloc extends Bloc { hasReachedPostsEnd: hasReachedPostsEnd, hasReachedCommentsEnd: hasReachedCommentsEnd, cursor: cursor, + errorReason: null, )); } catch (e) { debugPrint('Error fetching feed: $e'); - return emit(state.copyWith(status: FeedStatus.failure, message: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: FeedStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } @@ -650,9 +746,21 @@ class FeedBloc extends Bloc { List updatedPosts = List.from(state.posts); updatedPosts.insert(0, post); - emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); + emit(state.copyWith( + status: FeedStatus.success, + posts: updatedPosts, + errorReason: null, + )); } catch (e) { - return emit(state.copyWith(status: FeedStatus.failure, message: e.toString())); + final message = e.toString(); + return emit(state.copyWith( + status: FeedStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } diff --git a/lib/src/features/feed/presentation/bloc/feed_event.dart b/lib/src/features/feed/presentation/state/feed_event.dart similarity index 56% rename from lib/src/features/feed/presentation/bloc/feed_event.dart rename to lib/src/features/feed/presentation/state/feed_event.dart index f3016bceb..0fea50c88 100644 --- a/lib/src/features/feed/presentation/bloc/feed_event.dart +++ b/lib/src/features/feed/presentation/state/feed_event.dart @@ -1,10 +1,111 @@ part of 'feed_bloc.dart'; +sealed class FeedActionInput extends Equatable { + const FeedActionInput(); + + @override + List get props => []; +} + +final class VotePostInput extends FeedActionInput { + const VotePostInput(this.score); + + final int score; + + @override + List get props => [score]; +} + +final class SavePostInput extends FeedActionInput { + const SavePostInput(this.save); + + final bool save; + + @override + List get props => [save]; +} + +final class ReadPostInput extends FeedActionInput { + const ReadPostInput(this.read); + + final bool read; + + @override + List get props => [read]; +} + +final class MultiReadPostInput extends FeedActionInput { + const MultiReadPostInput(this.read); + + final bool read; + + @override + List get props => [read]; +} + +final class HidePostInput extends FeedActionInput { + const HidePostInput(this.hide); + + final bool hide; + + @override + List get props => [hide]; +} + +final class DeletePostInput extends FeedActionInput { + const DeletePostInput(this.delete); + + final bool delete; + + @override + List get props => [delete]; +} + +final class ReportPostInput extends FeedActionInput { + const ReportPostInput(this.reason); + + final String reason; + + @override + List get props => [reason]; +} + +final class LockPostInput extends FeedActionInput { + const LockPostInput(this.lock); + + final bool lock; + + @override + List get props => [lock]; +} + +final class PinCommunityPostInput extends FeedActionInput { + const PinCommunityPostInput(this.pin); + + final bool pin; + + @override + List get props => [pin]; +} + +final class RemovePostInput extends FeedActionInput { + const RemovePostInput({ + required this.remove, + required this.reason, + }); + + final bool remove; + final String reason; + + @override + List get props => [remove, reason]; +} + sealed class FeedEvent extends Equatable { const FeedEvent(); @override - List get props => []; + List get props => []; } final class FeedFetchedEvent extends FeedEvent { @@ -54,12 +155,18 @@ final class FeedFetchedEvent extends FeedEvent { this.showHidden = false, this.showSaved = false, }); + + @override + List get props => [feedType, feedTypeSubview, feedListType, postSortType, communityId, communityName, userId, username, reset, showHidden, showSaved]; } final class FeedChangePostSortTypeEvent extends FeedEvent { final PostSortType postSortType; const FeedChangePostSortTypeEvent(this.postSortType); + + @override + List get props => [postSortType]; } final class ResetFeedEvent extends FeedEvent { @@ -67,18 +174,27 @@ final class ResetFeedEvent extends FeedEvent { final bool softReset; const ResetFeedEvent({this.softReset = false}); + + @override + List get props => [softReset]; } final class FeedItemUpdatedEvent extends FeedEvent { final ThunderPost post; const FeedItemUpdatedEvent({required this.post}); + + @override + List get props => [post]; } final class FeedCommunityUpdatedEvent extends FeedEvent { final ThunderCommunity community; const FeedCommunityUpdatedEvent({required this.community}); + + @override + List get props => [community]; } final class FeedItemActionedEvent extends FeedEvent { @@ -95,11 +211,19 @@ final class FeedItemActionedEvent extends FeedEvent { /// This indicates the relevant action to perform on the post final PostAction postAction; - /// This indicates the value to assign the action to. It is of type dynamic to allow for any type - /// TODO: Change the dynamic type to the correct type(s) if possible - final dynamic value; + /// Typed payload for the selected [postAction]. + final FeedActionInput? actionInput; + + const FeedItemActionedEvent({ + this.post, + this.postId, + this.postIds, + required this.postAction, + this.actionInput, + }); - const FeedItemActionedEvent({this.post, this.postId, this.postIds, required this.postAction, this.value}); + @override + List get props => [post, postId, postIds, postAction, actionInput]; } final class FeedClearMessageEvent extends FeedEvent {} @@ -113,18 +237,27 @@ final class FeedDismissBlockedEvent extends FeedEvent { final int? userId; const FeedDismissBlockedEvent({this.communityId, this.userId}); + + @override + List get props => [communityId, userId]; } final class FeedDismissHiddenPostEvent extends FeedEvent { final int postId; const FeedDismissHiddenPostEvent({required this.postId}); + + @override + List get props => [postId]; } final class FeedHidePostsFromViewEvent extends FeedEvent { final List postIds; const FeedHidePostsFromViewEvent({required this.postIds}); + + @override + List get props => [postIds]; } final class CreatePostEvent extends FeedEvent { @@ -135,10 +268,16 @@ final class CreatePostEvent extends FeedEvent { final bool? nsfw; const CreatePostEvent({required this.communityId, required this.name, this.body, this.url, this.nsfw}); + + @override + List get props => [communityId, name, body, url, nsfw]; } final class PopulatePostsEvent extends FeedEvent { final List posts; const PopulatePostsEvent(this.posts); + + @override + List get props => [posts]; } diff --git a/lib/src/features/feed/presentation/bloc/feed_state.dart b/lib/src/features/feed/presentation/state/feed_state.dart similarity index 67% rename from lib/src/features/feed/presentation/bloc/feed_state.dart rename to lib/src/features/feed/presentation/state/feed_state.dart index 904d581ef..b1350db93 100644 --- a/lib/src/features/feed/presentation/bloc/feed_state.dart +++ b/lib/src/features/feed/presentation/state/feed_state.dart @@ -1,5 +1,7 @@ part of 'feed_bloc.dart'; +const _feedStateUnset = Object(); + enum FeedStatus { initial, fetching, success, failure, failureLoadingCommunity, failureLoadingUser } final class FeedState extends Equatable { @@ -23,6 +25,7 @@ final class FeedState extends Equatable { this.username, this.cursor, this.message, + this.errorReason, this.insertedPostIds = const [], this.showHidden = false, this.showSaved = false, @@ -86,6 +89,9 @@ final class FeedState extends Equatable { /// The message to display on failure final String? message; + /// Typed reason for failures. + final AppErrorReason? errorReason; + /// The inserted post ids. This is used to prevent duplicate posts final List insertedPostIds; @@ -104,24 +110,25 @@ final class FeedState extends Equatable { List? comments, bool? hasReachedPostsEnd, bool? hasReachedCommentsEnd, - FeedType? feedType, - FeedListType? feedListType, - PostSortType? postSortType, - ThunderCommunity? community, - ThunderSite? communityInstance, + Object? feedType = _feedStateUnset, + Object? feedListType = _feedStateUnset, + Object? postSortType = _feedStateUnset, + Object? community = _feedStateUnset, + Object? communityInstance = _feedStateUnset, List? communityModerators, - ThunderUser? user, + Object? user = _feedStateUnset, List? userModerates, - int? communityId, - String? communityName, - int? userId, - String? username, - String? cursor, - String? message, + Object? communityId = _feedStateUnset, + Object? communityName = _feedStateUnset, + Object? userId = _feedStateUnset, + Object? username = _feedStateUnset, + Object? cursor = _feedStateUnset, + Object? message = _feedStateUnset, + Object? errorReason = _feedStateUnset, List? insertedPostIds, bool? showHidden, bool? showSaved, - bool? excessivesApiCalls, + bool? excessiveApiCalls, }) { return FeedState( status: status ?? this.status, @@ -129,24 +136,25 @@ final class FeedState extends Equatable { comments: comments ?? this.comments, hasReachedPostsEnd: hasReachedPostsEnd ?? this.hasReachedPostsEnd, hasReachedCommentsEnd: hasReachedCommentsEnd ?? this.hasReachedCommentsEnd, - feedType: feedType ?? this.feedType, - feedListType: feedListType ?? this.feedListType, - postSortType: postSortType ?? this.postSortType, - community: community ?? this.community, - communityInstance: communityInstance ?? this.communityInstance, + feedType: identical(feedType, _feedStateUnset) ? this.feedType : feedType as FeedType?, + feedListType: identical(feedListType, _feedStateUnset) ? this.feedListType : feedListType as FeedListType?, + postSortType: identical(postSortType, _feedStateUnset) ? this.postSortType : postSortType as PostSortType?, + community: identical(community, _feedStateUnset) ? this.community : community as ThunderCommunity?, + communityInstance: identical(communityInstance, _feedStateUnset) ? this.communityInstance : communityInstance as ThunderSite?, communityModerators: communityModerators ?? this.communityModerators, - user: user ?? this.user, + user: identical(user, _feedStateUnset) ? this.user : user as ThunderUser?, userModerates: userModerates ?? this.userModerates, - communityId: communityId ?? this.communityId, - communityName: communityName ?? this.communityName, - userId: userId ?? this.userId, - username: username ?? this.username, - cursor: cursor ?? this.cursor, - message: message, + communityId: identical(communityId, _feedStateUnset) ? this.communityId : communityId as int?, + communityName: identical(communityName, _feedStateUnset) ? this.communityName : communityName as String?, + userId: identical(userId, _feedStateUnset) ? this.userId : userId as int?, + username: identical(username, _feedStateUnset) ? this.username : username as String?, + cursor: identical(cursor, _feedStateUnset) ? this.cursor : cursor as String?, + message: identical(message, _feedStateUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _feedStateUnset) ? this.errorReason : errorReason as AppErrorReason?, insertedPostIds: insertedPostIds ?? this.insertedPostIds, showHidden: showHidden ?? this.showHidden, showSaved: showSaved ?? this.showSaved, - excessiveApiCalls: excessivesApiCalls ?? false, + excessiveApiCalls: excessiveApiCalls ?? this.excessiveApiCalls, ); } @@ -156,7 +164,7 @@ final class FeedState extends Equatable { } @override - List get props => [ + List get props => [ status, community, communityInstance, @@ -176,6 +184,7 @@ final class FeedState extends Equatable { username, cursor, message, + errorReason, insertedPostIds, showHidden, showSaved, diff --git a/lib/src/features/feed/presentation/utils/community.dart b/lib/src/features/feed/presentation/utils/community_feed_utils.dart similarity index 86% rename from lib/src/features/feed/presentation/utils/community.dart rename to lib/src/features/feed/presentation/utils/community_feed_utils.dart index 12b5737a2..937b530bd 100644 --- a/lib/src/features/feed/presentation/utils/community.dart +++ b/lib/src/features/feed/presentation/utils/community_feed_utils.dart @@ -4,12 +4,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; + +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; Future toggleFavoriteCommunity(BuildContext context, ThunderCommunity community, bool isFavorite) async { try { @@ -19,7 +19,9 @@ Future toggleFavoriteCommunity(BuildContext context, ThunderCommunity comm if (isFavorite) { await Favorite.deleteFavorite(communityId: community.id); - if (context.mounted) context.read().add(const FetchProfileFavorites()); + if (context.mounted) { + context.read().add(const FetchProfileFavorites()); + } return; } @@ -30,7 +32,9 @@ Future toggleFavoriteCommunity(BuildContext context, ThunderCommunity comm ); await Favorite.insertFavorite(favorite); - if (context.mounted) context.read().add(const FetchProfileFavorites()); + if (context.mounted) { + context.read().add(const FetchProfileFavorites()); + } } catch (e) { showSnackbar(getExceptionErrorMessage(e)); } diff --git a/lib/src/features/feed/presentation/utils/post.dart b/lib/src/features/feed/presentation/utils/feed_fetch_utils.dart similarity index 91% rename from lib/src/features/feed/presentation/utils/post.dart rename to lib/src/features/feed/presentation/utils/feed_fetch_utils.dart index eeb336ed8..4726acbab 100644 --- a/lib/src/features/feed/presentation/utils/post.dart +++ b/lib/src/features/feed/presentation/utils/feed_fetch_utils.dart @@ -1,18 +1,15 @@ import 'package:flutter/foundation.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; /// Helper function which handles the logic of fetching items for the feed from the API /// This includes posts and user information (posts/comments) -Future> fetchFeedItems({ +Future fetchFeedItems({ String? cursor, FeedListType? feedListType, PostSortType? postSortType, @@ -110,6 +107,7 @@ Future> fetchFeedItems({ sort: postSortType, page: currentPage, saved: showSaved, + includeContent: true, ); List responsePosts = response!['posts']; @@ -133,5 +131,11 @@ Future> fetchFeedItems({ currentCursor = currentPage.toString(); } - return {'posts': posts, 'comments': comments, 'hasReachedPostsEnd': hasReachedPostsEnd, 'hasReachedCommentsEnd': hasReachedCommentsEnd, 'cursor': currentCursor}; + return FeedResult( + posts: posts, + comments: comments, + hasReachedPostsEnd: hasReachedPostsEnd, + hasReachedCommentsEnd: hasReachedCommentsEnd, + cursor: currentCursor, + ); } diff --git a/lib/src/features/feed/presentation/utils/utils.dart b/lib/src/features/feed/presentation/utils/feed_header_utils.dart similarity index 100% rename from lib/src/features/feed/presentation/utils/utils.dart rename to lib/src/features/feed/presentation/utils/feed_header_utils.dart diff --git a/lib/src/features/feed/presentation/utils/community_share.dart b/lib/src/features/feed/presentation/utils/feed_share_utils.dart similarity index 50% rename from lib/src/features/feed/presentation/utils/community_share.dart rename to lib/src/features/feed/presentation/utils/feed_share_utils.dart index d04938231..3e248b574 100644 --- a/lib/src/features/feed/presentation/utils/community_share.dart +++ b/lib/src/features/feed/presentation/utils/feed_share_utils.dart @@ -1,72 +1,133 @@ -import 'package:flutter/material.dart'; - -import 'package:share_plus/share_plus.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; - -enum CommunityShareOptions { link, localLink, lemmy } - -/// Shows a bottom modal sheet which allows sharing the given [community]. -Future showCommunityShareSheet(BuildContext context, ThunderCommunity community) async { - final l10n = AppLocalizations.of(context)!; - final account = await fetchActiveProfile(); - - final communityLink = await getLemmyCommunity(community.actorId) ?? ''; - final lemmyLink = '!$communityLink'; - final localLink = 'https://${account.instance}/c/$communityLink'; - - if (context.mounted) { - showModalBottomSheet( - showDragHandle: true, - isScrollControlled: true, - context: context, - builder: (builderContext) => BottomSheetListPicker( - title: l10n.shareCommunity, - items: [ - ListPickerItem( - label: l10n.shareCommunityLink, - icon: Icons.link_rounded, - subtitle: community.actorId, - payload: CommunityShareOptions.link, - ), - if (!community.actorId.contains(account.instance)) - ListPickerItem( - label: l10n.shareCommunityLinkLocal, - icon: Icons.link_rounded, - subtitle: localLink, - payload: CommunityShareOptions.localLink, - ), - ListPickerItem( - label: l10n.shareLemmyLink, - icon: Icons.share_rounded, - subtitle: lemmyLink, - payload: CommunityShareOptions.lemmy, - ), - ], - onSelect: (selection) async { - switch (selection.payload) { - case CommunityShareOptions.link: - SharePlus.instance.share(ShareParams( - uri: Uri.parse(community.actorId), - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - case CommunityShareOptions.localLink: - SharePlus.instance.share(ShareParams( - uri: Uri.parse(localLink), - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - case CommunityShareOptions.lemmy: - SharePlus.instance.share(ShareParams( - text: lemmyLink, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } - }, - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:share_plus/share_plus.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/feed/presentation/models/feed_share_options.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; + +/// Shows a bottom modal sheet which allows sharing the given [community]. +Future showCommunityShareSheet(BuildContext context, ThunderCommunity community) async { + final l10n = AppLocalizations.of(context)!; + final account = await fetchActiveProfile(); + + final communityLink = await getLemmyCommunity(community.actorId) ?? ''; + final lemmyLink = '!$communityLink'; + final localLink = 'https://${account.instance}/c/$communityLink'; + + if (context.mounted) { + showModalBottomSheet( + showDragHandle: true, + isScrollControlled: true, + context: context, + builder: (builderContext) => BottomSheetListPicker( + title: l10n.shareCommunity, + items: [ + ListPickerItem( + label: l10n.shareCommunityLink, + icon: Icons.link_rounded, + subtitle: community.actorId, + payload: CommunityShareOptions.link, + ), + if (!community.actorId.contains(account.instance)) + ListPickerItem( + label: l10n.shareCommunityLinkLocal, + icon: Icons.link_rounded, + subtitle: localLink, + payload: CommunityShareOptions.localLink, + ), + ListPickerItem( + label: l10n.shareLemmyLink, + icon: Icons.share_rounded, + subtitle: lemmyLink, + payload: CommunityShareOptions.lemmy, + ), + ], + onSelect: (selection) async { + switch (selection.payload) { + case CommunityShareOptions.link: + SharePlus.instance.share(ShareParams( + uri: Uri.parse(community.actorId), + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + case CommunityShareOptions.localLink: + SharePlus.instance.share(ShareParams( + uri: Uri.parse(localLink), + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + case CommunityShareOptions.lemmy: + SharePlus.instance.share(ShareParams( + text: lemmyLink, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + } + }, + ), + ); + } +} + +/// Shows a bottom modal sheet which allows sharing the given [person]. +Future showUserShareSheet(BuildContext context, ThunderUser person) async { + final AppLocalizations l10n = AppLocalizations.of(context)!; + final account = await fetchActiveProfile(); + + String user = await getLemmyUser(person.actorId) ?? ''; + String lemmyLink = '@$user'; + String localLink = 'https://${account.instance}/u/$user'; + + if (context.mounted) { + showModalBottomSheet( + showDragHandle: true, + isScrollControlled: true, + context: context, + builder: (builderContext) => BottomSheetListPicker( + title: l10n.shareUser, + items: [ + ListPickerItem( + label: l10n.shareUserLink, + payload: UserShareOptions.link, + subtitle: person.actorId, + icon: Icons.link_rounded, + ), + if (!person.actorId.contains(account.instance)) + ListPickerItem( + label: l10n.shareUserLinkLocal, + payload: UserShareOptions.localLink, + subtitle: localLink, + icon: Icons.link_rounded, + ), + ListPickerItem( + label: l10n.shareLemmyLink, + payload: UserShareOptions.lemmy, + subtitle: lemmyLink, + icon: Icons.share_rounded, + ), + ], + onSelect: (selection) async { + switch (selection.payload) { + case UserShareOptions.link: + SharePlus.instance.share(ShareParams( + uri: Uri.parse(person.actorId), + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + case UserShareOptions.localLink: + SharePlus.instance.share(ShareParams( + uri: Uri.parse(localLink), + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + case UserShareOptions.lemmy: + SharePlus.instance.share(ShareParams( + text: lemmyLink, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + } + }, + ), + ); + } +} diff --git a/lib/src/features/feed/presentation/utils/user_share.dart b/lib/src/features/feed/presentation/utils/user_share.dart deleted file mode 100644 index 97b1e7945..000000000 --- a/lib/src/features/feed/presentation/utils/user_share.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:share_plus/share_plus.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -enum UserShareOptions { - link, - localLink, - lemmy, -} - -/// Shows a mottom modal sheet which allows sharing the given [person]. -Future showUserShareSheet(BuildContext context, ThunderUser person) async { - final AppLocalizations l10n = AppLocalizations.of(context)!; - final account = await fetchActiveProfile(); - - String user = await getLemmyUser(person.actorId) ?? ''; - String lemmyLink = '@$user'; - String localLink = 'https://${account.instance}/u/$user'; - - if (context.mounted) { - showModalBottomSheet( - showDragHandle: true, - isScrollControlled: true, - context: context, - builder: (builderContext) => BottomSheetListPicker( - title: l10n.shareUser, - items: [ - ListPickerItem( - label: l10n.shareUserLink, - payload: UserShareOptions.link, - subtitle: person.actorId, - icon: Icons.link_rounded, - ), - if (!person.actorId.contains(account.instance)) - ListPickerItem( - label: l10n.shareUserLinkLocal, - payload: UserShareOptions.localLink, - subtitle: localLink, - icon: Icons.link_rounded, - ), - ListPickerItem( - label: l10n.shareLemmyLink, - payload: UserShareOptions.lemmy, - subtitle: lemmyLink, - icon: Icons.share_rounded, - ), - ], - onSelect: (selection) async { - switch (selection.payload) { - case UserShareOptions.link: - SharePlus.instance.share(ShareParams( - uri: Uri.parse(person.actorId), - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - case UserShareOptions.localLink: - SharePlus.instance.share(ShareParams( - uri: Uri.parse(localLink), - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - case UserShareOptions.lemmy: - SharePlus.instance.share(ShareParams( - text: lemmyLink, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } - }, - ), - ); - } -} diff --git a/lib/src/features/feed/presentation/widgets/feed_card_divider.dart b/lib/src/features/feed/presentation/widgets/feed_card_divider.dart index 087d4c9db..6b7a4c0b5 100644 --- a/lib/src/features/feed/presentation/widgets/feed_card_divider.dart +++ b/lib/src/features/feed/presentation/widgets/feed_card_divider.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; +import 'package:thunder/src/features/feed/api.dart'; /// A user-customizable divider used between items (posts/comments) in the feed page. /// diff --git a/lib/src/features/feed/presentation/widgets/feed_comment_card_list.dart b/lib/src/features/feed/presentation/widgets/feed_comment_card_list.dart index 0092ba0c1..8f35dbf6b 100644 --- a/lib/src/features/feed/presentation/widgets/feed_comment_card_list.dart +++ b/lib/src/features/feed/presentation/widgets/feed_comment_card_list.dart @@ -4,7 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/comment_reference.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; /// Widget representing the list of comments on the feed. This is used when viewing a user's profile. class FeedCommentCardList extends StatelessWidget { diff --git a/lib/src/features/feed/presentation/widgets/feed_fab.dart b/lib/src/features/feed/presentation/widgets/feed_fab.dart index 460ac7450..3bd56ff7a 100644 --- a/lib/src/features/feed/presentation/widgets/feed_fab.dart +++ b/lib/src/features/feed/presentation/widgets/feed_fab.dart @@ -5,19 +5,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; +import 'package:thunder/src/features/feed/api.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/fab_action.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/shared/gesture_fab.dart'; -import 'package:thunder/src/shared/snackbar.dart'; + import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; class FeedFAB extends StatelessWidget { const FeedFAB({super.key, this.heroTag}); diff --git a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart index 793e3db68..576d9f3d9 100644 --- a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart +++ b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart @@ -6,16 +6,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem; /// Holds the app bar for the feed page. The app bar actions changes depending on the type of feed (general, community, user) class FeedPageAppBar extends StatefulWidget { @@ -306,7 +306,9 @@ class _FeedDrawerButton extends StatelessWidget { @override Widget build(BuildContext context) { // Show profile picture only if it's the root feed and profile picture is enabled - if (isRoot && showProfilePicture) return _buildProfilePictureButton(context); + if (isRoot && showProfilePicture) { + return _buildProfilePictureButton(context); + } return _buildIconButton(context); } diff --git a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart index f7c107e43..1b5a21676 100644 --- a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart +++ b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart @@ -1,238 +1,238 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:visibility_detector/visibility_detector.dart'; - -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; - -/// Widget representing the list of posts on the feed. -class FeedPostCardList extends StatefulWidget { - /// Whether or not the screen is in tablet mode. Determines the number of columns to display - final bool tabletMode; - - /// Determines whether to mark posts as read on scroll - final bool markPostReadOnScroll; - - /// The list of posts that have been queued for removal using the dismiss read action - final List? queuedForRemoval; - - /// The list of posts to show on the feed - final List posts; - - /// Whether or not to dim read posts. This value overrides [dimReadPosts] in [ThunderBloc] - final bool? dimReadPosts; - - /// Whether to disable swiping of posts - final bool disableSwiping; - - /// Overrides the system setting for whether to indicate read posts - final bool? indicateRead; - - const FeedPostCardList({ - super.key, - required this.posts, - required this.tabletMode, - required this.markPostReadOnScroll, - this.queuedForRemoval, - this.dimReadPosts, - this.disableSwiping = false, - this.indicateRead, - }); - - @override - State createState() => _FeedPostCardListState(); -} - -class _FeedPostCardListState extends State { - /// The index of the last tapped post. - /// This is used to calculate the read status of posts in the range [0, lastTappedIndex] - int lastTappedIndex = -1; - - /// The index of the last processed post for read status. - int lastProcessedIndex = -1; - - /// Whether the user is scrolling down or not. The logic for determining read posts will - /// only be applied when the user is scrolling down - bool isScrollingDown = false; - - /// List of post ids to queue for being marked as read. - Set markReadPostIds = {}; - - /// List of post ids that have already previously been detected as read - Set readPostIds = {}; - - /// Timer for debouncing the read action - Timer? debounceTimer; - - /// The ID of the last post that the user tapped or navigated into - int? lastTappedPost; - - @override - void dispose() { - debounceTimer?.cancel(); - super.dispose(); - } - - /// Builds an individual post card with the given [post] and [index]. - Widget _buildPostCard({ - required ThunderPost post, - required int index, - FeedType? feedType, - bool dim = false, - FeedListType? feedListType, - bool isUserLoggedIn = false, - }) { - Widget child = PostCard( - post: post, - onVoteAction: (int voteType) { - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.vote, value: voteType)); - }, - onSaveAction: (bool saved) { - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.save, value: saved)); - }, - onReadAction: (bool read) { - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.read, value: read)); - }, - onHideAction: (bool hide) { - context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.hide, value: hide)); - context.read().add(FeedDismissHiddenPostEvent(postId: post.id)); - }, - onDownAction: () { - if (lastTappedIndex != index) lastTappedIndex = index; - }, - onUpAction: (double verticalDragDistance) { - bool updatedIsScrollingDown = verticalDragDistance < 0; - - if (isScrollingDown != updatedIsScrollingDown) { - isScrollingDown = updatedIsScrollingDown; - } - }, - onTap: () { - if (lastTappedPost != post.id) setState(() => lastTappedPost = post.id); - }, - indicateRead: dim, - isLastTapped: lastTappedPost == post.id, - disableSwiping: widget.disableSwiping, - ); - - // Apply VisibilityDetector if [markPostReadOnScroll] is enabled - if (isUserLoggedIn && widget.markPostReadOnScroll) { - child = VisibilityDetector( - key: Key(post.apId), - onVisibilityChanged: (info) { - if (!isScrollingDown) return; - - if (index <= lastTappedIndex && info.visibleFraction == 0) { - // Debounce the read action to account for quick scrolling. This reduces the number of times the read action is triggered - debounceTimer?.cancel(); - - debounceTimer = Timer(const Duration(milliseconds: 500), () { - // TODO: Improve logic here so that we don't have to iterate through all posts if possible. - int startIndex = index; - int endIndex = lastProcessedIndex > 0 ? lastProcessedIndex : 0; - - for (int i = startIndex; i >= endIndex; i--) { - final post = widget.posts[i]; - - // If we already checked this post's read status, or we already marked it as read, skip it - if (readPostIds.contains(post.id) || markReadPostIds.contains(post.id)) continue; - - // Otherwise, check the post read status. If it's unread, queue it for marking as read - if (post.read == false) markReadPostIds.add(post.id); - readPostIds.add(post.id); - } - - // Update the last processed index - if (index > lastProcessedIndex) lastProcessedIndex = index; - - if (markReadPostIds.isNotEmpty) { - context.read().add(FeedItemActionedEvent(postIds: [...markReadPostIds], postAction: PostAction.multiRead, value: true)); - readPostIds.addAll(markReadPostIds); // Add all post ids that were queued to prevent them from being queued again - markReadPostIds = {}; // Reset the list of post ids to mark as read - } - }); - } - }, - child: child, - ); - } - - // Only apply dismissal animation when the post is queued for removal - final isQueuedForRemoval = widget.queuedForRemoval?.contains(post.id) == true; - - if (isQueuedForRemoval) { - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 300), - tween: Tween(begin: 1.0, end: 0.0), - builder: (context, value, animatedChild) { - return ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: value, - child: Opacity( - opacity: value, - child: Transform.translate( - offset: Offset((1 - value) * 100, 0), - child: child, - ), - ), - ), - ); - }, - ); - } - - return child; - } - - @override - Widget build(BuildContext context) { - final feedType = context.select((bloc) => bloc.state.feedType); - final feedListType = context.select((bloc) => bloc.state.feedListType); - final isUserLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); - - bool dimReadPosts = widget.dimReadPosts ?? (isUserLoggedIn && context.select((cubit) => cubit.state.dimReadPosts)); - - if (widget.tabletMode) { - return SliverMasonryGrid.count( - crossAxisCount: widget.tabletMode ? 2 : 1, - crossAxisSpacing: 40, - mainAxisSpacing: 0, - itemBuilder: (BuildContext context, int index) { - return _buildPostCard( - post: widget.posts[index], - index: index, - dim: widget.indicateRead ?? dimReadPosts, - feedType: feedType, - feedListType: feedListType, - isUserLoggedIn: isUserLoggedIn, - ); - }, - childCount: widget.posts.length, - ); - } - - return SliverList.builder( - itemBuilder: (context, index) { - return _buildPostCard( - post: widget.posts[index], - index: index, - dim: widget.indicateRead ?? dimReadPosts, - feedType: feedType, - feedListType: feedListType, - isUserLoggedIn: isUserLoggedIn, - ); - }, - itemCount: widget.posts.length, - ); - } -} +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; + +/// Widget representing the list of posts on the feed. +class FeedPostCardList extends StatefulWidget { + /// Whether or not the screen is in tablet mode. Determines the number of columns to display + final bool tabletMode; + + /// Determines whether to mark posts as read on scroll + final bool markPostReadOnScroll; + + /// The list of posts that have been queued for removal using the dismiss read action + final List? queuedForRemoval; + + /// The list of posts to show on the feed + final List posts; + + /// Whether or not to dim read posts. This value overrides [dimReadPosts] in [ThunderBloc] + final bool? dimReadPosts; + + /// Whether to disable swiping of posts + final bool disableSwiping; + + /// Overrides the system setting for whether to indicate read posts + final bool? indicateRead; + + const FeedPostCardList({ + super.key, + required this.posts, + required this.tabletMode, + required this.markPostReadOnScroll, + this.queuedForRemoval, + this.dimReadPosts, + this.disableSwiping = false, + this.indicateRead, + }); + + @override + State createState() => _FeedPostCardListState(); +} + +class _FeedPostCardListState extends State { + /// The index of the last tapped post. + /// This is used to calculate the read status of posts in the range [0, lastTappedIndex] + int lastTappedIndex = -1; + + /// The index of the last processed post for read status. + int lastProcessedIndex = -1; + + /// Whether the user is scrolling down or not. The logic for determining read posts will + /// only be applied when the user is scrolling down + bool isScrollingDown = false; + + /// List of post ids to queue for being marked as read. + Set markReadPostIds = {}; + + /// List of post ids that have already previously been detected as read + Set readPostIds = {}; + + /// Timer for debouncing the read action + Timer? debounceTimer; + + /// The ID of the last post that the user tapped or navigated into + int? lastTappedPost; + + @override + void dispose() { + debounceTimer?.cancel(); + super.dispose(); + } + + /// Builds an individual post card with the given [post] and [index]. + Widget _buildPostCard({ + required ThunderPost post, + required int index, + FeedType? feedType, + bool dim = false, + FeedListType? feedListType, + bool isUserLoggedIn = false, + }) { + Widget child = PostCard( + post: post, + onVoteAction: (int voteType) { + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.vote, actionInput: VotePostInput(voteType))); + }, + onSaveAction: (bool saved) { + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.save, actionInput: SavePostInput(saved))); + }, + onReadAction: (bool read) { + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.read, actionInput: ReadPostInput(read))); + }, + onHideAction: (bool hide) { + context.read().add(FeedItemActionedEvent(postId: post.id, postAction: PostAction.hide, actionInput: HidePostInput(hide))); + context.read().add(FeedDismissHiddenPostEvent(postId: post.id)); + }, + onDownAction: () { + if (lastTappedIndex != index) lastTappedIndex = index; + }, + onUpAction: (double verticalDragDistance) { + bool updatedIsScrollingDown = verticalDragDistance < 0; + + if (isScrollingDown != updatedIsScrollingDown) { + isScrollingDown = updatedIsScrollingDown; + } + }, + onTap: () { + if (lastTappedPost != post.id) setState(() => lastTappedPost = post.id); + }, + indicateRead: dim, + isLastTapped: lastTappedPost == post.id, + disableSwiping: widget.disableSwiping, + ); + + // Apply VisibilityDetector if [markPostReadOnScroll] is enabled + if (isUserLoggedIn && widget.markPostReadOnScroll) { + child = VisibilityDetector( + key: Key(post.apId), + onVisibilityChanged: (info) { + if (!isScrollingDown) return; + + if (index <= lastTappedIndex && info.visibleFraction == 0) { + // Debounce the read action to account for quick scrolling. This reduces the number of times the read action is triggered + debounceTimer?.cancel(); + + debounceTimer = Timer(const Duration(milliseconds: 500), () { + // TODO: Improve logic here so that we don't have to iterate through all posts if possible. + int startIndex = index; + int endIndex = lastProcessedIndex > 0 ? lastProcessedIndex : 0; + + for (int i = startIndex; i >= endIndex; i--) { + final post = widget.posts[i]; + + // If we already checked this post's read status, or we already marked it as read, skip it + if (readPostIds.contains(post.id) || markReadPostIds.contains(post.id)) continue; + + // Otherwise, check the post read status. If it's unread, queue it for marking as read + if (post.read == false) markReadPostIds.add(post.id); + readPostIds.add(post.id); + } + + // Update the last processed index + if (index > lastProcessedIndex) lastProcessedIndex = index; + + if (markReadPostIds.isNotEmpty) { + context.read().add(FeedItemActionedEvent(postIds: [...markReadPostIds], postAction: PostAction.multiRead, actionInput: const MultiReadPostInput(true))); + readPostIds.addAll(markReadPostIds); // Add all post ids that were queued to prevent them from being queued again + markReadPostIds = {}; // Reset the list of post ids to mark as read + } + }); + } + }, + child: child, + ); + } + + // Only apply dismissal animation when the post is queued for removal + final isQueuedForRemoval = widget.queuedForRemoval?.contains(post.id) == true; + + if (isQueuedForRemoval) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + tween: Tween(begin: 1.0, end: 0.0), + builder: (context, value, animatedChild) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: value, + child: Opacity( + opacity: value, + child: Transform.translate( + offset: Offset((1 - value) * 100, 0), + child: child, + ), + ), + ), + ); + }, + ); + } + + return child; + } + + @override + Widget build(BuildContext context) { + final feedType = context.select((bloc) => bloc.state.feedType); + final feedListType = context.select((bloc) => bloc.state.feedListType); + final isUserLoggedIn = context.select((bloc) => bloc.state.isLoggedIn); + + bool dimReadPosts = widget.dimReadPosts ?? (isUserLoggedIn && context.select((cubit) => cubit.state.dimReadPosts)); + + if (widget.tabletMode) { + return SliverMasonryGrid.count( + crossAxisCount: widget.tabletMode ? 2 : 1, + crossAxisSpacing: 40, + mainAxisSpacing: 0, + itemBuilder: (BuildContext context, int index) { + return _buildPostCard( + post: widget.posts[index], + index: index, + dim: widget.indicateRead ?? dimReadPosts, + feedType: feedType, + feedListType: feedListType, + isUserLoggedIn: isUserLoggedIn, + ); + }, + childCount: widget.posts.length, + ); + } + + return SliverList.builder( + itemBuilder: (context, index) { + return _buildPostCard( + post: widget.posts[index], + index: index, + dim: widget.indicateRead ?? dimReadPosts, + feedType: feedType, + feedListType: feedListType, + isUserLoggedIn: isUserLoggedIn, + ); + }, + itemCount: widget.posts.length, + ); + } +} diff --git a/lib/src/features/feed/presentation/widgets/tagline.dart b/lib/src/features/feed/presentation/widgets/tagline.dart index c9b56a179..210918d29 100644 --- a/lib/src/features/feed/presentation/widgets/tagline.dart +++ b/lib/src/features/feed/presentation/widgets/tagline.dart @@ -7,9 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; /// Displays a random tagline from the site whenever the feed is refreshed. class TagLine extends StatefulWidget { diff --git a/lib/src/features/identity/presentation/widgets/avatars/community_avatar.dart b/lib/src/features/identity/presentation/widgets/avatars/community_avatar.dart new file mode 100644 index 000000000..fecc39bb3 --- /dev/null +++ b/lib/src/features/identity/presentation/widgets/avatars/community_avatar.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' as identity; +import 'package:thunder/src/features/community/api.dart'; + +/// App adapter for the generic identity package avatar. +class CommunityAvatar extends StatelessWidget { + final ThunderCommunity community; + final double radius; + final bool showCommunityStatus; + final int? thumbnailSize; + final String? format; + + const CommunityAvatar({ + super.key, + required this.community, + this.radius = 12.0, + this.showCommunityStatus = false, + this.thumbnailSize, + this.format, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + final queryParameters = {}; + if (thumbnailSize != null) { + queryParameters['thumbnail'] = thumbnailSize.toString(); + } + if (format != null) { + queryParameters['format'] = format; + } + + Uri? imageUri = community.icon != null ? Uri.parse(community.icon!) : null; + + if (imageUri != null && imageUri.path.contains('/pictrs/image/') && queryParameters.isNotEmpty) { + imageUri = Uri.https(imageUri.host, imageUri.path, queryParameters); + } + + return identity.CommunityAvatar( + data: identity.AvatarData( + fallbackLabel: community.titleOrName, + imageUrl: imageUri?.toString(), + radius: radius, + ), + showRestrictedBadge: community.postingRestrictedToMods && showCommunityStatus, + restrictedBadgeTooltip: l10n.onlyModsCanPostInCommunity, + restrictedBadgeSemanticLabel: l10n.onlyModsCanPostInCommunity, + ); + } +} diff --git a/lib/src/features/identity/presentation/widgets/avatars/instance_avatar.dart b/lib/src/features/identity/presentation/widgets/avatars/instance_avatar.dart new file mode 100644 index 000000000..3c412fb5d --- /dev/null +++ b/lib/src/features/identity/presentation/widgets/avatars/instance_avatar.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/ui.dart' as identity; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// App adapter for the generic identity package avatar. +class InstanceAvatar extends StatelessWidget { + final ThunderInstanceInfo instance; + final double radius; + + const InstanceAvatar({ + super.key, + required this.instance, + this.radius = 16.0, + }); + + @override + Widget build(BuildContext context) { + final fallbackLabel = instance.name.isNotEmpty + ? instance.name + : instance.domain.isNotEmpty + ? instance.domain + : ''; + + return identity.InstanceAvatar( + data: identity.AvatarData( + fallbackLabel: fallbackLabel, + imageUrl: instance.icon, + radius: radius, + ), + ); + } +} diff --git a/lib/src/features/identity/presentation/widgets/avatars/user_avatar.dart b/lib/src/features/identity/presentation/widgets/avatars/user_avatar.dart new file mode 100644 index 000000000..53a166568 --- /dev/null +++ b/lib/src/features/identity/presentation/widgets/avatars/user_avatar.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/ui.dart' as identity; +import 'package:thunder/src/features/user/api.dart'; + +/// App adapter for the generic identity package avatar. +class UserAvatar extends StatelessWidget { + final ThunderUser user; + final double radius; + final int? thumbnailSize; + final String? format; + + const UserAvatar({ + super.key, + required this.user, + this.radius = 16.0, + this.thumbnailSize, + this.format, + }); + + @override + Widget build(BuildContext context) { + final queryParameters = {}; + if (thumbnailSize != null) { + queryParameters['thumbnail'] = thumbnailSize.toString(); + } + if (format != null) { + queryParameters['format'] = format; + } + + Uri? imageUri = user.avatar != null ? Uri.parse(user.avatar!) : null; + + if (imageUri != null && imageUri.path.contains('/pictrs/image/') && queryParameters.isNotEmpty) { + imageUri = Uri.https(imageUri.host, imageUri.path, queryParameters); + } + + return identity.UserAvatar( + data: identity.AvatarData( + fallbackLabel: user.displayNameOrName, + imageUrl: imageUri?.toString(), + radius: radius, + ), + ); + } +} diff --git a/lib/src/features/identity/presentation/widgets/full_name_widgets.dart b/lib/src/features/identity/presentation/widgets/full_name_widgets.dart new file mode 100644 index 000000000..231578d0f --- /dev/null +++ b/lib/src/features/identity/presentation/widgets/full_name_widgets.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/packages/ui/ui.dart' as identity; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; + +/// App adapter for package-generic full-name widgets. +class UserFullNameWidget extends StatelessWidget { + const UserFullNameWidget( + this.outerContext, + this.name, + this.displayName, + this.instance, { + super.key, + this.userSeparator, + this.userNameThickness, + this.userNameColor, + this.instanceNameThickness, + this.instanceNameColor, + this.textStyle, + this.includeInstance = true, + this.fontScale, + this.autoSize = false, + this.transformColor, + this.useDisplayName, + }) : assert(outerContext != null || + (userSeparator != null && userNameThickness != null && userNameColor != null && instanceNameThickness != null && instanceNameColor != null && useDisplayName != null)), + assert(outerContext != null || textStyle != null); + + final BuildContext? outerContext; + final String? name; + final String? displayName; + final String? instance; + final FullNameSeparator? userSeparator; + final NameThickness? userNameThickness; + final NameColor? userNameColor; + final NameThickness? instanceNameThickness; + final NameColor? instanceNameColor; + final TextStyle? textStyle; + final bool includeInstance; + final FontScale? fontScale; + final bool autoSize; + final Color? Function(Color?)? transformColor; + final bool? useDisplayName; + + @override + Widget build(BuildContext context) { + final lookupContext = outerContext ?? context; + final themePreferences = lookupContext.read().state; + + return identity.UserFullNameWidget( + name: name, + displayName: displayName, + instance: instance, + separator: userSeparator ?? themePreferences.userSeparator, + useDisplayName: useDisplayName ?? themePreferences.useDisplayNamesForUsers, + userNameThickness: userNameThickness ?? themePreferences.userFullNameUserNameThickness, + userNameColor: userNameColor ?? themePreferences.userFullNameUserNameColor, + instanceNameThickness: instanceNameThickness ?? themePreferences.userFullNameInstanceNameThickness, + instanceNameColor: instanceNameColor ?? themePreferences.userFullNameInstanceNameColor, + textStyle: textStyle ?? Theme.of(lookupContext).textTheme.bodyMedium, + includeInstance: includeInstance, + textScaleFactor: fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor, + autoSize: autoSize, + transformColor: transformColor, + ); + } +} + +/// App adapter for package-generic full-name widgets. +class CommunityFullNameWidget extends StatelessWidget { + const CommunityFullNameWidget( + this.outerContext, + this.name, + this.displayName, + this.instance, { + super.key, + this.communitySeparator, + this.communityNameThickness, + this.communityNameColor, + this.instanceNameThickness, + this.instanceNameColor, + this.textStyle, + this.includeInstance = true, + this.fontScale, + this.autoSize = false, + this.transformColor, + this.useDisplayName, + }) : assert(outerContext != null || + (communitySeparator != null && communityNameThickness != null && communityNameColor != null && instanceNameThickness != null && instanceNameColor != null && useDisplayName != null)), + assert(outerContext != null || textStyle != null); + + final BuildContext? outerContext; + final String? name; + final String? displayName; + final String? instance; + final FullNameSeparator? communitySeparator; + final NameThickness? communityNameThickness; + final NameColor? communityNameColor; + final NameThickness? instanceNameThickness; + final NameColor? instanceNameColor; + final TextStyle? textStyle; + final bool includeInstance; + final FontScale? fontScale; + final bool autoSize; + final Color? Function(Color?)? transformColor; + final bool? useDisplayName; + + @override + Widget build(BuildContext context) { + final lookupContext = outerContext ?? context; + final themePreferences = lookupContext.read().state; + + return identity.CommunityFullNameWidget( + name: name, + displayName: displayName, + instance: instance, + separator: communitySeparator ?? themePreferences.communitySeparator, + useDisplayName: useDisplayName ?? themePreferences.useDisplayNamesForCommunities, + communityNameThickness: communityNameThickness ?? themePreferences.communityFullNameCommunityNameThickness, + communityNameColor: communityNameColor ?? themePreferences.communityFullNameCommunityNameColor, + instanceNameThickness: instanceNameThickness ?? themePreferences.communityFullNameInstanceNameThickness, + instanceNameColor: instanceNameColor ?? themePreferences.communityFullNameInstanceNameColor, + textStyle: textStyle ?? Theme.of(lookupContext).textTheme.bodyMedium, + includeInstance: includeInstance, + textScaleFactor: fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor, + autoSize: autoSize, + transformColor: transformColor, + ); + } +} diff --git a/lib/src/features/identity/presentation/widgets/text/scalable_text.dart b/lib/src/features/identity/presentation/widgets/text/scalable_text.dart new file mode 100644 index 000000000..c516cce8e --- /dev/null +++ b/lib/src/features/identity/presentation/widgets/text/scalable_text.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/packages/ui/ui.dart' as identity; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// App adapter for the generic identity package scalable text. +class ScalableText extends StatelessWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final FontScale? fontScale; + final String? semanticsLabel; + final TextOverflow? overflow; + final int? maxLines; + + const ScalableText( + this.text, { + super.key, + this.style, + this.textAlign, + this.fontScale, + this.semanticsLabel, + this.overflow, + this.maxLines, + }); + + @override + Widget build(BuildContext context) { + return identity.ScalableText( + text, + style: style, + textAlign: textAlign, + textScaleFactor: fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor, + semanticsLabel: semanticsLabel, + overflow: overflow, + maxLines: maxLines, + ); + } +} diff --git a/lib/src/features/inbox/api.dart b/lib/src/features/inbox/api.dart new file mode 100644 index 000000000..75e3b87c1 --- /dev/null +++ b/lib/src/features/inbox/api.dart @@ -0,0 +1 @@ +export 'inbox.dart'; diff --git a/lib/src/features/inbox/domain/utils/inbox_utils.dart b/lib/src/features/inbox/domain/utils/inbox_utils.dart new file mode 100644 index 000000000..c7a62a7da --- /dev/null +++ b/lib/src/features/inbox/domain/utils/inbox_utils.dart @@ -0,0 +1,56 @@ +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/inbox/inbox.dart'; + +List cleanDeletedMessages( + List messages, +) { + return messages.map(cleanDeletedPrivateMessage).toList(); +} + +List cleanDeletedMentions(List mentions) { + return mentions.map(cleanDeletedMention).toList(); +} + +ThunderPrivateMessage cleanDeletedPrivateMessage( + ThunderPrivateMessage message, +) { + if (message.deleted) { + return message.copyWith(content: '_deleted by creator_'); + } + return message; +} + +ThunderComment cleanDeletedMention(ThunderComment mention) { + if (mention.removed) { + return mention.copyWith(content: '_deleted by moderator_'); + } + if (mention.deleted) { + return mention.copyWith(content: '_deleted by creator_'); + } + return mention; +} + +({int mentionPage, int replyPage, int messagePage}) resetPagesForType(InboxType? type) { + final mentionsFetched = type == InboxType.mentions || type == InboxType.all; + final repliesFetched = type == InboxType.replies || type == InboxType.all; + final messagesFetched = type == InboxType.messages || type == InboxType.all; + + return ( + mentionPage: mentionsFetched ? 2 : 1, + replyPage: repliesFetched ? 2 : 1, + messagePage: messagesFetched ? 2 : 1, + ); +} + +({int mentionPage, int replyPage, int messagePage}) incrementFetchedPage({ + required InboxType? type, + required int currentMentionPage, + required int currentReplyPage, + required int currentMessagePage, +}) { + return ( + mentionPage: type == InboxType.mentions ? currentMentionPage + 1 : currentMentionPage, + replyPage: type == InboxType.replies ? currentReplyPage + 1 : currentReplyPage, + messagePage: type == InboxType.messages ? currentMessagePage + 1 : currentMessagePage, + ); +} diff --git a/lib/src/features/inbox/inbox.dart b/lib/src/features/inbox/inbox.dart index 0290b6d61..e6a37dc0f 100644 --- a/lib/src/features/inbox/inbox.dart +++ b/lib/src/features/inbox/inbox.dart @@ -1,5 +1,7 @@ export 'domain/enums/inbox_type.dart'; -export 'presentation/bloc/inbox_bloc.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +export 'domain/utils/inbox_utils.dart'; +export 'presentation/state/inbox_bloc.dart'; export 'presentation/pages/inbox_page.dart'; export 'presentation/widgets/inbox_mentions_view.dart'; export 'presentation/widgets/inbox_private_messages_view.dart'; diff --git a/lib/src/features/inbox/presentation/bloc/inbox_bloc.dart b/lib/src/features/inbox/presentation/bloc/inbox_bloc.dart deleted file mode 100644 index 7a14bf711..000000000 --- a/lib/src/features/inbox/presentation/bloc/inbox_bloc.dart +++ /dev/null @@ -1,489 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:stream_transform/stream_transform.dart'; - -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -part 'inbox_event.dart'; -part 'inbox_state.dart'; - -const throttleDuration = Duration(seconds: 1); -const timeout = Duration(seconds: 5); - -EventTransformer throttleDroppable(Duration duration) { - return (events, mapper) => droppable().call(events.throttle(duration), mapper); -} - -class InboxBloc extends Bloc { - Account account; - - late CommentRepository commentRepository; - late NotificationRepository notificationRepository; - - /// Constructor allowing an initial set of replies to be set in the state. - InboxBloc.initWith({ - required List replies, - required bool showUnreadOnly, - required this.account, - }) : super(InboxState(replies: replies, showUnreadOnly: showUnreadOnly)) { - commentRepository = CommentRepositoryImpl(account: account); - notificationRepository = NotificationRepositoryImpl(account: account); - _init(); - } - - /// Unnamed constructor with default state - InboxBloc({required this.account}) : super(const InboxState()) { - commentRepository = CommentRepositoryImpl(account: account); - notificationRepository = NotificationRepositoryImpl(account: account); - _init(); - } - - void _init() { - on(_getInboxEvent, transformer: restartable()); - on(_inboxItemActionEvent); - on(_markAllAsRead); - } - - Future _getInboxEvent(GetInboxEvent event, emit) async { - if (account.anonymous) { - return emit(state.copyWith( - status: InboxStatus.empty, - privateMessages: [], - mentions: [], - replies: [], - showUnreadOnly: !event.showAll, - inboxMentionPage: 1, - inboxReplyPage: 1, - inboxPrivateMessagePage: 1, - totalUnreadCount: 0, - repliesUnreadCount: 0, - mentionsUnreadCount: 0, - messagesUnreadCount: 0, - hasReachedInboxReplyEnd: true, - hasReachedInboxMentionEnd: true, - hasReachedInboxPrivateMessageEnd: true, - )); - } - - int limit = 20; - - try { - List? privateMessagesResponse = []; - List mentionsResponse = []; - List repliesResponse = []; - - if (event.reset) { - emit(state.copyWith(status: InboxStatus.loading, errorMessage: '')); - - switch (event.inboxType) { - case InboxType.replies: - repliesResponse = await notificationRepository.replies( - unread: !event.showAll, - limit: limit, - sort: event.commentSortType, - page: 1, - ); - break; - case InboxType.mentions: - mentionsResponse = await notificationRepository.mentions( - unread: !event.showAll, - limit: limit, - sort: event.commentSortType, - page: 1, - ); - break; - case InboxType.messages: - privateMessagesResponse = await notificationRepository.messages( - unread: !event.showAll, - limit: limit, - page: 1, - ); - break; - case InboxType.all: - repliesResponse = await notificationRepository.replies( - unread: !event.showAll, - limit: limit, - sort: event.commentSortType, - page: 1, - ); - - mentionsResponse = await notificationRepository.mentions( - unread: !event.showAll, - limit: limit, - sort: event.commentSortType, - page: 1, - ); - - privateMessagesResponse = await notificationRepository.messages( - unread: !event.showAll, - limit: limit, - page: 1, - ); - break; - default: - break; - } - - final unread = await notificationRepository.unreadNotificationsCount(); - int totalUnreadCount = unread['private_messages'] + unread['mentions'] + unread['replies']; - - return emit( - state.copyWith( - status: InboxStatus.success, - privateMessages: cleanDeletedMessages(privateMessagesResponse), - mentions: cleanDeletedMentions(mentionsResponse), - replies: repliesResponse, - showUnreadOnly: !event.showAll, - inboxMentionPage: 2, - inboxReplyPage: 2, - inboxPrivateMessagePage: 2, - totalUnreadCount: totalUnreadCount, - repliesUnreadCount: unread['replies'], - mentionsUnreadCount: unread['mentions'], - messagesUnreadCount: unread['private_messages'], - hasReachedInboxReplyEnd: repliesResponse.isEmpty || repliesResponse.length < limit, - hasReachedInboxMentionEnd: mentionsResponse.isEmpty == true || mentionsResponse.length < limit, - hasReachedInboxPrivateMessageEnd: privateMessagesResponse.isEmpty == true || privateMessagesResponse.length < limit, - ), - ); - } - - // Prevent fetching if we're already fetching - if (state.status == InboxStatus.refreshing) return; - emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); - - switch (event.inboxType) { - case InboxType.replies: - if (state.hasReachedInboxReplyEnd) return; - repliesResponse = await notificationRepository.replies( - unread: state.showUnreadOnly, - limit: limit, - sort: event.commentSortType, - page: state.inboxReplyPage, - ); - break; - case InboxType.mentions: - if (state.hasReachedInboxMentionEnd) return; - mentionsResponse = await notificationRepository.mentions( - unread: state.showUnreadOnly, - limit: limit, - sort: event.commentSortType, - page: state.inboxMentionPage, - ); - break; - case InboxType.messages: - if (state.hasReachedInboxPrivateMessageEnd) return; - privateMessagesResponse = await notificationRepository.messages( - unread: state.showUnreadOnly, - limit: limit, - page: state.inboxPrivateMessagePage, - ); - break; - default: - break; - } - - List replies = List.from(state.replies)..addAll(repliesResponse); - List mentions = List.from(state.mentions)..addAll(mentionsResponse); - List privateMessages = List.from(state.privateMessages)..addAll(privateMessagesResponse); - - return emit( - state.copyWith( - status: InboxStatus.success, - privateMessages: cleanDeletedMessages(privateMessages), - mentions: cleanDeletedMentions(mentions), - replies: replies, - showUnreadOnly: state.showUnreadOnly, - inboxMentionPage: state.inboxMentionPage + 1, - inboxReplyPage: state.inboxReplyPage + 1, - inboxPrivateMessagePage: state.inboxPrivateMessagePage + 1, - hasReachedInboxReplyEnd: repliesResponse.isEmpty || repliesResponse.length < limit, - hasReachedInboxMentionEnd: mentionsResponse.isEmpty == true || mentionsResponse.length < limit, - hasReachedInboxPrivateMessageEnd: privateMessages.isEmpty == true || privateMessages.length < limit, - ), - ); - } catch (e) { - emit(state.copyWith( - status: InboxStatus.failure, - errorMessage: e.toString(), - totalUnreadCount: 0, - repliesUnreadCount: 0, - mentionsUnreadCount: 0, - messagesUnreadCount: 0, - )); - } - } - - /// Handles comment related actions on a given item within the inbox - Future _inboxItemActionEvent(InboxItemActionEvent event, Emitter emit) async { - assert(!(event.commentReplyId == null && event.personMentionId == null && event.privateMessageId == null)); - emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); - - int existingIndex = -1; - - ThunderComment? existingCommentReplyView; - ThunderComment? existingPersonMentionView; - ThunderPrivateMessage? existingPrivateMessageView; - - if (event.commentReplyId != null && event.action == CommentAction.read) { - existingIndex = state.replies.indexWhere((element) => element.replyMentionId == event.commentReplyId); - existingCommentReplyView = state.replies[existingIndex]; - } else if (event.commentReplyId != null && event.action != CommentAction.read) { - existingIndex = state.replies.indexWhere((element) => element.id == event.commentReplyId); - existingCommentReplyView = state.replies[existingIndex]; - } - - if (event.personMentionId != null && event.action == CommentAction.read) { - existingIndex = state.mentions.indexWhere((element) => element.replyMentionId == event.personMentionId); - existingPersonMentionView = state.mentions[existingIndex]; - } else if (event.personMentionId != null && event.action != CommentAction.read) { - existingIndex = state.mentions.indexWhere((element) => element.id == event.personMentionId); - existingPersonMentionView = state.mentions[existingIndex]; - } - - if (event.privateMessageId != null) { - existingIndex = state.privateMessages.indexWhere((element) => element.id == event.privateMessageId); - existingPrivateMessageView = state.privateMessages[existingIndex]; - } - - if (existingCommentReplyView == null && existingPersonMentionView == null && existingPrivateMessageView == null) return emit(state.copyWith(status: InboxStatus.failure)); - - /// Convert the reply or mention to a comment - ThunderComment? comment; - - if (existingCommentReplyView != null) { - comment = existingCommentReplyView; - } else if (existingPersonMentionView != null) { - comment = existingPersonMentionView; - } - - switch (event.action) { - case CommentAction.read: - try { - // Optimistically remove the reply from the list or change the status (depending on whether we're showing all) - if (existingCommentReplyView != null) { - if (!state.showUnreadOnly) { - state.replies[existingIndex] = existingCommentReplyView.copyWith(read: event.value); - } else if (event.value == true) { - state.replies.remove(existingCommentReplyView); - } - } else if (existingPersonMentionView != null) { - if (!state.showUnreadOnly) { - state.mentions[existingIndex] = existingPersonMentionView.copyWith(read: event.value); - } else if (event.value == true) { - state.mentions.remove(existingPersonMentionView); - } - } else if (existingPrivateMessageView != null) { - if (!state.showUnreadOnly) { - state.privateMessages[existingIndex] = existingPrivateMessageView.copyWith(read: event.value); - } else if (event.value == true) { - state.privateMessages.remove(existingPrivateMessageView); - } - } - - if (existingCommentReplyView != null) { - await notificationRepository.markReplyAsRead( - replyId: event.commentReplyId!, - read: event.value, - ); - } else if (existingPersonMentionView != null) { - await notificationRepository.markMentionAsRead( - mentionId: event.personMentionId!, - read: event.value, - ); - } else if (existingPrivateMessageView != null) { - await notificationRepository.markMessageAsRead( - messageId: event.privateMessageId!, - read: event.value, - ); - } - - final unread = await notificationRepository.unreadNotificationsCount(); - int totalUnreadCount = unread['private_messages'] + unread['mentions'] + unread['replies']; - - return emit(state.copyWith( - status: InboxStatus.success, - totalUnreadCount: totalUnreadCount, - repliesUnreadCount: unread['replies'], - mentionsUnreadCount: unread['mentions'], - messagesUnreadCount: unread['private_messages'], - inboxReplyMarkedAsRead: event.commentReplyId, - )); - } catch (e) { - return emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); - } - case CommentAction.vote: - try { - ThunderComment updatedComment = optimisticallyVoteComment(comment!, event.value); - - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView.copyWith( - score: updatedComment.score, - upvotes: updatedComment.upvotes, - downvotes: updatedComment.downvotes, - myVote: updatedComment.myVote, - ); - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView.copyWith( - score: updatedComment.score, - upvotes: updatedComment.upvotes, - downvotes: updatedComment.downvotes, - myVote: updatedComment.myVote, - ); - } - - // Immediately set the status, and continue - emit(state.copyWith(status: InboxStatus.success)); - emit(state.copyWith(status: InboxStatus.refreshing)); - - await commentRepository.vote(comment, event.value).timeout(timeout, onTimeout: () { - // Restore the original comment if vote fails - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView; - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView; - } - - throw Exception(AppLocalizations.of(GlobalContext.context)!.timeoutUpvoteComment); - }); - - return emit(state.copyWith(status: InboxStatus.success)); - } catch (e) { - return emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); - } - case CommentAction.save: - try { - ThunderComment updatedComment = optimisticallySaveComment(comment!, event.value); - - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView.copyWith(saved: updatedComment.saved!); - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView.copyWith(saved: updatedComment.saved!); - } - - // Immediately set the status, and continue - emit(state.copyWith(status: InboxStatus.success)); - emit(state.copyWith(status: InboxStatus.refreshing)); - - await commentRepository.save(comment, event.value).timeout(timeout, onTimeout: () { - // Restore the original comment if saving fails - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView; - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView; - } - - throw Exception(AppLocalizations.of(GlobalContext.context)!.timeoutSaveComment); - }); - - return emit(state.copyWith(status: InboxStatus.success)); - } catch (e) { - return emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); - } - case CommentAction.delete: - try { - ThunderComment updatedComment = optimisticallyDeleteComment(comment!, event.value); - - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView.copyWith( - deleted: updatedComment.deleted, - ); - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView.copyWith( - deleted: updatedComment.deleted, - ); - } - - // Immediately set the status, and continue - emit(state.copyWith(status: InboxStatus.success)); - emit(state.copyWith(status: InboxStatus.refreshing)); - - await commentRepository.delete(comment, event.value).timeout(timeout, onTimeout: () { - // Restore the original comment if deleting fails - if (existingCommentReplyView != null) { - state.replies[existingIndex] = existingCommentReplyView; - } else if (existingPersonMentionView != null) { - state.mentions[existingIndex] = existingPersonMentionView; - } - - throw Exception(AppLocalizations.of(GlobalContext.context)!.timeoutErrorMessage); - }); - - return emit(state.copyWith(status: InboxStatus.success)); - } catch (e) { - return emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); - } - default: - return emit(state.copyWith(status: InboxStatus.failure, errorMessage: AppLocalizations.of(GlobalContext.context)!.unexpectedError)); - } - } - - Future _markAllAsRead(MarkAllAsReadEvent event, emit) async { - try { - emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); - await notificationRepository.markAllNotificationsAsRead(); - - // Update all the replies, mentions, and messages to be read locally - List updatedReplies = state.replies.map((comment) => comment.copyWith(read: true)).toList(); - List updatedMentions = state.mentions.map((comment) => comment.copyWith(read: true)).toList(); - List updatedPrivateMessages = state.privateMessages.map((privateMessage) => privateMessage.copyWith(read: true)).toList(); - - return emit(state.copyWith( - status: InboxStatus.success, - replies: state.showUnreadOnly ? [] : updatedReplies, - mentions: state.showUnreadOnly ? [] : updatedMentions, - privateMessages: state.showUnreadOnly ? [] : updatedPrivateMessages, - totalUnreadCount: 0, - repliesUnreadCount: 0, - mentionsUnreadCount: 0, - messagesUnreadCount: 0, - )); - } catch (e) { - emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); - } - } - - List cleanDeletedMessages(List messages) { - List cleanMessages = []; - - for (ThunderPrivateMessage message in messages) { - cleanMessages.add(cleanDeletedPrivateMessage(message)); - } - - return cleanMessages; - } - - List cleanDeletedMentions(List mentions) { - List cleanedMentions = []; - - for (ThunderComment mention in mentions) { - cleanedMentions.add(cleanDeletedMention(mention)); - } - - return cleanedMentions; - } - - ThunderPrivateMessage cleanDeletedPrivateMessage(ThunderPrivateMessage message) { - if (message.deleted) { - return message.copyWith( - content: "_deleted by creator_", - ); - } - - return message; - } - - ThunderComment cleanDeletedMention(ThunderComment mention) { - if (mention.removed) return mention.copyWith(content: "_deleted by moderator_"); - if (mention.deleted) return mention.copyWith(content: "_deleted by creator_"); - return mention; - } -} diff --git a/lib/src/features/inbox/presentation/pages/inbox_page.dart b/lib/src/features/inbox/presentation/pages/inbox_page.dart index 48c38175f..bfd1079e3 100644 --- a/lib/src/features/inbox/presentation/pages/inbox_page.dart +++ b/lib/src/features/inbox/presentation/pages/inbox_page.dart @@ -4,14 +4,12 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar, showThunderDialog; /// A widget that displays the user's inbox replies, mentions, and private messages. class InboxPage extends StatefulWidget { diff --git a/lib/src/features/inbox/presentation/state/inbox_bloc.dart b/lib/src/features/inbox/presentation/state/inbox_bloc.dart new file mode 100644 index 000000000..21d5170b4 --- /dev/null +++ b/lib/src/features/inbox/presentation/state/inbox_bloc.dart @@ -0,0 +1,593 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/inbox/inbox.dart'; +import 'package:thunder/src/features/notification/notification.dart'; + +part 'inbox_event.dart'; +part 'inbox_state.dart'; + +const throttleDuration = Duration(seconds: 1); +const timeout = Duration(seconds: 5); + +EventTransformer throttleDroppable(Duration duration) { + return (events, mapper) => droppable().call(events.throttle(duration), mapper); +} + +class InboxBloc extends Bloc { + final Account account; + + final CommentRepository commentRepository; + final NotificationRepository notificationRepository; + final LocalizationService _localizationService; + + /// Constructor allowing an initial set of replies to be set in the state. + InboxBloc.initWith({ + required List replies, + required bool showUnreadOnly, + required this.account, + required this.commentRepository, + required this.notificationRepository, + required LocalizationService localizationService, + }) : _localizationService = localizationService, + super(InboxState(replies: replies, showUnreadOnly: showUnreadOnly)) { + _init(); + } + + /// Unnamed constructor with default state + InboxBloc({ + required this.account, + required this.commentRepository, + required this.notificationRepository, + required LocalizationService localizationService, + }) : _localizationService = localizationService, + super(const InboxState()) { + _init(); + } + + void _init() { + on(_getInboxEvent, transformer: restartable()); + on(_inboxItemActionEvent); + on(_markAllAsRead); + } + + Future _getInboxEvent(GetInboxEvent event, emit) async { + if (account.anonymous) { + return emit(state.copyWith( + status: InboxStatus.empty, + privateMessages: [], + mentions: [], + replies: [], + showUnreadOnly: !event.showAll, + inboxMentionPage: 1, + inboxReplyPage: 1, + inboxPrivateMessagePage: 1, + totalUnreadCount: 0, + repliesUnreadCount: 0, + mentionsUnreadCount: 0, + messagesUnreadCount: 0, + hasReachedInboxReplyEnd: true, + hasReachedInboxMentionEnd: true, + hasReachedInboxPrivateMessageEnd: true, + errorReason: null, + )); + } + + int limit = 20; + + try { + List? privateMessagesResponse = []; + List mentionsResponse = []; + List repliesResponse = []; + + if (event.reset) { + emit(state.copyWith(status: InboxStatus.loading, errorMessage: '')); + + switch (event.inboxType) { + case InboxType.replies: + repliesResponse = await notificationRepository.replies( + unread: !event.showAll, + limit: limit, + sort: event.commentSortType, + page: 1, + ); + break; + case InboxType.mentions: + mentionsResponse = await notificationRepository.mentions( + unread: !event.showAll, + limit: limit, + sort: event.commentSortType, + page: 1, + ); + break; + case InboxType.messages: + privateMessagesResponse = await notificationRepository.messages( + unread: !event.showAll, + limit: limit, + page: 1, + ); + break; + case InboxType.all: + repliesResponse = await notificationRepository.replies( + unread: !event.showAll, + limit: limit, + sort: event.commentSortType, + page: 1, + ); + + mentionsResponse = await notificationRepository.mentions( + unread: !event.showAll, + limit: limit, + sort: event.commentSortType, + page: 1, + ); + + privateMessagesResponse = await notificationRepository.messages( + unread: !event.showAll, + limit: limit, + page: 1, + ); + break; + default: + break; + } + + final unread = await notificationRepository.unreadNotificationsCount(); + int totalUnreadCount = unread.total; + + final fetchedReplies = event.inboxType == InboxType.replies || event.inboxType == InboxType.all; + final fetchedMentions = event.inboxType == InboxType.mentions || event.inboxType == InboxType.all; + final fetchedMessages = event.inboxType == InboxType.messages || event.inboxType == InboxType.all; + final pages = resetPagesForType(event.inboxType); + + return emit( + state.copyWith( + status: InboxStatus.success, + privateMessages: cleanDeletedMessages(privateMessagesResponse), + mentions: cleanDeletedMentions(mentionsResponse), + replies: repliesResponse, + showUnreadOnly: !event.showAll, + inboxMentionPage: pages.mentionPage, + inboxReplyPage: pages.replyPage, + inboxPrivateMessagePage: pages.messagePage, + totalUnreadCount: totalUnreadCount, + repliesUnreadCount: unread.replies, + mentionsUnreadCount: unread.mentions, + messagesUnreadCount: unread.privateMessages, + hasReachedInboxReplyEnd: fetchedReplies ? (repliesResponse.isEmpty || repliesResponse.length < limit) : false, + hasReachedInboxMentionEnd: fetchedMentions ? (mentionsResponse.isEmpty || mentionsResponse.length < limit) : false, + hasReachedInboxPrivateMessageEnd: fetchedMessages ? (privateMessagesResponse.isEmpty || privateMessagesResponse.length < limit) : false, + errorReason: null, + ), + ); + } + + // Prevent fetching if we're already fetching + if (state.status == InboxStatus.refreshing) return; + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); + + switch (event.inboxType) { + case InboxType.replies: + if (state.hasReachedInboxReplyEnd) return; + repliesResponse = await notificationRepository.replies( + unread: state.showUnreadOnly, + limit: limit, + sort: event.commentSortType, + page: state.inboxReplyPage, + ); + break; + case InboxType.mentions: + if (state.hasReachedInboxMentionEnd) return; + mentionsResponse = await notificationRepository.mentions( + unread: state.showUnreadOnly, + limit: limit, + sort: event.commentSortType, + page: state.inboxMentionPage, + ); + break; + case InboxType.messages: + if (state.hasReachedInboxPrivateMessageEnd) return; + privateMessagesResponse = await notificationRepository.messages( + unread: state.showUnreadOnly, + limit: limit, + page: state.inboxPrivateMessagePage, + ); + break; + default: + break; + } + + List replies = List.from(state.replies)..addAll(repliesResponse); + List mentions = List.from(state.mentions)..addAll(mentionsResponse); + List privateMessages = List.from(state.privateMessages)..addAll(privateMessagesResponse); + + final pages = incrementFetchedPage( + type: event.inboxType, + currentMentionPage: state.inboxMentionPage, + currentReplyPage: state.inboxReplyPage, + currentMessagePage: state.inboxPrivateMessagePage, + ); + + return emit( + state.copyWith( + status: InboxStatus.success, + privateMessages: cleanDeletedMessages(privateMessages), + mentions: cleanDeletedMentions(mentions), + replies: replies, + showUnreadOnly: state.showUnreadOnly, + inboxMentionPage: pages.mentionPage, + inboxReplyPage: pages.replyPage, + inboxPrivateMessagePage: pages.messagePage, + hasReachedInboxReplyEnd: event.inboxType == InboxType.replies ? (repliesResponse.isEmpty || repliesResponse.length < limit) : state.hasReachedInboxReplyEnd, + hasReachedInboxMentionEnd: event.inboxType == InboxType.mentions ? (mentionsResponse.isEmpty || mentionsResponse.length < limit) : state.hasReachedInboxMentionEnd, + hasReachedInboxPrivateMessageEnd: + event.inboxType == InboxType.messages ? (privateMessagesResponse.isEmpty || privateMessagesResponse.length < limit) : state.hasReachedInboxPrivateMessageEnd, + errorReason: null, + ), + ); + } catch (e) { + final message = e.toString(); + emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + totalUnreadCount: 0, + repliesUnreadCount: 0, + mentionsUnreadCount: 0, + messagesUnreadCount: 0, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + /// Handles comment related actions on a given item within the inbox + Future _inboxItemActionEvent(InboxItemActionEvent event, Emitter emit) async { + assert(!(event.commentReplyId == null && event.personMentionId == null && event.privateMessageId == null)); + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); + + int existingReplyIndex = -1; + int existingMentionIndex = -1; + int existingPrivateMessageIndex = -1; + + ThunderComment? existingCommentReplyView; + ThunderComment? existingPersonMentionView; + ThunderPrivateMessage? existingPrivateMessageView; + + if (event.commentReplyId != null && event.action == CommentAction.read) { + existingReplyIndex = state.replies.indexWhere((element) => element.replyMentionId == event.commentReplyId); + if (existingReplyIndex != -1) { + existingCommentReplyView = state.replies[existingReplyIndex]; + } + } else if (event.commentReplyId != null && event.action != CommentAction.read) { + existingReplyIndex = state.replies.indexWhere((element) => element.id == event.commentReplyId); + if (existingReplyIndex != -1) { + existingCommentReplyView = state.replies[existingReplyIndex]; + } + } + + if (event.personMentionId != null && event.action == CommentAction.read) { + existingMentionIndex = state.mentions.indexWhere((element) => element.replyMentionId == event.personMentionId); + if (existingMentionIndex != -1) { + existingPersonMentionView = state.mentions[existingMentionIndex]; + } + } else if (event.personMentionId != null && event.action != CommentAction.read) { + existingMentionIndex = state.mentions.indexWhere((element) => element.id == event.personMentionId); + if (existingMentionIndex != -1) { + existingPersonMentionView = state.mentions[existingMentionIndex]; + } + } + + if (event.privateMessageId != null) { + existingPrivateMessageIndex = state.privateMessages.indexWhere((element) => element.id == event.privateMessageId); + if (existingPrivateMessageIndex != -1) { + existingPrivateMessageView = state.privateMessages[existingPrivateMessageIndex]; + } + } + + if (existingCommentReplyView == null && existingPersonMentionView == null && existingPrivateMessageView == null) { + return emit(state.copyWith( + status: InboxStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Inbox item not found.', + ), + )); + } + + /// Convert the reply or mention to a comment + ThunderComment? comment; + + if (existingCommentReplyView != null) { + comment = existingCommentReplyView; + } else if (existingPersonMentionView != null) { + comment = existingPersonMentionView; + } + + switch (event.action) { + case CommentAction.read: + final input = event.actionInput; + if (input is! ReadInboxActionInput) { + return emit(state.copyWith( + status: InboxStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for read action.', + ), + )); + } + final value = input.read; + try { + final updatedReplies = List.from(state.replies); + final updatedMentions = List.from(state.mentions); + final updatedPrivateMessages = List.from(state.privateMessages); + + // Optimistically remove the reply from the list or change the status (depending on whether we're showing all) + if (existingCommentReplyView != null && existingReplyIndex != -1) { + if (!state.showUnreadOnly) { + updatedReplies[existingReplyIndex] = existingCommentReplyView.copyWith(read: value); + } else if (value) { + updatedReplies.removeAt(existingReplyIndex); + } + } else if (existingPersonMentionView != null && existingMentionIndex != -1) { + if (!state.showUnreadOnly) { + updatedMentions[existingMentionIndex] = existingPersonMentionView.copyWith(read: value); + } else if (value) { + updatedMentions.removeAt(existingMentionIndex); + } + } else if (existingPrivateMessageView != null && existingPrivateMessageIndex != -1) { + if (!state.showUnreadOnly) { + updatedPrivateMessages[existingPrivateMessageIndex] = existingPrivateMessageView.copyWith(read: value); + } else if (value) { + updatedPrivateMessages.removeAt(existingPrivateMessageIndex); + } + } + + if (existingCommentReplyView != null) { + await notificationRepository.markReplyAsRead( + replyId: event.commentReplyId!, + read: value, + ); + } else if (existingPersonMentionView != null) { + await notificationRepository.markMentionAsRead( + mentionId: event.personMentionId!, + read: value, + ); + } else if (existingPrivateMessageView != null) { + await notificationRepository.markMessageAsRead( + messageId: event.privateMessageId!, + read: value, + ); + } + + final unread = await notificationRepository.unreadNotificationsCount(); + int totalUnreadCount = unread.total; + + return emit(state.copyWith( + status: InboxStatus.success, + replies: updatedReplies, + mentions: updatedMentions, + privateMessages: updatedPrivateMessages, + totalUnreadCount: totalUnreadCount, + repliesUnreadCount: unread.replies, + mentionsUnreadCount: unread.mentions, + messagesUnreadCount: unread.privateMessages, + inboxReplyMarkedAsRead: event.commentReplyId, + errorReason: null, + )); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + case CommentAction.vote: + final originalReplies = List.from(state.replies); + final originalMentions = List.from(state.mentions); + final input = event.actionInput; + if (input is! VoteInboxActionInput) { + return emit(state.copyWith( + status: InboxStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for vote action.', + ), + )); + } + final value = input.vote; + try { + final updatedReplies = List.from(state.replies); + final updatedMentions = List.from(state.mentions); + + ThunderComment updatedComment = optimisticallyVoteComment(comment!, value); + + if (existingCommentReplyView != null && existingReplyIndex != -1) { + updatedReplies[existingReplyIndex] = existingCommentReplyView.copyWith( + score: updatedComment.score, + upvotes: updatedComment.upvotes, + downvotes: updatedComment.downvotes, + myVote: updatedComment.myVote, + ); + } else if (existingPersonMentionView != null && existingMentionIndex != -1) { + updatedMentions[existingMentionIndex] = existingPersonMentionView.copyWith( + score: updatedComment.score, + upvotes: updatedComment.upvotes, + downvotes: updatedComment.downvotes, + myVote: updatedComment.myVote, + ); + } + + // Immediately set the status, and continue + emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions)); + emit(state.copyWith(status: InboxStatus.refreshing, replies: updatedReplies, mentions: updatedMentions)); + + await commentRepository.vote(comment, value).timeout(timeout, onTimeout: () { + throw Exception(_localizationService.l10n.timeoutUpvoteComment); + }); + + return emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions, errorReason: null)); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + replies: originalReplies, + mentions: originalMentions, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + case CommentAction.save: + final originalReplies = List.from(state.replies); + final originalMentions = List.from(state.mentions); + final input = event.actionInput; + if (input is! SaveInboxActionInput) { + return emit(state.copyWith( + status: InboxStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for save action.', + ), + )); + } + final value = input.save; + try { + final updatedReplies = List.from(state.replies); + final updatedMentions = List.from(state.mentions); + + ThunderComment updatedComment = optimisticallySaveComment(comment!, value); + + if (existingCommentReplyView != null && existingReplyIndex != -1) { + updatedReplies[existingReplyIndex] = existingCommentReplyView.copyWith(saved: updatedComment.saved!); + } else if (existingPersonMentionView != null && existingMentionIndex != -1) { + updatedMentions[existingMentionIndex] = existingPersonMentionView.copyWith(saved: updatedComment.saved!); + } + + // Immediately set the status, and continue + emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions)); + emit(state.copyWith(status: InboxStatus.refreshing, replies: updatedReplies, mentions: updatedMentions)); + + await commentRepository.save(comment, value).timeout(timeout, onTimeout: () { + throw Exception(_localizationService.l10n.timeoutSaveComment); + }); + + return emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions, errorReason: null)); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + replies: originalReplies, + mentions: originalMentions, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + case CommentAction.delete: + final originalReplies = List.from(state.replies); + final originalMentions = List.from(state.mentions); + final input = event.actionInput; + if (input is! DeleteInboxActionInput) { + return emit(state.copyWith( + status: InboxStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for delete action.', + ), + )); + } + final value = input.delete; + try { + final updatedReplies = List.from(state.replies); + final updatedMentions = List.from(state.mentions); + + ThunderComment updatedComment = optimisticallyDeleteComment(comment!, value); + + if (existingCommentReplyView != null && existingReplyIndex != -1) { + updatedReplies[existingReplyIndex] = existingCommentReplyView.copyWith( + deleted: updatedComment.deleted, + ); + } else if (existingPersonMentionView != null && existingMentionIndex != -1) { + updatedMentions[existingMentionIndex] = existingPersonMentionView.copyWith( + deleted: updatedComment.deleted, + ); + } + + // Immediately set the status, and continue + emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions)); + emit(state.copyWith(status: InboxStatus.refreshing, replies: updatedReplies, mentions: updatedMentions)); + + await commentRepository.delete(comment, value).timeout(timeout, onTimeout: () { + throw Exception(_localizationService.l10n.timeoutErrorMessage); + }); + + return emit(state.copyWith(status: InboxStatus.success, replies: updatedReplies, mentions: updatedMentions, errorReason: null)); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + replies: originalReplies, + mentions: originalMentions, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + default: + return emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: _localizationService.l10n.unexpectedError, + errorReason: AppErrorReason.unexpected( + message: _localizationService.l10n.unexpectedError, + ), + )); + } + } + + Future _markAllAsRead(MarkAllAsReadEvent event, emit) async { + try { + emit(state.copyWith(status: InboxStatus.refreshing, errorMessage: '')); + await notificationRepository.markAllNotificationsAsRead(); + + // Update all the replies, mentions, and messages to be read locally + List updatedReplies = state.replies.map((comment) => comment.copyWith(read: true)).toList(); + List updatedMentions = state.mentions.map((comment) => comment.copyWith(read: true)).toList(); + List updatedPrivateMessages = state.privateMessages.map((privateMessage) => privateMessage.copyWith(read: true)).toList(); + + return emit(state.copyWith( + status: InboxStatus.success, + replies: state.showUnreadOnly ? [] : updatedReplies, + mentions: state.showUnreadOnly ? [] : updatedMentions, + privateMessages: state.showUnreadOnly ? [] : updatedPrivateMessages, + totalUnreadCount: 0, + repliesUnreadCount: 0, + mentionsUnreadCount: 0, + messagesUnreadCount: 0, + errorReason: null, + )); + } catch (e) { + final message = e.toString(); + emit(state.copyWith( + status: InboxStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } +} diff --git a/lib/src/features/inbox/presentation/bloc/inbox_event.dart b/lib/src/features/inbox/presentation/state/inbox_event.dart similarity index 51% rename from lib/src/features/inbox/presentation/bloc/inbox_event.dart rename to lib/src/features/inbox/presentation/state/inbox_event.dart index 9ed0e2211..b333d0d3a 100644 --- a/lib/src/features/inbox/presentation/bloc/inbox_event.dart +++ b/lib/src/features/inbox/presentation/state/inbox_event.dart @@ -1,10 +1,53 @@ part of 'inbox_bloc.dart'; +sealed class InboxActionInput extends Equatable { + const InboxActionInput(); + + @override + List get props => []; +} + +final class ReadInboxActionInput extends InboxActionInput { + const ReadInboxActionInput(this.read); + + final bool read; + + @override + List get props => [read]; +} + +final class VoteInboxActionInput extends InboxActionInput { + const VoteInboxActionInput(this.vote); + + final int vote; + + @override + List get props => [vote]; +} + +final class SaveInboxActionInput extends InboxActionInput { + const SaveInboxActionInput(this.save); + + final bool save; + + @override + List get props => [save]; +} + +final class DeleteInboxActionInput extends InboxActionInput { + const DeleteInboxActionInput(this.delete); + + final bool delete; + + @override + List get props => [delete]; +} + abstract class InboxEvent extends Equatable { const InboxEvent(); @override - List get props => []; + List get props => []; } class GetInboxEvent extends InboxEvent { @@ -21,6 +64,9 @@ class GetInboxEvent extends InboxEvent { final CommentSortType commentSortType; const GetInboxEvent({this.inboxType, this.showAll = false, this.reset = false, this.commentSortType = CommentSortType.new_}); + + @override + List get props => [inboxType, showAll, reset, commentSortType]; } class InboxItemActionEvent extends InboxEvent { @@ -36,10 +82,19 @@ class InboxItemActionEvent extends InboxEvent { /// The id of the private message. Only one of [commentReplyId], [personMentionId], or [privateMessageId] should be set final int? privateMessageId; - /// The value to pass to the action - final dynamic value; + /// Typed payload to apply for the selected [action]. + final InboxActionInput? actionInput; + + const InboxItemActionEvent({ + required this.action, + this.commentReplyId, + this.personMentionId, + this.privateMessageId, + this.actionInput, + }); - const InboxItemActionEvent({required this.action, this.commentReplyId, this.personMentionId, this.privateMessageId, this.value}); + @override + List get props => [action, commentReplyId, personMentionId, privateMessageId, actionInput]; } class MarkAllAsReadEvent extends InboxEvent {} diff --git a/lib/src/features/inbox/presentation/bloc/inbox_state.dart b/lib/src/features/inbox/presentation/state/inbox_state.dart similarity index 85% rename from lib/src/features/inbox/presentation/bloc/inbox_state.dart rename to lib/src/features/inbox/presentation/state/inbox_state.dart index 062a24825..65eddab1b 100644 --- a/lib/src/features/inbox/presentation/bloc/inbox_state.dart +++ b/lib/src/features/inbox/presentation/state/inbox_state.dart @@ -2,10 +2,13 @@ part of 'inbox_bloc.dart'; enum InboxStatus { initial, loading, refreshing, success, empty, failure } +const _inboxUnset = Object(); + class InboxState extends Equatable { const InboxState({ this.status = InboxStatus.initial, this.errorMessage, + this.errorReason, this.privateMessages = const [], this.mentions = const [], this.replies = const [], @@ -25,6 +28,7 @@ class InboxState extends Equatable { final InboxStatus status; final String? errorMessage; + final AppErrorReason? errorReason; final List privateMessages; final List mentions; @@ -49,7 +53,8 @@ class InboxState extends Equatable { InboxState copyWith({ required InboxStatus status, - String? errorMessage, + Object? errorMessage = _inboxUnset, + Object? errorReason = _inboxUnset, List? privateMessages, List? mentions, List? replies, @@ -64,11 +69,12 @@ class InboxState extends Equatable { bool? hasReachedInboxReplyEnd, bool? hasReachedInboxMentionEnd, bool? hasReachedInboxPrivateMessageEnd, - int? inboxReplyMarkedAsRead, + Object? inboxReplyMarkedAsRead = _inboxUnset, }) { return InboxState( status: status, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _inboxUnset) ? this.errorMessage : errorMessage as String?, + errorReason: identical(errorReason, _inboxUnset) ? this.errorReason : errorReason as AppErrorReason?, privateMessages: privateMessages ?? this.privateMessages, mentions: mentions ?? this.mentions, replies: replies ?? this.replies, @@ -83,7 +89,7 @@ class InboxState extends Equatable { hasReachedInboxReplyEnd: hasReachedInboxReplyEnd ?? this.hasReachedInboxReplyEnd, hasReachedInboxMentionEnd: hasReachedInboxMentionEnd ?? this.hasReachedInboxMentionEnd, hasReachedInboxPrivateMessageEnd: hasReachedInboxPrivateMessageEnd ?? this.hasReachedInboxPrivateMessageEnd, - inboxReplyMarkedAsRead: inboxReplyMarkedAsRead ?? this.inboxReplyMarkedAsRead, + inboxReplyMarkedAsRead: identical(inboxReplyMarkedAsRead, _inboxUnset) ? this.inboxReplyMarkedAsRead : inboxReplyMarkedAsRead as int?, ); } @@ -91,6 +97,7 @@ class InboxState extends Equatable { List get props => [ status, errorMessage, + errorReason, privateMessages, mentions, replies, diff --git a/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart index 74a9f62ba..7f25ea48b 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_mentions_view.dart @@ -8,8 +8,8 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/shared/comment_reference.dart'; -import 'package:thunder/src/shared/divider.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; class InboxMentionsView extends StatefulWidget { final List mentions; @@ -53,7 +53,8 @@ class _InboxMentionsViewState extends State { CommentReference( comment: comment, child: IconButton( - onPressed: () => context.read().add(InboxItemActionEvent(action: CommentAction.read, personMentionId: comment.replyMentionId!, value: !comment.read!)), + onPressed: () => + context.read().add(InboxItemActionEvent(action: CommentAction.read, personMentionId: comment.replyMentionId!, actionInput: ReadInboxActionInput(!comment.read!))), icon: Icon( Icons.check, semanticLabel: l10n.markAsRead, diff --git a/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart index d4b645079..1322ff9e1 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_private_messages_view.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; class InboxPrivateMessagesView extends StatefulWidget { final List privateMessages; @@ -103,7 +102,7 @@ class _InboxPrivateMessagesViewState extends State { InboxItemActionEvent( action: CommentAction.read, privateMessageId: widget.privateMessages[index].id, - value: !widget.privateMessages[index].read, + actionInput: ReadInboxActionInput(!widget.privateMessages[index].read), ), ), icon: Icon( diff --git a/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart b/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart index da61e6831..cc9d3af02 100644 --- a/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart +++ b/lib/src/features/inbox/presentation/widgets/inbox_replies_view.dart @@ -9,8 +9,8 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/shared/comment_reference.dart'; -import 'package:thunder/src/shared/divider.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; class InboxRepliesView extends StatefulWidget { final List replies; @@ -54,7 +54,8 @@ class _InboxRepliesViewState extends State { CommentReference( comment: reply, child: IconButton( - onPressed: () => context.read().add(InboxItemActionEvent(action: CommentAction.read, commentReplyId: reply.replyMentionId!, value: !reply.read!)), + onPressed: () => + context.read().add(InboxItemActionEvent(action: CommentAction.read, commentReplyId: reply.replyMentionId!, actionInput: ReadInboxActionInput(!reply.read!))), icon: Icon( Icons.check, semanticLabel: l10n.markAsRead, diff --git a/lib/src/features/instance/api.dart b/lib/src/features/instance/api.dart new file mode 100644 index 000000000..f97871ead --- /dev/null +++ b/lib/src/features/instance/api.dart @@ -0,0 +1 @@ +export 'instance.dart'; diff --git a/lib/instances.dart b/lib/src/features/instance/data/constants/known_instances.dart similarity index 94% rename from lib/instances.dart rename to lib/src/features/instance/data/constants/known_instances.dart index cd42f79c5..7d633f48a 100644 --- a/lib/instances.dart +++ b/lib/src/features/instance/data/constants/known_instances.dart @@ -1,6 +1,6 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; -const Map instances = { +const Map knownInstances = { 'ani.social': ThreadiversePlatform.lemmy, 'awful.systems': ThreadiversePlatform.lemmy, 'beehaw.org': ThreadiversePlatform.lemmy, diff --git a/lib/src/features/instance/data/repositories/instance_repository.dart b/lib/src/features/instance/data/repositories/instance_repository.dart index 38eaf63e3..9ce7d8009 100644 --- a/lib/src/features/instance/data/repositories/instance_repository.dart +++ b/lib/src/features/instance/data/repositories/instance_repository.dart @@ -2,11 +2,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/account/account.dart'; /// Interface for a instance repository diff --git a/lib/src/shared/utils/instance.dart b/lib/src/features/instance/data/services/instance_discovery_service.dart similarity index 52% rename from lib/src/shared/utils/instance.dart rename to lib/src/features/instance/data/services/instance_discovery_service.dart index 647273a2f..df27ae176 100644 --- a/lib/src/shared/utils/instance.dart +++ b/lib/src/features/instance/data/services/instance_discovery_service.dart @@ -1,100 +1,11 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/pages/loading_page.dart'; -import 'package:thunder/src/shared/utils/link_utils.dart'; - -String? fetchInstanceNameFromUrl(String? url) { - if (url == null) { - return null; - } - - final uri = Uri.parse(url); - return uri.host; -} - -/// Checks if the given text references a community on a valid Lemmy/PieFed server. -/// If so, returns the community name in the format community@instance.tld. -/// Otherwise, returns null. -Future getLemmyCommunity(String text) async { - final result = parseCommunity(text); - return result?.qualified; -} - -/// Checks if the given text references a user on a valid Lemmy/PieFed server. -/// If so, returns the username in the format username@instance.tld. -/// Otherwise, returns null. -Future getLemmyUser(String text) async { - final result = parseUser(text); - return result?.qualified; -} - -/// Gets the post ID from a Lemmy/PieFed URL. -/// If the URL is from a different instance, it will attempt to resolve it. -Future getLemmyPostId(BuildContext context, String text) async { - final parsed = parsePostId(text); - if (parsed == null) { - return null; - } - - final account = context.read().state.account; - final postId = int.tryParse(parsed.value); - - if (postId == null) { - return null; - } - - if (parsed.instance == account.instance) { - return postId; - } else { - // This is a post on another instance. Try to resolve it - try { - showLoadingPage(context); - final response = await SearchRepositoryImpl(account: account).resolve(query: text); - return response['post']?.id; - } catch (e) { - return null; - } - } -} - -/// Gets the comment ID from a Lemmy/PieFed URL. -/// If the URL is from a different instance, it will attempt to resolve it. -Future getLemmyCommentId(BuildContext context, String text) async { - final parsed = parseCommentId(text); - if (parsed == null) { - return null; - } - - final account = context.read().state.account; - final commentId = int.tryParse(parsed.value); - - if (commentId == null) { - return null; - } - - if (parsed.instance == account.instance) { - return commentId; - } else { - // This is a comment on another instance. Try to resolve it - try { - showLoadingPage(context); - final response = await SearchRepositoryImpl(account: account).resolve(query: text); - return response['comment']?.id; - } catch (e) { - return null; - } - } -} +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/instance/api.dart'; /// Fetches the instance info for a given URL. /// @@ -113,7 +24,6 @@ Future getInstanceInfo(String? url, {int? id, Duration? tim final platformInfo = await detectPlatformFromNodeInfo(url!); final platform = platformInfo?['platform']; - // Create a temporary Account for the request final account = Account(instance: url, id: '', index: -1, platform: platform); final site = await InstanceRepositoryImpl(account: account).info().timeout(timeout ?? const Duration(seconds: 5)); @@ -121,7 +31,7 @@ Future getInstanceInfo(String? url, {int? id, Duration? tim return ThunderInstanceInfo( id: id, - domain: fetchInstanceNameFromUrl(instance.actorId)!, + domain: _fetchInstanceNameFromUrl(instance.actorId) ?? '', version: site.version, name: instance.name, icon: instance.icon, @@ -133,7 +43,6 @@ Future getInstanceInfo(String? url, {int? id, Duration? tim } catch (e) { debugPrint('Error getting instance info: $e'); - // Bad instances will throw an exception, so no icon return ThunderInstanceInfo( domain: '', name: '', @@ -152,7 +61,6 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? if (url.isEmpty) return null; try { - // Ensure the URL has proper protocol Uri uri; if (!url.startsWith('http://') && !url.startsWith('https://')) { uri = Uri.parse('https://$url'); @@ -160,7 +68,6 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? uri = Uri.parse(url); } - // Construct the nodeinfo URL final nodeInfoUri = Uri( scheme: uri.scheme, host: uri.host, @@ -168,20 +75,16 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? path: '/.well-known/nodeinfo', ); - // Fetch the nodeinfo response final response = await http.get(nodeInfoUri).timeout(timeout ?? const Duration(seconds: 5)); if (response.statusCode != 200) { return null; } - // Parse the JSON response final Map nodeInfo = json.decode(response.body); - // Extract the nodeinfo link from the well-known response String? nodeInfoUrl; if (nodeInfo['links'] != null && nodeInfo['links'].isNotEmpty) { - // Look for a nodeinfo schema link (prefer 2.0 or 2.1) for (final link in nodeInfo['links']) { final rel = link['rel']?.toString(); if (rel != null && rel.contains('nodeinfo.diaspora.software/ns/schema/')) { @@ -193,7 +96,6 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? if (nodeInfoUrl == null) return null; - // Fetch the actual nodeinfo document final nodeInfoResponse = await http.get(Uri.parse(nodeInfoUrl)).timeout(timeout ?? const Duration(seconds: 5)); if (nodeInfoResponse.statusCode != 200) { @@ -206,7 +108,6 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? if (softwareName == null) return null; - // Map software names to ThreadiversePlatform switch (softwareName) { case 'lemmy': return {'platform': ThreadiversePlatform.lemmy, 'version': softwareVersion}; @@ -216,7 +117,15 @@ Future?> detectPlatformFromNodeInfo(String url, {Duration? return null; } } catch (e) { - // Return null if any error occurs during detection return null; } } + +String? _fetchInstanceNameFromUrl(String? url) { + if (url == null) { + return null; + } + + final uri = Uri.parse(url); + return uri.host; +} diff --git a/lib/src/features/instance/domain/utils/instance_link_utils.dart b/lib/src/features/instance/domain/utils/instance_link_utils.dart new file mode 100644 index 000000000..f58863552 --- /dev/null +++ b/lib/src/features/instance/domain/utils/instance_link_utils.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/app/shell/navigation/loading_page.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/features/instance/data/services/instance_discovery_service.dart' as instance_discovery; + +String? fetchInstanceNameFromUrl(String? url) { + if (url == null) { + return null; + } + + final uri = Uri.parse(url); + return uri.host; +} + +/// Checks if the given text references a community on a valid Lemmy/PieFed server. +/// If so, returns the community name in the format community@instance.tld. +/// Otherwise, returns null. +Future getLemmyCommunity(String text) async { + final result = parseCommunity(text); + return result?.qualified; +} + +/// Checks if the given text references a user on a valid Lemmy/PieFed server. +/// If so, returns the username in the format username@instance.tld. +/// Otherwise, returns null. +Future getLemmyUser(String text) async { + final result = parseUser(text); + return result?.qualified; +} + +/// Gets the post ID from a Lemmy/PieFed URL. +/// If the URL is from a different instance, it will attempt to resolve it. +Future getLemmyPostId(BuildContext context, String text) async { + final parsed = parsePostId(text); + if (parsed == null) { + return null; + } + + final account = context.read().state.account; + final postId = int.tryParse(parsed.value); + + if (postId == null) { + return null; + } + + if (parsed.instance == account.instance) { + return postId; + } else { + // This is a post on another instance. Try to resolve it + try { + showLoadingPage(context); + final response = await SearchRepositoryImpl(account: account).resolve(query: text); + return response.post?.id; + } catch (e) { + return null; + } + } +} + +/// Gets the comment ID from a Lemmy/PieFed URL. +/// If the URL is from a different instance, it will attempt to resolve it. +Future getLemmyCommentId(BuildContext context, String text) async { + final parsed = parseCommentId(text); + if (parsed == null) { + return null; + } + + final account = context.read().state.account; + final commentId = int.tryParse(parsed.value); + + if (commentId == null) { + return null; + } + + if (parsed.instance == account.instance) { + return commentId; + } else { + // This is a comment on another instance. Try to resolve it + try { + showLoadingPage(context); + final response = await SearchRepositoryImpl(account: account).resolve(query: text); + return response.comment?.id; + } catch (e) { + return null; + } + } +} + +/// Fetches the instance info for a given URL. +/// +/// This includes the instance name, version, icon, and user count. +/// If the URL is invalid or the instance is unreachable, it returns a default [ThunderInstanceInfo] with success set to false. +Future getInstanceInfo(String? url, {int? id, Duration? timeout}) async { + return instance_discovery.getInstanceInfo(url, id: id, timeout: timeout); +} + +/// Determines the proper ThreadiversePlatform by fetching software information from nodeinfo. +/// +/// Given a URL, fetches the .well-known/nodeinfo endpoint and parses the JSON response +/// to determine the underlying software platform (lemmy, piefed, etc.). +/// +/// Returns the detected ThreadiversePlatform or null if detection fails. +Future?> detectPlatformFromNodeInfo(String url, {Duration? timeout}) async { + return instance_discovery.detectPlatformFromNodeInfo(url, timeout: timeout); +} diff --git a/lib/src/features/instance/domain/utils/instance_utils.dart b/lib/src/features/instance/domain/utils/instance_utils.dart new file mode 100644 index 000000000..55383f3f4 --- /dev/null +++ b/lib/src/features/instance/domain/utils/instance_utils.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +bool shouldSkipFetch({ + required bool currentlyLoading, + required int page, +}) { + return currentlyLoading && page != 1; +} + +List previousItemsForPage({ + required int page, + required List currentItems, +}) { + return page == 1 ? [] : List.from(currentItems); +} + +bool hasReachedEnd({ + required int fetchedCount, + required int pageLimit, +}) { + return fetchedCount == 0 || fetchedCount < pageLimit; +} + +Future> resolveInBatches({ + required List source, + required int batchSize, + required Future Function(TSource item) resolver, + FutureOr Function(List resolvedItems)? onBatchResolved, +}) async { + final resolvedItems = []; + + for (var i = 0; i < source.length; i += batchSize) { + final end = (i + batchSize < source.length) ? i + batchSize : source.length; + final batch = source.sublist(i, end); + final resolvedBatch = await Future.wait(batch.map(resolver)); + + resolvedItems.addAll(resolvedBatch.whereType()); + + if (onBatchResolved != null) { + await onBatchResolved(List.from(resolvedItems)); + } + } + + return resolvedItems; +} diff --git a/lib/src/features/instance/presentation/pages/instance_page.dart b/lib/src/features/instance/presentation/pages/instance_page.dart index 0136f817c..0f069f0ec 100644 --- a/lib/src/features/instance/presentation/pages/instance_page.dart +++ b/lib/src/features/instance/presentation/pages/instance_page.dart @@ -1,246 +1,250 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/features/instance/presentation/bloc/instance_page_bloc.dart'; -import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; -import 'package:thunder/src/features/instance/presentation/widgets/instance_page_app_bar.dart'; -import 'package:thunder/src/features/instance/presentation/widgets/instance_tabs.dart'; - -/// A widget that displays the instance page. -/// -/// The page contains information about a given instance, with the ability to explore its content. -class InstancePage extends StatefulWidget { - /// The instance to display. - final ThunderInstanceInfo instance; - - const InstancePage({ - super.key, - required this.instance, - }); - - @override - State createState() => _InstancePageState(); -} - -class _InstancePageState extends State with SingleTickerProviderStateMixin { - /// The tab controller - late final TabController _tabController; - - /// The post sort type to use - SearchSortType searchSortType = SearchSortType.topAll; - - /// Context for [_onScroll] to use to find the proper cubit - BuildContext? buildContext; - - /// The query to use for search - String? query; - - @override - void initState() { - _tabController = TabController(length: 5, vsync: this); - _tabController.addListener(() { - if (!_tabController.indexIsChanging) _handleTabChange(); - }); - - super.initState(); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - void _onRefresh() { - final context = buildContext; - if (context == null || !context.mounted) return; - - // Refresh specific tab and reset others - final bloc = context.read(); - - switch (_tabController.index) { - case 1: - bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.communities)); - bloc.add(GetInstanceCommunities(page: 1, sortType: searchSortType, query: query)); - break; - case 2: - bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.users)); - bloc.add(GetInstanceUsers(page: 1, sortType: searchSortType, query: query)); - break; - case 3: - bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.posts)); - bloc.add(GetInstancePosts(page: 1, sortType: searchSortType, query: query)); - break; - case 4: - bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.comments)); - bloc.add(GetInstanceComments(page: 1, sortType: searchSortType, query: query)); - break; - default: - bloc.add(const ResetInstanceTabs(excludeType: null)); - break; - } - } - - void _handleTabChange() { - final context = buildContext; - if (context == null || !context.mounted) return; - - final bloc = context.read(); - - switch (_tabController.index) { - case 1: - if (bloc.state.communities.items.isEmpty) bloc.add(GetInstanceCommunities(sortType: searchSortType, query: query)); - break; - case 2: - if (bloc.state.users.items.isEmpty) bloc.add(GetInstanceUsers(sortType: searchSortType, query: query)); - break; - case 3: - if (bloc.state.posts.items.isEmpty) bloc.add(GetInstancePosts(sortType: searchSortType, query: query)); - break; - case 4: - if (bloc.state.comments.items.isEmpty) bloc.add(GetInstanceComments(sortType: searchSortType, query: query)); - break; - } - } - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - - final account = context.read().state.account; - - return MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => InstancePageBloc(account: account, instanceInfo: widget.instance)), - BlocProvider(create: (context) => FeedBloc(account: account)), - ], - child: BlocConsumer( - listener: (context, state) { - context.read().add(PopulatePostsEvent(state.posts.items)); - }, - builder: (context, state) { - buildContext = context; - - return Scaffold( - body: SafeArea( - top: false, - child: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: InstancePageAppBar( - instance: widget.instance, - searchSortType: searchSortType, - account: account, - onSortSelected: (sortType) { - setState(() => searchSortType = sortType); - _onRefresh(); - }, - onQueryChanged: (query) { - setState(() => this.query = query); - _onRefresh(); - }, - bottom: TabBar( - controller: _tabController, - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: [ - Tab( - child: Row( - spacing: 6.0, - children: [const Icon(Icons.info_outline_rounded, size: 20.0), Text(l10n.about)], - ), - ), - Tab( - child: Row( - spacing: 6.0, - children: [const Icon(Icons.groups_outlined, size: 20.0), Text(l10n.communities)], - ), - ), - Tab( - child: Row( - spacing: 6.0, - children: [const Icon(Icons.people_outlined, size: 20.0), Text(l10n.users)], - ), - ), - Tab( - child: Row( - spacing: 6.0, - children: [const Icon(Icons.splitscreen_rounded, size: 20.0), Text(l10n.posts)], - ), - ), - Tab( - child: Row( - spacing: 6.0, - children: [const Icon(Icons.comment_outlined, size: 20.0), Text(l10n.comments)], - ), - ), - ], - ), - ), - ), - ]; - }, - body: TabBarView( - controller: _tabController, - children: [ - // About Tab - Builder(builder: (context) { - return CustomScrollView( - key: const PageStorageKey('about'), - slivers: [ - SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(20), - child: Material( - child: InstanceInformation(instance: widget.instance), - ), - ), - ), - ], - ); - }), - InstanceCommunityTab( - account: account, - query: query, - searchSortType: searchSortType, - onRetry: () => context.read().add(GetInstanceCommunities(sortType: searchSortType, query: query)), - ), - InstanceUserTab( - account: account, - query: query, - searchSortType: searchSortType, - onRetry: () => context.read().add(GetInstanceUsers(sortType: searchSortType, query: query)), - ), - InstancePostTab( - account: account, - query: query, - searchSortType: searchSortType, - onRetry: () => context.read().add(GetInstancePosts(sortType: searchSortType, query: query)), - ), - InstanceCommentTab( - account: account, - query: query, - searchSortType: searchSortType, - onRetry: () => context.read().add(GetInstanceComments(sortType: searchSortType, query: query)), - ), - ], - ), - ), - ), - ); - }, - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_bloc.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_event.dart'; +import 'package:thunder/src/features/instance/presentation/widgets/instance_page_app_bar.dart'; +import 'package:thunder/src/features/instance/presentation/widgets/instance_tabs.dart'; + +/// A widget that displays the instance page. +/// +/// The page contains information about a given instance, with the ability to explore its content. +class InstancePage extends StatefulWidget { + /// The instance to display. + final ThunderInstanceInfo instance; + + const InstancePage({ + super.key, + required this.instance, + }); + + @override + State createState() => _InstancePageState(); +} + +class _InstancePageState extends State with SingleTickerProviderStateMixin { + /// The tab controller + late final TabController _tabController; + + /// The post sort type to use + SearchSortType searchSortType = SearchSortType.topAll; + + /// Context for [_onScroll] to use to find the proper cubit + BuildContext? buildContext; + + /// The query to use for search + String? query; + + @override + void initState() { + _tabController = TabController(length: 5, vsync: this); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) _handleTabChange(); + }); + + super.initState(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _onRefresh() { + final context = buildContext; + if (context == null || !context.mounted) return; + + // Refresh specific tab and reset others + final bloc = context.read(); + + switch (_tabController.index) { + case 1: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.communities)); + bloc.add(GetInstanceCommunities(page: 1, sortType: searchSortType, query: query)); + break; + case 2: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.users)); + bloc.add(GetInstanceUsers(page: 1, sortType: searchSortType, query: query)); + break; + case 3: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.posts)); + bloc.add(GetInstancePosts(page: 1, sortType: searchSortType, query: query)); + break; + case 4: + bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.comments)); + bloc.add(GetInstanceComments(page: 1, sortType: searchSortType, query: query)); + break; + default: + bloc.add(const ResetInstanceTabs(excludeType: null)); + break; + } + } + + void _handleTabChange() { + final context = buildContext; + if (context == null || !context.mounted) return; + + final bloc = context.read(); + + switch (_tabController.index) { + case 1: + if (bloc.state.communities.items.isEmpty) bloc.add(GetInstanceCommunities(sortType: searchSortType, query: query)); + break; + case 2: + if (bloc.state.users.items.isEmpty) bloc.add(GetInstanceUsers(sortType: searchSortType, query: query)); + break; + case 3: + if (bloc.state.posts.items.isEmpty) bloc.add(GetInstancePosts(sortType: searchSortType, query: query)); + break; + case 4: + if (bloc.state.comments.items.isEmpty) bloc.add(GetInstanceComments(sortType: searchSortType, query: query)); + break; + } + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + final account = context.read().state.account; + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => createInstancePageBloc( + account: account, + instanceInfo: widget.instance, + ), + ), + BlocProvider(create: (context) => createFeedBloc(account)), + ], + child: BlocConsumer( + listener: (context, state) { + context.read().add(PopulatePostsEvent(state.posts.items)); + }, + builder: (context, state) { + buildContext = context; + + return Scaffold( + body: SafeArea( + top: false, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: InstancePageAppBar( + instance: widget.instance, + searchSortType: searchSortType, + account: account, + onSortSelected: (sortType) { + setState(() => searchSortType = sortType); + _onRefresh(); + }, + onQueryChanged: (query) { + setState(() => this.query = query); + _onRefresh(); + }, + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: [ + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.info_outline_rounded, size: 20.0), Text(l10n.about)], + ), + ), + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.groups_outlined, size: 20.0), Text(l10n.communities)], + ), + ), + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.people_outlined, size: 20.0), Text(l10n.users)], + ), + ), + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.splitscreen_rounded, size: 20.0), Text(l10n.posts)], + ), + ), + Tab( + child: Row( + spacing: 6.0, + children: [const Icon(Icons.comment_outlined, size: 20.0), Text(l10n.comments)], + ), + ), + ], + ), + ), + ), + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + // About Tab + Builder(builder: (context) { + return CustomScrollView( + key: const PageStorageKey('about'), + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Material( + child: InstanceInformation(instance: widget.instance), + ), + ), + ), + ], + ); + }), + InstanceCommunityTab( + account: account, + query: query, + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceCommunities(sortType: searchSortType, query: query)), + ), + InstanceUserTab( + account: account, + query: query, + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceUsers(sortType: searchSortType, query: query)), + ), + InstancePostTab( + account: account, + query: query, + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstancePosts(sortType: searchSortType, query: query)), + ), + InstanceCommentTab( + account: account, + query: query, + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceComments(sortType: searchSortType, query: query)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/features/instance/presentation/bloc/instance_page_bloc.dart b/lib/src/features/instance/presentation/state/instance_page_bloc.dart similarity index 52% rename from lib/src/features/instance/presentation/bloc/instance_page_bloc.dart rename to lib/src/features/instance/presentation/state/instance_page_bloc.dart index cc7bc889d..6997d20b6 100644 --- a/lib/src/features/instance/presentation/bloc/instance_page_bloc.dart +++ b/lib/src/features/instance/presentation/state/instance_page_bloc.dart @@ -1,347 +1,415 @@ -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/core/models/thunder_instance_info.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; - -part 'instance_page_state.dart'; - -class InstancePageBloc extends Bloc { - /// The account that should be used to resolve data (communities, users, posts, comments) - Account account; - - /// The instance info to use for fetching data - ThunderInstanceInfo instanceInfo; - - /// The search repository to use for fetching data - late SearchRepository repository; - - /// The limit of items to fetch per page - static const int _pageLimit = 30; - - /// The number of items to resolve in parallel at a time - static const int _resolveBatchSize = 6; - - /// The repository to use for resolving items on the user's instance - late SearchRepository localRepository; - - InstancePageBloc({required this.account, required this.instanceInfo}) : super(const InstancePageState()) { - final uri = Uri.parse(instanceInfo.domain); - final tempAccount = Account(instance: uri.host, id: '', index: -1, platform: instanceInfo.platform); - repository = SearchRepositoryImpl(account: tempAccount); - localRepository = SearchRepositoryImpl(account: account); - - on(_onGetInstanceCommunities, transformer: restartable()); - on(_onGetInstanceUsers, transformer: restartable()); - on(_onGetInstancePosts, transformer: restartable()); - on(_onGetInstanceComments, transformer: restartable()); - on(_onResetInstanceTabs); - } - - Future _onGetInstanceCommunities(GetInstanceCommunities event, Emitter emit) async { - final currentPage = event.page ?? state.communities.page; - if (state.communities.status == InstancePageStatus.loading && currentPage != 1) return; - - emit( - state.copyWith( - communities: state.communities.copyWith( - status: InstancePageStatus.loading, - items: currentPage == 1 ? [] : state.communities.items, - ), - ), - ); - - try { - final response = await repository.search( - query: event.query ?? '', - type: MetaSearchType.communities, - sort: event.sortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: currentPage, - ); - - final List communities = List.from(response['communities']); - final status = communities.isEmpty || communities.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - communities: state.communities.copyWith( - status: status, - items: [...(currentPage == 1 ? [] : state.communities.items), ...communities], - page: currentPage, - ), - ), - ); - } catch (e) { - emit( - state.copyWith( - communities: state.communities.copyWith( - status: InstancePageStatus.failure, - message: getExceptionErrorMessage(e), - ), - ), - ); - } - } - - Future _onGetInstanceUsers(GetInstanceUsers event, Emitter emit) async { - final currentPage = event.page ?? state.users.page; - if (state.users.status == InstancePageStatus.loading && currentPage != 1) return; - - emit( - state.copyWith( - users: state.users.copyWith( - status: InstancePageStatus.loading, - items: currentPage == 1 ? [] : state.users.items, - ), - ), - ); - - try { - final response = await repository.search( - query: event.query ?? '', - type: MetaSearchType.users, - sort: event.sortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: currentPage, - ); - - final List users = List.from(response['users']); - final status = users.isEmpty || users.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - emit( - state.copyWith( - users: state.users.copyWith( - status: status, - items: [...(currentPage == 1 ? [] : state.users.items), ...users], - page: currentPage, - ), - ), - ); - } catch (e) { - emit( - state.copyWith( - users: state.users.copyWith( - status: InstancePageStatus.failure, - message: getExceptionErrorMessage(e), - ), - ), - ); - } - } - - Future _onGetInstancePosts(GetInstancePosts event, Emitter emit) async { - final currentPage = event.page ?? state.posts.page; - if (state.posts.status == InstancePageStatus.loading && currentPage != 1) return; - - emit( - state.copyWith( - posts: state.posts.copyWith( - status: InstancePageStatus.loading, - items: currentPage == 1 ? [] : state.posts.items, - ), - ), - ); - - try { - final response = await repository.search( - query: event.query ?? '', - type: MetaSearchType.posts, - sort: event.sortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: currentPage, - ); - - final List posts = response['posts']; - final status = posts.isEmpty || posts.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - final List previousItems = currentPage == 1 ? [] : state.posts.items; - List allResolvedPosts = []; - - if (posts.isEmpty) { - emit( - state.copyWith( - posts: state.posts.copyWith( - status: InstancePageStatus.done, - items: previousItems, - page: currentPage, - ), - ), - ); - return; - } - - for (int i = 0; i < posts.length; i += _resolveBatchSize) { - final end = (i + _resolveBatchSize < posts.length) ? i + _resolveBatchSize : posts.length; - final batch = posts.sublist(i, end); - - final resolvedBatch = await Future.wait( - batch.map( - (post) async { - try { - final response = await localRepository.resolve(query: post.apId); - return response['post'] as ThunderPost?; - } catch (e) { - return null; - } - }, - ), - ); - - final nonNullResolved = resolvedBatch.whereType().toList(); - allResolvedPosts.addAll(nonNullResolved); - - emit( - state.copyWith( - posts: state.posts.copyWith( - status: InstancePageStatus.loading, - items: [...previousItems, ...allResolvedPosts], - page: currentPage, - ), - ), - ); - } - - emit( - state.copyWith( - posts: state.posts.copyWith( - status: status, - items: [...previousItems, ...allResolvedPosts], - page: currentPage, - ), - ), - ); - } catch (e) { - emit( - state.copyWith( - posts: state.posts.copyWith( - status: InstancePageStatus.failure, - message: getExceptionErrorMessage(e), - ), - ), - ); - } - } - - Future _onGetInstanceComments(GetInstanceComments event, Emitter emit) async { - final currentPage = event.page ?? state.comments.page; - if (state.comments.status == InstancePageStatus.loading && currentPage != 1) return; - - emit( - state.copyWith( - comments: state.comments.copyWith( - status: InstancePageStatus.loading, - items: currentPage == 1 ? [] : state.comments.items, - ), - ), - ); - - try { - final response = await repository.search( - query: event.query ?? '', - type: MetaSearchType.comments, - sort: event.sortType, - listingType: FeedListType.local, - limit: _pageLimit, - page: currentPage, - ); - - final List comments = response['comments']; - final status = comments.isEmpty || comments.length < _pageLimit ? InstancePageStatus.done : InstancePageStatus.success; - - final List previousItems = currentPage == 1 ? [] : state.comments.items; - List allResolvedComments = []; - - if (comments.isEmpty) { - emit( - state.copyWith( - comments: state.comments.copyWith( - status: InstancePageStatus.done, - items: previousItems, - page: currentPage, - ), - ), - ); - return; - } - - for (var i = 0; i < comments.length; i += _resolveBatchSize) { - final end = (i + _resolveBatchSize < comments.length) ? i + _resolveBatchSize : comments.length; - final batch = comments.sublist(i, end); - - final resolvedBatch = await Future.wait( - batch.map( - (comment) async { - try { - final response = await localRepository.resolve(query: comment.apId); - return response['comment'] as ThunderComment?; - } catch (e) { - return null; - } - }, - ), - ); - - final nonNullResolved = resolvedBatch.whereType().toList(); - allResolvedComments.addAll(nonNullResolved); - - emit( - state.copyWith( - comments: state.comments.copyWith( - status: InstancePageStatus.loading, - items: [...previousItems, ...allResolvedComments], - page: currentPage, - ), - ), - ); - } - - emit( - state.copyWith( - comments: state.comments.copyWith( - status: status, - items: [...previousItems, ...allResolvedComments], - page: currentPage, - ), - ), - ); - } catch (e) { - emit( - state.copyWith( - comments: state.comments.copyWith( - status: InstancePageStatus.failure, - message: getExceptionErrorMessage(e), - ), - ), - ); - } - } - - void _onResetInstanceTabs(ResetInstanceTabs event, Emitter emit) { - if (event.excludeType != MetaSearchType.communities) { - emit(state.copyWith(communities: const InstanceTypeState())); - } - if (event.excludeType != MetaSearchType.users) { - emit(state.copyWith(users: const InstanceTypeState())); - } - if (event.excludeType != MetaSearchType.posts) { - emit(state.copyWith(posts: const InstanceTypeState())); - } - if (event.excludeType != MetaSearchType.comments) { - emit(state.copyWith(comments: const InstanceTypeState())); - } - } -} +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_utils.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_event.dart'; + +part 'instance_page_state.dart'; + +class InstancePageBloc extends Bloc { + /// The account that should be used to resolve data (communities, users, posts, comments) + final Account account; + + /// The instance info to use for fetching data + final ThunderInstanceInfo instanceInfo; + + /// The search repository to use for fetching data + final SearchRepository repository; + + /// The limit of items to fetch per page + static const int _pageLimit = 30; + + /// The number of items to resolve in parallel at a time + static const int _resolveBatchSize = 6; + + /// The repository to use for resolving items on the user's instance + final SearchRepository localRepository; + + InstancePageBloc({ + required this.account, + required this.instanceInfo, + required this.repository, + required this.localRepository, + }) : super(const InstancePageState()) { + on(_onGetInstanceCommunities, transformer: restartable()); + on(_onGetInstanceUsers, transformer: restartable()); + on(_onGetInstancePosts, transformer: restartable()); + on(_onGetInstanceComments, transformer: restartable()); + on(_onResetInstanceTabs); + } + + Future _onGetInstanceCommunities(GetInstanceCommunities event, Emitter emit) async { + final currentPage = event.page ?? state.communities.page; + if (shouldSkipFetch( + currentlyLoading: state.communities.status == InstancePageStatus.loading, + page: currentPage, + )) { + return; + } + + final previousItems = previousItemsForPage( + page: currentPage, + currentItems: state.communities.items, + ); + + emit( + state.copyWith( + communities: state.communities.copyWith( + status: InstancePageStatus.loading, + items: previousItems, + message: null, + errorReason: null, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.communities, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List communities = List.from(response.communities); + final status = hasReachedEnd( + fetchedCount: communities.length, + pageLimit: _pageLimit, + ) + ? InstancePageStatus.done + : InstancePageStatus.success; + + emit( + state.copyWith( + communities: state.communities.copyWith( + status: status, + items: [...previousItems, ...communities], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit( + state.copyWith( + communities: state.communities.copyWith( + status: InstancePageStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected(message: message), + ), + ), + ); + } + } + + Future _onGetInstanceUsers(GetInstanceUsers event, Emitter emit) async { + final currentPage = event.page ?? state.users.page; + if (shouldSkipFetch( + currentlyLoading: state.users.status == InstancePageStatus.loading, + page: currentPage, + )) { + return; + } + + final previousItems = previousItemsForPage( + page: currentPage, + currentItems: state.users.items, + ); + + emit( + state.copyWith( + users: state.users.copyWith( + status: InstancePageStatus.loading, + items: previousItems, + message: null, + errorReason: null, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.users, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List users = List.from(response.users); + final status = hasReachedEnd( + fetchedCount: users.length, + pageLimit: _pageLimit, + ) + ? InstancePageStatus.done + : InstancePageStatus.success; + + emit( + state.copyWith( + users: state.users.copyWith( + status: status, + items: [...previousItems, ...users], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit( + state.copyWith( + users: state.users.copyWith( + status: InstancePageStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected(message: message), + ), + ), + ); + } + } + + Future _onGetInstancePosts(GetInstancePosts event, Emitter emit) async { + final currentPage = event.page ?? state.posts.page; + if (shouldSkipFetch( + currentlyLoading: state.posts.status == InstancePageStatus.loading, + page: currentPage, + )) { + return; + } + + final previousItems = previousItemsForPage( + page: currentPage, + currentItems: state.posts.items, + ); + + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.loading, + items: previousItems, + message: null, + errorReason: null, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.posts, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List posts = response.posts; + final status = hasReachedEnd( + fetchedCount: posts.length, + pageLimit: _pageLimit, + ) + ? InstancePageStatus.done + : InstancePageStatus.success; + + if (posts.isEmpty) { + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.done, + items: previousItems, + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + return; + } + + final allResolvedPosts = await resolveInBatches( + source: posts, + batchSize: _resolveBatchSize, + resolver: (post) async { + try { + final response = await localRepository.resolve(query: post.apId); + return response.post; + } catch (_) { + return null; + } + }, + onBatchResolved: (resolvedPosts) { + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.loading, + items: [...previousItems, ...resolvedPosts], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + }, + ); + + emit( + state.copyWith( + posts: state.posts.copyWith( + status: status, + items: [...previousItems, ...allResolvedPosts], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit( + state.copyWith( + posts: state.posts.copyWith( + status: InstancePageStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected(message: message), + ), + ), + ); + } + } + + Future _onGetInstanceComments(GetInstanceComments event, Emitter emit) async { + final currentPage = event.page ?? state.comments.page; + if (shouldSkipFetch( + currentlyLoading: state.comments.status == InstancePageStatus.loading, + page: currentPage, + )) { + return; + } + + final previousItems = previousItemsForPage( + page: currentPage, + currentItems: state.comments.items, + ); + + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.loading, + items: previousItems, + message: null, + errorReason: null, + ), + ), + ); + + try { + final response = await repository.search( + query: event.query ?? '', + type: MetaSearchType.comments, + sort: event.sortType, + listingType: FeedListType.local, + limit: _pageLimit, + page: currentPage, + ); + + final List comments = response.comments; + final status = hasReachedEnd( + fetchedCount: comments.length, + pageLimit: _pageLimit, + ) + ? InstancePageStatus.done + : InstancePageStatus.success; + + if (comments.isEmpty) { + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.done, + items: previousItems, + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + return; + } + + final allResolvedComments = await resolveInBatches( + source: comments, + batchSize: _resolveBatchSize, + resolver: (comment) async { + try { + final response = await localRepository.resolve(query: comment.apId); + return response.comment; + } catch (_) { + return null; + } + }, + onBatchResolved: (resolvedComments) { + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.loading, + items: [...previousItems, ...resolvedComments], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + }, + ); + + emit( + state.copyWith( + comments: state.comments.copyWith( + status: status, + items: [...previousItems, ...allResolvedComments], + page: currentPage, + message: null, + errorReason: null, + ), + ), + ); + } catch (e) { + final message = getExceptionErrorMessage(e); + emit( + state.copyWith( + comments: state.comments.copyWith( + status: InstancePageStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected(message: message), + ), + ), + ); + } + } + + void _onResetInstanceTabs(ResetInstanceTabs event, Emitter emit) { + if (event.excludeType != MetaSearchType.communities) { + emit(state.copyWith(communities: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.users) { + emit(state.copyWith(users: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.posts) { + emit(state.copyWith(posts: const InstanceTypeState())); + } + if (event.excludeType != MetaSearchType.comments) { + emit(state.copyWith(comments: const InstanceTypeState())); + } + } +} diff --git a/lib/src/features/instance/presentation/bloc/instance_page_event.dart b/lib/src/features/instance/presentation/state/instance_page_event.dart similarity index 92% rename from lib/src/features/instance/presentation/bloc/instance_page_event.dart rename to lib/src/features/instance/presentation/state/instance_page_event.dart index efc85de26..8534c8ce9 100644 --- a/lib/src/features/instance/presentation/bloc/instance_page_event.dart +++ b/lib/src/features/instance/presentation/state/instance_page_event.dart @@ -1,7 +1,6 @@ import 'package:equatable/equatable.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; abstract class InstancePageEvent extends Equatable { const InstancePageEvent(); diff --git a/lib/src/features/instance/presentation/bloc/instance_page_state.dart b/lib/src/features/instance/presentation/state/instance_page_state.dart similarity index 68% rename from lib/src/features/instance/presentation/bloc/instance_page_state.dart rename to lib/src/features/instance/presentation/state/instance_page_state.dart index c0a9ed42e..6ff283559 100644 --- a/lib/src/features/instance/presentation/bloc/instance_page_state.dart +++ b/lib/src/features/instance/presentation/state/instance_page_state.dart @@ -1,91 +1,105 @@ -part of 'instance_page_bloc.dart'; - -enum InstancePageStatus { none, loading, success, failure, done } - -class InstanceTypeState extends Equatable { - /// The status of the instance type - final InstancePageStatus status; - - /// The error message if the instance type failed to load - final String? message; - - /// The current page of the instance type - final int page; - - /// The list of items for the instance type - final List items; - - const InstanceTypeState({ - this.status = InstancePageStatus.none, - this.message, - this.page = 1, - this.items = const [], - }); - - InstanceTypeState copyWith({ - InstancePageStatus? status, - String? message, - int? page, - List? items, - }) { - return InstanceTypeState( - status: status ?? this.status, - message: message ?? this.message, - page: page ?? this.page, - items: items ?? this.items, - ); - } - - @override - List get props => [status, message, page, items]; -} - -class InstancePageState extends Equatable { - /// The status of the instance page - final InstancePageStatus status; - - /// The error message if the instance page failed to load - final String? message; - - /// The communities for the instance page - final InstanceTypeState communities; - - /// The posts for the instance page - final InstanceTypeState posts; - - /// The users for the instance page - final InstanceTypeState users; - - /// The comments for the instance page - final InstanceTypeState comments; - - const InstancePageState({ - this.status = InstancePageStatus.success, - this.message, - this.communities = const InstanceTypeState(), - this.posts = const InstanceTypeState(), - this.users = const InstanceTypeState(), - this.comments = const InstanceTypeState(), - }); - - InstancePageState copyWith({ - InstancePageStatus? status, - String? message, - InstanceTypeState? communities, - InstanceTypeState? posts, - InstanceTypeState? users, - InstanceTypeState? comments, - }) { - return InstancePageState( - status: status ?? this.status, - message: message ?? this.message, - communities: communities ?? this.communities, - posts: posts ?? this.posts, - users: users ?? this.users, - comments: comments ?? this.comments, - ); - } - - @override - List get props => [status, message, communities, posts, users, comments]; -} +part of 'instance_page_bloc.dart'; + +enum InstancePageStatus { none, loading, success, failure, done } + +const _instancePageUnset = Object(); + +class InstanceTypeState extends Equatable { + /// The status of the instance type + final InstancePageStatus status; + + /// The error message if the instance type failed to load + final String? message; + + /// Typed error reason for failure states. + final AppErrorReason? errorReason; + + /// The current page of the instance type + final int page; + + /// The list of items for the instance type + final List items; + + const InstanceTypeState({ + this.status = InstancePageStatus.none, + this.message, + this.errorReason, + this.page = 1, + this.items = const [], + }); + + InstanceTypeState copyWith({ + InstancePageStatus? status, + Object? message = _instancePageUnset, + Object? errorReason = _instancePageUnset, + int? page, + List? items, + }) { + return InstanceTypeState( + status: status ?? this.status, + message: identical(message, _instancePageUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _instancePageUnset) ? this.errorReason : errorReason as AppErrorReason?, + page: page ?? this.page, + items: items ?? this.items, + ); + } + + @override + List get props => [status, message, errorReason, page, items]; +} + +class InstancePageState extends Equatable { + /// The status of the instance page + final InstancePageStatus status; + + /// The error message if the instance page failed to load + final String? message; + + /// Typed error reason for top-level page failures. + final AppErrorReason? errorReason; + + /// The communities for the instance page + final InstanceTypeState communities; + + /// The posts for the instance page + final InstanceTypeState posts; + + /// The users for the instance page + final InstanceTypeState users; + + /// The comments for the instance page + final InstanceTypeState comments; + + const InstancePageState({ + this.status = InstancePageStatus.success, + this.message, + this.errorReason, + this.communities = const InstanceTypeState(), + this.posts = const InstanceTypeState(), + this.users = const InstanceTypeState(), + this.comments = const InstanceTypeState(), + }); + + InstancePageState copyWith({ + InstancePageStatus? status, + Object? message = _instancePageUnset, + Object? errorReason = _instancePageUnset, + InstanceTypeState? communities, + InstanceTypeState? posts, + InstanceTypeState? users, + InstanceTypeState? comments, + }) { + return InstancePageState( + status: status ?? this.status, + message: identical(message, _instancePageUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _instancePageUnset) ? this.errorReason : errorReason as AppErrorReason?, + communities: communities ?? this.communities, + posts: posts ?? this.posts, + users: users ?? this.users, + comments: comments ?? this.comments, + ); + } + + @override + List get props => [status, message, errorReason, communities, posts, users, comments]; +} diff --git a/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart b/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart index afe89ed0a..af8b49475 100644 --- a/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart +++ b/lib/src/features/instance/presentation/widgets/instance_action_bottom_sheet.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/models/thunder_my_user.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, showSnackbar; /// Defines the actions that can be taken on an instance enum InstanceBottomSheetAction { @@ -117,13 +116,17 @@ class _InstanceActionBottomSheetState extends State { case InstanceBottomSheetAction.blockCommunityInstance: Navigator.of(context).pop(); final blocked = await repository.block(widget.communityInstanceId!, true); - if (blocked) showSnackbar(l10n.successfullyBlockedCommunity(communityInstance!)); + if (blocked) { + showSnackbar(l10n.successfullyBlockedCommunity(communityInstance!)); + } widget.onAction?.call(); break; case InstanceBottomSheetAction.unblockCommunityInstance: Navigator.of(context).pop(); final blocked = await repository.block(widget.communityInstanceId!, false); - if (!blocked) showSnackbar(l10n.successfullyUnblockedCommunity(communityInstance!)); + if (!blocked) { + showSnackbar(l10n.successfullyUnblockedCommunity(communityInstance!)); + } widget.onAction?.call(); break; case InstanceBottomSheetAction.visitUserInstance: @@ -138,7 +141,9 @@ class _InstanceActionBottomSheetState extends State { case InstanceBottomSheetAction.unblockUserInstance: Navigator.of(context).pop(); final blocked = await repository.block(widget.userInstanceId!, false); - if (!blocked) showSnackbar(l10n.successfullyUnblockedUser(userInstance!)); + if (!blocked) { + showSnackbar(l10n.successfullyUnblockedUser(userInstance!)); + } widget.onAction?.call(); break; } diff --git a/lib/src/features/instance/presentation/widgets/instance_information.dart b/lib/src/features/instance/presentation/widgets/instance_information.dart index 5b41056ae..aa677bc83 100644 --- a/lib/src/features/instance/presentation/widgets/instance_information.dart +++ b/lib/src/features/instance/presentation/widgets/instance_information.dart @@ -1,83 +1,83 @@ -import 'package:flutter/material.dart'; - -import 'package:intl/intl.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/widgets/avatars/instance_avatar.dart'; - -/// A widget that displays information about a given instance. -class InstanceInformation extends StatelessWidget { - /// Information about the instance. - final ThunderInstanceInfo instance; - - const InstanceInformation({super.key, required this.instance}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - return Column( - children: [ - Row( - spacing: 16.0, - children: [ - InstanceAvatar( - radius: 24.0, - instance: instance, - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - instance.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), - ), - Flexible( - child: Text( - instance.description ?? '-', - style: theme.textTheme.bodyMedium, - ), - ), - ], - ), - ), - ], - ), - if (instance.sidebar?.isNotEmpty == true) ...[ - const ThunderDivider(sliver: false, padding: false), - const SizedBox(height: 8.0), - Row( - spacing: 6.0, - children: [ - Badge( - label: Text(instance.platform?.displayName ?? '-'), - backgroundColor: theme.colorScheme.primary, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - ), - Badge( - label: Text('v${instance.version ?? '-'}'), - backgroundColor: theme.colorScheme.secondary, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - ), - Badge( - label: Text(l10n.countUsers(NumberFormat.decimalPattern(l10n.localeName).format(instance.users ?? 0))), - backgroundColor: theme.colorScheme.tertiary, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - ), - ], - ), - const SizedBox(height: 16.0), - CommonMarkdownBody(body: instance.sidebar ?? '-'), - ], - ], - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/instance_avatar.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; + +/// A widget that displays information about a given instance. +class InstanceInformation extends StatelessWidget { + /// Information about the instance. + final ThunderInstanceInfo instance; + + const InstanceInformation({super.key, required this.instance}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return Column( + children: [ + Row( + spacing: 16.0, + children: [ + InstanceAvatar( + radius: 24.0, + instance: instance, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + instance.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + Flexible( + child: Text( + instance.description ?? '-', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ), + if (instance.sidebar?.isNotEmpty == true) ...[ + const ThunderDivider(sliver: false, padding: false), + const SizedBox(height: 8.0), + Row( + spacing: 6.0, + children: [ + Badge( + label: Text(instance.platform?.displayName ?? '-'), + backgroundColor: theme.colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + Badge( + label: Text('v${instance.version ?? '-'}'), + backgroundColor: theme.colorScheme.secondary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + Badge( + label: Text(l10n.countUsers(NumberFormat.decimalPattern(l10n.localeName).format(instance.users ?? 0))), + backgroundColor: theme.colorScheme.tertiary, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + ), + ], + ), + const SizedBox(height: 16.0), + CommonMarkdownBody(body: instance.sidebar ?? '-'), + ], + ], + ); + } +} diff --git a/lib/src/features/instance/presentation/widgets/instance_list_entry.dart b/lib/src/features/instance/presentation/widgets/instance_list_entry.dart index 9d133b9ab..4fd8a24ce 100644 --- a/lib/src/features/instance/presentation/widgets/instance_list_entry.dart +++ b/lib/src/features/instance/presentation/widgets/instance_list_entry.dart @@ -1,57 +1,57 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/widgets/avatars/instance_avatar.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; - -/// Creates a widget which can display a summary of an instance for a list. -class InstanceListEntry extends StatefulWidget { - /// The instance to display. - final ThunderInstanceInfo instance; - - const InstanceListEntry({super.key, required this.instance}); - - @override - State createState() => _InstanceListEntryState(); -} - -class _InstanceListEntryState extends State { - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - - final name = widget.instance.name; - final domain = widget.instance.domain; - final users = widget.instance.users ?? 0; - final version = widget.instance.version; - - if (!widget.instance.success) { - return ListTile( - leading: InstanceAvatar(instance: widget.instance), - title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), - subtitle: Wrap( - children: [ - Text(domain, overflow: TextOverflow.ellipsis), - Text(' · ${l10n.unreachable}'), - ], - ), - onTap: null, - ); - } - - return ListTile( - leading: InstanceAvatar(instance: widget.instance), - title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), - subtitle: Wrap( - children: [ - Text(domain, overflow: TextOverflow.ellipsis), - if (widget.instance.users != null) Text(' · ${l10n.countUsers(formatLongNumber(users))}', semanticsLabel: l10n.countUsers(users)), - if (version?.isNotEmpty == true) Text(' · v$version', semanticsLabel: 'v$version'), - ], - ), - onTap: () => navigateToInstancePage(context, instanceHost: domain, instanceId: widget.instance.id), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/instance_avatar.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; + +/// Creates a widget which can display a summary of an instance for a list. +class InstanceListEntry extends StatefulWidget { + /// The instance to display. + final ThunderInstanceInfo instance; + + const InstanceListEntry({super.key, required this.instance}); + + @override + State createState() => _InstanceListEntryState(); +} + +class _InstanceListEntryState extends State { + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + final name = widget.instance.name; + final domain = widget.instance.domain; + final users = widget.instance.users ?? 0; + final version = widget.instance.version; + + if (!widget.instance.success) { + return ListTile( + leading: InstanceAvatar(instance: widget.instance), + title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), + subtitle: Wrap( + children: [ + Text(domain, overflow: TextOverflow.ellipsis), + Text(' · ${l10n.unreachable}'), + ], + ), + onTap: null, + ); + } + + return ListTile( + leading: InstanceAvatar(instance: widget.instance), + title: Text(name.isNotEmpty ? name : domain, overflow: TextOverflow.ellipsis), + subtitle: Wrap( + children: [ + Text(domain, overflow: TextOverflow.ellipsis), + if (widget.instance.users != null) Text(' · ${l10n.countUsers(formatLongNumber(users))}', semanticsLabel: l10n.countUsers(users)), + if (version?.isNotEmpty == true) Text(' · v$version', semanticsLabel: 'v$version'), + ], + ), + onTap: () => navigateToInstancePage(context, instanceHost: domain, instanceId: widget.instance.id), + ); + } +} diff --git a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart index 853decc49..06af7edf8 100644 --- a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart +++ b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart @@ -5,18 +5,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/src/shared/snackbar.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar; class InstancePageAppBar extends StatefulWidget { /// The instance being displayed. diff --git a/lib/src/features/instance/presentation/widgets/instance_tabs.dart b/lib/src/features/instance/presentation/widgets/instance_tabs.dart index 8fbd34d05..d631ee534 100644 --- a/lib/src/features/instance/presentation/widgets/instance_tabs.dart +++ b/lib/src/features/instance/presentation/widgets/instance_tabs.dart @@ -2,17 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/instance/presentation/bloc/instance_page_bloc.dart'; -import 'package:thunder/src/features/instance/presentation/bloc/instance_page_event.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_bloc.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_event.dart'; import 'package:thunder/src/shared/error_message.dart'; /// A scaffold for instance tabs. Handles loading, retry and loading more. diff --git a/lib/src/features/moderator/api.dart b/lib/src/features/moderator/api.dart new file mode 100644 index 000000000..ab67e041d --- /dev/null +++ b/lib/src/features/moderator/api.dart @@ -0,0 +1 @@ +export 'moderator.dart'; diff --git a/lib/src/features/moderator/domain/utils/report_utils.dart b/lib/src/features/moderator/domain/utils/report_utils.dart new file mode 100644 index 000000000..2ff090448 --- /dev/null +++ b/lib/src/features/moderator/domain/utils/report_utils.dart @@ -0,0 +1,46 @@ +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +bool shouldSkipPagination({ + required bool isFetching, + required bool hasReachedPostReportsEnd, + required bool hasReachedCommentReportsEnd, + required bool isPostFeed, +}) { + if (isFetching) { + return true; + } + + if (hasReachedPostReportsEnd && isPostFeed) { + return true; + } + + if (hasReachedCommentReportsEnd && !isPostFeed) { + return true; + } + + return false; +} + +List appendPostReports({ + required List current, + required List incoming, +}) { + return [...current, ...incoming]; +} + +List appendCommentReports({ + required List current, + required List incoming, +}) { + return [...current, ...incoming]; +} + +List replaceAt({ + required List source, + required int index, + required T value, +}) { + final updated = List.from(source); + updated[index] = value; + return updated; +} diff --git a/lib/src/features/moderator/moderator.dart b/lib/src/features/moderator/moderator.dart index 22c4894c4..4f56c42c6 100644 --- a/lib/src/features/moderator/moderator.dart +++ b/lib/src/features/moderator/moderator.dart @@ -1,5 +1,8 @@ export 'domain/enums/report_action.dart'; -export 'presentation/bloc/report_bloc.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +export 'domain/utils/report_utils.dart'; +export 'presentation/state/report_bloc.dart'; export 'presentation/pages/report_page.dart'; export 'presentation/widgets/report_page_filter_bottom_sheet.dart'; -export 'presentation/utils/report.dart'; +export 'presentation/utils/report_actions_utils.dart'; diff --git a/lib/src/features/moderator/presentation/bloc/report_bloc.dart b/lib/src/features/moderator/presentation/bloc/report_bloc.dart deleted file mode 100644 index 770464152..000000000 --- a/lib/src/features/moderator/presentation/bloc/report_bloc.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:stream_transform/stream_transform.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -import 'package:thunder/src/features/moderator/moderator.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -part 'report_event.dart'; -part 'report_state.dart'; - -const throttleDuration = Duration(milliseconds: 100); - -EventTransformer throttleDroppable(Duration duration) { - return (events, mapper) { - return droppable().call(events.throttle(duration), mapper); - }; -} - -class ReportBloc extends Bloc { - ReportBloc() : super(const ReportState()) { - /// Handles resetting the report feed to its initial state - on( - _onResetReportFeed, - transformer: restartable(), - ); - - /// Handles fetching the report - on( - _onReportFeedFetched, - transformer: restartable(), - ); - - /// Handles actions on a given item within the feed - on( - _onReportFeedItemActioned, - transformer: throttleDroppable(Duration.zero), - ); - - /// Handles changing the filter type of the report feed - on( - _onReportFeedChangeFilterType, - transformer: restartable(), - ); - - /// Handles clearing any messages from the state - on( - _onReportFeedClearMessage, - transformer: throttleDroppable(Duration.zero), - ); - } - - /// Handles clearing any messages from the state - Future _onReportFeedClearMessage(ReportFeedClearMessageEvent event, Emitter emit) async { - emit(state.copyWith(status: state.status == ReportStatus.failure ? state.status : ReportStatus.success, message: null)); - } - - /// Resets the ReportState to its initial state - Future _onResetReportFeed(ResetReportEvent event, Emitter emit) async { - emit( - const ReportState( - status: ReportStatus.initial, - reportFeedType: ReportFeedType.post, - showResolved: false, - communityId: null, - postReports: [], - commentReports: [], - hasReachedPostReportsEnd: false, - hasReachedCommentReportsEnd: false, - currentPage: 1, - message: null, - ), - ); - } - - /// Changes the current filter type of the report feed - Future _onReportFeedChangeFilterType(ReportFeedChangeFilterTypeEvent event, Emitter emit) async { - add(ReportFeedFetchedEvent( - reportFeedType: state.reportFeedType, - showResolved: event.showResolved, - communityId: event.communityId, - reset: true, - )); - } - - /// Fetches the list of report events - Future _onReportFeedFetched(ReportFeedFetchedEvent event, Emitter emit) async { - // Handle the initial fetch or reload of a feed - if (event.reset) { - if (state.status != ReportStatus.initial) add(ResetReportEvent()); - - Map fetchReportsResult = await fetchReports( - page: 1, - unresolved: !event.showResolved, - communityId: event.communityId, - postId: null, // TODO: This is introduced in 0.19.4 - commentId: null, // TODO: This is introduced in 0.19.4 - reportFeedType: event.reportFeedType, - ); - - // Extract information from the response - List postReportViews = fetchReportsResult['postReportViews']; - List commentReportViews = fetchReportsResult['commentReportViews']; - bool hasReachedPostReportsEnd = fetchReportsResult['hasReachedPostReportsEnd']; - bool hasReachedCommentReportsEnd = fetchReportsResult['hasReachedCommentReportsEnd']; - int currentPage = fetchReportsResult['currentPage']; - - return emit( - state.copyWith( - status: ReportStatus.success, - reportFeedType: event.reportFeedType, - showResolved: event.showResolved, - communityId: event.communityId, - postReports: postReportViews, - commentReports: commentReportViews, - hasReachedPostReportsEnd: hasReachedPostReportsEnd, - hasReachedCommentReportsEnd: hasReachedCommentReportsEnd, - currentPage: currentPage, - ), - ); - } - - // If the feed is already being fetched but it is not a reset, then just wait - if (state.status == ReportStatus.fetching) return; - if (state.hasReachedPostReportsEnd && event.reportFeedType == ReportFeedType.post) return; - if (state.hasReachedCommentReportsEnd && event.reportFeedType == ReportFeedType.comment) return; - - // Handle fetching the next page of the feed - emit(state.copyWith(status: ReportStatus.fetching)); - - List postReportViews = List.from(state.postReports); - List commentReportViews = List.from(state.commentReports); - - Map fetchReportsResult = await fetchReports( - page: state.currentPage, - unresolved: !state.showResolved, - communityId: state.communityId, - postId: null, // TODO: This is introduced in 0.19.4 - commentId: null, // TODO: This is introduced in 0.19.4 - reportFeedType: state.reportFeedType, - ); - - // Extract information from the response - List newPostReportViews = fetchReportsResult['postReportViews']; - List newCommentReportViews = fetchReportsResult['commentReportViews']; - bool hasReachedPostReportsEnd = fetchReportsResult['hasReachedPostReportsEnd']; - bool hasReachedCommentReportsEnd = fetchReportsResult['hasReachedCommentReportsEnd']; - int currentPage = fetchReportsResult['currentPage']; - - postReportViews.addAll(newPostReportViews); - commentReportViews.addAll(newCommentReportViews); - - return emit( - state.copyWith( - status: ReportStatus.success, - reportFeedType: event.reportFeedType, - postReports: postReportViews, - commentReports: commentReportViews, - hasReachedPostReportsEnd: hasReachedPostReportsEnd, - hasReachedCommentReportsEnd: hasReachedCommentReportsEnd, - currentPage: currentPage, - ), - ); - } - - /// Handles related actions on a given item within the feed - Future _onReportFeedItemActioned(ReportFeedItemActionedEvent event, Emitter emit) async { - assert(!(event.postReportView == null && event.commentReportView == null)); - emit(state.copyWith(status: ReportStatus.fetching)); - - switch (event.reportAction) { - case ReportAction.resolvePost: - // Optimistically update the report - int existingPostReportViewIndex = state.postReports.indexWhere((ThunderPostReport postReportView) => postReportView.id == event.postReportView!.id); - - ThunderPostReport postReportView = state.postReports[existingPostReportViewIndex]; - - try { - ThunderPostReport updatedPostReport = optimisticallyResolvePostReport(postReportView, event.value); - state.postReports[existingPostReportViewIndex] = updatedPostReport; - - // Emit the state to update UI immediately - emit(state.copyWith(status: ReportStatus.success)); - emit(state.copyWith(status: ReportStatus.fetching)); - - bool success = await resolvePostReport(postReportView.id, event.value); - if (success) return emit(state.copyWith(status: ReportStatus.success)); - - state.postReports[existingPostReportViewIndex] = postReportView; - return emit(state.copyWith(status: ReportStatus.failure, message: AppLocalizations.of(GlobalContext.context)!.unableToResolveReport)); - } catch (e) { - // Restore the original post report contents - state.postReports[existingPostReportViewIndex] = postReportView; - emit(state.copyWith(status: ReportStatus.failure, message: e.toString())); - } - case ReportAction.resolveComment: - // Optimistically update the report - int existingCommentReportViewIndex = state.commentReports.indexWhere((ThunderCommentReport commentReportView) => commentReportView.id == event.commentReportView!.id); - - ThunderCommentReport commentReportView = state.commentReports[existingCommentReportViewIndex]; - ThunderCommentReport originalCommentReport = commentReportView; - - try { - ThunderCommentReport updatedCommentReport = optimisticallyResolveCommentReport(commentReportView, event.value); - state.commentReports[existingCommentReportViewIndex] = updatedCommentReport; - - // Emit the state to update UI immediately - emit(state.copyWith(status: ReportStatus.success)); - emit(state.copyWith(status: ReportStatus.fetching)); - - bool success = await resolveCommentReport(originalCommentReport.id, event.value); - if (success) return emit(state.copyWith(status: ReportStatus.success)); - - state.commentReports[existingCommentReportViewIndex] = commentReportView; - return emit(state.copyWith(status: ReportStatus.failure, message: AppLocalizations.of(GlobalContext.context)!.unableToResolveReport)); - } catch (e) { - // Restore the original comment report contents - state.commentReports[existingCommentReportViewIndex] = commentReportView; - emit(state.copyWith(status: ReportStatus.failure, message: e.toString())); - } - } - } -} diff --git a/lib/src/features/moderator/presentation/pages/report_page.dart b/lib/src/features/moderator/presentation/pages/report_page.dart index 2c0ff9dca..b9e5865d0 100644 --- a/lib/src/features/moderator/presentation/pages/report_page.dart +++ b/lib/src/features/moderator/presentation/pages/report_page.dart @@ -6,19 +6,21 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/moderator/moderator.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/comment_reference.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/comment/presentation/widgets/comment_reference.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; enum ReportFeedType { post, comment } @@ -34,7 +36,7 @@ class _ReportFeedPageState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => ReportBloc()..add(const ReportFeedFetchedEvent(reportFeedType: ReportFeedType.post, reset: true)), + create: (_) => createReportBloc()..add(const ReportFeedFetchedEvent(reportFeedType: ReportFeedType.post, reset: true)), child: const ReportFeedView(), ); } @@ -164,7 +166,9 @@ class _ReportFeedViewState extends State { }, body: BlocConsumer( listenWhen: (previous, current) { - if (current.status == ReportStatus.initial) globalKey.currentState?.innerController.jumpTo(0); + if (current.status == ReportStatus.initial) { + globalKey.currentState?.innerController.jumpTo(0); + } return true; }, listener: (context, state) { @@ -263,7 +267,7 @@ class _ReportFeedViewState extends State { context.read().add(ReportFeedItemActionedEvent( reportAction: ReportAction.resolvePost, postReportView: state.postReports[index], - value: !state.postReports[index].resolved, + actionInput: ResolveReportActionInput(!state.postReports[index].resolved), )); }, icon: Icon(state.postReports[index].resolved ? Icons.undo_rounded : Icons.check_rounded), @@ -367,7 +371,7 @@ class _ReportFeedViewState extends State { context.read().add(ReportFeedItemActionedEvent( reportAction: ReportAction.resolveComment, commentReportView: state.commentReports[index], - value: !state.commentReports[index].resolved, + actionInput: ResolveReportActionInput(!state.commentReports[index].resolved), )); }, icon: Icon(state.commentReports[index].resolved ? Icons.undo_rounded : Icons.check_rounded), diff --git a/lib/src/features/moderator/presentation/state/report_bloc.dart b/lib/src/features/moderator/presentation/state/report_bloc.dart new file mode 100644 index 000000000..cb7de67c9 --- /dev/null +++ b/lib/src/features/moderator/presentation/state/report_bloc.dart @@ -0,0 +1,343 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; + +import 'package:thunder/src/features/moderator/moderator.dart'; + +part 'report_event.dart'; +part 'report_state.dart'; + +const throttleDuration = Duration(milliseconds: 100); + +EventTransformer throttleDroppable(Duration duration) { + return (events, mapper) { + return droppable().call(events.throttle(duration), mapper); + }; +} + +class ReportBloc extends Bloc { + ReportBloc({required LocalizationService localizationService}) + : _localizationService = localizationService, + super(const ReportState()) { + /// Handles resetting the report feed to its initial state + on( + _onResetReportFeed, + transformer: restartable(), + ); + + /// Handles fetching the report + on( + _onReportFeedFetched, + transformer: restartable(), + ); + + /// Handles actions on a given item within the feed + on( + _onReportFeedItemActioned, + transformer: throttleDroppable(Duration.zero), + ); + + /// Handles changing the filter type of the report feed + on( + _onReportFeedChangeFilterType, + transformer: restartable(), + ); + + /// Handles clearing any messages from the state + on( + _onReportFeedClearMessage, + transformer: throttleDroppable(Duration.zero), + ); + } + + final LocalizationService _localizationService; + + /// Handles clearing any messages from the state + Future _onReportFeedClearMessage(ReportFeedClearMessageEvent event, Emitter emit) async { + emit( + state.copyWith( + status: state.status == ReportStatus.failure ? state.status : ReportStatus.success, + message: null, + errorReason: null, + ), + ); + } + + /// Resets the ReportState to its initial state + Future _onResetReportFeed(ResetReportEvent event, Emitter emit) async { + emit( + const ReportState( + status: ReportStatus.initial, + reportFeedType: ReportFeedType.post, + showResolved: false, + communityId: null, + postReports: [], + commentReports: [], + hasReachedPostReportsEnd: false, + hasReachedCommentReportsEnd: false, + currentPage: 1, + message: null, + errorReason: null, + ), + ); + } + + /// Changes the current filter type of the report feed + Future _onReportFeedChangeFilterType(ReportFeedChangeFilterTypeEvent event, Emitter emit) async { + add(ReportFeedFetchedEvent( + reportFeedType: state.reportFeedType, + showResolved: event.showResolved, + communityId: event.communityId, + reset: true, + )); + } + + /// Fetches the list of report events + Future _onReportFeedFetched(ReportFeedFetchedEvent event, Emitter emit) async { + try { + // Handle the initial fetch or reload of a feed + if (event.reset) { + if (state.status != ReportStatus.initial) add(ResetReportEvent()); + + Map fetchReportsResult = await fetchReports( + page: 1, + unresolved: !event.showResolved, + communityId: event.communityId, + postId: null, // TODO: This is introduced in 0.19.4 + commentId: null, // TODO: This is introduced in 0.19.4 + reportFeedType: event.reportFeedType, + ); + + // Extract information from the response + List postReportViews = fetchReportsResult['postReportViews']; + List commentReportViews = fetchReportsResult['commentReportViews']; + bool hasReachedPostReportsEnd = fetchReportsResult['hasReachedPostReportsEnd']; + bool hasReachedCommentReportsEnd = fetchReportsResult['hasReachedCommentReportsEnd']; + int currentPage = fetchReportsResult['currentPage']; + + return emit( + state.copyWith( + status: ReportStatus.success, + reportFeedType: event.reportFeedType, + showResolved: event.showResolved, + communityId: event.communityId, + postReports: postReportViews, + commentReports: commentReportViews, + hasReachedPostReportsEnd: hasReachedPostReportsEnd, + hasReachedCommentReportsEnd: hasReachedCommentReportsEnd, + currentPage: currentPage, + errorReason: null, + ), + ); + } + + if (shouldSkipPagination( + isFetching: state.status == ReportStatus.fetching, + hasReachedPostReportsEnd: state.hasReachedPostReportsEnd, + hasReachedCommentReportsEnd: state.hasReachedCommentReportsEnd, + isPostFeed: event.reportFeedType == ReportFeedType.post, + )) { + return; + } + + // Handle fetching the next page of the feed + emit(state.copyWith(status: ReportStatus.fetching)); + + List postReportViews = List.from(state.postReports); + List commentReportViews = List.from(state.commentReports); + + Map fetchReportsResult = await fetchReports( + page: state.currentPage, + unresolved: !state.showResolved, + communityId: state.communityId, + postId: null, // TODO: This is introduced in 0.19.4 + commentId: null, // TODO: This is introduced in 0.19.4 + reportFeedType: state.reportFeedType, + ); + + // Extract information from the response + List newPostReportViews = fetchReportsResult['postReportViews']; + List newCommentReportViews = fetchReportsResult['commentReportViews']; + bool hasReachedPostReportsEnd = fetchReportsResult['hasReachedPostReportsEnd']; + bool hasReachedCommentReportsEnd = fetchReportsResult['hasReachedCommentReportsEnd']; + int currentPage = fetchReportsResult['currentPage']; + + postReportViews = appendPostReports( + current: postReportViews, + incoming: newPostReportViews, + ); + commentReportViews = appendCommentReports( + current: commentReportViews, + incoming: newCommentReportViews, + ); + + return emit( + state.copyWith( + status: ReportStatus.success, + reportFeedType: event.reportFeedType, + postReports: postReportViews, + commentReports: commentReportViews, + hasReachedPostReportsEnd: hasReachedPostReportsEnd, + hasReachedCommentReportsEnd: hasReachedCommentReportsEnd, + currentPage: currentPage, + errorReason: null, + ), + ); + } catch (e) { + final message = e.toString(); + return emit(state.copyWith( + status: ReportStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + /// Handles related actions on a given item within the feed + Future _onReportFeedItemActioned(ReportFeedItemActionedEvent event, Emitter emit) async { + assert(!(event.postReportView == null && event.commentReportView == null)); + emit(state.copyWith(status: ReportStatus.fetching)); + + switch (event.reportAction) { + case ReportAction.resolvePost: + final input = event.actionInput; + if (input is! ResolveReportActionInput) { + final message = _localizationService.l10n.unableToResolveReport; + return emit(state.copyWith( + status: ReportStatus.failure, + message: message, + errorReason: AppErrorReason.validation(message: message), + )); + } + // Optimistically update the report + int existingPostReportViewIndex = state.postReports.indexWhere((ThunderPostReport postReportView) => postReportView.id == event.postReportView!.id); + if (existingPostReportViewIndex == -1) { + final message = _localizationService.l10n.unableToResolveReport; + return emit(state.copyWith( + status: ReportStatus.failure, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + + ThunderPostReport postReportView = state.postReports[existingPostReportViewIndex]; + final originalPostReports = List.from(state.postReports); + final value = input.resolved; + + try { + ThunderPostReport updatedPostReport = optimisticallyResolvePostReport(postReportView, value); + final optimisticPostReports = replaceAt( + source: state.postReports, + index: existingPostReportViewIndex, + value: updatedPostReport, + ); + + // Emit the state to update UI immediately + emit(state.copyWith(status: ReportStatus.success, postReports: optimisticPostReports)); + emit(state.copyWith(status: ReportStatus.fetching, postReports: optimisticPostReports)); + + bool success = await resolvePostReport(postReportView.id, value); + if (success) { + return emit(state.copyWith( + status: ReportStatus.success, + postReports: optimisticPostReports, + errorReason: null, + )); + } + + final message = _localizationService.l10n.unableToResolveReport; + return emit(state.copyWith( + status: ReportStatus.failure, + postReports: originalPostReports, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } catch (e) { + final message = e.toString(); + emit(state.copyWith( + status: ReportStatus.failure, + postReports: originalPostReports, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + case ReportAction.resolveComment: + final input = event.actionInput; + if (input is! ResolveReportActionInput) { + final message = _localizationService.l10n.unableToResolveReport; + return emit(state.copyWith( + status: ReportStatus.failure, + message: message, + errorReason: AppErrorReason.validation(message: message), + )); + } + // Optimistically update the report + int existingCommentReportViewIndex = state.commentReports.indexWhere((ThunderCommentReport commentReportView) => commentReportView.id == event.commentReportView!.id); + if (existingCommentReportViewIndex == -1) { + final message = _localizationService.l10n.unableToResolveReport; + return emit(state.copyWith( + status: ReportStatus.failure, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + + ThunderCommentReport commentReportView = state.commentReports[existingCommentReportViewIndex]; + ThunderCommentReport originalCommentReport = commentReportView; + final originalCommentReports = List.from(state.commentReports); + final value = input.resolved; + + try { + ThunderCommentReport updatedCommentReport = optimisticallyResolveCommentReport(commentReportView, value); + final optimisticCommentReports = replaceAt( + source: state.commentReports, + index: existingCommentReportViewIndex, + value: updatedCommentReport, + ); + + // Emit the state to update UI immediately + emit(state.copyWith(status: ReportStatus.success, commentReports: optimisticCommentReports)); + emit(state.copyWith(status: ReportStatus.fetching, commentReports: optimisticCommentReports)); + + bool success = await resolveCommentReport(originalCommentReport.id, value); + if (success) { + return emit(state.copyWith( + status: ReportStatus.success, + commentReports: optimisticCommentReports, + errorReason: null, + )); + } + + final message = _localizationService.l10n.unableToResolveReport; + return emit( + state.copyWith( + status: ReportStatus.failure, + commentReports: originalCommentReports, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + ), + ); + } catch (e) { + final message = e.toString(); + emit(state.copyWith( + status: ReportStatus.failure, + commentReports: originalCommentReports, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + } +} diff --git a/lib/src/features/moderator/presentation/bloc/report_event.dart b/lib/src/features/moderator/presentation/state/report_event.dart similarity index 67% rename from lib/src/features/moderator/presentation/bloc/report_event.dart rename to lib/src/features/moderator/presentation/state/report_event.dart index d09749591..263d36ff5 100644 --- a/lib/src/features/moderator/presentation/bloc/report_event.dart +++ b/lib/src/features/moderator/presentation/state/report_event.dart @@ -1,10 +1,26 @@ part of 'report_bloc.dart'; +sealed class ReportActionInput extends Equatable { + const ReportActionInput(); + + @override + List get props => []; +} + +final class ResolveReportActionInput extends ReportActionInput { + const ResolveReportActionInput(this.resolved); + + final bool resolved; + + @override + List get props => [resolved]; +} + sealed class ReportEvent extends Equatable { const ReportEvent(); @override - List get props => []; + List get props => []; } /// Event for resetting the report feed @@ -30,6 +46,9 @@ final class ReportFeedFetchedEvent extends ReportEvent { this.communityId, this.reset = false, }); + + @override + List get props => [reportFeedType, showResolved, communityId, reset]; } /// Event for changing the filter type of the report feed @@ -41,6 +60,9 @@ final class ReportFeedChangeFilterTypeEvent extends ReportEvent { final int? communityId; const ReportFeedChangeFilterTypeEvent({this.showResolved = false, this.communityId}); + + @override + List get props => [showResolved, communityId]; } final class ReportFeedItemActionedEvent extends ReportEvent { @@ -53,11 +75,18 @@ final class ReportFeedItemActionedEvent extends ReportEvent { /// This indicates the relevant action to perform on the post/comment report final ReportAction reportAction; - /// This indicates the value to assign the action to. It is of type dynamic to allow for any type - /// TODO: Change the dynamic type to the correct type(s) if possible - final dynamic value; + /// Typed payload to apply for the selected [reportAction]. + final ReportActionInput? actionInput; + + const ReportFeedItemActionedEvent({ + this.postReportView, + this.commentReportView, + required this.reportAction, + this.actionInput, + }); - const ReportFeedItemActionedEvent({this.postReportView, this.commentReportView, required this.reportAction, this.value}); + @override + List get props => [postReportView, commentReportView, reportAction, actionInput]; } /// Event for clearing the report feed snackbar message diff --git a/lib/src/features/moderator/presentation/bloc/report_state.dart b/lib/src/features/moderator/presentation/state/report_state.dart similarity index 74% rename from lib/src/features/moderator/presentation/bloc/report_state.dart rename to lib/src/features/moderator/presentation/state/report_state.dart index b4e49340b..62ca72bf4 100644 --- a/lib/src/features/moderator/presentation/bloc/report_state.dart +++ b/lib/src/features/moderator/presentation/state/report_state.dart @@ -2,6 +2,8 @@ part of 'report_bloc.dart'; enum ReportStatus { initial, fetching, success, failure } +const _reportUnset = Object(); + final class ReportState extends Equatable { const ReportState({ this.status = ReportStatus.initial, @@ -14,6 +16,7 @@ final class ReportState extends Equatable { this.hasReachedCommentReportsEnd = false, this.currentPage = 1, this.message, + this.errorReason, }); /// The status of the report feed @@ -46,29 +49,34 @@ final class ReportState extends Equatable { /// The message to display on failure final String? message; + /// Typed reason for failures. + final AppErrorReason? errorReason; + ReportState copyWith({ ReportStatus? status, ReportFeedType? reportFeedType, bool? showResolved, - int? communityId, + Object? communityId = _reportUnset, List? postReports, List? commentReports, bool? hasReachedPostReportsEnd, bool? hasReachedCommentReportsEnd, int? currentPage, - String? message, + Object? message = _reportUnset, + Object? errorReason = _reportUnset, }) { return ReportState( status: status ?? this.status, reportFeedType: reportFeedType ?? this.reportFeedType, showResolved: showResolved ?? this.showResolved, - communityId: communityId ?? this.communityId, + communityId: identical(communityId, _reportUnset) ? this.communityId : communityId as int?, postReports: postReports ?? this.postReports, commentReports: commentReports ?? this.commentReports, hasReachedPostReportsEnd: hasReachedPostReportsEnd ?? this.hasReachedPostReportsEnd, hasReachedCommentReportsEnd: hasReachedCommentReportsEnd ?? this.hasReachedCommentReportsEnd, currentPage: currentPage ?? this.currentPage, - message: message ?? this.message, + message: identical(message, _reportUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _reportUnset) ? this.errorReason : errorReason as AppErrorReason?, ); } @@ -78,5 +86,17 @@ final class ReportState extends Equatable { } @override - List get props => [status, reportFeedType, showResolved, communityId, postReports, commentReports, hasReachedPostReportsEnd, hasReachedCommentReportsEnd, currentPage, message]; + List get props => [ + status, + reportFeedType, + showResolved, + communityId, + postReports, + commentReports, + hasReachedPostReportsEnd, + hasReachedCommentReportsEnd, + currentPage, + message, + errorReason, + ]; } diff --git a/lib/src/features/moderator/presentation/utils/report.dart b/lib/src/features/moderator/presentation/utils/report_actions_utils.dart similarity index 96% rename from lib/src/features/moderator/presentation/utils/report.dart rename to lib/src/features/moderator/presentation/utils/report_actions_utils.dart index e267f64b4..2e4f11e31 100644 --- a/lib/src/features/moderator/presentation/utils/report.dart +++ b/lib/src/features/moderator/presentation/utils/report_actions_utils.dart @@ -1,7 +1,5 @@ -import 'package:thunder/src/core/models/thunder_comment_report.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; import 'package:thunder/src/features/moderator/moderator.dart'; import 'package:thunder/src/features/post/post.dart'; diff --git a/lib/src/features/modlog/api.dart b/lib/src/features/modlog/api.dart new file mode 100644 index 000000000..f9b560bc3 --- /dev/null +++ b/lib/src/features/modlog/api.dart @@ -0,0 +1 @@ +export 'modlog.dart'; diff --git a/lib/src/features/modlog/data/models/modlog_event_item.dart b/lib/src/features/modlog/data/models/modlog_event_item.dart index aabbd5ceb..ce512dfb6 100644 --- a/lib/src/features/modlog/data/models/modlog_event_item.dart +++ b/lib/src/features/modlog/data/models/modlog_event_item.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// Represents a modlog event based on [ModlogActionType]. /// This class is used to display modlog events in the UI. @@ -24,6 +25,21 @@ class ModlogEventItem { required this.actioned, }); + factory ModlogEventItem.fromModlogEvent(ModlogEvent event) { + return ModlogEventItem( + type: event.type, + dateTime: event.dateTime, + moderator: event.moderator, + admin: event.admin, + reason: event.reason, + user: event.user, + post: event.post, + comment: event.comment, + community: event.community, + actioned: event.actioned, + ); + } + /// The type of the event. final ModlogActionType type; diff --git a/lib/src/features/modlog/data/repositories/modlog_repository.dart b/lib/src/features/modlog/data/repositories/modlog_repository.dart index 95fb78c5a..4f7805fb0 100644 --- a/lib/src/features/modlog/data/repositories/modlog_repository.dart +++ b/lib/src/features/modlog/data/repositories/modlog_repository.dart @@ -2,9 +2,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/contracts/account.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; /// Model representing a page of modlog events @@ -72,7 +71,7 @@ class ModlogRepositoryImpl implements ModlogRepository { commentId: commentId, ); - modLogEventItems.addAll(items); + modLogEventItems.addAll(items.map((event) => ModlogEventItem.fromModlogEvent(event))); if (items.isEmpty) hasReachedEnd = true; currentPage++; diff --git a/lib/src/features/modlog/domain/enums/enums.dart b/lib/src/features/modlog/domain/enums/enums.dart index c17cfd385..e34393a2a 100644 --- a/lib/src/features/modlog/domain/enums/enums.dart +++ b/lib/src/features/modlog/domain/enums/enums.dart @@ -1,2 +1,2 @@ -export 'modlog_action_type.dart'; +export 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; export 'modlog_action_type_category.dart'; diff --git a/lib/src/features/modlog/modlog.dart b/lib/src/features/modlog/modlog.dart index af3f1b840..fe14165f8 100644 --- a/lib/src/features/modlog/modlog.dart +++ b/lib/src/features/modlog/modlog.dart @@ -1,6 +1,6 @@ export 'data/models/models.dart'; export 'presentation/pages/pages.dart'; -export 'presentation/bloc/modlog_cubit.dart'; +export 'presentation/state/modlog_cubit.dart'; export 'presentation/widgets/widgets.dart'; export 'domain/enums/enums.dart'; export 'data/repositories/modlog_repository.dart'; diff --git a/lib/src/features/modlog/presentation/pages/modlog_page.dart b/lib/src/features/modlog/presentation/pages/modlog_page.dart index 29b1c6aab..674e5e1b4 100644 --- a/lib/src/features/modlog/presentation/pages/modlog_page.dart +++ b/lib/src/features/modlog/presentation/pages/modlog_page.dart @@ -6,9 +6,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; + +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// Creates a [ModlogPage] which holds a list of modlog events. class ModlogFeedPage extends StatefulWidget { @@ -140,7 +141,9 @@ class _ModlogFeedViewState extends State { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { // Wait until the layout is complete before performing check bool isScrollable = _scrollController.position.maxScrollExtent > _scrollController.position.viewportDimension; - if (!isScrollable) context.read().fetchModlogFeed(); + if (!isScrollable) { + context.read().fetchModlogFeed(); + } }); } diff --git a/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart b/lib/src/features/modlog/presentation/state/modlog_cubit.dart similarity index 96% rename from lib/src/features/modlog/presentation/bloc/modlog_cubit.dart rename to lib/src/features/modlog/presentation/state/modlog_cubit.dart index e8d269ab4..bc0e9ed55 100644 --- a/lib/src/features/modlog/presentation/bloc/modlog_cubit.dart +++ b/lib/src/features/modlog/presentation/state/modlog_cubit.dart @@ -1,9 +1,10 @@ import 'package:bloc/bloc.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; part 'modlog_state.dart'; part 'modlog_cubit.freezed.dart'; diff --git a/lib/src/features/modlog/presentation/bloc/modlog_cubit.freezed.dart b/lib/src/features/modlog/presentation/state/modlog_cubit.freezed.dart similarity index 100% rename from lib/src/features/modlog/presentation/bloc/modlog_cubit.freezed.dart rename to lib/src/features/modlog/presentation/state/modlog_cubit.freezed.dart diff --git a/lib/src/features/modlog/presentation/bloc/modlog_state.dart b/lib/src/features/modlog/presentation/state/modlog_state.dart similarity index 82% rename from lib/src/features/modlog/presentation/bloc/modlog_state.dart rename to lib/src/features/modlog/presentation/state/modlog_state.dart index d74fc73b5..93fa9042e 100644 --- a/lib/src/features/modlog/presentation/bloc/modlog_state.dart +++ b/lib/src/features/modlog/presentation/state/modlog_state.dart @@ -37,4 +37,12 @@ class ModlogState with _$ModlogState { }) = _ModlogState; const ModlogState._(); + + AppErrorReason? get errorReason { + final currentMessage = message; + if (currentMessage == null || currentMessage.isEmpty) { + return null; + } + return AppErrorReason.unexpected(message: currentMessage); + } } diff --git a/lib/src/features/modlog/presentation/widgets/modlog_feed_page_app_bar.dart b/lib/src/features/modlog/presentation/widgets/modlog_feed_page_app_bar.dart index c6f318b2f..e368b0a06 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_feed_page_app_bar.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_feed_page_app_bar.dart @@ -8,8 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; /// The app bar for the modlog feed page class ModlogFeedPageAppBar extends StatelessWidget { diff --git a/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart b/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart index 302f98c4e..12e82ff9c 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_filter_picker.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/shared/picker_item.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, PickerItem; List> defaultModlogActionTypeItems = [ ListPickerItem( diff --git a/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart b/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart index 57e792208..fca783feb 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_item_card.dart @@ -3,12 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; /// Widget to display a single modlog event item class ModlogItemCard extends StatelessWidget { diff --git a/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart b/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart index 71504d6c5..62526136e 100644 --- a/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart +++ b/lib/src/features/modlog/presentation/widgets/modlog_item_context_card.dart @@ -2,24 +2,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:html_unescape/html_unescape_small.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// Provides some additional context for a [ModlogEventItem] class ModlogItemContextCard extends StatelessWidget { diff --git a/lib/src/features/notification/api.dart b/lib/src/features/notification/api.dart new file mode 100644 index 000000000..301352cbc --- /dev/null +++ b/lib/src/features/notification/api.dart @@ -0,0 +1,3 @@ +export 'notification.dart'; +export 'application/state/notifications_cubit.dart'; +export 'presentation/pages/notifications_page.dart'; diff --git a/lib/src/app/cubits/notifications_cubit/notifications_cubit.dart b/lib/src/features/notification/application/state/notifications_cubit.dart similarity index 64% rename from lib/src/app/cubits/notifications_cubit/notifications_cubit.dart rename to lib/src/features/notification/application/state/notifications_cubit.dart index 8edffc454..2339e969d 100644 --- a/lib/src/app/cubits/notifications_cubit/notifications_cubit.dart +++ b/lib/src/features/notification/application/state/notifications_cubit.dart @@ -1,52 +1,71 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; - -import 'package:thunder/src/features/notification/notification.dart'; - -part 'notifications_state.dart'; - -/// A cubit for handling notifications -class NotificationsCubit extends Cubit { - final Stream notificationsStream; - - StreamSubscription? _notificationsStreamSubscription; - - NotificationsCubit({required this.notificationsStream}) : super(const NotificationsState()); - - void handleNotifications() { - if (_notificationsStreamSubscription != null) return; // Only set up the listener once - the stream is single-subscription - - _notificationsStreamSubscription = notificationsStream.listen((notificationResponse) async { - final payload = notificationResponse.payload?.isNotEmpty == true ? NotificationPayload.fromJson(jsonDecode(notificationResponse.payload!)) : null; - - if (payload == null) return; - - // Map notification inbox type to status - final status = switch (payload.inboxType) { - NotificationInboxType.reply => NotificationsStatus.reply, - NotificationInboxType.mention => NotificationsStatus.mention, - NotificationInboxType.message => NotificationsStatus.message, - }; - - emit(state.copyWith(status: status, notificationId: payload.id, accountId: payload.accountId, pending: false)); - }); - } - - /// Marks the notification as pending. This is used when we need to switch accounts before navigating to the notification. - void setPending() { - emit(state.copyWith(pending: true)); - } - - /// Clears the notification state after navigation is complete - void clearNotification() { - emit(state.clear()); - } - - void dispose() { - _notificationsStreamSubscription?.cancel(); - } -} +import 'dart:async'; +import 'dart:convert'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; + +import 'package:thunder/src/features/notification/notification.dart'; + +part 'notifications_state.dart'; + +/// A cubit for handling notifications. +class NotificationsCubit extends Cubit { + final Stream notificationsStream; + + StreamSubscription? _notificationsStreamSubscription; + + NotificationsCubit({required this.notificationsStream}) : super(const NotificationsState()); + + NotificationsCubit.fromService(NotificationService notificationService) + : notificationsStream = notificationService.notifications, + super(const NotificationsState()); + + void handleNotifications() { + if (_notificationsStreamSubscription != null) { + return; // Only set up the listener once - the stream is single-subscription. + } + + _notificationsStreamSubscription = notificationsStream.listen((notificationResponse) async { + final payload = notificationResponse.payload?.isNotEmpty == true + ? NotificationPayload.fromJson( + jsonDecode(notificationResponse.payload!), + ) + : null; + + if (payload == null) return; + + final status = switch (payload.inboxType) { + NotificationInboxType.reply => NotificationsStatus.reply, + NotificationInboxType.mention => NotificationsStatus.mention, + NotificationInboxType.message => NotificationsStatus.message, + }; + + emit( + state.copyWith( + status: status, + notificationId: payload.id, + accountId: payload.accountId, + pending: false, + ), + ); + }); + } + + /// Marks the notification as pending. This is used when we need to switch accounts before navigating to the notification. + void setPending() { + emit(state.copyWith(pending: true)); + } + + /// Clears the notification state after navigation is complete. + void clearNotification() { + emit(state.clear()); + } + + @override + Future close() async { + _notificationsStreamSubscription?.cancel(); + await super.close(); + } +} diff --git a/lib/src/app/cubits/notifications_cubit/notifications_state.dart b/lib/src/features/notification/application/state/notifications_state.dart similarity index 58% rename from lib/src/app/cubits/notifications_cubit/notifications_state.dart rename to lib/src/features/notification/application/state/notifications_state.dart index 7b51d5e73..5572e5779 100644 --- a/lib/src/app/cubits/notifications_cubit/notifications_state.dart +++ b/lib/src/features/notification/application/state/notifications_state.dart @@ -1,51 +1,53 @@ -part of 'notifications_cubit.dart'; - -enum NotificationsStatus { none, reply, mention, message } - -class NotificationsState extends Equatable { - /// The status of the notification - final NotificationsStatus status; - - /// The ID of the notification (reply, mention, or message) - final int? notificationId; - - /// The account ID associated with the notification - final String? accountId; - - /// Whether the notification is pending navigation (waiting for account switch to complete) - final bool pending; - - const NotificationsState({ - this.status = NotificationsStatus.none, - this.notificationId, - this.accountId, - this.pending = false, - }); - - NotificationsState copyWith({ - NotificationsStatus? status, - int? notificationId, - String? accountId, - bool? pending, - }) { - return NotificationsState( - status: status ?? this.status, - notificationId: notificationId ?? this.notificationId, - accountId: accountId ?? this.accountId, - pending: pending ?? this.pending, - ); - } - - /// Clears the notification state - NotificationsState clear() { - return const NotificationsState( - status: NotificationsStatus.none, - notificationId: null, - accountId: null, - pending: false, - ); - } - - @override - List get props => [status, notificationId, accountId, pending]; -} +part of 'notifications_cubit.dart'; + +enum NotificationsStatus { none, reply, mention, message } + +const _notificationsUnset = Object(); + +class NotificationsState extends Equatable { + /// The status of the notification. + final NotificationsStatus status; + + /// The ID of the notification (reply, mention, or message). + final int? notificationId; + + /// The account ID associated with the notification. + final String? accountId; + + /// Whether the notification is pending navigation (waiting for account switch to complete). + final bool pending; + + const NotificationsState({ + this.status = NotificationsStatus.none, + this.notificationId, + this.accountId, + this.pending = false, + }); + + NotificationsState copyWith({ + NotificationsStatus? status, + Object? notificationId = _notificationsUnset, + Object? accountId = _notificationsUnset, + bool? pending, + }) { + return NotificationsState( + status: status ?? this.status, + notificationId: identical(notificationId, _notificationsUnset) ? this.notificationId : notificationId as int?, + accountId: identical(accountId, _notificationsUnset) ? this.accountId : accountId as String?, + pending: pending ?? this.pending, + ); + } + + /// Clears the notification state. + NotificationsState clear() { + return const NotificationsState( + status: NotificationsStatus.none, + notificationId: null, + accountId: null, + pending: false, + ); + } + + @override + List get props => [status, notificationId, accountId, pending]; +} diff --git a/lib/src/features/notification/data/repositories/notification_repository.dart b/lib/src/features/notification/data/repositories/notification_repository.dart index 7a48ee69a..2afb7f942 100644 --- a/lib/src/features/notification/data/repositories/notification_repository.dart +++ b/lib/src/features/notification/data/repositories/notification_repository.dart @@ -2,13 +2,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/features/notification/domain/models/unread_notifications_count.dart'; /// Interface for a notification repository abstract class NotificationRepository { @@ -54,7 +51,7 @@ abstract class NotificationRepository { }); /// Fetches number of unread notifications - Future> unreadNotificationsCount(); + Future unreadNotificationsCount(); /// Marks all notifications as read Future markAllNotificationsAsRead(); @@ -68,10 +65,18 @@ class NotificationRepositoryImpl implements NotificationRepository { /// The API client to use for the repository final ThunderApiClient _api; + /// The localization service to use for user-facing errors + final LocalizationService _localizationService; + /// Creates a new NotificationRepositoryImpl. /// /// An optional [api] client can be provided for testing. - NotificationRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); + NotificationRepositoryImpl({ + required this.account, + ThunderApiClient? api, + LocalizationService localizationService = const GlobalContextLocalizationService(), + }) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode), + _localizationService = localizationService; @override Future> replies({ @@ -80,13 +85,15 @@ class NotificationRepositoryImpl implements NotificationRepository { CommentSortType sort = CommentSortType.new_, int page = 1, }) async { - if (account.anonymous) throw Exception(GlobalContext.l10n.userNotLoggedIn); + if (account.anonymous) { + throw Exception(_localizationService.l10n.userNotLoggedIn); + } return await _api.getCommentReplies(page: page, limit: limit, sort: sort, unread: unread); } @override Future markReplyAsRead({required int replyId, bool read = true}) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); await _api.markCommentReplyAsRead(replyId: replyId, read: read); @@ -99,7 +106,7 @@ class NotificationRepositoryImpl implements NotificationRepository { CommentSortType sort = CommentSortType.new_, int page = 1, }) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.getCommentMentions(page: page, limit: limit, sort: sort, unread: unread); @@ -107,7 +114,7 @@ class NotificationRepositoryImpl implements NotificationRepository { @override Future markMentionAsRead({required int mentionId, bool read = true}) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); await _api.markCommentMentionAsRead(mentionId: mentionId, read: read); @@ -119,7 +126,7 @@ class NotificationRepositoryImpl implements NotificationRepository { int limit = 50, int page = 1, }) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); return await _api.getPrivateMessages(page: page, limit: limit, unread: unread); @@ -127,28 +134,28 @@ class NotificationRepositoryImpl implements NotificationRepository { @override Future markMessageAsRead({required int messageId, bool read = true}) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); await _api.markPrivateMessageAsRead(messageId: messageId, read: read); } @override - Future> unreadNotificationsCount() async { - final l10n = GlobalContext.l10n; + Future unreadNotificationsCount() async { + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); final response = await _api.unreadCount(); - return { - 'replies': response.replies, - 'mentions': response.mentions, - 'private_messages': response.privateMessages, - }; + return UnreadNotificationsCount( + replies: response.replies, + mentions: response.mentions, + privateMessages: response.privateMessages, + ); } @override Future markAllNotificationsAsRead() async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; if (account.anonymous) throw Exception(l10n.userNotLoggedIn); await _api.markAllNotificationsAsRead(); diff --git a/lib/src/features/notification/domain/enums/notification_type.dart b/lib/src/features/notification/domain/enums/notification_type.dart index c384f82d4..bd324ca1f 100644 --- a/lib/src/features/notification/domain/enums/notification_type.dart +++ b/lib/src/features/notification/domain/enums/notification_type.dart @@ -2,7 +2,7 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; // Project imports -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; enum NotificationType { none, diff --git a/lib/src/features/notification/presentation/utils/notification_payload.dart b/lib/src/features/notification/domain/models/notification_payload.dart similarity index 100% rename from lib/src/features/notification/presentation/utils/notification_payload.dart rename to lib/src/features/notification/domain/models/notification_payload.dart diff --git a/lib/src/features/notification/domain/models/unread_notifications_count.dart b/lib/src/features/notification/domain/models/unread_notifications_count.dart new file mode 100644 index 000000000..bf41dba92 --- /dev/null +++ b/lib/src/features/notification/domain/models/unread_notifications_count.dart @@ -0,0 +1,13 @@ +class UnreadNotificationsCount { + final int replies; + final int mentions; + final int privateMessages; + + const UnreadNotificationsCount({ + required this.replies, + required this.mentions, + required this.privateMessages, + }); + + int get total => replies + mentions + privateMessages; +} diff --git a/lib/src/features/notification/notification.dart b/lib/src/features/notification/notification.dart index a0ee57d31..e3c60d578 100644 --- a/lib/src/features/notification/notification.dart +++ b/lib/src/features/notification/notification.dart @@ -1,11 +1,12 @@ export 'domain/enums/notification_type.dart'; -export 'presentation/utils/android_notification.dart'; -export 'presentation/utils/apns.dart'; -export 'presentation/utils/local_notifications.dart'; -export 'presentation/utils/notification_payload.dart'; -export 'presentation/utils/notification_server.dart'; -export 'presentation/utils/notification_settings.dart'; -export 'presentation/utils/notification_utils.dart'; -export 'presentation/utils/unified_push.dart'; +export 'domain/models/notification_payload.dart'; +export 'domain/models/unread_notifications_count.dart'; +export 'presentation/utils/android_notification_utils.dart'; +export 'presentation/utils/apns_notification_utils.dart'; +export 'presentation/utils/local_notification_utils.dart'; +export 'presentation/utils/notification_content_utils.dart'; +export 'presentation/utils/notification_server_utils.dart'; +export 'presentation/utils/notification_settings_utils.dart'; +export 'presentation/utils/unified_push_utils.dart'; export 'notifications.dart'; export 'data/repositories/notification_repository.dart'; diff --git a/lib/src/features/notification/notifications.dart b/lib/src/features/notification/notifications.dart index a5b30b8a0..9366ffabb 100644 --- a/lib/src/features/notification/notifications.dart +++ b/lib/src/features/notification/notifications.dart @@ -9,8 +9,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Project imports -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; /// The main function which triggers push notification logic. This handles delegating push notification logic to the correct service. @@ -44,7 +44,7 @@ Future initPushNotificationLogic({required StreamController controller.add(notificationResponse), ); diff --git a/lib/src/app/pages/notifications_pages.dart b/lib/src/features/notification/presentation/pages/notifications_page.dart similarity index 53% rename from lib/src/app/pages/notifications_pages.dart rename to lib/src/features/notification/presentation/pages/notifications_page.dart index 9d62c83af..f71796334 100644 --- a/lib/src/app/pages/notifications_pages.dart +++ b/lib/src/features/notification/presentation/pages/notifications_page.dart @@ -1,113 +1,163 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; - -/// A page for displaying notifications (replies, mentions, or messages) -class NotificationsPage extends StatelessWidget { - /// The type of inbox notification to display - final InboxType inboxType; - - /// The list of comments (used for replies and mentions) - final List comments; - - /// The list of private messages (used for messages) - final List messages; - - const NotificationsPage({ - super.key, - required this.inboxType, - this.comments = const [], - this.messages = const [], - }); - - factory NotificationsPage.replies({Key? key, required List replies}) { - return NotificationsPage(key: key, inboxType: InboxType.replies, comments: replies); - } - - factory NotificationsPage.mentions({Key? key, required List mentions}) { - return NotificationsPage(key: key, inboxType: InboxType.mentions, comments: mentions); - } - - factory NotificationsPage.messages({Key? key, required List messages}) { - return NotificationsPage(key: key, inboxType: InboxType.messages, messages: messages); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - final account = context.select((bloc) => bloc.state.account); - - return MultiBlocProvider( - providers: [ - BlocProvider.value( - value: inboxType == InboxType.messages - ? (InboxBloc(account: account)..add(const GetInboxEvent(reset: true, inboxType: InboxType.messages))) - : InboxBloc.initWith(replies: comments, showUnreadOnly: true, account: account), - ), - BlocProvider.value(value: PostBloc(account: account)), - ], - child: BlocConsumer( - listener: (BuildContext context, InboxState state) { - final shouldPop = switch (inboxType) { - InboxType.replies => state.replies.isEmpty, - InboxType.mentions => state.replies.isEmpty, - InboxType.messages => state.privateMessages.isEmpty && state.status == InboxStatus.success, - InboxType.all => false, - }; - - if (shouldPop && (ModalRoute.of(context)?.isCurrent ?? false)) { - Navigator.of(context).pop(); - } - }, - builder: (context, state) { - final subtitle = switch (inboxType) { - InboxType.replies => l10n.reply(comments.length), - InboxType.mentions => l10n.mention(comments.length), - InboxType.messages => l10n.message(messages.length), - InboxType.all => '', - }; - - final body = switch (inboxType) { - InboxType.replies => InboxRepliesView(replies: state.replies), - InboxType.mentions => InboxMentionsView(mentions: state.replies), - InboxType.messages => InboxPrivateMessagesView(privateMessages: state.privateMessages.isEmpty ? messages : state.privateMessages), - InboxType.all => const SizedBox.shrink(), - }; - - return Material( - child: NestedScrollView( - headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { - return [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: SliverAppBar( - pinned: true, - centerTitle: false, - toolbarHeight: APP_BAR_HEIGHT, - forceElevated: innerBoxIsScrolled, - title: ListTile( - title: Text(l10n.inbox, style: theme.textTheme.titleLarge), - subtitle: Text(subtitle), - ), - ), - ), - ]; - }, - body: body, - ), - ); - }, - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/inbox/inbox.dart'; +import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +/// A page for displaying notifications (replies, mentions, or messages). +class NotificationsPage extends StatelessWidget { + /// The type of inbox notification to display. + final InboxType inboxType; + + /// The list of comments (used for replies and mentions). + final List comments; + + /// The list of private messages (used for messages). + final List messages; + + const NotificationsPage({ + super.key, + required this.inboxType, + this.comments = const [], + this.messages = const [], + }); + + factory NotificationsPage.replies({ + Key? key, + required List replies, + }) { + return NotificationsPage( + key: key, + inboxType: InboxType.replies, + comments: replies, + ); + } + + factory NotificationsPage.mentions({ + Key? key, + required List mentions, + }) { + return NotificationsPage( + key: key, + inboxType: InboxType.mentions, + comments: mentions, + ); + } + + factory NotificationsPage.messages({ + Key? key, + required List messages, + }) { + return NotificationsPage( + key: key, + inboxType: InboxType.messages, + messages: messages, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + final account = context.select((bloc) => bloc.state.account); + + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: inboxType == InboxType.messages + ? (InboxBloc( + account: account, + commentRepository: CommentRepositoryImpl(account: account), + notificationRepository: NotificationRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + )..add( + const GetInboxEvent(reset: true, inboxType: InboxType.messages), + )) + : InboxBloc.initWith( + account: account, + replies: comments, + showUnreadOnly: true, + commentRepository: CommentRepositoryImpl(account: account), + notificationRepository: NotificationRepositoryImpl(account: account), + localizationService: const GlobalContextLocalizationService(), + ), + ), + BlocProvider.value( + value: PostBloc( + account: account, + postRepository: PostRepositoryImpl(account: account), + commentRepository: CommentRepositoryImpl(account: account), + communityRepository: CommunityRepositoryImpl(account: account), + preferencesStore: const UserPreferencesStore(), + localizationService: const GlobalContextLocalizationService(), + ), + ), + ], + child: BlocConsumer( + listener: (BuildContext context, InboxState state) { + final shouldPop = switch (inboxType) { + InboxType.replies => state.replies.isEmpty, + InboxType.mentions => state.replies.isEmpty, + InboxType.messages => state.privateMessages.isEmpty && state.status == InboxStatus.success, + InboxType.all => false, + }; + + if (shouldPop && (ModalRoute.of(context)?.isCurrent ?? false)) { + Navigator.of(context).pop(); + } + }, + builder: (context, state) { + final subtitle = switch (inboxType) { + InboxType.replies => l10n.reply(comments.length), + InboxType.mentions => l10n.mention(comments.length), + InboxType.messages => l10n.message(messages.length), + InboxType.all => '', + }; + + final body = switch (inboxType) { + InboxType.replies => InboxRepliesView(replies: state.replies), + InboxType.mentions => InboxMentionsView(mentions: state.replies), + InboxType.messages => InboxPrivateMessagesView( + privateMessages: state.privateMessages.isEmpty ? messages : state.privateMessages, + ), + InboxType.all => const SizedBox.shrink(), + }; + + return Material( + child: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: SliverAppBar( + pinned: true, + centerTitle: false, + toolbarHeight: APP_BAR_HEIGHT, + forceElevated: innerBoxIsScrolled, + title: ListTile( + title: Text(l10n.inbox, style: theme.textTheme.titleLarge), + subtitle: Text(subtitle), + ), + ), + ), + ]; + }, + body: body, + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/features/notification/presentation/utils/android_notification.dart b/lib/src/features/notification/presentation/utils/android_notification_utils.dart similarity index 87% rename from lib/src/features/notification/presentation/utils/android_notification.dart rename to lib/src/features/notification/presentation/utils/android_notification_utils.dart index d32682fa0..33edfc6cc 100644 --- a/lib/src/features/notification/presentation/utils/android_notification.dart +++ b/lib/src/features/notification/presentation/utils/android_notification_utils.dart @@ -4,9 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; const String _repliesChannelId = 'inbox_replies'; @@ -66,10 +66,10 @@ Future showNotificationGroups({required NotificationType type, required Li // Send the summary message! await flutterLocalNotificationsPlugin.show( - account.id.hashCode, - '', - '', - notificationDetailsSummary, + id: account.id.hashCode, + title: '', + body: '', + notificationDetails: notificationDetailsSummary, payload: jsonEncode(NotificationPayload( type: type, accountId: account.id, @@ -113,7 +113,7 @@ Future showAndroidNotification({ final notificationDetails = NotificationDetails(android: androidNotificationDetails); // Show the notification! - await flutterLocalNotificationsPlugin.show(id, title, content, notificationDetails, payload: payload); + await flutterLocalNotificationsPlugin.show(id: id, title: title, body: content, notificationDetails: notificationDetails, payload: payload); } /// Displays a test notification on Android. @@ -136,7 +136,7 @@ Future showTestAndroidNotification() async { const notificationDetails = NotificationDetails(android: androidNotificationDetails); // Show the notification! - await flutterLocalNotificationsPlugin.show(-1, 'Test', 'Test', notificationDetails); + await flutterLocalNotificationsPlugin.show(id: -1, title: 'Test', body: 'Test', notificationDetails: notificationDetails); } /// The notification ID used for the background check notification. @@ -165,7 +165,7 @@ Future showBackgroundCheckNotification() async { final notificationDetails = NotificationDetails(android: androidNotificationDetails); // Show the notification! - await flutterLocalNotificationsPlugin.show(_backgroundCheckNotificationId, 'Notification Check', 'Notification Check', notificationDetails); + await flutterLocalNotificationsPlugin.show(id: _backgroundCheckNotificationId, title: 'Notification Check', body: 'Notification Check', notificationDetails: notificationDetails); } /// Dismisses the background check notification. @@ -176,7 +176,7 @@ Future dismissBackgroundCheckNotification() async { // Initialize the plugin in headless mode to ensure cancel works const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('icon'); const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - await flutterLocalNotificationsPlugin.initialize(initializationSettings); + await flutterLocalNotificationsPlugin.initialize(settings: initializationSettings); - await flutterLocalNotificationsPlugin.cancel(_backgroundCheckNotificationId); + await flutterLocalNotificationsPlugin.cancel(id: _backgroundCheckNotificationId); } diff --git a/lib/src/features/notification/presentation/utils/apns.dart b/lib/src/features/notification/presentation/utils/apns_notification_utils.dart similarity index 100% rename from lib/src/features/notification/presentation/utils/apns.dart rename to lib/src/features/notification/presentation/utils/apns_notification_utils.dart diff --git a/lib/src/features/notification/presentation/utils/local_notifications.dart b/lib/src/features/notification/presentation/utils/local_notification_utils.dart similarity index 97% rename from lib/src/features/notification/presentation/utils/local_notifications.dart rename to lib/src/features/notification/presentation/utils/local_notification_utils.dart index e546d7563..fc54d53d1 100644 --- a/lib/src/features/notification/presentation/utils/local_notifications.dart +++ b/lib/src/features/notification/presentation/utils/local_notification_utils.dart @@ -10,14 +10,11 @@ import 'package:markdown/markdown.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/main.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; diff --git a/lib/src/features/notification/presentation/utils/notification_utils.dart b/lib/src/features/notification/presentation/utils/notification_content_utils.dart similarity index 100% rename from lib/src/features/notification/presentation/utils/notification_utils.dart rename to lib/src/features/notification/presentation/utils/notification_content_utils.dart diff --git a/lib/src/features/notification/presentation/utils/notification_server.dart b/lib/src/features/notification/presentation/utils/notification_server_utils.dart similarity index 93% rename from lib/src/features/notification/presentation/utils/notification_server.dart rename to lib/src/features/notification/presentation/utils/notification_server_utils.dart index 0ae210d43..26448ab31 100644 --- a/lib/src/features/notification/presentation/utils/notification_server.dart +++ b/lib/src/features/notification/presentation/utils/notification_server_utils.dart @@ -10,11 +10,11 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; // Project imports import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Sends a request to the push notification server, including the [NotificationType], [jwt], and [instance]. /// diff --git a/lib/src/features/notification/presentation/utils/notification_settings.dart b/lib/src/features/notification/presentation/utils/notification_settings_utils.dart similarity index 94% rename from lib/src/features/notification/presentation/utils/notification_settings.dart rename to lib/src/features/notification/presentation/utils/notification_settings_utils.dart index ec5318153..83d9eee92 100644 --- a/lib/src/features/notification/presentation/utils/notification_settings.dart +++ b/lib/src/features/notification/presentation/utils/notification_settings_utils.dart @@ -5,16 +5,15 @@ import 'package:flutter/material.dart'; // Package imports import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/foundation/config/config.dart'; import 'package:unifiedpush/unifiedpush.dart'; // Project imports -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; /// This function is used to update the notification settings. It is called when the user changes the notification settings. /// diff --git a/lib/src/features/notification/presentation/utils/unified_push.dart b/lib/src/features/notification/presentation/utils/unified_push_utils.dart similarity index 97% rename from lib/src/features/notification/presentation/utils/unified_push.dart rename to lib/src/features/notification/presentation/utils/unified_push_utils.dart index ff67e25b9..ed79df275 100644 --- a/lib/src/features/notification/presentation/utils/unified_push.dart +++ b/lib/src/features/notification/presentation/utils/unified_push_utils.dart @@ -9,17 +9,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:html/parser.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/main.dart'; import 'package:thunder/src/features/notification/notification.dart'; import 'package:unifiedpush/unifiedpush.dart'; import 'package:markdown/markdown.dart'; // Project imports import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; /// Initializes push notifications for UnifiedPush. /// For now, initializing UnifiedPush will enable push notifications for all accounts active on the app. diff --git a/lib/src/features/post/api.dart b/lib/src/features/post/api.dart new file mode 100644 index 000000000..70a2c36b3 --- /dev/null +++ b/lib/src/features/post/api.dart @@ -0,0 +1 @@ +export 'post.dart'; diff --git a/lib/src/features/post/data/repositories/post_repository.dart b/lib/src/features/post/data/repositories/post_repository.dart index 989a3039a..a80355a0f 100644 --- a/lib/src/features/post/data/repositories/post_repository.dart +++ b/lib/src/features/post/data/repositories/post_repository.dart @@ -2,18 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; /// Interface for a post repository abstract class PostRepository { @@ -133,10 +127,16 @@ class PostRepositoryImpl implements PostRepository { @override Future?> getPost(int postId, {int? commentId}) async { final response = await _api.getPost(postId, commentId: commentId); + + final parsedPost = await parsePostWithCurrentPreferences(response.post); + final parsedCrossPosts = await Future.wait(response.crossPosts.map(parsePostWithCurrentPreferences)); + return { - 'post': response.post, + 'post': parsedPost, 'moderators': response.moderators, - 'cross_posts': response.crossPosts, + 'cross_posts': parsedCrossPosts, + // Keep camelCase key for existing consumers. + 'crossPosts': parsedCrossPosts, }; } diff --git a/lib/src/features/post/domain/enums/enums.dart b/lib/src/features/post/domain/enums/enums.dart index 20844e620..a18da90b8 100644 --- a/lib/src/features/post/domain/enums/enums.dart +++ b/lib/src/features/post/domain/enums/enums.dart @@ -1,3 +1,3 @@ export 'post_action.dart'; -export 'post_card_metadata_item.dart'; +export 'package:thunder/src/foundation/primitives/enums/post_card_metadata_item.dart'; export 'post_status.dart'; diff --git a/lib/src/features/post/domain/enums/post_status.dart b/lib/src/features/post/domain/enums/post_status.dart index 4443f2879..3347d4348 100644 --- a/lib/src/features/post/domain/enums/post_status.dart +++ b/lib/src/features/post/domain/enums/post_status.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; enum PostStatusType { hidden(icon: Icons.visibility_off_rounded, size: 16.0), diff --git a/lib/src/features/post/domain/utils/comment_state_utils.dart b/lib/src/features/post/domain/utils/comment_state_utils.dart new file mode 100644 index 000000000..538676287 --- /dev/null +++ b/lib/src/features/post/domain/utils/comment_state_utils.dart @@ -0,0 +1,22 @@ +import 'package:thunder/src/features/comment/comment.dart'; + +CommentNode clone(CommentNode root) { + return CommentNode( + comment: root.comment, + replies: root.replies.map(clone).toList(), + ); +} + +List update({ + required List current, + required int commentId, + required bool collapsed, +}) { + final updated = current.toList(); + if (collapsed) { + updated.add(commentId); + } else { + updated.remove(commentId); + } + return updated; +} diff --git a/lib/src/features/post/post.dart b/lib/src/features/post/post.dart index fab80ea20..f5aa7f70b 100644 --- a/lib/src/features/post/post.dart +++ b/lib/src/features/post/post.dart @@ -1,11 +1,13 @@ -export 'presentation/bloc/post_bloc.dart'; -export 'presentation/cubit/create_post_cubit.dart'; -export 'presentation/cubits/post_navigation_cubit/post_navigation_cubit.dart'; +export 'presentation/state/post_bloc.dart'; +export 'presentation/state/create_post_cubit.dart'; +export 'presentation/state/post_navigation_cubit/post_navigation_cubit.dart'; export 'domain/enums/enums.dart'; export 'presentation/pages/pages.dart'; -export 'presentation/utils/utils.dart'; +export 'presentation/utils/post_media_utils.dart'; +export 'presentation/utils/post_optimistic_utils.dart'; +export 'presentation/utils/user_label_dialog_utils.dart'; export 'presentation/widgets/widgets.dart'; -export 'data/models/thunder_post.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; export 'data/repositories/post_repository.dart'; export 'presentation/pages/create_post_page.dart'; export 'presentation/widgets/post_body/post_body.dart'; diff --git a/lib/src/features/post/presentation/cubit/create_post_cubit.dart b/lib/src/features/post/presentation/cubit/create_post_cubit.dart deleted file mode 100644 index 823e7fb56..000000000 --- a/lib/src/features/post/presentation/cubit/create_post_cubit.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -part 'create_post_state.dart'; - -class CreatePostCubit extends Cubit { - /// The current account. - Account account; - - /// The repository for the post. - late PostRepository repository; - - CreatePostCubit({required this.account}) : super(const CreatePostState(status: CreatePostStatus.initial)) { - repository = PostRepositoryImpl(account: account); - } - - Future clearMessage() async { - emit(state.copyWith(status: CreatePostStatus.initial, message: null)); - } - - /// Switches the account for the post. - Future switchAccount(Account newAccount) async { - account = newAccount; - repository = PostRepositoryImpl(account: account); - - debugPrint('Account switched to ${account.username}@${account.instance}'); - emit(state.copyWith(status: CreatePostStatus.success)); - } - - Future uploadImages(List imageFiles, {bool isPostImage = false}) async { - final l10n = GlobalContext.l10n; - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - List urls = []; - - isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadInProgress)) : emit(state.copyWith(status: CreatePostStatus.imageUploadInProgress)); - - try { - final accountRepository = AccountRepositoryImpl(account: account); - - for (String imageFile in imageFiles) { - final url = await accountRepository.uploadImage(imageFile); - urls.add(url); - - // Add a delay between each upload to avoid possible rate limiting - await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); - } - - isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadSuccess, imageUrls: urls)) : emit(state.copyWith(status: CreatePostStatus.imageUploadSuccess, imageUrls: urls)); - } catch (e) { - isPostImage - ? emit(state.copyWith(status: CreatePostStatus.postImageUploadFailure, message: getExceptionErrorMessage(e))) - : emit(state.copyWith(status: CreatePostStatus.imageUploadFailure, message: getExceptionErrorMessage(e))); - } - } - - /// Creates or edits a post. When successful, it emits the newly created/updated post in the form of a [PostViewMedia] - /// and returns the newly created post id. - Future createOrEditPost({ - required int communityId, - required String name, - String? body, - String? url, - String? customThumbnail, - String? altText, - bool? nsfw, - int? postIdBeingEdited, - int? languageId, - }) async { - emit(state.copyWith(status: CreatePostStatus.submitting)); - - try { - final post = await repository.create( - communityId: communityId, - name: name, - body: body, - url: url, - customThumbnail: customThumbnail, - altText: altText, - nsfw: nsfw, - postIdBeingEdited: postIdBeingEdited, - languageId: languageId, - ); - - emit(state.copyWith(status: CreatePostStatus.success, post: post)); - return post.id; - } catch (e) { - emit(state.copyWith(status: CreatePostStatus.error, message: getExceptionErrorMessage(e))); - } - - return null; - } -} diff --git a/lib/src/features/post/presentation/pages/create_post_page.dart b/lib/src/features/post/presentation/pages/create_post_page.dart index 200a686c5..6d077fd19 100644 --- a/lib/src/features/post/presentation/pages/create_post_page.dart +++ b/lib/src/features/post/presentation/pages/create_post_page.dart @@ -13,34 +13,26 @@ import 'package:link_preview_generator/link_preview_generator.dart'; import 'package:markdown_editor/markdown_editor.dart'; // Project imports -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/features/drafts/drafts.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/cross_posts.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/shared/language_selector.dart'; -import 'package:thunder/src/shared/widgets/media/media_view.dart'; -import 'package:thunder/src/shared/snackbar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_view.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; -import 'package:thunder/src/shared/utils/debounce.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show isImageUrl, selectImagesToUpload, showSnackbar; class CreatePostPage extends StatefulWidget { /// The community ID to create the post in @@ -240,7 +232,9 @@ class _CreatePostPageState extends State { if (widget.image != null) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (context.mounted) context.read().uploadImages([widget.image!.path], isPostImage: true); + if (context.mounted) { + context.read().uploadImages([widget.image!.path], isPostImage: true); + } }); } @@ -528,10 +522,14 @@ class _CreatePostPageState extends State { contentPadding: const EdgeInsets.all(13), suffixIcon: IconButton( onPressed: () async { - if (state.status == CreatePostStatus.postImageUploadInProgress) return; + if (state.status == CreatePostStatus.postImageUploadInProgress) { + return; + } List imagesPath = await selectImagesToUpload(); - if (context.mounted) context.read().uploadImages(imagesPath, isPostImage: true); + if (context.mounted) { + context.read().uploadImages(imagesPath, isPostImage: true); + } }, icon: state.status == CreatePostStatus.postImageUploadInProgress ? const SizedBox( @@ -712,10 +710,14 @@ class _CreatePostPageState extends State { }, imageIsLoading: state.status == CreatePostStatus.imageUploadInProgress, customImageButtonAction: () async { - if (state.status == CreatePostStatus.imageUploadInProgress) return; + if (state.status == CreatePostStatus.imageUploadInProgress) { + return; + } List imagesPath = await selectImagesToUpload(allowMultiple: true); - if (context.mounted) context.read().uploadImages(imagesPath, isPostImage: false); + if (context.mounted) { + context.read().uploadImages(imagesPath, isPostImage: false); + } }, ), ), @@ -729,7 +731,9 @@ class _CreatePostPageState extends State { } setState(() => showPreview = !showPreview); - if (!showPreview && wasKeyboardVisible) _bodyFocusNode.requestFocus(); + if (!showPreview && wasKeyboardVisible) { + _bodyFocusNode.requestFocus(); + } }, icon: Icon( showPreview ? Icons.visibility_off_rounded : Icons.visibility, @@ -793,7 +797,7 @@ class _CreatePostPageState extends State { limit: 20, ); - setState(() => crossPosts = response['posts']); + setState(() => crossPosts = response.posts); } catch (e) { // Ignore } diff --git a/lib/src/features/post/presentation/pages/post_page.dart b/lib/src/features/post/presentation/pages/post_page.dart index 1f9245628..211e903f8 100644 --- a/lib/src/features/post/presentation/pages/post_page.dart +++ b/lib/src/features/post/presentation/pages/post_page.dart @@ -8,20 +8,21 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; + +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/cross_posts.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; import 'package:thunder/src/shared/widgets/text/selectable_text_modal.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// A page that displays the post details and comments associated with a post. class PostPage extends StatefulWidget { @@ -452,7 +453,9 @@ class _PostPageFeedEndState extends State<_PostPageFeedEnd> { final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); if (bottomSpacerHeight == null) { - if (_calculateBottomSpacerTimer != null) _calculateBottomSpacerTimer!.cancel(); + if (_calculateBottomSpacerTimer != null) { + _calculateBottomSpacerTimer!.cancel(); + } _calculateBottomSpacerTimer = Timer(Duration(milliseconds: 250), _getBottomSpacerHeight); } diff --git a/lib/src/features/post/presentation/state/create_post_cubit.dart b/lib/src/features/post/presentation/state/create_post_cubit.dart new file mode 100644 index 000000000..bf496bd2a --- /dev/null +++ b/lib/src/features/post/presentation/state/create_post_cubit.dart @@ -0,0 +1,176 @@ +import 'package:flutter/foundation.dart'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; + +part 'create_post_state.dart'; + +class CreatePostCubit extends Cubit { + /// The current account. + Account account; + + /// The repository for the post. + late PostRepository repository; + + /// Factories for creating repositories when the account changes. + final PostRepository Function(Account) _postRepositoryFactory; + final AccountRepository Function(Account) _accountRepositoryFactory; + final LocalizationService _localizationService; + + CreatePostCubit({ + required this.account, + required PostRepository Function(Account) postRepositoryFactory, + required AccountRepository Function(Account) accountRepositoryFactory, + required LocalizationService localizationService, + }) : _postRepositoryFactory = postRepositoryFactory, + _accountRepositoryFactory = accountRepositoryFactory, + _localizationService = localizationService, + super(const CreatePostState(status: CreatePostStatus.initial)) { + repository = _postRepositoryFactory(account); + } + + Future clearMessage() async { + emit( + state.copyWith( + status: CreatePostStatus.initial, + message: null, + errorReason: null, + ), + ); + } + + /// Switches the account for the post. + Future switchAccount(Account newAccount) async { + account = newAccount; + repository = _postRepositoryFactory(account); + + debugPrint('Account switched to ${account.username}@${account.instance}'); + emit(state.copyWith( + status: CreatePostStatus.success, + errorReason: null, + message: null, + )); + } + + Future uploadImages(List imageFiles, {bool isPostImage = false}) async { + final l10n = _localizationService.l10n; + if (account.anonymous) { + emit(state.copyWith( + status: isPostImage ? CreatePostStatus.postImageUploadFailure : CreatePostStatus.imageUploadFailure, + message: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + return; + } + + List urls = []; + + isPostImage + ? emit(state.copyWith( + status: CreatePostStatus.postImageUploadInProgress, + message: null, + errorReason: null, + )) + : emit(state.copyWith( + status: CreatePostStatus.imageUploadInProgress, + message: null, + errorReason: null, + )); + + try { + final accountRepository = _accountRepositoryFactory(account); + + for (String imageFile in imageFiles) { + final url = await accountRepository.uploadImage(imageFile); + urls.add(url); + + // Add a delay between each upload to avoid possible rate limiting + await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); + } + + isPostImage + ? emit(state.copyWith( + status: CreatePostStatus.postImageUploadSuccess, + imageUrls: urls, + message: null, + errorReason: null, + )) + : emit(state.copyWith( + status: CreatePostStatus.imageUploadSuccess, + imageUrls: urls, + message: null, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + isPostImage + ? emit(state.copyWith( + status: CreatePostStatus.postImageUploadFailure, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )) + : emit(state.copyWith( + status: CreatePostStatus.imageUploadFailure, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + } + + /// Creates or edits a post. When successful, it emits the newly created/updated post in the form of a [PostViewMedia] + /// and returns the newly created post id. + Future createOrEditPost({ + required int communityId, + required String name, + String? body, + String? url, + String? customThumbnail, + String? altText, + bool? nsfw, + int? postIdBeingEdited, + int? languageId, + }) async { + emit(state.copyWith( + status: CreatePostStatus.submitting, + message: null, + errorReason: null, + )); + + try { + final post = await repository.create( + communityId: communityId, + name: name, + body: body, + url: url, + customThumbnail: customThumbnail, + altText: altText, + nsfw: nsfw, + postIdBeingEdited: postIdBeingEdited, + languageId: languageId, + ); + + emit(state.copyWith( + status: CreatePostStatus.success, + post: post, + message: null, + errorReason: null, + )); + return post.id; + } catch (e) { + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: CreatePostStatus.error, + message: message, + errorReason: AppErrorReason.actionFailed(message: message), + )); + } + + return null; + } +} diff --git a/lib/src/features/post/presentation/cubit/create_post_state.dart b/lib/src/features/post/presentation/state/create_post_state.dart similarity index 51% rename from lib/src/features/post/presentation/cubit/create_post_state.dart rename to lib/src/features/post/presentation/state/create_post_state.dart index 4bf112e7d..bffe8434a 100644 --- a/lib/src/features/post/presentation/cubit/create_post_state.dart +++ b/lib/src/features/post/presentation/state/create_post_state.dart @@ -1,5 +1,7 @@ part of 'create_post_cubit.dart'; +const _createPostStateUnset = Object(); + enum CreatePostStatus { initial, loading, @@ -21,6 +23,7 @@ class CreatePostState extends Equatable { this.post, this.imageUrls, this.message, + this.errorReason, }); /// The status of the current cubit @@ -35,20 +38,25 @@ class CreatePostState extends Equatable { /// The info or error message to be displayed as a snackbar final String? message; + /// Typed error details used for deterministic tests and failure handling. + final AppErrorReason? errorReason; + CreatePostState copyWith({ required CreatePostStatus status, - ThunderPost? post, - List? imageUrls, - String? message, + Object? post = _createPostStateUnset, + Object? imageUrls = _createPostStateUnset, + Object? message = _createPostStateUnset, + Object? errorReason = _createPostStateUnset, }) { return CreatePostState( status: status, - post: post ?? this.post, - imageUrls: imageUrls ?? this.imageUrls, - message: message ?? this.message, + post: identical(post, _createPostStateUnset) ? this.post : post as ThunderPost?, + imageUrls: identical(imageUrls, _createPostStateUnset) ? this.imageUrls : imageUrls as List?, + message: identical(message, _createPostStateUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _createPostStateUnset) ? this.errorReason : errorReason as AppErrorReason?, ); } @override - List get props => [status, post, imageUrls, message]; + List get props => [status, post, imageUrls, message, errorReason]; } diff --git a/lib/src/features/post/presentation/bloc/post_bloc.dart b/lib/src/features/post/presentation/state/post_bloc.dart similarity index 54% rename from lib/src/features/post/presentation/bloc/post_bloc.dart rename to lib/src/features/post/presentation/state/post_bloc.dart index 84ee0aa76..c8c9b925f 100644 --- a/lib/src/features/post/presentation/bloc/post_bloc.dart +++ b/lib/src/features/post/presentation/state/post_bloc.dart @@ -2,18 +2,15 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/features/post/domain/utils/comment_state_utils.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; part 'post_event.dart'; part 'post_state.dart'; @@ -24,15 +21,18 @@ class PostBloc extends Bloc { final PostRepository postRepository; final CommentRepository commentRepository; final CommunityRepository communityRepository; + final PreferencesStore _preferencesStore; + final LocalizationService _localizationService; PostBloc({ required this.account, - PostRepository? postRepository, - CommentRepository? commentRepository, - CommunityRepository? communityRepository, - }) : postRepository = postRepository ?? PostRepositoryImpl(account: account), - commentRepository = commentRepository ?? CommentRepositoryImpl(account: account), - communityRepository = communityRepository ?? CommunityRepositoryImpl(account: account), + required this.postRepository, + required this.commentRepository, + required this.communityRepository, + required PreferencesStore preferencesStore, + required LocalizationService localizationService, + }) : _preferencesStore = preferencesStore, + _localizationService = localizationService, super(PostState()) { on(_getPostEvent); on(_getPostCommentsEvent); @@ -83,7 +83,7 @@ class PostBloc extends Bloc { if (moderators == null && post != null) { try { final response = await communityRepository.getCommunity(id: post.community?.id); - moderators = response['moderators']; + moderators = response.moderators; } catch (e) { // Not critical to get the community, so if we throw due to timeout, catch immediately and swallow. debugPrint('GetPostEvent: Error getting community: $e'); @@ -105,19 +105,37 @@ class PostBloc extends Bloc { commentSortType: event.commentSortType, )); } catch (e) { - emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } Future _onPostUpdated(PostUpdatedEvent event, Emitter emit) async { - return emit(state.copyWith(status: state.status, post: event.post)); + return emit(state.copyWith( + status: state.status, + post: event.post, + errorReason: null, + )); } Future _votePostEvent(VotePostEvent event, Emitter emit) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; final originalPost = state.post; if (originalPost == null) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: l10n.failedToPerformAction)); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: l10n.failedToPerformAction, + errorReason: AppErrorReason.actionFailed( + message: l10n.failedToPerformAction, + ), + )); } try { @@ -130,21 +148,36 @@ class PostBloc extends Bloc { updatedPost = await postRepository.vote(originalPost, event.score); - return emit(state.copyWith(status: PostStatus.success, post: updatedPost)); + return emit(state.copyWith( + status: PostStatus.success, + post: updatedPost, + errorReason: null, + )); } catch (e) { + final message = getExceptionErrorMessage(e); return emit(state.copyWith( status: PostStatus.failure, post: originalPost, - errorMessage: getExceptionErrorMessage(e), + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), )); } } Future _savePostEvent(SavePostEvent event, Emitter emit) async { - final l10n = GlobalContext.l10n; + final l10n = _localizationService.l10n; final originalPost = state.post; if (originalPost == null) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: l10n.failedToPerformAction)); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: l10n.failedToPerformAction, + errorReason: AppErrorReason.actionFailed( + message: l10n.failedToPerformAction, + ), + )); } try { @@ -157,12 +190,21 @@ class PostBloc extends Bloc { updatedPost = await postRepository.save(originalPost, event.save); - return emit(state.copyWith(status: PostStatus.success, post: updatedPost)); + return emit(state.copyWith( + status: PostStatus.success, + post: updatedPost, + errorReason: null, + )); } catch (e) { + final message = getExceptionErrorMessage(e); return emit(state.copyWith( status: PostStatus.failure, post: originalPost, - errorMessage: getExceptionErrorMessage(e), + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), )); } } @@ -172,7 +214,7 @@ class PostBloc extends Bloc { final platform = account.platform ?? ThreadiversePlatform.lemmy; final isReplyFetch = event.commentParentId != null; - final defaultCommentSortType = CommentSortType.values.byName(UserPreferences.getLocalSetting(LocalSettings.defaultCommentSortType)?.toLowerCase() ?? DEFAULT_COMMENT_SORT_TYPE.name); + final defaultCommentSortType = CommentSortType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.defaultCommentSortType)?.toLowerCase() ?? DEFAULT_COMMENT_SORT_TYPE.name); final commentSortType = event.commentSortType ?? (state.commentSortType ?? defaultCommentSortType); try { @@ -218,6 +260,7 @@ class PostBloc extends Bloc { // If we're intentionally loading a single comment thread, prevent root-level auto pagination. hasReachedCommentEnd: isReplyFetch || nextPage == null, commentSortType: commentSortType, + errorReason: null, ), ); } @@ -259,7 +302,7 @@ class PostBloc extends Bloc { if (event.commentParentId != null) { final anyDirectChildren = containsDirectReplyToParent(comments, event.commentParentId!); if (!anyDirectChildren) { - throw Exception(GlobalContext.l10n.unableToLoadReplies); + throw Exception(_localizationService.l10n.unableToLoadReplies); } } @@ -285,106 +328,221 @@ class PostBloc extends Bloc { commentCount: listing.api.length, // Reply loads should not terminate root pagination. hasReachedCommentEnd: isReplyFetch ? state.hasReachedCommentEnd : nextPage == null, + errorReason: null, ), ); } catch (e) { - emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } /// Handles comment related actions on a given item within the post Future _commentActionEvent(CommentActionEvent event, Emitter emit) async { emit(state.copyWith(status: PostStatus.refreshing)); - if (state.commentNodes == null) { - return emit(state.copyWith(status: PostStatus.failure)); + final originalCommentTree = state.commentNodes; + if (originalCommentTree == null) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Comment tree is unavailable.', + ), + )); } - CommentNode? existingCommentNode = state.commentNodes!.search(event.commentId); + final updatedCommentTree = clone(originalCommentTree); + CommentNode? existingCommentNode = updatedCommentTree.search(event.commentId); if (existingCommentNode == null) { - return emit(state.copyWith(status: PostStatus.failure)); + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Comment node was not found.', + ), + )); } switch (event.action) { case CommentAction.vote: try { - CommentNode newCommentNode = CommentNode(comment: optimisticallyVoteComment(existingCommentNode.comment!, event.value), replies: existingCommentNode.replies); + if (event.actionInput is! VoteCommentActionInput) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for vote action.', + ), + )); + } + final value = (event.actionInput as VoteCommentActionInput).score; + CommentNode newCommentNode = CommentNode(comment: optimisticallyVoteComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); existingCommentNode.insert(newCommentNode); // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success)); - emit(state.copyWith(status: PostStatus.refreshing)); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - await commentRepository.vote(existingCommentNode.comment!, event.value); + await commentRepository.vote(existingCommentNode.comment!, value); - return emit(state.copyWith(status: PostStatus.success)); + return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); } catch (e) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + commentNodes: originalCommentTree, + comments: originalCommentTree.flatten(), + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); } case CommentAction.save: try { - CommentNode newCommentNode = CommentNode(comment: optimisticallySaveComment(existingCommentNode.comment!, event.value), replies: existingCommentNode.replies); + if (event.actionInput is! SaveCommentActionInput) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for save action.', + ), + )); + } + final value = (event.actionInput as SaveCommentActionInput).save; + CommentNode newCommentNode = CommentNode(comment: optimisticallySaveComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); existingCommentNode.insert(newCommentNode); // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success)); - emit(state.copyWith(status: PostStatus.refreshing)); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - await commentRepository.save(existingCommentNode.comment!, event.value); + await commentRepository.save(existingCommentNode.comment!, value); - return emit(state.copyWith(status: PostStatus.success)); + return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); } catch (e) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + commentNodes: originalCommentTree, + comments: originalCommentTree.flatten(), + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); } case CommentAction.delete: try { - CommentNode newCommentNode = CommentNode(comment: optimisticallyDeleteComment(existingCommentNode.comment!, event.value), replies: existingCommentNode.replies); + if (event.actionInput is! DeleteCommentActionInput) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.validation( + message: 'Invalid payload for delete action.', + ), + )); + } + final value = (event.actionInput as DeleteCommentActionInput).delete; + CommentNode newCommentNode = CommentNode(comment: optimisticallyDeleteComment(existingCommentNode.comment!, value), replies: existingCommentNode.replies); existingCommentNode.insert(newCommentNode); // Immediately set the status, and continue - emit(state.copyWith(status: PostStatus.success)); - emit(state.copyWith(status: PostStatus.refreshing)); + emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); + emit(state.copyWith(status: PostStatus.refreshing, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten())); - await commentRepository.delete(existingCommentNode.comment!, event.value); + await commentRepository.delete(existingCommentNode.comment!, value); - return emit(state.copyWith(status: PostStatus.success)); + return emit(state.copyWith(status: PostStatus.success, commentNodes: updatedCommentTree, comments: updatedCommentTree.flatten(), errorReason: null)); } catch (e) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + commentNodes: originalCommentTree, + comments: originalCommentTree.flatten(), + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); } default: - return emit(state.copyWith(status: PostStatus.failure, errorMessage: 'Unsupported action: ${event.action}')); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: 'Unsupported action: ${event.action}', + errorReason: AppErrorReason.validation( + message: 'Unsupported action: ${event.action}', + ), + )); } } Future _commentItemUpdatedEvent(CommentItemUpdatedEvent event, Emitter emit) async { if (state.comments.isEmpty) { - return emit(state.copyWith(status: PostStatus.failure)); + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'No comments are loaded.', + ), + )); } emit(state.copyWith(status: PostStatus.refreshing)); - final existingCommentNode = state.commentNodes?.search(event.comment.id); + final currentCommentTree = state.commentNodes; + if (currentCommentTree == null) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Comment tree is unavailable.', + ), + )); + } + + final updatedCommentTree = clone(currentCommentTree); + final existingCommentNode = updatedCommentTree.search(event.comment.id); if (existingCommentNode == null) { - return emit(state.copyWith(status: PostStatus.failure)); + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Comment node was not found.', + ), + )); } existingCommentNode.insert(CommentNode(comment: event.comment, replies: existingCommentNode.replies)); return emit(state.copyWith( status: PostStatus.success, - comments: state.commentNodes!.flatten(), + commentNodes: updatedCommentTree, + comments: updatedCommentTree.flatten(), moddingCommentId: -1, + errorReason: null, )); } Future _commentItemInsertedEvent(CommentItemInsertedEvent event, Emitter emit) async { - if (state.commentNodes == null) { - return emit(state.copyWith(status: PostStatus.failure)); + final currentCommentTree = state.commentNodes; + if (currentCommentTree == null) { + return emit(state.copyWith( + status: PostStatus.failure, + errorReason: const AppErrorReason.actionFailed( + message: 'Comment tree is unavailable.', + ), + )); } emit(state.copyWith(status: PostStatus.refreshing)); + final updatedCommentTree = clone(currentCommentTree); + final commentPath = event.comment.path.split('.'); final parentId = commentPath.length > 2 ? commentPath[commentPath.length - 2] : commentPath.first; - final parentNode = state.commentNodes?.search(int.parse(parentId)); + final parentNode = updatedCommentTree.search(int.parse(parentId)); if (parentNode == null) { debugPrint('Parent node not found for comment ${event.comment.id}. Path: ${event.comment.path}'); return; @@ -394,8 +552,10 @@ class PostBloc extends Bloc { return emit(state.copyWith( status: PostStatus.success, - comments: state.commentNodes!.flatten(), + commentNodes: updatedCommentTree, + comments: updatedCommentTree.flatten(), moddingCommentId: -1, + errorReason: null, )); } @@ -403,14 +563,35 @@ class PostBloc extends Bloc { try { emit(state.copyWith(status: PostStatus.refreshing, moddingCommentId: event.commentId)); await commentRepository.report(event.commentId, event.message); - return emit(state.copyWith(status: PostStatus.success, moddingCommentId: -1)); + return emit(state.copyWith( + status: PostStatus.success, + moddingCommentId: -1, + errorReason: null, + )); } catch (e) { - return emit(state.copyWith(status: PostStatus.failure, errorMessage: getExceptionErrorMessage(e), moddingCommentId: -1)); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: PostStatus.failure, + errorMessage: message, + moddingCommentId: -1, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); } } void _onUpdateCollapsedComment(UpdateCollapsedComment event, Emitter emit) { - List collapsedComments = event.collapsed ? (state.collapsedComments.toList()..add(event.commentId)) : (state.collapsedComments.toList()..remove(event.commentId)); - return emit(state.copyWith(status: state.status, collapsedComments: collapsedComments)); + final collapsedComments = update( + current: state.collapsedComments, + commentId: event.commentId, + collapsed: event.collapsed, + ); + return emit(state.copyWith( + status: state.status, + collapsedComments: collapsedComments, + errorReason: null, + )); } } diff --git a/lib/src/features/post/presentation/bloc/post_event.dart b/lib/src/features/post/presentation/state/post_event.dart similarity index 55% rename from lib/src/features/post/presentation/bloc/post_event.dart rename to lib/src/features/post/presentation/state/post_event.dart index 8c0b564c4..be0bfccc7 100644 --- a/lib/src/features/post/presentation/bloc/post_event.dart +++ b/lib/src/features/post/presentation/state/post_event.dart @@ -1,10 +1,44 @@ part of 'post_bloc.dart'; +sealed class CommentActionInput extends Equatable { + const CommentActionInput(); + + @override + List get props => []; +} + +final class VoteCommentActionInput extends CommentActionInput { + const VoteCommentActionInput(this.score); + + final int score; + + @override + List get props => [score]; +} + +final class SaveCommentActionInput extends CommentActionInput { + const SaveCommentActionInput(this.save); + + final bool save; + + @override + List get props => [save]; +} + +final class DeleteCommentActionInput extends CommentActionInput { + const DeleteCommentActionInput(this.delete); + + final bool delete; + + @override + List get props => [delete]; +} + abstract class PostEvent extends Equatable { const PostEvent(); @override - List get props => []; + List get props => []; } class GetPostEvent extends PostEvent { @@ -14,6 +48,9 @@ class GetPostEvent extends PostEvent { final String? selectedCommentPath; const GetPostEvent({this.commentSortType, this.post, this.postId, this.selectedCommentPath}); + + @override + List get props => [postId, post, commentSortType, selectedCommentPath]; } class GetPostCommentsEvent extends PostEvent { @@ -23,6 +60,9 @@ class GetPostCommentsEvent extends PostEvent { final CommentSortType? commentSortType; const GetPostCommentsEvent({this.postId, this.commentParentId, this.reset = false, this.commentSortType}); + + @override + List get props => [postId, commentParentId, reset, commentSortType]; } class VotePostEvent extends PostEvent { @@ -30,6 +70,9 @@ class VotePostEvent extends PostEvent { final int score; const VotePostEvent({required this.postId, required this.score}); + + @override + List get props => [postId, score]; } class SavePostEvent extends PostEvent { @@ -37,14 +80,24 @@ class SavePostEvent extends PostEvent { final bool save; const SavePostEvent({required this.postId, required this.save}); + + @override + List get props => [postId, save]; } class CommentActionEvent extends PostEvent { final int commentId; final CommentAction action; - final dynamic value; + final CommentActionInput actionInput; - const CommentActionEvent({required this.commentId, required this.action, required this.value}); + const CommentActionEvent({ + required this.commentId, + required this.action, + required this.actionInput, + }); + + @override + List get props => [commentId, action, actionInput]; } /// Event for updating an existing comment in the tree. @@ -52,6 +105,9 @@ final class CommentItemUpdatedEvent extends PostEvent { final ThunderComment comment; const CommentItemUpdatedEvent({required this.comment}); + + @override + List get props => [comment]; } /// Event for inserting a new comment into the tree. @@ -59,6 +115,9 @@ final class CommentItemInsertedEvent extends PostEvent { final ThunderComment comment; const CommentItemInsertedEvent({required this.comment}); + + @override + List get props => [comment]; } class ReportCommentEvent extends PostEvent { @@ -69,6 +128,9 @@ class ReportCommentEvent extends PostEvent { required this.commentId, required this.message, }); + + @override + List get props => [commentId, message]; } class UpdateCollapsedComment extends PostEvent { @@ -76,10 +138,16 @@ class UpdateCollapsedComment extends PostEvent { final bool collapsed; const UpdateCollapsedComment({required this.commentId, required this.collapsed}); + + @override + List get props => [commentId, collapsed]; } final class PostUpdatedEvent extends PostEvent { final ThunderPost post; const PostUpdatedEvent({required this.post}); + + @override + List get props => [post]; } diff --git a/lib/src/features/post/presentation/cubits/post_navigation_cubit/post_navigation_cubit.dart b/lib/src/features/post/presentation/state/post_navigation_cubit/post_navigation_cubit.dart similarity index 100% rename from lib/src/features/post/presentation/cubits/post_navigation_cubit/post_navigation_cubit.dart rename to lib/src/features/post/presentation/state/post_navigation_cubit/post_navigation_cubit.dart diff --git a/lib/src/features/post/presentation/cubits/post_navigation_cubit/post_navigation_state.dart b/lib/src/features/post/presentation/state/post_navigation_cubit/post_navigation_state.dart similarity index 69% rename from lib/src/features/post/presentation/cubits/post_navigation_cubit/post_navigation_state.dart rename to lib/src/features/post/presentation/state/post_navigation_cubit/post_navigation_state.dart index aecc59ec8..a8924fc4d 100644 --- a/lib/src/features/post/presentation/cubits/post_navigation_cubit/post_navigation_state.dart +++ b/lib/src/features/post/presentation/state/post_navigation_cubit/post_navigation_state.dart @@ -1,5 +1,7 @@ part of 'post_navigation_cubit.dart'; +const _postNavigationUnset = Object(); + class PostNavigationState extends Equatable { const PostNavigationState({ this.navigateCommentIndex = 0, @@ -27,16 +29,16 @@ class PostNavigationState extends Equatable { PostNavigationState copyWith({ int? navigateCommentIndex, - int? highlightedCommentId, - Map? commentSearchResults, - double? scrollPosition, + Object? highlightedCommentId = _postNavigationUnset, + Object? commentSearchResults = _postNavigationUnset, + Object? scrollPosition = _postNavigationUnset, bool? didScrollPositionChange, }) { return PostNavigationState( navigateCommentIndex: navigateCommentIndex ?? this.navigateCommentIndex, - highlightedCommentId: highlightedCommentId, - commentSearchResults: commentSearchResults ?? this.commentSearchResults, - scrollPosition: scrollPosition ?? this.scrollPosition, + highlightedCommentId: identical(highlightedCommentId, _postNavigationUnset) ? this.highlightedCommentId : highlightedCommentId as int?, + commentSearchResults: identical(commentSearchResults, _postNavigationUnset) ? this.commentSearchResults : commentSearchResults as Map?, + scrollPosition: identical(scrollPosition, _postNavigationUnset) ? this.scrollPosition : scrollPosition as double?, didScrollPositionChange: didScrollPositionChange ?? this.didScrollPositionChange, ); } diff --git a/lib/src/features/post/presentation/bloc/post_state.dart b/lib/src/features/post/presentation/state/post_state.dart similarity index 63% rename from lib/src/features/post/presentation/bloc/post_state.dart rename to lib/src/features/post/presentation/state/post_state.dart index 20712e5ef..7ec5fb311 100644 --- a/lib/src/features/post/presentation/bloc/post_state.dart +++ b/lib/src/features/post/presentation/state/post_state.dart @@ -1,5 +1,7 @@ part of 'post_bloc.dart'; +const _postStateUnset = Object(); + enum PostStatus { initial, loading, @@ -24,6 +26,7 @@ class PostState extends Equatable { this.crossPosts, this.hasReachedCommentEnd = false, this.errorMessage, + this.errorReason, this.commentSortType, this.selectedCommentPath, this.moddingCommentId = -1, @@ -59,44 +62,47 @@ class PostState extends Equatable { final int moddingCommentId; final String? errorMessage; + final AppErrorReason? errorReason; /// Keeps track of which comments should be collapsed. When a comment is collapsed, its child comments are hidden. final List collapsedComments; PostState copyWith({ - required PostStatus status, - ThunderPost? post, + PostStatus? status, + Object? post = _postStateUnset, List? comments, - CommentNode? commentNodes, + Object? commentNodes = _postStateUnset, List? commentResponseMap, int? commentPage, - String? commentCursor, + Object? commentCursor = _postStateUnset, int? commentCount, bool? hasReachedCommentEnd, int? communityId, - List? moderators, - List? crossPosts, - String? errorMessage, - CommentSortType? commentSortType, - String? selectedCommentPath, + Object? moderators = _postStateUnset, + Object? crossPosts = _postStateUnset, + Object? errorMessage = _postStateUnset, + Object? errorReason = _postStateUnset, + Object? commentSortType = _postStateUnset, + Object? selectedCommentPath = _postStateUnset, int? moddingCommentId, List? collapsedComments, }) { return PostState( - status: status, - post: post ?? this.post, + status: status ?? this.status, + post: identical(post, _postStateUnset) ? this.post : post as ThunderPost?, comments: comments ?? this.comments, - commentNodes: commentNodes ?? this.commentNodes, + commentNodes: identical(commentNodes, _postStateUnset) ? this.commentNodes : commentNodes as CommentNode?, commentResponseMap: commentResponseMap ?? this.commentResponseMap, commentPage: commentPage ?? this.commentPage, - commentCursor: commentCursor ?? this.commentCursor, + commentCursor: identical(commentCursor, _postStateUnset) ? this.commentCursor : commentCursor as String?, commentCount: commentCount ?? this.commentCount, hasReachedCommentEnd: hasReachedCommentEnd ?? this.hasReachedCommentEnd, - moderators: moderators ?? this.moderators, - crossPosts: crossPosts ?? this.crossPosts, - errorMessage: errorMessage ?? this.errorMessage, - commentSortType: commentSortType ?? this.commentSortType, - selectedCommentPath: selectedCommentPath, + moderators: identical(moderators, _postStateUnset) ? this.moderators : moderators as List?, + crossPosts: identical(crossPosts, _postStateUnset) ? this.crossPosts : crossPosts as List?, + errorMessage: identical(errorMessage, _postStateUnset) ? this.errorMessage : errorMessage as String?, + errorReason: identical(errorReason, _postStateUnset) ? this.errorReason : errorReason as AppErrorReason?, + commentSortType: identical(commentSortType, _postStateUnset) ? this.commentSortType : commentSortType as CommentSortType?, + selectedCommentPath: identical(selectedCommentPath, _postStateUnset) ? this.selectedCommentPath : selectedCommentPath as String?, moddingCommentId: moddingCommentId ?? this.moddingCommentId, collapsedComments: collapsedComments ?? this.collapsedComments, ); @@ -114,6 +120,7 @@ class PostState extends Equatable { moderators, crossPosts, errorMessage, + errorReason, hasReachedCommentEnd, commentSortType, selectedCommentPath, diff --git a/lib/src/features/post/presentation/utils/post.dart b/lib/src/features/post/presentation/utils/post_media_utils.dart similarity index 61% rename from lib/src/features/post/presentation/utils/post.dart rename to lib/src/features/post/presentation/utils/post_media_utils.dart index ba16e0c43..f1bbb5adf 100644 --- a/lib/src/features/post/presentation/utils/post.dart +++ b/lib/src/features/post/presentation/utils/post_media_utils.dart @@ -1,94 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:html_unescape/html_unescape_small.dart'; import 'package:html/parser.dart'; +import 'package:html_unescape/html_unescape_small.dart'; import 'package:markdown/markdown.dart' hide Text; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; -import 'package:thunder/src/shared/utils/media/video.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show getScaledMediaSize, isImageUrl, isVideoUrl, retrieveImageDimensions; final _htmlUnescape = HtmlUnescape(); -// Optimistically updates a post. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyVotePost(ThunderPost post, int voteType) { - int newScore = post.score!; - int newUpvotes = post.upvotes!; - int newDownvotes = post.downvotes!; - int? existingVoteType = post.myVote; - - switch (voteType) { - case -1: - existingVoteType == 1 ? newScore -= 2 : newScore--; - newDownvotes++; - if (existingVoteType == 1) newUpvotes--; - case 1: - existingVoteType == -1 ? newScore += 2 : newScore++; - newUpvotes++; - if (existingVoteType == -1) newDownvotes--; - break; - case 0: - // Determine score from existing - if (existingVoteType == -1) { - newScore++; - newDownvotes--; - } else if (existingVoteType == 1) { - newScore--; - newUpvotes--; - } - break; - } - - return post.copyWith(myVote: voteType, score: newScore, upvotes: newUpvotes, downvotes: newDownvotes); -} - -// Optimistically saves a post. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallySavePost(ThunderPost post, bool saved) { - return post.copyWith(saved: saved); -} - -// Optimistically marks a post as read/unread. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyReadPost(ThunderPost post, bool read) { - return post.copyWith(read: read); -} - -// Optimistically marks a post as hidden/unhidden. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyHidePost(ThunderPost post, bool hidden) { - return post.copyWith(hidden: hidden); -} - -// Optimistically deletes a post. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyDeletePost(ThunderPost post, bool delete) { - return post.copyWith(deleted: delete); -} - -// Optimistically locks a post. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyLockPost(ThunderPost post, bool lock) { - return post.copyWith(locked: lock); -} - -// Optimistically pins a post to a community. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyPinPostToCommunity(ThunderPost post, bool pin) { - return post.copyWith(featuredCommunity: pin); -} - -// Optimistically removes a post. This changes the value of the post locally, without sending the network request -ThunderPost optimisticallyRemovePost(ThunderPost post, bool remove) { - return post.copyWith(removed: remove); -} - /// Parse a post with media Future> parsePosts(List posts, {String? resolutionInstance}) async { final prefs = UserPreferences.instance.preferences; - final fetchImageDimensions = prefs.getBool(LocalSettings.showPostFullHeightImages.name) != false && prefs.getBool(LocalSettings.useCompactView.name) != true; - final edgeToEdgeImages = prefs.getBool(LocalSettings.showPostEdgeToEdgeImages.name) ?? false; - final tabletMode = prefs.getBool(LocalSettings.useTabletMode.name) ?? false; + final mediaOptions = _getMediaParsingOptions(); final hideNsfwPosts = prefs.getBool(LocalSettings.hideNsfwPosts.name) ?? false; List resolvedPosts = []; @@ -100,7 +28,9 @@ Future> parsePosts(List posts, {String? resolutio for (ThunderPost post in posts) { try { final response = await SearchRepositoryImpl(account: account).resolve(query: post.apId); - resolvedPosts.add(response['post']); + if (response.post != null) { + resolvedPosts.add(response.post!); + } } catch (e) { // If we can't resolve it, we won't even add it } @@ -109,11 +39,28 @@ Future> parsePosts(List posts, {String? resolutio resolvedPosts = posts.toList(); } - final postFutures = resolvedPosts.expand((post) => [if (!hideNsfwPosts || (!post.nsfw && hideNsfwPosts)) parsePost(post, fetchImageDimensions, edgeToEdgeImages, tabletMode)]).toList(); + final postFutures = resolvedPosts + .expand((post) => [if (!hideNsfwPosts || (!post.nsfw && hideNsfwPosts)) parsePost(post, mediaOptions.fetchImageDimensions, mediaOptions.edgeToEdgeImages, mediaOptions.tabletMode)]) + .toList(); final parsedPosts = await Future.wait(postFutures); return parsedPosts; } +/// Parses a single post with media using current user preferences. +Future parsePostWithCurrentPreferences(ThunderPost post) { + final mediaOptions = _getMediaParsingOptions(); + return parsePost(post, mediaOptions.fetchImageDimensions, mediaOptions.edgeToEdgeImages, mediaOptions.tabletMode); +} + +({bool fetchImageDimensions, bool edgeToEdgeImages, bool tabletMode}) _getMediaParsingOptions() { + final prefs = UserPreferences.instance.preferences; + final fetchImageDimensions = prefs.getBool(LocalSettings.showPostFullHeightImages.name) != false && prefs.getBool(LocalSettings.useCompactView.name) != true; + final edgeToEdgeImages = prefs.getBool(LocalSettings.showPostEdgeToEdgeImages.name) ?? false; + final tabletMode = prefs.getBool(LocalSettings.useTabletMode.name) ?? false; + + return (fetchImageDimensions: fetchImageDimensions, edgeToEdgeImages: edgeToEdgeImages, tabletMode: tabletMode); +} + /// Perform some pre-processing on the post before displaying it. /// /// This includes unescaping the title and parsing any associated media. diff --git a/lib/src/features/post/presentation/utils/post_optimistic_utils.dart b/lib/src/features/post/presentation/utils/post_optimistic_utils.dart new file mode 100644 index 000000000..6acd7506e --- /dev/null +++ b/lib/src/features/post/presentation/utils/post_optimistic_utils.dart @@ -0,0 +1,68 @@ +import 'package:thunder/src/features/post/post.dart'; + +// Optimistically updates a post. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyVotePost(ThunderPost post, int voteType) { + int newScore = post.score!; + int newUpvotes = post.upvotes!; + int newDownvotes = post.downvotes!; + int? existingVoteType = post.myVote; + + switch (voteType) { + case -1: + existingVoteType == 1 ? newScore -= 2 : newScore--; + newDownvotes++; + if (existingVoteType == 1) newUpvotes--; + case 1: + existingVoteType == -1 ? newScore += 2 : newScore++; + newUpvotes++; + if (existingVoteType == -1) newDownvotes--; + break; + case 0: + // Determine score from existing + if (existingVoteType == -1) { + newScore++; + newDownvotes--; + } else if (existingVoteType == 1) { + newScore--; + newUpvotes--; + } + break; + } + + return post.copyWith(myVote: voteType, score: newScore, upvotes: newUpvotes, downvotes: newDownvotes); +} + +// Optimistically saves a post. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallySavePost(ThunderPost post, bool saved) { + return post.copyWith(saved: saved); +} + +// Optimistically marks a post as read/unread. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyReadPost(ThunderPost post, bool read) { + return post.copyWith(read: read); +} + +// Optimistically marks a post as hidden/unhidden. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyHidePost(ThunderPost post, bool hidden) { + return post.copyWith(hidden: hidden); +} + +// Optimistically deletes a post. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyDeletePost(ThunderPost post, bool delete) { + return post.copyWith(deleted: delete); +} + +// Optimistically locks a post. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyLockPost(ThunderPost post, bool lock) { + return post.copyWith(locked: lock); +} + +// Optimistically pins a post to a community. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyPinPostToCommunity(ThunderPost post, bool pin) { + return post.copyWith(featuredCommunity: pin); +} + +// Optimistically removes a post. This changes the value of the post locally, without sending the network request +ThunderPost optimisticallyRemovePost(ThunderPost post, bool remove) { + return post.copyWith(removed: remove); +} diff --git a/lib/src/features/post/presentation/utils/user_label_utils.dart b/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart similarity index 95% rename from lib/src/features/post/presentation/utils/user_label_utils.dart rename to lib/src/features/post/presentation/utils/user_label_dialog_utils.dart index 3431006a9..d91dabd76 100644 --- a/lib/src/features/post/presentation/utils/user_label_utils.dart +++ b/lib/src/features/post/presentation/utils/user_label_dialog_utils.dart @@ -1,73 +1,74 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -/// Shows a dialog which allows the user to create/modify/edit a label for the given [username]. -/// Tip: Call `UserLabel.usernameFromParts` to generate a [username] in the right format. -/// If an existing user label was found (regardless of whether it was changed or deleted) or a new user label was created, -/// it will be returned in the record. -/// If a user label was found and deleted, the deleted flag will be set in the record. -Future<({UserLabel? userLabel, bool deleted})> showUserLabelEditorDialog(BuildContext context, String username) async { - final l10n = AppLocalizations.of(context)!; - - // Load up any existing label - UserLabel? existingLabel = await UserLabel.fetchUserLabel(username); - bool deleted = false; - - if (!context.mounted) return (userLabel: existingLabel, deleted: false); - - final TextEditingController controller = TextEditingController(text: existingLabel?.label); - - await showThunderDialog( - // We're checking context.mounted above, so ignore this warning - // ignore: use_build_context_synchronously - context: context, - title: l10n.addUserLabel, - contentWidgetBuilder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - textInputAction: TextInputAction.done, - keyboardType: TextInputType.text, - controller: controller, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.label, - hintText: l10n.userLabelHint, - ), - autofocus: true, - ), - ], - ); - }, - tertiaryButtonText: existingLabel != null ? l10n.delete : null, - onTertiaryButtonPressed: (dialogContext) async { - await UserLabel.deleteUserLabel(username); - deleted = true; - - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - primaryButtonText: l10n.save, - onPrimaryButtonPressed: (dialogContext, _) async { - if (controller.text.isNotEmpty) { - existingLabel = await UserLabel.upsertUserLabel(UserLabel(id: '', username: username, label: controller.text)); - } else { - await UserLabel.deleteUserLabel(username); - deleted = true; - } - - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - ); - - return (userLabel: existingLabel, deleted: deleted); -} +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/user/user.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; + +/// Shows a dialog which allows the user to create/modify/edit a label for the given [username]. +/// Tip: Call `UserLabel.usernameFromParts` to generate a [username] in the right format. +/// If an existing user label was found (regardless of whether it was changed or deleted) or a new user label was created, +/// it will be returned in the record. +/// If a user label was found and deleted, the deleted flag will be set in the record. +Future<({UserLabel? userLabel, bool deleted})> showUserLabelEditorDialog(BuildContext context, String username) async { + final l10n = AppLocalizations.of(context)!; + + // Load up any existing label + UserLabel? existingLabel = await UserLabel.fetchUserLabel(username); + bool deleted = false; + + if (!context.mounted) return (userLabel: existingLabel, deleted: false); + + final TextEditingController controller = TextEditingController(text: existingLabel?.label); + + await showThunderDialog( + // We're checking context.mounted above, so ignore this warning + // ignore: use_build_context_synchronously + context: context, + title: l10n.addUserLabel, + contentWidgetBuilder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.text, + controller: controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.label, + hintText: l10n.userLabelHint, + ), + autofocus: true, + ), + ], + ); + }, + tertiaryButtonText: existingLabel != null ? l10n.delete : null, + onTertiaryButtonPressed: (dialogContext) async { + await UserLabel.deleteUserLabel(username); + deleted = true; + + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.save, + onPrimaryButtonPressed: (dialogContext, _) async { + if (controller.text.isNotEmpty) { + existingLabel = await UserLabel.upsertUserLabel(UserLabel(id: '', username: username, label: controller.text)); + } else { + await UserLabel.deleteUserLabel(username); + deleted = true; + } + + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + }, + ); + + return (userLabel: existingLabel, deleted: deleted); +} diff --git a/lib/src/features/post/presentation/utils/utils.dart b/lib/src/features/post/presentation/utils/utils.dart deleted file mode 100644 index 804acbdb6..000000000 --- a/lib/src/features/post/presentation/utils/utils.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'post.dart'; -export 'user_label_utils.dart'; diff --git a/lib/src/shared/cross_posts.dart b/lib/src/features/post/presentation/widgets/cross_posts.dart similarity index 92% rename from lib/src/shared/cross_posts.dart rename to lib/src/features/post/presentation/widgets/cross_posts.dart index 6aa49d9ac..997a21df8 100644 --- a/lib/src/shared/cross_posts.dart +++ b/lib/src/features/post/presentation/widgets/cross_posts.dart @@ -1,167 +1,167 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; - -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/features/community/community.dart'; - -/// Widget which displays a post's cross-posts -class CrossPosts extends StatefulWidget { - final List crossPosts; - final ThunderPost? originalPost; - final bool? isNewPost; - - const CrossPosts({ - super.key, - required this.crossPosts, - this.originalPost, - this.isNewPost, - }) : assert(originalPost != null || isNewPost == true); - - @override - State createState() => _CrossPostsState(); -} - -class _CrossPostsState extends State { - bool _areCrossPostsExpanded = false; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - final crossPostTextStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.4)); - final crossPostLinkTextStyle = crossPostTextStyle?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)); - - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox(height: 8.0), - const Divider(height: 1.0), - AnimatedSize( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOutCubicEmphasized, - child: _areCrossPostsExpanded - ? ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - return Column( - children: [ - InkWell( - onTap: () async => navigateToPost(context, postId: widget.crossPosts[index].id), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - children: [ - Icon( - Icons.repeat_rounded, - size: 14.0, - color: theme.colorScheme.onSurface.withValues(alpha: 0.9), - ), - SizedBox(width: 4.0), - Flexible( - child: CommunityFullNameWidget( - context, - widget.crossPosts[index].community?.name, - widget.crossPosts[index].community?.title, - fetchInstanceNameFromUrl(widget.crossPosts[index].community?.actorId), - textStyle: crossPostLinkTextStyle, - ), - ), - ], - ), - ), - CrossPostMetaData(post: widget.crossPosts[index]), - ], - ), - ), - ), - const Divider(height: 1.0), - ], - ); - }, - itemCount: widget.crossPosts.length, - ) - : SizedBox(width: MediaQuery.sizeOf(context).width), - ), - InkWell( - onTap: () => setState(() => _areCrossPostsExpanded = !_areCrossPostsExpanded), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Row( - children: [ - Expanded( - child: RichText( - overflow: TextOverflow.ellipsis, - maxLines: 1, - text: TextSpan( - children: [ - TextSpan( - text: _areCrossPostsExpanded - ? l10n.collapse - : widget.isNewPost == true - ? '${l10n.alreadyPostedTo} ' - : '${l10n.crossPostedTo} ', - style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5)), - ), - if (!_areCrossPostsExpanded) - WidgetSpan( - child: CommunityFullNameWidget( - context, - widget.crossPosts[0].community?.name, - widget.crossPosts[0].community?.title, - fetchInstanceNameFromUrl(widget.crossPosts[0].community?.actorId), - textStyle: theme.textTheme.bodySmall?.copyWith(color: crossPostLinkTextStyle?.color), - ), - ), - TextSpan( - text: _areCrossPostsExpanded || widget.crossPosts.length == 1 ? '' : ' ${l10n.andXMore(widget.crossPosts.length - 1)}', - style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5)), - ), - ], - ), - ), - ), - _areCrossPostsExpanded ? const Icon(Icons.expand_less_rounded, size: 18) : const Icon(Icons.expand_more_rounded, size: 18), - ], - ), - ), - ), - Divider(height: 1.0), - ], - ); - } -} - -void createCrossPost( - BuildContext context, { - required String title, - String? url, - String? text, - String? postUrl, -}) async { - final l10n = AppLocalizations.of(context)!; - - final quotedText = text?.split('\n').map((value) => '> $value\n').join(); - text = "${l10n.crossPostedFrom(postUrl ?? '')}\n\n$quotedText"; - - await navigateToCreatePostPage( - context, - title: title, - url: url, - text: text, - prePopulated: true, - isCrossPost: true, - ); -} +import 'package:flutter/material.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; + +import 'package:thunder/src/features/post/api.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/community/api.dart'; + +/// Widget which displays a post's cross-posts +class CrossPosts extends StatefulWidget { + final List crossPosts; + final ThunderPost? originalPost; + final bool? isNewPost; + + const CrossPosts({ + super.key, + required this.crossPosts, + this.originalPost, + this.isNewPost, + }) : assert(originalPost != null || isNewPost == true); + + @override + State createState() => _CrossPostsState(); +} + +class _CrossPostsState extends State { + bool _areCrossPostsExpanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + final crossPostTextStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.4)); + final crossPostLinkTextStyle = crossPostTextStyle?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75)); + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox(height: 8.0), + const Divider(height: 1.0), + AnimatedSize( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOutCubicEmphasized, + child: _areCrossPostsExpanded + ? ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + return Column( + children: [ + InkWell( + onTap: () async => navigateToPost(context, postId: widget.crossPosts[index].id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.repeat_rounded, + size: 14.0, + color: theme.colorScheme.onSurface.withValues(alpha: 0.9), + ), + SizedBox(width: 4.0), + Flexible( + child: CommunityFullNameWidget( + context, + widget.crossPosts[index].community?.name, + widget.crossPosts[index].community?.title, + fetchInstanceNameFromUrl(widget.crossPosts[index].community?.actorId), + textStyle: crossPostLinkTextStyle, + ), + ), + ], + ), + ), + CrossPostMetaData(post: widget.crossPosts[index]), + ], + ), + ), + ), + const Divider(height: 1.0), + ], + ); + }, + itemCount: widget.crossPosts.length, + ) + : SizedBox(width: MediaQuery.sizeOf(context).width), + ), + InkWell( + onTap: () => setState(() => _areCrossPostsExpanded = !_areCrossPostsExpanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: RichText( + overflow: TextOverflow.ellipsis, + maxLines: 1, + text: TextSpan( + children: [ + TextSpan( + text: _areCrossPostsExpanded + ? l10n.collapse + : widget.isNewPost == true + ? '${l10n.alreadyPostedTo} ' + : '${l10n.crossPostedTo} ', + style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5)), + ), + if (!_areCrossPostsExpanded) + WidgetSpan( + child: CommunityFullNameWidget( + context, + widget.crossPosts[0].community?.name, + widget.crossPosts[0].community?.title, + fetchInstanceNameFromUrl(widget.crossPosts[0].community?.actorId), + textStyle: theme.textTheme.bodySmall?.copyWith(color: crossPostLinkTextStyle?.color), + ), + ), + TextSpan( + text: _areCrossPostsExpanded || widget.crossPosts.length == 1 ? '' : ' ${l10n.andXMore(widget.crossPosts.length - 1)}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5)), + ), + ], + ), + ), + ), + _areCrossPostsExpanded ? const Icon(Icons.expand_less_rounded, size: 18) : const Icon(Icons.expand_more_rounded, size: 18), + ], + ), + ), + ), + Divider(height: 1.0), + ], + ); + } +} + +void createCrossPost( + BuildContext context, { + required String title, + String? url, + String? text, + String? postUrl, +}) async { + final l10n = AppLocalizations.of(context)!; + + final quotedText = text?.split('\n').map((value) => '> $value\n').join(); + text = "${l10n.crossPostedFrom(postUrl ?? '')}\n\n$quotedText"; + + await navigateToCreatePostPage( + context, + title: title, + url: url, + text: text, + prePopulated: true, + isCrossPost: true, + ); +} diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body.dart b/lib/src/features/post/presentation/widgets/post_body/post_body.dart index 9d8ded335..2f1a06eb9 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body.dart @@ -12,24 +12,20 @@ import 'package:flutter_bloc/flutter_bloc.dart'; // Project imports import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/post_body_view_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/conditional_parent_widget.dart'; -import 'package:thunder/src/shared/cross_posts.dart'; -import 'package:thunder/src/shared/widgets/media/media_view.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/post/presentation/widgets/cross_posts.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_view.dart'; import 'package:thunder/src/shared/reply_to_preview_actions.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/packages/ui/ui.dart' show ConditionalParentWidget; /// A widget that displays the body of a post. This includes the title, body, media, and metadata. /// @@ -250,8 +246,12 @@ class _PostBodyState extends State with SingleTickerProviderStateMixin post, page: GeneralPostAction.share, onAction: ({postAction, userAction, communityAction, post}) { - if (postAction == null && userAction == null && communityAction == null) return; - if (post != null) context.read().add(FeedItemUpdatedEvent(post: post)); + if (postAction == null && userAction == null && communityAction == null) { + return; + } + if (post != null) { + context.read().add(FeedItemUpdatedEvent(post: post)); + } switch (postAction) { case PostAction.hide: diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart index 73f11d1e0..5e9388c1a 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_action_bar.dart @@ -3,11 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/action_color.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; + +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// A widget that displays the quick actions bar for a post class PostBodyActionsBar extends StatelessWidget { diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart index 7e0481f7e..5610d4119 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_preview.dart @@ -2,13 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/thunder.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; /// Provides a preview of the post body when the post is collapsed. /// diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart index ca85e5de8..85c43459a 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart @@ -3,21 +3,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/post_body_view_type.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; import 'package:thunder/src/shared/widgets/chips/community_chip.dart'; import 'package:thunder/src/shared/widgets/chips/user_chip.dart'; -import 'package:thunder/src/shared/widgets/media/compact_thumbnail_preview.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/compact_thumbnail_preview.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Displays the title and related information for a given post. /// diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart index 31a2bb64a..84faca5a4 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/community_post_action_bottom_sheet.dart @@ -2,15 +2,12 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/widgets/thunder_icons.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar; /// Defines the actions that can be taken on a community enum CommunityPostAction { @@ -111,25 +108,33 @@ class _CommunityPostActionBottomSheetState extends State submenus = GeneralPostAction.values.where((page) => page != GeneralPostAction.general).toList(); - if (widget.account.anonymous) submenus = submenus.where((action) => action != GeneralPostAction.post).toList(); + if (widget.account.anonymous) { + submenus = submenus.where((action) => action != GeneralPostAction.post).toList(); + } return Column( children: [ diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart index 2b37d269f..d8aedcd87 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart @@ -7,14 +7,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/models/thunder_my_user.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/shared/share/share_action_bottom_sheet.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/profile_site_info_cache.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/features/account/data/cache/profile_site_info_cache.dart'; /// Programatically show the post action bottom sheet void showPostActionBottomModalSheet( diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart index 80ce29bf4..75044a3e6 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_post_action_bottom_sheet.dart @@ -3,14 +3,10 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/widgets/thunder_icons.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; /// Defines the actions that can be taken on a post enum PostPostAction { diff --git a/lib/src/features/post/presentation/widgets/post_card_title.dart b/lib/src/features/post/presentation/widgets/post_card_title.dart index 6459cbfe0..d394cfca8 100644 --- a/lib/src/features/post/presentation/widgets/post_card_title.dart +++ b/lib/src/features/post/presentation/widgets/post_card_title.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/post/post.dart'; /// Creates the title of a post card. This includes the post title and any status icons. diff --git a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart index f499658ed..d9575102a 100644 --- a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart @@ -4,16 +4,14 @@ import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/shared/links/widgets/link_bottom_sheet.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem, ThunderPopupMenuItem; /// Holds the app bar for the post page. class PostPageAppBar extends StatelessWidget { @@ -216,7 +214,9 @@ class PostAppBarActions extends StatelessWidget { title: l10n.sortOptions, onSelect: (selected) async { await onReset?.call(); - if (context.mounted) context.read().add(GetPostCommentsEvent(commentSortType: selected.payload, reset: true)); + if (context.mounted) { + context.read().add(GetPostCommentsEvent(commentSortType: selected.payload, reset: true)); + } }, previouslySelected: state.commentSortType, ), diff --git a/lib/src/features/post/presentation/widgets/post_page_fab.dart b/lib/src/features/post/presentation/widgets/post_page_fab.dart index 3efba3377..0a53fbb4b 100644 --- a/lib/src/features/post/presentation/widgets/post_page_fab.dart +++ b/lib/src/features/post/presentation/widgets/post_page_fab.dart @@ -4,21 +4,20 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/core/enums/fab_action.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/shared/gesture_fab.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; + import 'package:thunder/src/shared/widgets/comment_navigator_fab.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// The FAB for the post page. class PostPageFAB extends StatefulWidget { @@ -62,7 +61,9 @@ class _PostPageFABState extends State { title: l10n.sortOptions, onSelect: (selected) async { await widget.scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOutCubicEmphasized); - if (context.mounted) context.read().add(GetPostCommentsEvent(commentSortType: selected.payload, reset: true)); + if (context.mounted) { + context.read().add(GetPostCommentsEvent(commentSortType: selected.payload, reset: true)); + } }, previouslySelected: commentSortType, ), diff --git a/lib/src/features/post/presentation/widgets/post_status_icon.dart b/lib/src/features/post/presentation/widgets/post_status_icon.dart index 6957b3bc6..798e8d016 100644 --- a/lib/src/features/post/presentation/widgets/post_status_icon.dart +++ b/lib/src/features/post/presentation/widgets/post_status_icon.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/post/post.dart'; /// Given a list of statuses, returns a list of icons representing the statuses. diff --git a/lib/src/features/search/api.dart b/lib/src/features/search/api.dart new file mode 100644 index 000000000..24e5411ff --- /dev/null +++ b/lib/src/features/search/api.dart @@ -0,0 +1 @@ +export 'search.dart'; diff --git a/lib/src/features/search/data/repositories/search_repository.dart b/lib/src/features/search/data/repositories/search_repository.dart index a9a8f81cd..d04c27cd1 100644 --- a/lib/src/features/search/data/repositories/search_repository.dart +++ b/lib/src/features/search/data/repositories/search_repository.dart @@ -2,22 +2,17 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/features/search/domain/models/search_results.dart'; +import 'package:thunder/src/features/search/domain/models/search_resolve_result.dart'; /// Interface for a search repository abstract class SearchRepository { /// Searches for posts, comments, users, communities, etc. - Future> search({ + Future search({ required String query, MetaSearchType? type, SearchSortType? sort, @@ -31,7 +26,7 @@ abstract class SearchRepository { }); /// Resolves a given query - Future> resolve({required String query}); + Future resolve({required String query}); } /// Implementation of [SearchRepository] @@ -48,7 +43,7 @@ class SearchRepositoryImpl implements SearchRepository { SearchRepositoryImpl({required this.account, ThunderApiClient? api}) : _api = api ?? ApiClientFactory.create(account, debug: kDebugMode); @override - Future> search({ + Future search({ required String query, MetaSearchType? type, SearchSortType? sort, @@ -93,23 +88,23 @@ class SearchRepositoryImpl implements SearchRepository { } } - return { - 'type': response.type, - 'comments': comments, - 'posts': posts, - 'communities': communities, - 'users': users, - }; + return SearchResults( + type: response.type, + comments: comments, + posts: posts, + communities: communities, + users: users, + ); } @override - Future> resolve({required String query}) async { + Future resolve({required String query}) async { final response = await _api.resolve(query: query); - return { - 'community': response.community, - 'post': response.post, - 'comment': response.comment, - 'person': response.user, - }; + return SearchResolveResult( + community: response.community, + post: response.post, + comment: response.comment, + user: response.user, + ); } } diff --git a/lib/src/features/search/domain/models/search_resolve_result.dart b/lib/src/features/search/domain/models/search_resolve_result.dart new file mode 100644 index 000000000..4a7616858 --- /dev/null +++ b/lib/src/features/search/domain/models/search_resolve_result.dart @@ -0,0 +1,18 @@ +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; + +class SearchResolveResult { + final ThunderCommunity? community; + final ThunderPost? post; + final ThunderComment? comment; + final ThunderUser? user; + + const SearchResolveResult({ + this.community, + this.post, + this.comment, + this.user, + }); +} diff --git a/lib/src/features/search/domain/models/search_results.dart b/lib/src/features/search/domain/models/search_results.dart new file mode 100644 index 000000000..2914e9b29 --- /dev/null +++ b/lib/src/features/search/domain/models/search_results.dart @@ -0,0 +1,17 @@ +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +class SearchResults { + final MetaSearchType type; + final List comments; + final List posts; + final List communities; + final List users; + + const SearchResults({ + required this.type, + required this.comments, + required this.posts, + required this.communities, + required this.users, + }); +} diff --git a/lib/src/features/search/presentation/pages/search_page.dart b/lib/src/features/search/presentation/pages/search_page.dart index d672d6be9..c897a53ba 100644 --- a/lib/src/features/search/presentation/pages/search_page.dart +++ b/lib/src/features/search/presentation/pages/search_page.dart @@ -5,21 +5,19 @@ import 'package:flutter/material.dart'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_body.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_filters_row.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_page_app_bar.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/debounce.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem; /// The main search page that handles search functionality. class SearchPage extends StatefulWidget { diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/state/search_bloc.dart similarity index 77% rename from lib/src/features/search/presentation/bloc/search_bloc.dart rename to lib/src/features/search/presentation/state/search_bloc.dart index 60e77f24d..77192e333 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/state/search_bloc.dart @@ -10,15 +10,13 @@ import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; part 'search_event.dart'; part 'search_state.dart'; @@ -50,30 +48,31 @@ int _resultsCount( class SearchBloc extends Bloc { /// The account to use for repositories - Account account; + final Account account; /// The comment repository to use for comment operations - late CommentRepository commentRepository; + final CommentRepository commentRepository; /// The search repository to use for search operations - late SearchRepository searchRepository; + final SearchRepository searchRepository; /// The community repository to use for community operations - late CommunityRepository communityRepository; + final CommunityRepository communityRepository; /// The user repository to use for user operations - late UserRepository userRepository; + final UserRepository userRepository; /// The instance repository to use for instance operations - late InstanceRepository instanceRepository; - - SearchBloc({required this.account}) : super(SearchState()) { - commentRepository = CommentRepositoryImpl(account: account); - searchRepository = SearchRepositoryImpl(account: account); - communityRepository = CommunityRepositoryImpl(account: account); - userRepository = UserRepositoryImpl(account: account); - instanceRepository = InstanceRepositoryImpl(account: account); - + final InstanceRepository instanceRepository; + + SearchBloc({ + required this.account, + required this.commentRepository, + required this.searchRepository, + required this.communityRepository, + required this.userRepository, + required this.instanceRepository, + }) : super(SearchState()) { on( _onSearchReset, transformer: restartable(), @@ -107,7 +106,9 @@ class SearchBloc extends Bloc { Future _onSearchStarted(SearchStarted event, Emitter emit) async { try { emit(state.copyWith(status: SearchStatus.loading, hasReachedMax: false)); - if (event.query.isEmpty && event.force != true) return emit(state.copyWith(status: SearchStatus.initial)); + if (event.query.isEmpty && event.force != true) { + return emit(state.copyWith(status: SearchStatus.initial)); + } List? users; List? communities; @@ -157,10 +158,10 @@ class SearchBloc extends Bloc { creatorId: state.creatorFilter, ); - users = response['users']; - communities = response['communities']; - comments = response['comments']; - posts = response['posts']; + users = response.users; + communities = response.communities; + comments = response.comments; + posts = response.posts; } // If there are no search results, see if this is an exact search @@ -172,7 +173,7 @@ class SearchBloc extends Bloc { if (communityName != null) { try { final response = await communityRepository.getCommunity(name: communityName); - communities = [response['community']]; + communities = [response.community]; } catch (e) { debugPrint('SearchBloc: Failed to fetch community by name: $e'); } @@ -201,9 +202,18 @@ class SearchBloc extends Bloc { instances: instances, page: 2, viewingAll: event.query.isEmpty, + errorReason: null, )); } catch (e) { - return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: SearchStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } @@ -241,13 +251,22 @@ class SearchBloc extends Bloc { creatorId: state.creatorFilter, ); - users = response['users']; - communities = response['communities']; - comments = response['comments']; - posts = response['posts']; + users = response.users; + communities = response.communities; + comments = response.comments; + posts = response.posts; } - if (searchIsEmpty(effectiveSearchType, searchResponse: {'users': users, 'communities': communities, 'comments': comments, 'posts': posts})) { + if (searchIsEmpty( + effectiveSearchType, + searchResponse: SearchResults( + type: effectiveSearchType, + users: users ?? const [], + communities: communities ?? const [], + comments: comments ?? const [], + posts: posts ?? const [], + ), + )) { return emit(state.copyWith(status: SearchStatus.success, hasReachedMax: true)); } @@ -265,19 +284,36 @@ class SearchBloc extends Bloc { posts: allPosts, page: state.page + 1, hasReachedMax: _resultsCount(effectiveSearchType, communities, users, comments, posts) < searchResultsPerPage, + errorReason: null, )); } catch (e) { attemptCount++; debugPrint('SearchBloc: Continue search attempt $attemptCount failed: $e'); if (attemptCount >= 2) { - return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: SearchStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } await Future.delayed(const Duration(milliseconds: 500)); } } } catch (e) { debugPrint('SearchBloc: Continue search failed: $e'); - return emit(state.copyWith(status: SearchStatus.failure, message: getExceptionErrorMessage(e))); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: SearchStatus.failure, + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } @@ -288,10 +324,23 @@ class SearchBloc extends Bloc { Future _onTrendingCommunitiesRequested(TrendingCommunitiesRequested event, Emitter emit) async { try { final communities = await communityRepository.trending(); - return emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: communities)); + return emit(state.copyWith( + status: SearchStatus.trending, + trendingCommunities: communities, + errorReason: null, + )); } catch (e) { debugPrint('SearchBloc: Failed to load trending communities: $e'); - return emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: [])); + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: SearchStatus.trending, + trendingCommunities: [], + message: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); } } diff --git a/lib/src/features/search/presentation/bloc/search_event.dart b/lib/src/features/search/presentation/state/search_event.dart similarity index 100% rename from lib/src/features/search/presentation/bloc/search_event.dart rename to lib/src/features/search/presentation/state/search_event.dart diff --git a/lib/src/features/search/presentation/bloc/search_state.dart b/lib/src/features/search/presentation/state/search_state.dart similarity index 60% rename from lib/src/features/search/presentation/bloc/search_state.dart rename to lib/src/features/search/presentation/state/search_state.dart index d19e1792c..8c13eca47 100644 --- a/lib/src/features/search/presentation/bloc/search_state.dart +++ b/lib/src/features/search/presentation/state/search_state.dart @@ -2,6 +2,8 @@ part of 'search_bloc.dart'; enum SearchStatus { initial, trending, loading, refreshing, success, empty, failure, done, performingCommentAction } +const _searchUnset = Object(); + class SearchState extends Equatable { const SearchState({ this.status = SearchStatus.initial, @@ -12,6 +14,7 @@ class SearchState extends Equatable { this.posts, this.instances, this.message, + this.errorReason, this.page = 1, this.hasReachedMax = false, this.searchSortType, @@ -79,6 +82,9 @@ class SearchState extends Equatable { /// The error message to display for errors final String? message; + /// Typed reason for failures. + final AppErrorReason? errorReason; + /// The current page of the search for the specific search type final int page; @@ -99,53 +105,55 @@ class SearchState extends Equatable { SearchState copyWith({ SearchStatus? status, - List? communities, - List? trendingCommunities, - List? users, - List? comments, - List? posts, - List? instances, - String? message, + Object? communities = _searchUnset, + Object? trendingCommunities = _searchUnset, + Object? users = _searchUnset, + Object? comments = _searchUnset, + Object? posts = _searchUnset, + Object? instances = _searchUnset, + Object? message = _searchUnset, + Object? errorReason = _searchUnset, int? page, bool? hasReachedMax, - SearchSortType? searchSortType, - IconData? sortTypeIcon, - String? sortTypeLabel, + Object? searchSortType = _searchUnset, + Object? sortTypeIcon = _searchUnset, + Object? sortTypeLabel = _searchUnset, int? focusSearchId, bool? viewingAll, MetaSearchType? searchType, FeedListType? feedListType, bool? searchByUrl, - int? communityFilter, - String? communityFilterName, - int? creatorFilter, - String? creatorFilterName, + Object? communityFilter = _searchUnset, + Object? communityFilterName = _searchUnset, + Object? creatorFilter = _searchUnset, + Object? creatorFilterName = _searchUnset, bool clearCommunityFilter = false, bool clearCreatorFilter = false, }) { return SearchState( status: status ?? this.status, - communities: communities ?? this.communities, - trendingCommunities: trendingCommunities ?? this.trendingCommunities, - users: users ?? this.users, - comments: comments ?? this.comments, - posts: posts ?? this.posts, - instances: instances ?? this.instances, - message: message ?? this.message, + communities: identical(communities, _searchUnset) ? this.communities : communities as List?, + trendingCommunities: identical(trendingCommunities, _searchUnset) ? this.trendingCommunities : trendingCommunities as List?, + users: identical(users, _searchUnset) ? this.users : users as List?, + comments: identical(comments, _searchUnset) ? this.comments : comments as List?, + posts: identical(posts, _searchUnset) ? this.posts : posts as List?, + instances: identical(instances, _searchUnset) ? this.instances : instances as List?, + message: identical(message, _searchUnset) ? this.message : message as String?, + errorReason: identical(errorReason, _searchUnset) ? this.errorReason : errorReason as AppErrorReason?, page: page ?? this.page, hasReachedMax: hasReachedMax ?? this.hasReachedMax, - searchSortType: searchSortType ?? this.searchSortType, - sortTypeIcon: sortTypeIcon ?? this.sortTypeIcon, - sortTypeLabel: sortTypeLabel ?? this.sortTypeLabel, + searchSortType: identical(searchSortType, _searchUnset) ? this.searchSortType : searchSortType as SearchSortType?, + sortTypeIcon: identical(sortTypeIcon, _searchUnset) ? this.sortTypeIcon : sortTypeIcon as IconData?, + sortTypeLabel: identical(sortTypeLabel, _searchUnset) ? this.sortTypeLabel : sortTypeLabel as String?, focusSearchId: focusSearchId ?? this.focusSearchId, viewingAll: viewingAll ?? this.viewingAll, searchType: searchType ?? this.searchType, feedListType: feedListType ?? this.feedListType, searchByUrl: searchByUrl ?? this.searchByUrl, - communityFilter: clearCommunityFilter ? null : (communityFilter ?? this.communityFilter), - communityFilterName: clearCommunityFilter ? null : (communityFilterName ?? this.communityFilterName), - creatorFilter: clearCreatorFilter ? null : (creatorFilter ?? this.creatorFilter), - creatorFilterName: clearCreatorFilter ? null : (creatorFilterName ?? this.creatorFilterName), + communityFilter: clearCommunityFilter ? null : (identical(communityFilter, _searchUnset) ? this.communityFilter : communityFilter as int?), + communityFilterName: clearCommunityFilter ? null : (identical(communityFilterName, _searchUnset) ? this.communityFilterName : communityFilterName as String?), + creatorFilter: clearCreatorFilter ? null : (identical(creatorFilter, _searchUnset) ? this.creatorFilter : creatorFilter as int?), + creatorFilterName: clearCreatorFilter ? null : (identical(creatorFilterName, _searchUnset) ? this.creatorFilterName : creatorFilterName as String?), ); } @@ -159,6 +167,7 @@ class SearchState extends Equatable { posts, instances, message, + errorReason, page, hasReachedMax, searchSortType, diff --git a/lib/src/features/search/presentation/utils/search_utils.dart b/lib/src/features/search/presentation/utils/search_utils.dart index d495c5a31..daebfa199 100644 --- a/lib/src/features/search/presentation/utils/search_utils.dart +++ b/lib/src/features/search/presentation/utils/search_utils.dart @@ -1,26 +1,21 @@ -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/features/user/user.dart'; - -/// Checks whether there are any results for the current given [searchType] in the [searchState] or the given [searchResponse]. -bool searchIsEmpty(MetaSearchType searchType, {SearchState? searchState, Map? searchResponse}) { - final List? communities = searchState?.communities ?? searchResponse?['communities']; - final List? users = searchState?.users ?? searchResponse?['users']; - final List? comments = searchState?.comments ?? searchResponse?['comments']; - final List? posts = searchState?.posts ?? searchResponse?['posts']; - final List? instances = searchState?.instances; - - return switch (searchType) { - MetaSearchType.communities => communities?.isNotEmpty != true, - MetaSearchType.users => users?.isNotEmpty != true, - MetaSearchType.comments => comments?.isNotEmpty != true, - MetaSearchType.posts => posts?.isNotEmpty != true, - MetaSearchType.url => posts?.isNotEmpty != true, - MetaSearchType.instances => instances?.isNotEmpty != true, - _ => false, - }; -} +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/search/search.dart'; + +/// Checks whether there are any results for the current given [searchType] in the [searchState] or the given [searchResponse]. +bool searchIsEmpty(MetaSearchType searchType, {SearchState? searchState, SearchResults? searchResponse}) { + final List? communities = searchState?.communities ?? searchResponse?.communities; + final List? users = searchState?.users ?? searchResponse?.users; + final List? comments = searchState?.comments ?? searchResponse?.comments; + final List? posts = searchState?.posts ?? searchResponse?.posts; + final List? instances = searchState?.instances; + + return switch (searchType) { + MetaSearchType.communities => communities?.isNotEmpty != true, + MetaSearchType.users => users?.isNotEmpty != true, + MetaSearchType.comments => comments?.isNotEmpty != true, + MetaSearchType.posts => posts?.isNotEmpty != true, + MetaSearchType.url => posts?.isNotEmpty != true, + MetaSearchType.instances => instances?.isNotEmpty != true, + _ => false, + }; +} diff --git a/lib/src/features/search/presentation/widgets/search_body.dart b/lib/src/features/search/presentation/widgets/search_body.dart index 6ea3f7831..9b1be7b3b 100644 --- a/lib/src/features/search/presentation/widgets/search_body.dart +++ b/lib/src/features/search/presentation/widgets/search_body.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/search/search.dart'; @@ -15,7 +15,7 @@ import 'package:thunder/src/features/search/presentation/widgets/search_instance import 'package:thunder/src/features/search/presentation/widgets/search_posts_results.dart'; import 'package:thunder/src/features/search/presentation/widgets/search_users_results.dart'; import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; /// The main body content of the search page showing results based on search state. class SearchBody extends StatelessWidget { diff --git a/lib/src/features/search/presentation/widgets/search_filters_row.dart b/lib/src/features/search/presentation/widgets/search_filters_row.dart index 65c916de3..bbf4c9ee9 100644 --- a/lib/src/features/search/presentation/widgets/search_filters_row.dart +++ b/lib/src/features/search/presentation/widgets/search_filters_row.dart @@ -4,18 +4,14 @@ import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, ThunderActionChip; /// The horizontal filter chips row for search options. class SearchFiltersRow extends StatefulWidget { diff --git a/lib/src/features/search/presentation/widgets/search_instances_results.dart b/lib/src/features/search/presentation/widgets/search_instances_results.dart index a6d0c2e9b..1783a6954 100644 --- a/lib/src/features/search/presentation/widgets/search_instances_results.dart +++ b/lib/src/features/search/presentation/widgets/search_instances_results.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; /// Displays search results for instances. class SearchInstancesResults extends StatelessWidget { diff --git a/lib/src/features/search/presentation/widgets/search_page_app_bar.dart b/lib/src/features/search/presentation/widgets/search_page_app_bar.dart index c3e96e08a..7ab6ec073 100644 --- a/lib/src/features/search/presentation/widgets/search_page_app_bar.dart +++ b/lib/src/features/search/presentation/widgets/search_page_app_bar.dart @@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; /// The app bar for the search page containing the search bar. class SearchPageAppBar extends StatelessWidget implements PreferredSizeWidget { diff --git a/lib/src/features/search/presentation/widgets/search_posts_results.dart b/lib/src/features/search/presentation/widgets/search_posts_results.dart index 2718a00ed..bb9b59369 100644 --- a/lib/src/features/search/presentation/widgets/search_posts_results.dart +++ b/lib/src/features/search/presentation/widgets/search_posts_results.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -28,7 +29,7 @@ class _SearchPostsResultsState extends State { @override void initState() { super.initState(); - _feedBloc = FeedBloc(account: widget.account); + _feedBloc = createFeedBloc(widget.account); // Initialize with current posts final posts = context.read().state.posts; diff --git a/lib/src/features/search/search.dart b/lib/src/features/search/search.dart index d47577ca2..9e147bdbc 100644 --- a/lib/src/features/search/search.dart +++ b/lib/src/features/search/search.dart @@ -1,4 +1,6 @@ export 'presentation/pages/search_page.dart'; -export 'presentation/bloc/search_bloc.dart'; +export 'presentation/state/search_bloc.dart'; export 'data/repositories/search_repository.dart'; +export 'domain/models/search_results.dart'; +export 'domain/models/search_resolve_result.dart'; export 'presentation/utils/search_utils.dart'; diff --git a/lib/src/features/settings/presentation/utils/utils.dart b/lib/src/features/settings/api.dart similarity index 100% rename from lib/src/features/settings/presentation/utils/utils.dart rename to lib/src/features/settings/api.dart diff --git a/lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_cubit.dart b/lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_cubit.dart new file mode 100644 index 000000000..917b92815 --- /dev/null +++ b/lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_cubit.dart @@ -0,0 +1,70 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/domain/swipe_action.dart'; + +part 'gesture_preferences_state.dart'; + +/// Cubit for managing gesture-related preferences +class GesturePreferencesCubit extends Cubit { + GesturePreferencesCubit({required PreferencesStore preferencesStore}) + : _preferencesStore = preferencesStore, + super(const GesturePreferencesState()) { + load(); + } + + final PreferencesStore _preferencesStore; + + /// Loads gesture preferences from UserPreferences + void load() { + // Sidebar Gesture Settings + final bottomNavBarSwipeGestures = _preferencesStore.getLocalSetting(LocalSettings.sidebarBottomNavBarSwipeGesture) ?? true; + final bottomNavBarDoubleTapGestures = _preferencesStore.getLocalSetting(LocalSettings.sidebarBottomNavBarDoubleTapGesture) ?? false; + + // Post Gestures + final enablePostGestures = _preferencesStore.getLocalSetting(LocalSettings.enablePostGestures) ?? true; + final leftPrimaryPostGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postGestureLeftPrimary) ?? SwipeAction.upvote.name); + final leftSecondaryPostGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postGestureLeftSecondary) ?? SwipeAction.downvote.name); + final rightPrimaryPostGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postGestureRightPrimary) ?? SwipeAction.save.name); + final rightSecondaryPostGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.postGestureRightSecondary) ?? SwipeAction.toggleRead.name); + + // Comment Gestures + final enableCommentGestures = _preferencesStore.getLocalSetting(LocalSettings.enableCommentGestures) ?? true; + final leftPrimaryCommentGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.commentGestureLeftPrimary) ?? SwipeAction.upvote.name); + final leftSecondaryCommentGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.commentGestureLeftSecondary) ?? SwipeAction.downvote.name); + final rightPrimaryCommentGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.commentGestureRightPrimary) ?? SwipeAction.reply.name); + final rightSecondaryCommentGesture = SwipeAction.values.byName(_preferencesStore.getLocalSetting(LocalSettings.commentGestureRightSecondary) ?? SwipeAction.save.name); + + // Navigation Gestures + final enableFullScreenSwipeNavigationGesture = _preferencesStore.getLocalSetting(LocalSettings.enableFullScreenSwipeNavigationGesture) ?? true; + + // Image Peek Settings + final imagePeekDuration = _preferencesStore.getLocalSetting(LocalSettings.imagePeekDuration) ?? 300; + + emit( + GesturePreferencesState( + bottomNavBarSwipeGestures: bottomNavBarSwipeGestures, + bottomNavBarDoubleTapGestures: bottomNavBarDoubleTapGestures, + enablePostGestures: enablePostGestures, + leftPrimaryPostGesture: leftPrimaryPostGesture, + leftSecondaryPostGesture: leftSecondaryPostGesture, + rightPrimaryPostGesture: rightPrimaryPostGesture, + rightSecondaryPostGesture: rightSecondaryPostGesture, + enableCommentGestures: enableCommentGestures, + leftPrimaryCommentGesture: leftPrimaryCommentGesture, + leftSecondaryCommentGesture: leftSecondaryCommentGesture, + rightPrimaryCommentGesture: rightPrimaryCommentGesture, + rightSecondaryCommentGesture: rightSecondaryCommentGesture, + enableFullScreenSwipeNavigationGesture: enableFullScreenSwipeNavigationGesture, + imagePeekDuration: imagePeekDuration, + ), + ); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_state.dart b/lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_state.dart similarity index 100% rename from lib/src/app/cubits/gesture_preferences_cubit/gesture_preferences_state.dart rename to lib/src/features/settings/application/state/gesture_preferences_cubit/gesture_preferences_state.dart diff --git a/lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_cubit.dart b/lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_cubit.dart new file mode 100644 index 000000000..c7a1c1798 --- /dev/null +++ b/lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_cubit.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' show FullNameSeparator, NameColor, NameThickness; + +part 'theme_preferences_state.dart'; + +/// Cubit for managing theme-related preferences +class ThemePreferencesCubit extends Cubit { + ThemePreferencesCubit({required PreferencesStore preferencesStore}) + : _preferencesStore = preferencesStore, + super(const ThemePreferencesState()) { + load(); + } + + final PreferencesStore _preferencesStore; + + /// Loads theme preferences from UserPreferences + void load() { + // Theme Settings + ThemeType themeType = ThemeType.values[_preferencesStore.getLocalSetting(LocalSettings.appTheme) ?? ThemeType.system.index]; + Brightness brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; + + // Check if the user has selected to use a pure black theme, if so override the themeType to pureBlack + bool usePureBlackTheme = _preferencesStore.getLocalSetting(LocalSettings.usePureBlackTheme) ?? false; + if (usePureBlackTheme && (themeType == ThemeType.dark || (themeType == ThemeType.system && brightness == Brightness.dark))) { + themeType = ThemeType.pureBlack; + } + + final selectedTheme = CustomThemeType.values.byName(_preferencesStore.getLocalSetting(LocalSettings.appThemeAccentColor) ?? CustomThemeType.deepBlue.name); + final useMaterialYouTheme = _preferencesStore.getLocalSetting(LocalSettings.useMaterialYouTheme) ?? false; + + // Fetch reduce animations preferences to remove overscrolling effects + final reduceAnimations = _preferencesStore.getLocalSetting(LocalSettings.reduceAnimations) ?? false; + + // Color Settings + final upvoteColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.upvoteColor) ?? ActionColor.orange); + final downvoteColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.downvoteColor) ?? ActionColor.blue); + final saveColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.saveColor) ?? ActionColor.purple); + final markReadColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.markReadColor) ?? ActionColor.teal); + final replyColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.replyColor) ?? ActionColor.green); + final hideColor = ActionColor.fromString(colorRaw: _preferencesStore.getLocalSetting(LocalSettings.hideColor) ?? ActionColor.red); + + // Font Settings + final titleFontSizeScale = FontScale.values.byName(_preferencesStore.getLocalSetting(LocalSettings.titleFontSizeScale) ?? FontScale.base.name); + final contentFontSizeScale = FontScale.values.byName(_preferencesStore.getLocalSetting(LocalSettings.contentFontSizeScale) ?? FontScale.base.name); + final commentFontSizeScale = FontScale.values.byName(_preferencesStore.getLocalSetting(LocalSettings.commentFontSizeScale) ?? FontScale.base.name); + final metadataFontSizeScale = FontScale.values.byName(_preferencesStore.getLocalSetting(LocalSettings.metadataFontSizeScale) ?? FontScale.base.name); + + // User/Community Display Name Settings + final useDisplayNamesForUsers = _preferencesStore.getLocalSetting(LocalSettings.useDisplayNamesForUsers) ?? false; + final useDisplayNamesForCommunities = _preferencesStore.getLocalSetting(LocalSettings.useDisplayNamesForCommunities) ?? false; + final userSeparator = FullNameSeparator.values.byName(_preferencesStore.getLocalSetting(LocalSettings.userFormat) ?? FullNameSeparator.at.name); + final userFullNameUserNameThickness = NameThickness.values.byName(_preferencesStore.getLocalSetting(LocalSettings.userFullNameUserNameThickness) ?? NameThickness.normal.name); + final userFullNameUserNameColor = NameColor.fromString(color: _preferencesStore.getLocalSetting(LocalSettings.userFullNameUserNameColor) ?? NameColor.defaultColor); + final userFullNameInstanceNameThickness = NameThickness.values.byName(_preferencesStore.getLocalSetting(LocalSettings.userFullNameInstanceNameThickness) ?? NameThickness.light.name); + final userFullNameInstanceNameColor = NameColor.fromString(color: _preferencesStore.getLocalSetting(LocalSettings.userFullNameInstanceNameColor) ?? NameColor.defaultColor); + final communitySeparator = FullNameSeparator.values.byName(_preferencesStore.getLocalSetting(LocalSettings.communityFormat) ?? FullNameSeparator.dot.name); + final communityFullNameCommunityNameThickness = NameThickness.values.byName(_preferencesStore.getLocalSetting(LocalSettings.communityFullNameCommunityNameThickness) ?? NameThickness.normal.name); + final communityFullNameCommunityNameColor = NameColor.fromString(color: _preferencesStore.getLocalSetting(LocalSettings.communityFullNameCommunityNameColor) ?? NameColor.defaultColor); + final communityFullNameInstanceNameThickness = NameThickness.values.byName(_preferencesStore.getLocalSetting(LocalSettings.communityFullNameInstanceNameThickness) ?? NameThickness.light.name); + final communityFullNameInstanceNameColor = NameColor.fromString(color: _preferencesStore.getLocalSetting(LocalSettings.communityFullNameInstanceNameColor) ?? NameColor.defaultColor); + + emit(ThemePreferencesState( + themeType: themeType, + selectedTheme: selectedTheme, + useMaterialYouTheme: useMaterialYouTheme, + reduceAnimations: reduceAnimations, + upvoteColor: upvoteColor, + downvoteColor: downvoteColor, + saveColor: saveColor, + markReadColor: markReadColor, + replyColor: replyColor, + hideColor: hideColor, + titleFontSizeScale: titleFontSizeScale, + contentFontSizeScale: contentFontSizeScale, + commentFontSizeScale: commentFontSizeScale, + metadataFontSizeScale: metadataFontSizeScale, + useDisplayNamesForUsers: useDisplayNamesForUsers, + useDisplayNamesForCommunities: useDisplayNamesForCommunities, + userSeparator: userSeparator, + userFullNameUserNameThickness: userFullNameUserNameThickness, + userFullNameUserNameColor: userFullNameUserNameColor, + userFullNameInstanceNameThickness: userFullNameInstanceNameThickness, + userFullNameInstanceNameColor: userFullNameInstanceNameColor, + communitySeparator: communitySeparator, + communityFullNameCommunityNameThickness: communityFullNameCommunityNameThickness, + communityFullNameCommunityNameColor: communityFullNameCommunityNameColor, + communityFullNameInstanceNameThickness: communityFullNameInstanceNameThickness, + communityFullNameInstanceNameColor: communityFullNameInstanceNameColor, + )); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/theme_preferences_cubit/theme_preferences_state.dart b/lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_state.dart similarity index 100% rename from lib/src/app/cubits/theme_preferences_cubit/theme_preferences_state.dart rename to lib/src/features/settings/application/state/theme_preferences_cubit/theme_preferences_state.dart diff --git a/lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_cubit.dart b/lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_cubit.dart new file mode 100644 index 000000000..e959e7fd2 --- /dev/null +++ b/lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_cubit.dart @@ -0,0 +1,44 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +part 'video_preferences_state.dart'; + +/// Cubit for managing video player preferences +class VideoPreferencesCubit extends Cubit { + VideoPreferencesCubit({required PreferencesStore preferencesStore}) + : _preferencesStore = preferencesStore, + super(const VideoPreferencesState()) { + load(); + } + + final PreferencesStore _preferencesStore; + + /// Loads video preferences from UserPreferences + void load() { + final videoAutoFullscreen = _preferencesStore.getLocalSetting(LocalSettings.videoAutoFullscreen) ?? false; + final videoAutoLoop = _preferencesStore.getLocalSetting(LocalSettings.videoAutoLoop) ?? false; + final videoAutoMute = _preferencesStore.getLocalSetting(LocalSettings.videoAutoMute) ?? true; + final videoAutoPlay = VideoAutoPlay.values.byName(_preferencesStore.getLocalSetting(LocalSettings.videoAutoPlay) ?? VideoAutoPlay.never.name); + final videoDefaultPlaybackSpeed = VideoPlayBackSpeed.values.byName(_preferencesStore.getLocalSetting(LocalSettings.videoDefaultPlaybackSpeed) ?? VideoPlayBackSpeed.normal.name); + final videoPlayerMode = VideoPlayerMode.values.byName(_preferencesStore.getLocalSetting(LocalSettings.videoPlayerMode) ?? VideoPlayerMode.inApp.name); + + emit( + VideoPreferencesState( + videoAutoFullscreen: videoAutoFullscreen, + videoAutoLoop: videoAutoLoop, + videoAutoMute: videoAutoMute, + videoAutoPlay: videoAutoPlay, + videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed, + videoPlayerMode: videoPlayerMode, + ), + ); + } + + /// Reloads preferences from storage. This should be called when preferences are updated elsewhere + void reload() { + load(); + } +} diff --git a/lib/src/app/cubits/video_preferences_cubit/video_preferences_state.dart b/lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_state.dart similarity index 100% rename from lib/src/app/cubits/video_preferences_cubit/video_preferences_state.dart rename to lib/src/features/settings/application/state/video_preferences_cubit/video_preferences_state.dart diff --git a/lib/src/features/settings/domain/full_name.dart b/lib/src/features/settings/domain/full_name.dart new file mode 100644 index 000000000..d1cab8e57 --- /dev/null +++ b/lib/src/features/settings/domain/full_name.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' + show + CommunityFullNameWidget, + FullNameSeparator, + NameColor, + NameThickness, + UserFullNameWidget, + formatCommunityFullNamePrefix, + formatCommunityFullNameSuffix, + formatUserFullNamePrefix, + formatUserFullNameSuffix; + +export 'package:thunder/packages/ui/ui.dart' show FullNameSeparator, NameColor, NameThickness; + +/// --- SAMPLES --- + +String generateSampleUserFullName(FullNameSeparator separator, bool useDisplayName) => generateUserFullName( + null, + 'name', + 'name', + 'instance.tld', + userSeparator: separator, + useDisplayName: useDisplayName, + ); + +Widget generateSampleUserFullNameWidget( + FullNameSeparator separator, { + NameThickness? userNameThickness, + NameColor? userNameColor, + NameThickness? instanceNameThickness, + NameColor? instanceNameColor, + TextStyle? textStyle, + bool? useDisplayName, +}) => + UserFullNameWidget( + name: 'name', + displayName: 'name', + instance: 'instance.tld', + separator: separator, + useDisplayName: useDisplayName ?? false, + userNameThickness: userNameThickness ?? NameThickness.normal, + userNameColor: userNameColor ?? const NameColor.fromString(color: NameColor.defaultColor), + instanceNameThickness: instanceNameThickness ?? NameThickness.light, + instanceNameColor: instanceNameColor ?? const NameColor.fromString(color: NameColor.defaultColor), + textStyle: textStyle, + textScaleFactor: FontScale.base.textScaleFactor, + ); + +String generateSampleCommunityFullName(FullNameSeparator separator, bool useDisplayName) => generateCommunityFullName( + null, + 'name', + 'name', + 'instance.tld', + communitySeparator: separator, + useDisplayName: useDisplayName, + ); + +Widget generateSampleCommunityFullNameWidget( + FullNameSeparator separator, { + NameThickness? communityNameThickness, + NameColor? communityNameColor, + NameThickness? instanceNameThickness, + NameColor? instanceNameColor, + TextStyle? textStyle, + bool? useDisplayName, +}) => + CommunityFullNameWidget( + name: 'name', + displayName: 'name', + instance: 'instance.tld', + separator: separator, + useDisplayName: useDisplayName ?? false, + communityNameThickness: communityNameThickness ?? NameThickness.normal, + communityNameColor: communityNameColor ?? const NameColor.fromString(color: NameColor.defaultColor), + instanceNameThickness: instanceNameThickness ?? NameThickness.light, + instanceNameColor: instanceNameColor ?? const NameColor.fromString(color: NameColor.defaultColor), + textStyle: textStyle, + textScaleFactor: FontScale.base.textScaleFactor, + ); + +/// --- USERS --- + +String generateUserFullNamePrefix(BuildContext? context, String? name, String? displayName, {FullNameSeparator? userSeparator, bool? useDisplayName}) { + assert(context != null || (userSeparator != null && useDisplayName != null)); + + final resolvedSeparator = userSeparator ?? context!.read().state.userSeparator; + final resolvedUseDisplayName = useDisplayName ?? context!.read().state.useDisplayNamesForUsers; + + return formatUserFullNamePrefix( + name, + displayName, + separator: resolvedSeparator, + useDisplayName: resolvedUseDisplayName, + ); +} + +String generateUserFullNameSuffix(BuildContext? context, String? instance, {FullNameSeparator? userSeparator}) { + assert(context != null || userSeparator != null); + + final resolvedSeparator = userSeparator ?? context!.read().state.userSeparator; + + return formatUserFullNameSuffix(instance, separator: resolvedSeparator); +} + +String generateUserFullName(BuildContext? context, String? name, String? displayName, String? instance, {FullNameSeparator? userSeparator, bool? useDisplayName}) { + final prefix = generateUserFullNamePrefix(context, name, displayName, userSeparator: userSeparator, useDisplayName: useDisplayName); + final suffix = generateUserFullNameSuffix(context, instance, userSeparator: userSeparator); + return '$prefix$suffix'; +} + +/// --- COMMUNITIES --- + +String generateCommunityFullNamePrefix(BuildContext? context, String? name, String? displayName, {FullNameSeparator? communitySeparator, bool? useDisplayName}) { + assert(context != null || (communitySeparator != null && useDisplayName != null)); + + final resolvedSeparator = communitySeparator ?? context!.read().state.communitySeparator; + final resolvedUseDisplayName = useDisplayName ?? context!.read().state.useDisplayNamesForCommunities; + + return formatCommunityFullNamePrefix( + name, + displayName, + separator: resolvedSeparator, + useDisplayName: resolvedUseDisplayName, + ); +} + +String generateCommunityFullNameSuffix(BuildContext? context, String? instance, {FullNameSeparator? communitySeparator}) { + assert(context != null || communitySeparator != null); + + final resolvedSeparator = communitySeparator ?? context!.read().state.communitySeparator; + + return formatCommunityFullNameSuffix(instance, separator: resolvedSeparator); +} + +String generateCommunityFullName(BuildContext? context, String? name, String? displayName, String? instance, {FullNameSeparator? communitySeparator, bool? useDisplayName}) { + final prefix = generateCommunityFullNamePrefix(context, name, displayName, communitySeparator: communitySeparator, useDisplayName: useDisplayName); + final suffix = generateCommunityFullNameSuffix(context, instance, communitySeparator: communitySeparator); + return '$prefix$suffix'; +} diff --git a/lib/src/shared/utils/language/language.dart b/lib/src/features/settings/domain/models/language_local.dart similarity index 100% rename from lib/src/shared/utils/language/language.dart rename to lib/src/features/settings/domain/models/language_local.dart diff --git a/lib/src/core/enums/swipe_action.dart b/lib/src/features/settings/domain/swipe_action.dart similarity index 95% rename from lib/src/core/enums/swipe_action.dart rename to lib/src/features/settings/domain/swipe_action.dart index 1a5e728f0..83ec4f081 100644 --- a/lib/src/core/enums/swipe_action.dart +++ b/lib/src/features/settings/domain/swipe_action.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; enum SwipeAction { upvote(label: 'Upvote'), diff --git a/lib/src/features/settings/presentation/pages/about_settings_page.dart b/lib/src/features/settings/presentation/pages/about_settings_page.dart index 83b2be2c6..37ee4255e 100644 --- a/lib/src/features/settings/presentation/pages/about_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/about_settings_page.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/utils/check_github_update.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; class AboutSettingsPage extends StatelessWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/accessibility_settings_page.dart b/lib/src/features/settings/presentation/pages/accessibility_settings_page.dart index 78404b839..7b11b9baa 100644 --- a/lib/src/features/settings/presentation/pages/accessibility_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/accessibility_settings_page.dart @@ -4,13 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; class AccessibilitySettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart similarity index 83% rename from lib/src/features/settings/presentation/pages/appearance_settings_page.dart rename to lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart index 4d188ae1e..147fe9da8 100644 --- a/lib/src/features/settings/presentation/pages/appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/appearance_settings_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider; class AppearanceSettingsPage extends StatelessWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/comment_appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart similarity index 95% rename from lib/src/features/settings/presentation/pages/comment_appearance_settings_page.dart rename to lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart index 516147290..c9394c753 100644 --- a/lib/src/features/settings/presentation/pages/comment_appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/comment_appearance_settings_page.dart @@ -5,20 +5,18 @@ import 'package:flutter/material.dart'; import 'package:expandable/expandable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/comment/api.dart'; import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem, showThunderDialog; class CommentAppearanceSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -299,7 +297,7 @@ class _CommentAppearanceSettingsPageState extends State().state.account; return BlocProvider( - create: (context) => PostBloc(account: account), + create: (context) => createPostBloc(account), child: IgnorePointer( child: ListView( padding: EdgeInsets.zero, diff --git a/lib/src/features/settings/presentation/pages/post_appearance_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart similarity index 98% rename from lib/src/features/settings/presentation/pages/post_appearance_settings_page.dart rename to lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart index af35ed537..aaad846cc 100644 --- a/lib/src/features/settings/presentation/pages/post_appearance_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/post_appearance_settings_page.dart @@ -8,25 +8,20 @@ import 'package:expandable/expandable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/custom_theme_type.dart'; -import 'package:thunder/src/core/enums/feed_card_divider_thickness.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/post_body_view_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, showThunderDialog; class PostAppearanceSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -548,7 +543,7 @@ class _PostAppearanceSettingsPageState extends State final account = context.read().state.account; return BlocProvider( - create: (context) => FeedBloc(account: account), + create: (context) => createFeedBloc(account), child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, diff --git a/lib/src/features/settings/presentation/pages/theme_settings_page.dart b/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart similarity index 98% rename from lib/src/features/settings/presentation/pages/theme_settings_page.dart rename to lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart index 5fa6f871c..f64429beb 100644 --- a/lib/src/features/settings/presentation/pages/theme_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/appearance/theme_settings_page.dart @@ -7,19 +7,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:thunder/src/core/enums/action_color.dart'; -import 'package:thunder/src/core/enums/custom_theme_type.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/theme_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, FullNameSeparator, ListPickerItem, NameColor, NameThickness; class ThemeSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/fab_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart similarity index 98% rename from lib/src/features/settings/presentation/pages/fab_settings_page.dart rename to lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart index f9c816bc9..515953a85 100644 --- a/lib/src/features/settings/presentation/pages/fab_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/fab_settings_page.dart @@ -5,16 +5,15 @@ import 'package:flutter/material.dart'; import 'package:expandable/expandable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/fab_action.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; class FabSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/filter_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart similarity index 94% rename from lib/src/features/settings/presentation/pages/filter_settings_page.dart rename to lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart index a971a0aaf..6e46bf270 100644 --- a/lib/src/features/settings/presentation/pages/filter_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/filter_settings_page.dart @@ -6,16 +6,17 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/dialogs.dart'; + import 'package:thunder/src/shared/input_dialogs.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; class FilterSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/general_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart similarity index 97% rename from lib/src/features/settings/presentation/pages/general_settings_page.dart rename to lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart index d1ca34afe..9f60472b4 100644 --- a/lib/src/features/settings/presentation/pages/general_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/general_settings_page.dart @@ -9,33 +9,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:thunder/src/core/database/database_utils.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:unifiedpush/unifiedpush.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/browser_mode.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/image_caching_mode.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; import 'package:thunder/src/features/notification/notification.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/snackbar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/language/language.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/settings/domain/models/language_local.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, ThunderDivider, showSnackbar, showThunderDialog; class GeneralSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -905,7 +896,9 @@ class _GeneralSettingsPageState extends State with SingleTi ), ], onSelect: (ListPickerItem notificationType) async { - if (notificationType.payload == inboxNotificationType) return; + if (notificationType.payload == inboxNotificationType) { + return; + } bool success = await updateNotificationSettings( context, @@ -925,7 +918,9 @@ class _GeneralSettingsPageState extends State with SingleTi }, ); - if (!success) showSnackbar(l10n.failedToUpdateNotificationSettings); + if (!success) { + showSnackbar(l10n.failedToUpdateNotificationSettings); + } _initPreferences(); }, ); diff --git a/lib/src/features/settings/presentation/pages/gesture_settings_page.dart b/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart similarity index 97% rename from lib/src/features/settings/presentation/pages/gesture_settings_page.dart rename to lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart index 58ed9dd1b..1ac637b69 100644 --- a/lib/src/features/settings/presentation/pages/gesture_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/behavior/gesture_settings_page.dart @@ -4,16 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem; class GestureSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/video_player_settings.dart b/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart similarity index 94% rename from lib/src/features/settings/presentation/pages/video_player_settings.dart rename to lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart index d97337339..166685d3c 100644 --- a/lib/src/features/settings/presentation/pages/video_player_settings.dart +++ b/lib/src/features/settings/presentation/pages/behavior/video_player_settings.dart @@ -4,16 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/video_auto_play.dart'; -import 'package:thunder/src/core/enums/video_playback_speed.dart'; -import 'package:thunder/src/core/enums/video_player_mode.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem; class VideoPlayerSettingsPage extends StatefulWidget { const VideoPlayerSettingsPage({super.key, this.settingToHighlight}); diff --git a/lib/src/features/settings/presentation/pages/debug_settings_page.dart b/lib/src/features/settings/presentation/pages/debug_settings_page.dart index 5b7d2e81b..4082155e7 100644 --- a/lib/src/features/settings/presentation/pages/debug_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/debug_settings_page.dart @@ -14,20 +14,17 @@ import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; import 'package:thunder/src/features/notification/notification.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/shared/utils/cache.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; import 'package:unifiedpush/unifiedpush.dart'; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem, ThunderDivider, showSnackbar, showThunderDialog; class DebugSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/pages/pages.dart b/lib/src/features/settings/presentation/pages/pages.dart index f9ef7915f..44ebed46a 100644 --- a/lib/src/features/settings/presentation/pages/pages.dart +++ b/lib/src/features/settings/presentation/pages/pages.dart @@ -1,14 +1,14 @@ export 'about_settings_page.dart'; export 'accessibility_settings_page.dart'; -export 'appearance_settings_page.dart'; -export 'comment_appearance_settings_page.dart'; +export 'appearance/appearance_settings_page.dart'; +export 'appearance/comment_appearance_settings_page.dart'; export 'debug_settings_page.dart'; -export 'fab_settings_page.dart'; -export 'filter_settings_page.dart'; -export 'general_settings_page.dart'; -export 'gesture_settings_page.dart'; -export 'post_appearance_settings_page.dart'; +export 'behavior/fab_settings_page.dart'; +export 'behavior/filter_settings_page.dart'; +export 'behavior/general_settings_page.dart'; +export 'behavior/gesture_settings_page.dart'; +export 'appearance/post_appearance_settings_page.dart'; export 'settings_page.dart'; -export 'theme_settings_page.dart'; +export 'appearance/theme_settings_page.dart'; export 'user_labels_settings_page.dart'; -export 'video_player_settings.dart'; +export 'behavior/video_player_settings.dart'; diff --git a/lib/src/features/settings/presentation/pages/settings_page.dart b/lib/src/features/settings/presentation/pages/settings_page.dart index de095c133..64b0f371c 100644 --- a/lib/src/features/settings/presentation/pages/settings_page.dart +++ b/lib/src/features/settings/presentation/pages/settings_page.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/utils/check_github_update.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; class SettingTopic { final String title; diff --git a/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart b/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart index 0bf062b9a..11f794de9 100644 --- a/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/user_labels_settings_page.dart @@ -9,12 +9,13 @@ import 'package:smooth_highlight/smooth_highlight.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; class UserLabelSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; diff --git a/lib/src/features/settings/presentation/utils/settings.dart b/lib/src/features/settings/presentation/utils/setting_link_utils.dart similarity index 85% rename from lib/src/features/settings/presentation/utils/settings.dart rename to lib/src/features/settings/presentation/utils/setting_link_utils.dart index 5ae720d46..9eae9fa28 100644 --- a/lib/src/features/settings/presentation/utils/settings.dart +++ b/lib/src/features/settings/presentation/utils/setting_link_utils.dart @@ -3,8 +3,8 @@ import 'package:flutter/services.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/shared/snackbar.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// Generates a link to a local setting. /// diff --git a/lib/src/features/settings/presentation/widgets/accessibility_profile.dart b/lib/src/features/settings/presentation/widgets/accessibility_profile.dart index f23508680..85d9f0222 100644 --- a/lib/src/features/settings/presentation/widgets/accessibility_profile.dart +++ b/lib/src/features/settings/presentation/widgets/accessibility_profile.dart @@ -1,98 +1,99 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:dynamic_color/dynamic_color.dart'; - -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -class SettingProfile extends StatelessWidget { - final IconData icon; - final String name; - final String description; - final Map settingsToChange; - - const SettingProfile({ - super.key, - required this.icon, - required this.name, - required this.description, - required this.settingsToChange, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final ThemeData theme = Theme.of(context); - bool recentSuccess = false; - - return ExpandableOption( - icon: icon, - description: name, - child: Column( - children: [ - Text(description), - ...settingsToChange.entries.map( - (entry) { - return Row( - children: [ - Text('• ${l10n.getLocalSettingLocalization(entry.key.key)}'), - const Icon(Icons.arrow_right_rounded, size: 20), - Text(_humanizeValue(context, entry.value)), - ], - ); - }, - ), - const SizedBox(height: 12), - StatefulBuilder( - builder: (context, setState) => TextButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(45), - backgroundColor: theme.colorScheme.primaryContainer.harmonizeWith(theme.colorScheme.errorContainer), - disabledBackgroundColor: theme.colorScheme.primaryContainer.harmonizeWith(theme.colorScheme.errorContainer).withValues(alpha: 0.5), - ), - onPressed: recentSuccess - ? null - : () async { - bool success = true; - - for (MapEntry entry in settingsToChange.entries) { - if (entry.value is bool) { - await UserPreferences.instance.preferences.setBool(entry.key.name, entry.value as bool); - } else { - // This should never happen in production, since we should add support for any unsupported types - // before adding a profile containing those types. - success = false; - if (context.mounted) { - showSnackbar(AppLocalizations.of(context)!.settingTypeNotSupported(entry.value.runtimeType)); - } - } - } - if (context.mounted && success) { - showSnackbar(AppLocalizations.of(context)!.profileAppliedSuccessfully(name)); - setState(() => recentSuccess = true); - Future.delayed(const Duration(seconds: 5), () async { - setState(() => recentSuccess = false); - }); - } - }, - child: recentSuccess ? Text(AppLocalizations.of(context)!.applied) : Text(AppLocalizations.of(context)!.apply), - ), - ), - ], - ), - ); - } - - String _humanizeValue(BuildContext context, Object value) { - if (value is bool) { - return value ? AppLocalizations.of(context)!.on : AppLocalizations.of(context)!.off; - } - - return value.toString(); - } -} +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:dynamic_color/dynamic_color.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/settings/settings.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; + +class SettingProfile extends StatelessWidget { + final IconData icon; + final String name; + final String description; + final Map settingsToChange; + + const SettingProfile({ + super.key, + required this.icon, + required this.name, + required this.description, + required this.settingsToChange, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final ThemeData theme = Theme.of(context); + bool recentSuccess = false; + + return ExpandableOption( + icon: icon, + description: name, + child: Column( + children: [ + Text(description), + ...settingsToChange.entries.map( + (entry) { + return Row( + children: [ + Text('• ${l10n.getLocalSettingLocalization(entry.key.key)}'), + const Icon(Icons.arrow_right_rounded, size: 20), + Text(_humanizeValue(context, entry.value)), + ], + ); + }, + ), + const SizedBox(height: 12), + StatefulBuilder( + builder: (context, setState) => TextButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(45), + backgroundColor: theme.colorScheme.primaryContainer.harmonizeWith(theme.colorScheme.errorContainer), + disabledBackgroundColor: theme.colorScheme.primaryContainer.harmonizeWith(theme.colorScheme.errorContainer).withValues(alpha: 0.5), + ), + onPressed: recentSuccess + ? null + : () async { + bool success = true; + + for (MapEntry entry in settingsToChange.entries) { + if (entry.value is bool) { + await UserPreferences.instance.preferences.setBool(entry.key.name, entry.value as bool); + } else { + // This should never happen in production, since we should add support for any unsupported types + // before adding a profile containing those types. + success = false; + if (context.mounted) { + showSnackbar(AppLocalizations.of(context)!.settingTypeNotSupported(entry.value.runtimeType)); + } + } + } + if (context.mounted && success) { + showSnackbar(AppLocalizations.of(context)!.profileAppliedSuccessfully(name)); + setState(() => recentSuccess = true); + Future.delayed(const Duration(seconds: 5), () async { + setState(() => recentSuccess = false); + }); + } + }, + child: recentSuccess ? Text(AppLocalizations.of(context)!.applied) : Text(AppLocalizations.of(context)!.apply), + ), + ), + ], + ), + ); + } + + String _humanizeValue(BuildContext context, Object value) { + if (value is bool) { + return value ? AppLocalizations.of(context)!.on : AppLocalizations.of(context)!.off; + } + + return value.toString(); + } +} diff --git a/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart b/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart index 86b050746..5cea6014d 100644 --- a/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart +++ b/lib/src/features/settings/presentation/widgets/action_color_setting_widget.dart @@ -1,335 +1,334 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/action_color.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; - -class ActionColorSettingWidget extends StatelessWidget { - final LocalSettings? settingToHighlight; - final GlobalKey settingToHighlightKey; - final Future Function(LocalSettings attribute, String? value) setPreferences; - final ActionColor upvoteColor; - final ActionColor downvoteColor; - final ActionColor saveColor; - final ActionColor markReadColor; - final ActionColor replyColor; - final ActionColor hideColor; - - const ActionColorSettingWidget({ - super.key, - required this.settingToHighlight, - required this.settingToHighlightKey, - required this.setPreferences, - required this.upvoteColor, - required this.downvoteColor, - required this.saveColor, - required this.markReadColor, - required this.replyColor, - required this.hideColor, - }); - - @override - Widget build(BuildContext context) { - final AppLocalizations l10n = AppLocalizations.of(context)!; - final ThemeData theme = Theme.of(context); - - ActionColor upvoteColor = this.upvoteColor; - ActionColor downvoteColor = this.downvoteColor; - ActionColor saveColor = this.saveColor; - ActionColor markReadColor = this.markReadColor; - ActionColor replyColor = this.replyColor; - ActionColor hideColor = this.hideColor; - - return Container( - padding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), - child: Text(l10n.colors, style: theme.textTheme.titleLarge), - ), - ListOption( - isBottomModalScrollControlled: true, - value: const ListPickerItem(payload: -1), - description: l10n.actionColors, - icon: Icons.color_lens_rounded, - highlightKey: settingToHighlightKey, - setting: LocalSettings.actionColors, - highlightedSetting: settingToHighlight, - customListPicker: StatefulBuilder( - builder: (context, setState) { - return BottomSheetListPicker( - title: l10n.actionColors, - items: [ - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.upvoteColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: upvoteColor, - items: ActionColor.getPossibleValues(upvoteColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.upvoteColor, value?.colorRaw); - setState(() => upvoteColor = value ?? upvoteColor); - }, - ), - ), - ), - ), - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.downvoteColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: downvoteColor, - items: ActionColor.getPossibleValues(downvoteColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.downvoteColor, value?.colorRaw); - setState(() => downvoteColor = value ?? downvoteColor); - }, - ), - ), - ), - ), - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.saveColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: saveColor, - items: ActionColor.getPossibleValues(saveColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.saveColor, value?.colorRaw); - setState(() => saveColor = value ?? saveColor); - }, - ), - ), - ), - ), - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.markReadColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: markReadColor, - items: ActionColor.getPossibleValues(markReadColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.markReadColor, value?.colorRaw); - setState(() => markReadColor = value ?? markReadColor); - }, - ), - ), - ), - ), - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.replyColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: replyColor, - items: ActionColor.getPossibleValues(replyColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.replyColor, value?.colorRaw); - setState(() => replyColor = value ?? replyColor); - }, - ), - ), - ), - ), - ListPickerItem( - payload: -1, - customWidget: ListTile( - title: Text( - l10n.hideColor, - style: theme.textTheme.bodyMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: DropdownButton( - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - isExpanded: true, - underline: Container(), - value: hideColor, - items: ActionColor.getPossibleValues(hideColor) - .map( - (actionColor) => DropdownMenuItem( - alignment: Alignment.center, - value: actionColor, - child: Row( - children: [ - CircleAvatar( - radius: 10.0, - backgroundColor: actionColor.color, - ), - const SizedBox(width: 16.0), - Text( - actionColor.label(context), - style: theme.textTheme.bodyMedium, - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) async { - await setPreferences(LocalSettings.hideColor, value?.colorRaw); - setState(() => hideColor = value ?? hideColor); - }, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/settings.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; + +class ActionColorSettingWidget extends StatelessWidget { + final LocalSettings? settingToHighlight; + final GlobalKey settingToHighlightKey; + final Future Function(LocalSettings attribute, String? value) setPreferences; + final ActionColor upvoteColor; + final ActionColor downvoteColor; + final ActionColor saveColor; + final ActionColor markReadColor; + final ActionColor replyColor; + final ActionColor hideColor; + + const ActionColorSettingWidget({ + super.key, + required this.settingToHighlight, + required this.settingToHighlightKey, + required this.setPreferences, + required this.upvoteColor, + required this.downvoteColor, + required this.saveColor, + required this.markReadColor, + required this.replyColor, + required this.hideColor, + }); + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + final ThemeData theme = Theme.of(context); + + ActionColor upvoteColor = this.upvoteColor; + ActionColor downvoteColor = this.downvoteColor; + ActionColor saveColor = this.saveColor; + ActionColor markReadColor = this.markReadColor; + ActionColor replyColor = this.replyColor; + ActionColor hideColor = this.hideColor; + + return Container( + padding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Text(l10n.colors, style: theme.textTheme.titleLarge), + ), + ListOption( + isBottomModalScrollControlled: true, + value: const ListPickerItem(payload: -1), + description: l10n.actionColors, + icon: Icons.color_lens_rounded, + highlightKey: settingToHighlightKey, + setting: LocalSettings.actionColors, + highlightedSetting: settingToHighlight, + customListPicker: StatefulBuilder( + builder: (context, setState) { + return BottomSheetListPicker( + title: l10n.actionColors, + items: [ + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.upvoteColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: upvoteColor, + items: ActionColor.getPossibleValues(upvoteColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.upvoteColor, value?.colorRaw); + setState(() => upvoteColor = value ?? upvoteColor); + }, + ), + ), + ), + ), + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.downvoteColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: downvoteColor, + items: ActionColor.getPossibleValues(downvoteColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.downvoteColor, value?.colorRaw); + setState(() => downvoteColor = value ?? downvoteColor); + }, + ), + ), + ), + ), + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.saveColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: saveColor, + items: ActionColor.getPossibleValues(saveColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.saveColor, value?.colorRaw); + setState(() => saveColor = value ?? saveColor); + }, + ), + ), + ), + ), + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.markReadColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: markReadColor, + items: ActionColor.getPossibleValues(markReadColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.markReadColor, value?.colorRaw); + setState(() => markReadColor = value ?? markReadColor); + }, + ), + ), + ), + ), + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.replyColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: replyColor, + items: ActionColor.getPossibleValues(replyColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.replyColor, value?.colorRaw); + setState(() => replyColor = value ?? replyColor); + }, + ), + ), + ), + ), + ListPickerItem( + payload: -1, + customWidget: ListTile( + title: Text( + l10n.hideColor, + style: theme.textTheme.bodyMedium, + ), + subtitle: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: DropdownButton( + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + isExpanded: true, + underline: Container(), + value: hideColor, + items: ActionColor.getPossibleValues(hideColor) + .map( + (actionColor) => DropdownMenuItem( + alignment: Alignment.center, + value: actionColor, + child: Row( + children: [ + CircleAvatar( + radius: 10.0, + backgroundColor: actionColor.color, + ), + const SizedBox(width: 16.0), + Text( + actionColor.label(context), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) async { + await setPreferences(LocalSettings.hideColor, value?.colorRaw); + setState(() => hideColor = value ?? hideColor); + }, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart b/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart index 570b45af5..3f1cc2547 100644 --- a/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart +++ b/lib/src/features/settings/presentation/widgets/discussion_language_selector.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/shared/dialogs.dart'; + import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; class DiscussionLanguageSelector extends StatefulWidget { const DiscussionLanguageSelector({super.key}); diff --git a/lib/src/features/settings/presentation/widgets/list_option.dart b/lib/src/features/settings/presentation/widgets/list_option.dart index c27c3821d..42b8d0ae1 100644 --- a/lib/src/features/settings/presentation/widgets/list_option.dart +++ b/lib/src/features/settings/presentation/widgets/list_option.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/settings/settings.dart'; - -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; class ListOption extends StatelessWidget { // Appearance diff --git a/lib/src/features/settings/presentation/widgets/settings_list_tile.dart b/lib/src/features/settings/presentation/widgets/settings_list_tile.dart index fd8b33840..cf062139b 100644 --- a/lib/src/features/settings/presentation/widgets/settings_list_tile.dart +++ b/lib/src/features/settings/presentation/widgets/settings_list_tile.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/settings/settings.dart'; class SettingsListTile extends StatelessWidget { diff --git a/lib/src/features/settings/presentation/widgets/swipe_picker.dart b/lib/src/features/settings/presentation/widgets/swipe_picker.dart index 7ff33ac71..cb14f29f0 100644 --- a/lib/src/features/settings/presentation/widgets/swipe_picker.dart +++ b/lib/src/features/settings/presentation/widgets/swipe_picker.dart @@ -1,239 +1,238 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/core/enums/swipe_action.dart'; -import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; - -enum SwipePickerSide { left, right } - -class SwipePickerItem { - String label; - List> options; - SwipeAction value; - final void Function(ListPickerItem) onChanged; - - SwipePickerItem({ - required this.label, - required this.options, - required this.value, - required this.onChanged, - }); -} - -class SwipePicker extends StatelessWidget { - final SwipePickerSide side; - final List items; - - const SwipePicker({super.key, required this.side, required this.items}); - - @override - Widget build(BuildContext context) { - return Material( - child: Card( - child: Padding( - padding: const EdgeInsets.only( - left: 1, - top: 1, - bottom: 0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (side == SwipePickerSide.left && items.isNotEmpty) - SizedBox( - width: 100, - height: 65, - child: Material( - color: items[0].value.getColor(context), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - child: InkWell( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => BottomSheetListPicker( - title: items[0].label, - items: items[0].options, - onSelect: (value) async { - items[0].onChanged(value); - }, - previouslySelected: items[0].value, - ), - ); - }, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: Icon( - items[0].value.getIcon(), - semanticLabel: 'Short swipe right, ${items[0].value.label}', - ), - ), - const Align( - alignment: Alignment.bottomRight, - child: Icon( - Icons.keyboard_arrow_right_rounded, - size: 20, - ), - ), - ], - ), - ), - ), - ), - if (side == SwipePickerSide.left && items.length >= 2) - SizedBox( - width: 100, - height: 65, - child: Material( - color: items[1].value.getColor(context), - child: InkWell( - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => BottomSheetListPicker( - title: items[1].label, - items: items[1].options, - onSelect: (value) async { - items[1].onChanged(value); - }, - previouslySelected: items[1].value, - ), - ); - }, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: Icon( - items[1].value.getIcon(), - semanticLabel: 'Long swipe right, ${items[1].value.label}', - ), - ), - const Align( - alignment: Alignment.bottomRight, - child: Icon( - Icons.keyboard_double_arrow_right_rounded, - size: 20, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: Container( - height: 65, - decoration: const BoxDecoration(), - child: const PostPlaceholder(), - ), - ), - if (side == SwipePickerSide.right && items.length >= 2) - SizedBox( - width: 100, - height: 65, - child: Material( - color: items[1].value.getColor(context), - child: InkWell( - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => BottomSheetListPicker( - title: items[1].label, - items: items[1].options, - onSelect: (value) async { - items[1].onChanged(value); - }, - previouslySelected: items[1].value, - ), - ); - }, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: Icon( - items[1].value.getIcon(), - semanticLabel: 'Long swipe left, ${items[1].value.label}', - ), - ), - const Align( - alignment: Alignment.bottomLeft, - child: Icon( - Icons.keyboard_double_arrow_left_rounded, - size: 20, - ), - ), - ], - ), - ), - ), - ), - if (side == SwipePickerSide.right && items.isNotEmpty) - SizedBox( - width: 100, - height: 65, - child: Material( - color: items[0].value.getColor(context), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - child: InkWell( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => BottomSheetListPicker( - title: items[0].label, - items: items[0].options, - onSelect: (value) async { - items[0].onChanged(value); - }, - previouslySelected: items[0].value, - ), - ); - }, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: Icon( - items[0].value.getIcon(), - semanticLabel: 'Short swipe left, ${items[0].value.label}', - ), - ), - const Align( - alignment: Alignment.bottomLeft, - child: Icon( - Icons.keyboard_arrow_left_rounded, - size: 20, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/settings/settings.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem; + +enum SwipePickerSide { left, right } + +class SwipePickerItem { + String label; + List> options; + SwipeAction value; + final void Function(ListPickerItem) onChanged; + + SwipePickerItem({ + required this.label, + required this.options, + required this.value, + required this.onChanged, + }); +} + +class SwipePicker extends StatelessWidget { + final SwipePickerSide side; + final List items; + + const SwipePicker({super.key, required this.side, required this.items}); + + @override + Widget build(BuildContext context) { + return Material( + child: Card( + child: Padding( + padding: const EdgeInsets.only( + left: 1, + top: 1, + bottom: 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (side == SwipePickerSide.left && items.isNotEmpty) + SizedBox( + width: 100, + height: 65, + child: Material( + color: items[0].value.getColor(context), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + child: InkWell( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => BottomSheetListPicker( + title: items[0].label, + items: items[0].options, + onSelect: (value) async { + items[0].onChanged(value); + }, + previouslySelected: items[0].value, + ), + ); + }, + child: Stack( + children: [ + Align( + alignment: Alignment.center, + child: Icon( + items[0].value.getIcon(), + semanticLabel: 'Short swipe right, ${items[0].value.label}', + ), + ), + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.keyboard_arrow_right_rounded, + size: 20, + ), + ), + ], + ), + ), + ), + ), + if (side == SwipePickerSide.left && items.length >= 2) + SizedBox( + width: 100, + height: 65, + child: Material( + color: items[1].value.getColor(context), + child: InkWell( + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => BottomSheetListPicker( + title: items[1].label, + items: items[1].options, + onSelect: (value) async { + items[1].onChanged(value); + }, + previouslySelected: items[1].value, + ), + ); + }, + child: Stack( + children: [ + Align( + alignment: Alignment.center, + child: Icon( + items[1].value.getIcon(), + semanticLabel: 'Long swipe right, ${items[1].value.label}', + ), + ), + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.keyboard_double_arrow_right_rounded, + size: 20, + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: Container( + height: 65, + decoration: const BoxDecoration(), + child: const PostPlaceholder(), + ), + ), + if (side == SwipePickerSide.right && items.length >= 2) + SizedBox( + width: 100, + height: 65, + child: Material( + color: items[1].value.getColor(context), + child: InkWell( + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => BottomSheetListPicker( + title: items[1].label, + items: items[1].options, + onSelect: (value) async { + items[1].onChanged(value); + }, + previouslySelected: items[1].value, + ), + ); + }, + child: Stack( + children: [ + Align( + alignment: Alignment.center, + child: Icon( + items[1].value.getIcon(), + semanticLabel: 'Long swipe left, ${items[1].value.label}', + ), + ), + const Align( + alignment: Alignment.bottomLeft, + child: Icon( + Icons.keyboard_double_arrow_left_rounded, + size: 20, + ), + ), + ], + ), + ), + ), + ), + if (side == SwipePickerSide.right && items.isNotEmpty) + SizedBox( + width: 100, + height: 65, + child: Material( + color: items[0].value.getColor(context), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + child: InkWell( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => BottomSheetListPicker( + title: items[0].label, + items: items[0].options, + onSelect: (value) async { + items[0].onChanged(value); + }, + previouslySelected: items[0].value, + ), + ); + }, + child: Stack( + children: [ + Align( + alignment: Alignment.center, + child: Icon( + items[0].value.getIcon(), + semanticLabel: 'Short swipe left, ${items[0].value.label}', + ), + ), + const Align( + alignment: Alignment.bottomLeft, + child: Icon( + Icons.keyboard_arrow_left_rounded, + size: 20, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/settings/presentation/widgets/toggle_option.dart b/lib/src/features/settings/presentation/widgets/toggle_option.dart index dd6ab021a..aeb980d08 100644 --- a/lib/src/features/settings/presentation/widgets/toggle_option.dart +++ b/lib/src/features/settings/presentation/widgets/toggle_option.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:smooth_highlight/smooth_highlight.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/settings/settings.dart'; class ToggleOption extends StatelessWidget { diff --git a/lib/src/features/settings/settings.dart b/lib/src/features/settings/settings.dart index 0edab316c..9a741c79a 100644 --- a/lib/src/features/settings/settings.dart +++ b/lib/src/features/settings/settings.dart @@ -1,3 +1,8 @@ +export 'application/state/gesture_preferences_cubit/gesture_preferences_cubit.dart'; +export 'application/state/theme_preferences_cubit/theme_preferences_cubit.dart'; +export 'application/state/video_preferences_cubit/video_preferences_cubit.dart'; +export 'domain/full_name.dart'; +export 'domain/swipe_action.dart'; export 'presentation/pages/pages.dart'; export 'presentation/widgets/widgets.dart'; -export 'presentation/utils/utils.dart'; +export 'presentation/utils/setting_link_utils.dart'; diff --git a/lib/src/features/user/api.dart b/lib/src/features/user/api.dart new file mode 100644 index 000000000..00db20287 --- /dev/null +++ b/lib/src/features/user/api.dart @@ -0,0 +1 @@ +export 'user.dart'; diff --git a/lib/src/features/user/data/models/user_label.dart b/lib/src/features/user/data/models/user_label.dart index eddce7ce0..d89108ec8 100644 --- a/lib/src/features/user/data/models/user_label.dart +++ b/lib/src/features/user/data/models/user_label.dart @@ -1,115 +1,114 @@ -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; - -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/main.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; - -/// Represents a UserLabel, which is used to associate a textual description along with a Lemmy user. -/// Contains helper methods to load/save corresponding objects in the database. -class UserLabel { - /// The ID of the object in the database (should never need to be set explicitly). - final String id; - - /// The username of the user being labeled (in the form user@instance.tld). - /// Use [usernameFromParts] to consistently generate this. - final String username; - - /// The label which is being applied to the user. - final String label; - - const UserLabel({ - required this.id, - required this.username, - required this.label, - }); - - UserLabel copyWith({String? id}) => UserLabel( - id: id ?? this.id, - username: username, - label: label, - ); - - static Future upsertUserLabel(UserLabel userLabel) async { - try { - // Check if the userLabel with the given username already exists - final existingUserLabel = await (database.select(database.userLabels)..where((t) => t.username.equals(userLabel.username))).getSingleOrNull(); - - if (existingUserLabel == null) { - // Insert new userLabel if it doesn't exist - int id = await database.into(database.userLabels).insert( - UserLabelsCompanion.insert( - username: userLabel.username, - label: userLabel.label, - ), - ); - return userLabel.copyWith(id: id.toString()); - } else { - // Update existing userLabel if it exists - await database.update(database.userLabels).replace( - UserLabelsCompanion( - id: Value(existingUserLabel.id), - username: Value(userLabel.username), - label: Value(userLabel.label), - ), - ); - return userLabel.copyWith(id: existingUserLabel.id.toString()); - } - } catch (e) { - debugPrint(e.toString()); - return null; - } - } - - static Future fetchUserLabel(String username) async { - if (username.isEmpty) return null; - - try { - return await (database.select(database.userLabels)..where((t) => t.username.equals(username))).getSingleOrNull().then((userLabel) { - if (userLabel == null) return null; - return UserLabel( - id: userLabel.id.toString(), - username: userLabel.username, - label: userLabel.label, - ); - }); - } catch (e) { - debugPrint(e.toString()); - return null; - } - } - - static Future deleteUserLabel(String username) async { - try { - await (database.delete(database.userLabels)..where((t) => t.username.equals(username))).go(); - } catch (e) { - debugPrint(e.toString()); - } - } - - static Future> fetchAllUserLabels() async { - try { - final userLabelRows = await database.select(database.userLabels).get(); - return userLabelRows - .map((userLabel) => UserLabel( - id: userLabel.id.toString(), - username: userLabel.username, - label: userLabel.label, - )) - .toList(); - } catch (e) { - debugPrint(e.toString()); - return []; - } - } - - /// Generates a username string that can be used to uniquely identify entries in the UserLabels table - static String usernameFromParts(String username, String actorId) { - return '$username@${fetchInstanceNameFromUrl(actorId)}'; - } - - /// Splits a username generated by [usernameFromParts] back into name and instance - static ({String username, String instance}) partsFromUsername(String username) { - return (username: username.split('@')[0], instance: username.split('@')[1]); - } -} +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; + +/// Represents a UserLabel, which is used to associate a textual description along with a Lemmy user. +/// Contains helper methods to load/save corresponding objects in the database. +class UserLabel { + /// The ID of the object in the database (should never need to be set explicitly). + final String id; + + /// The username of the user being labeled (in the form user@instance.tld). + /// Use [usernameFromParts] to consistently generate this. + final String username; + + /// The label which is being applied to the user. + final String label; + + const UserLabel({ + required this.id, + required this.username, + required this.label, + }); + + UserLabel copyWith({String? id}) => UserLabel( + id: id ?? this.id, + username: username, + label: label, + ); + + static Future upsertUserLabel(UserLabel userLabel) async { + try { + // Check if the userLabel with the given username already exists + final existingUserLabel = await (database.select(database.userLabels)..where((t) => t.username.equals(userLabel.username))).getSingleOrNull(); + + if (existingUserLabel == null) { + // Insert new userLabel if it doesn't exist + int id = await database.into(database.userLabels).insert( + UserLabelsCompanion.insert( + username: userLabel.username, + label: userLabel.label, + ), + ); + return userLabel.copyWith(id: id.toString()); + } else { + // Update existing userLabel if it exists + await database.update(database.userLabels).replace( + UserLabelsCompanion( + id: Value(existingUserLabel.id), + username: Value(userLabel.username), + label: Value(userLabel.label), + ), + ); + return userLabel.copyWith(id: existingUserLabel.id.toString()); + } + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + static Future fetchUserLabel(String username) async { + if (username.isEmpty) return null; + + try { + return await (database.select(database.userLabels)..where((t) => t.username.equals(username))).getSingleOrNull().then((userLabel) { + if (userLabel == null) return null; + return UserLabel( + id: userLabel.id.toString(), + username: userLabel.username, + label: userLabel.label, + ); + }); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + static Future deleteUserLabel(String username) async { + try { + await (database.delete(database.userLabels)..where((t) => t.username.equals(username))).go(); + } catch (e) { + debugPrint(e.toString()); + } + } + + static Future> fetchAllUserLabels() async { + try { + final userLabelRows = await database.select(database.userLabels).get(); + return userLabelRows + .map((userLabel) => UserLabel( + id: userLabel.id.toString(), + username: userLabel.username, + label: userLabel.label, + )) + .toList(); + } catch (e) { + debugPrint(e.toString()); + return []; + } + } + + /// Generates a username string that can be used to uniquely identify entries in the UserLabels table + static String usernameFromParts(String username, String actorId) { + return '$username@${fetchInstanceNameFromUrl(actorId)}'; + } + + /// Splits a username generated by [usernameFromParts] back into name and instance + static ({String username, String instance}) partsFromUsername(String username) { + return (username: username.split('@')[0], instance: username.split('@')[1]); + } +} diff --git a/lib/src/features/user/data/repositories/user_repository.dart b/lib/src/features/user/data/repositories/user_repository.dart index e5a2ad60d..22567c529 100644 --- a/lib/src/features/user/data/repositories/user_repository.dart +++ b/lib/src/features/user/data/repositories/user_repository.dart @@ -2,12 +2,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/network/api_client_factory.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/user/user.dart'; /// Interface for a user repository abstract class UserRepository { @@ -19,6 +17,7 @@ abstract class UserRepository { int? page, int? limit, bool? saved, + bool? includeContent, }); /// Blocks or unblocks a person @@ -46,6 +45,7 @@ class UserRepositoryImpl implements UserRepository { int? page, int? limit, bool? saved, + bool? includeContent, }) async { final response = await _api.getUser( userId: userId, @@ -54,6 +54,7 @@ class UserRepositoryImpl implements UserRepository { page: page, limit: limit, saved: saved, + includeContent: includeContent, ); return { diff --git a/lib/src/features/user/domain/utils/user_media_utils.dart b/lib/src/features/user/domain/utils/user_media_utils.dart new file mode 100644 index 000000000..d53ce6521 --- /dev/null +++ b/lib/src/features/user/domain/utils/user_media_utils.dart @@ -0,0 +1,25 @@ +import 'package:thunder/src/features/post/post.dart'; + +List> removeImageByAlias({ + required List> images, + required String alias, +}) { + final updated = List>.from(images); + updated.removeWhere( + (localImageView) => localImageView['local_image']['pictrs_alias'] == alias, + ); + return updated; +} + +List mergeUniquePosts({ + required List primary, + required List secondary, +}) { + final merged = List.from(primary); + merged.addAll( + secondary.where( + (candidate) => !merged.any((existing) => existing.id == candidate.id), + ), + ); + return merged; +} diff --git a/lib/src/features/user/presentation/pages/media_management_page.dart b/lib/src/features/user/presentation/pages/media_management_page.dart index 1cfac731e..9ec35fa2c 100644 --- a/lib/src/features/user/presentation/pages/media_management_page.dart +++ b/lib/src/features/user/presentation/pages/media_management_page.dart @@ -1,303 +1,303 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import 'package:extended_image/extended_image.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/image_caching_mode.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; - -class MediaManagementPage extends StatelessWidget { - const MediaManagementPage({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - final dateFormat = context.select((cubit) => cubit.state.dateFormat); - final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); - final imageCachingMode = context.select((cubit) => cubit.state.imageCachingMode); - - return BlocBuilder( - builder: (context, state) { - if (state.status == UserSettingsStatus.failedListingMedia && state.errorMessage?.isNotEmpty == true) { - showSnackbar( - state.errorMessage!, - trailingIcon: Icons.refresh_rounded, - trailingAction: () => context.read().add(const ListMediaEvent()), - ); - } - - return Scaffold( - body: Container( - color: theme.colorScheme.surface, - child: SafeArea( - top: false, - child: CustomScrollView( - slivers: [ - SliverAppBar( - pinned: true, - toolbarHeight: APP_BAR_HEIGHT, - title: ListTile( - title: Text( - l10n.manageMedia, - style: theme.textTheme.titleLarge, - ), - subtitle: UserFullNameWidget( - context, - context.read().state.account.username, - context.read().state.account.displayName, - context.read().state.account.instance, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 0), - ), - ), - if (state.status == UserSettingsStatus.listingMedia) - const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(), - ), - ), - if (state.status == UserSettingsStatus.searchingMedia || - state.status == UserSettingsStatus.succeededSearchingMedia || - state.status == UserSettingsStatus.deletingMedia || - state.status == UserSettingsStatus.failedListingMedia || - state.status == UserSettingsStatus.succeededListingMedia) ...[ - if (state.images?.isNotEmpty == true) - SliverList.builder( - addSemanticIndexes: false, - addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: state.images!.length, - itemBuilder: (context, index) { - final account = context.read().state.account; - String url = 'https://${account.instance}/pictrs/image/${state.images![index]['local_image']['pictrs_alias']}'; - - return KeepAlive( - keepAlive: true, - child: Card( - elevation: 2, - clipBehavior: Clip.hardEdge, - child: Column( - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: Stack( - children: [ - ExtendedImage.network( - url, - cache: true, - clearMemoryCacheWhenDispose: imageCachingMode == ImageCachingMode.relaxed, - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.loading) { - return SizedBox( - width: double.infinity, - child: Align( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text(l10n.loading), - ), - ), - ); - } - if (state.extendedImageLoadState == LoadState.failed) { - return SizedBox( - width: double.infinity, - child: Align( - alignment: Alignment.center, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - l10n.unableToLoadImageFrom(account.instance), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5), - ), - ), - ), - ), - ); - } - return null; - }, - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => showImageViewer(context, url: url), - ), - ), - ), - ], - ), - ), - Row( - children: [ - const SizedBox(width: 12), - Text(l10n.uploadedDate(dateFormat?.format(DateTime.parse(state.images![index]['local_image']['published']).toLocal()) ?? '')), - const Spacer(), - IconButton( - onPressed: () async { - final UserSettingsBloc userSettingsBloc = context.read(); - userSettingsBloc.add(FindMediaUsagesEvent(id: state.images![index]['local_image']['pictrs_alias'])); - - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: false, - builder: (context) { - return AnimatedSize( - duration: const Duration(milliseconds: 250), - child: BlocProvider.value( - value: userSettingsBloc, - child: BlocBuilder( - builder: (context, state) { - if (state.status == UserSettingsStatus.failedListingMedia) { - Navigator.of(context).pop(); - } - - final account = context.read().state.account; - - return SingleChildScrollView( - child: Column( - children: [ - if (state.status == UserSettingsStatus.searchingMedia) - const SizedBox( - height: 200, - child: Center( - child: CircularProgressIndicator(), - ), - ) - else if (state.status == UserSettingsStatus.succeededSearchingMedia) ...[ - if (state.imageSearchPosts?.isNotEmpty == true) - BlocProvider.value( - value: FeedBloc(account: account), - child: CustomScrollView( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - slivers: [ - FeedPostCardList( - posts: state.imageSearchPosts!, - tabletMode: false, - markPostReadOnScroll: false, - disableSwiping: true, - indicateRead: false, - ) - ], - ), - ), - if (state.imageSearchComments?.isNotEmpty == true) - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.imageSearchComments!.length, - itemBuilder: (context, index) => CommentListEntry(comment: state.imageSearchComments![index]), - ), - ], - if (state.status == UserSettingsStatus.succeededSearchingMedia && - state.imageSearchComments?.isNotEmpty != true && - state.imageSearchPosts?.isNotEmpty != true) - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Container( - color: theme.dividerColor.withValues(alpha: 0.1), - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( - l10n.noReferencesToImage, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - fontScale: metadataFontSizeScale, - ), - ), - ), - ), - if (state.status == UserSettingsStatus.succeededSearchingMedia && - (state.imageSearchComments?.isNotEmpty == true || state.imageSearchPosts?.isNotEmpty == true)) - const SizedBox(height: 50), - ], - ), - ); - }, - ), - ), - ); - }, - ); - }, - icon: const Icon(Icons.search_rounded), - ), - IconButton( - onPressed: () async { - bool result = false; - await showThunderDialog( - context: context, - title: l10n.deleteImageConfirmTitle, - contentText: l10n.deleteImageConfirmMessage, - onSecondaryButtonPressed: (dialogContext) { - result = false; - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onPrimaryButtonPressed: (dialogContext, _) { - result = true; - Navigator.of(dialogContext).pop(); - }, - primaryButtonText: l10n.delete, - ); - - if (result && context.mounted) { - context.read().add( - DeleteMediaEvent(deleteToken: state.images![index]['local_image']['pictrs_delete_token'], id: state.images![index]['local_image']['pictrs_alias'])); - } - }, - icon: const Icon(Icons.delete_forever), - ), - ], - ), - ], - ), - ), - ); - }, - ), - if (state.images?.isNotEmpty != true) - SliverToBoxAdapter( - child: Container( - color: theme.dividerColor.withValues(alpha: 0.1), - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: ScalableText( - l10n.noImages, - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - fontScale: metadataFontSizeScale, - ), - ), - ), - ] - ], - ), - ), - ), - ); - }, - ); - } -} +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:extended_image/extended_image.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/wiring/state_factories.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/feed/feed.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar, showThunderDialog; + +class MediaManagementPage extends StatelessWidget { + const MediaManagementPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + final dateFormat = context.select((cubit) => cubit.state.dateFormat); + final metadataFontSizeScale = context.select((cubit) => cubit.state.metadataFontSizeScale); + final imageCachingMode = context.select((cubit) => cubit.state.imageCachingMode); + + return BlocBuilder( + builder: (context, state) { + if (state.status == UserSettingsStatus.failedListingMedia && state.errorMessage?.isNotEmpty == true) { + showSnackbar( + state.errorMessage!, + trailingIcon: Icons.refresh_rounded, + trailingAction: () => context.read().add(const ListMediaEvent()), + ); + } + + return Scaffold( + body: Container( + color: theme.colorScheme.surface, + child: SafeArea( + top: false, + child: CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + toolbarHeight: APP_BAR_HEIGHT, + title: ListTile( + title: Text( + l10n.manageMedia, + style: theme.textTheme.titleLarge, + ), + subtitle: UserFullNameWidget( + context, + context.read().state.account.username, + context.read().state.account.displayName, + context.read().state.account.instance, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 0), + ), + ), + if (state.status == UserSettingsStatus.listingMedia) + const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ), + if (state.status == UserSettingsStatus.searchingMedia || + state.status == UserSettingsStatus.succeededSearchingMedia || + state.status == UserSettingsStatus.deletingMedia || + state.status == UserSettingsStatus.failedListingMedia || + state.status == UserSettingsStatus.succeededListingMedia) ...[ + if (state.images?.isNotEmpty == true) + SliverList.builder( + addSemanticIndexes: false, + addAutomaticKeepAlives: false, + addRepaintBoundaries: false, + itemCount: state.images!.length, + itemBuilder: (context, index) { + final account = context.read().state.account; + String url = 'https://${account.instance}/pictrs/image/${state.images![index]['local_image']['pictrs_alias']}'; + + return KeepAlive( + keepAlive: true, + child: Card( + elevation: 2, + clipBehavior: Clip.hardEdge, + child: Column( + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: Stack( + children: [ + ExtendedImage.network( + url, + cache: true, + clearMemoryCacheWhenDispose: imageCachingMode == ImageCachingMode.relaxed, + loadStateChanged: (state) { + if (state.extendedImageLoadState == LoadState.loading) { + return SizedBox( + width: double.infinity, + child: Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(l10n.loading), + ), + ), + ); + } + if (state.extendedImageLoadState == LoadState.failed) { + return SizedBox( + width: double.infinity, + child: Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + l10n.unableToLoadImageFrom(account.instance), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + ), + ), + ), + ), + ); + } + return null; + }, + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showImageViewer(context, url: url), + ), + ), + ), + ], + ), + ), + Row( + children: [ + const SizedBox(width: 12), + Text(l10n.uploadedDate(dateFormat?.format(DateTime.parse(state.images![index]['local_image']['published']).toLocal()) ?? '')), + const Spacer(), + IconButton( + onPressed: () async { + final UserSettingsBloc userSettingsBloc = context.read(); + userSettingsBloc.add(FindMediaUsagesEvent(id: state.images![index]['local_image']['pictrs_alias'])); + + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: false, + builder: (context) { + return AnimatedSize( + duration: const Duration(milliseconds: 250), + child: BlocProvider.value( + value: userSettingsBloc, + child: BlocBuilder( + builder: (context, state) { + if (state.status == UserSettingsStatus.failedListingMedia) { + Navigator.of(context).pop(); + } + + final account = context.read().state.account; + + return SingleChildScrollView( + child: Column( + children: [ + if (state.status == UserSettingsStatus.searchingMedia) + const SizedBox( + height: 200, + child: Center( + child: CircularProgressIndicator(), + ), + ) + else if (state.status == UserSettingsStatus.succeededSearchingMedia) ...[ + if (state.imageSearchPosts?.isNotEmpty == true) + BlocProvider.value( + value: createFeedBloc(account), + child: CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + slivers: [ + FeedPostCardList( + posts: state.imageSearchPosts!, + tabletMode: false, + markPostReadOnScroll: false, + disableSwiping: true, + indicateRead: false, + ) + ], + ), + ), + if (state.imageSearchComments?.isNotEmpty == true) + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: state.imageSearchComments!.length, + itemBuilder: (context, index) => CommentListEntry(comment: state.imageSearchComments![index]), + ), + ], + if (state.status == UserSettingsStatus.succeededSearchingMedia && + state.imageSearchComments?.isNotEmpty != true && + state.imageSearchPosts?.isNotEmpty != true) + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Container( + color: theme.dividerColor.withValues(alpha: 0.1), + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: ScalableText( + l10n.noReferencesToImage, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + fontScale: metadataFontSizeScale, + ), + ), + ), + ), + if (state.status == UserSettingsStatus.succeededSearchingMedia && + (state.imageSearchComments?.isNotEmpty == true || state.imageSearchPosts?.isNotEmpty == true)) + const SizedBox(height: 50), + ], + ), + ); + }, + ), + ), + ); + }, + ); + }, + icon: const Icon(Icons.search_rounded), + ), + IconButton( + onPressed: () async { + bool result = false; + await showThunderDialog( + context: context, + title: l10n.deleteImageConfirmTitle, + contentText: l10n.deleteImageConfirmMessage, + onSecondaryButtonPressed: (dialogContext) { + result = false; + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onPrimaryButtonPressed: (dialogContext, _) { + result = true; + Navigator.of(dialogContext).pop(); + }, + primaryButtonText: l10n.delete, + ); + + if (result && context.mounted) { + context.read().add( + DeleteMediaEvent(deleteToken: state.images![index]['local_image']['pictrs_delete_token'], id: state.images![index]['local_image']['pictrs_alias'])); + } + }, + icon: const Icon(Icons.delete_forever), + ), + ], + ), + ], + ), + ), + ); + }, + ), + if (state.images?.isNotEmpty != true) + SliverToBoxAdapter( + child: Container( + color: theme.dividerColor.withValues(alpha: 0.1), + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: ScalableText( + l10n.noImages, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall, + fontScale: metadataFontSizeScale, + ), + ), + ), + ] + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/user/presentation/pages/user_settings_block_page.dart b/lib/src/features/user/presentation/pages/user_settings_block_page.dart index 3d7801db6..02b0acd2b 100644 --- a/lib/src/features/user/presentation/pages/user_settings_block_page.dart +++ b/lib/src/features/user/presentation/pages/user_settings_block_page.dart @@ -4,18 +4,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; + +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; /// A widget that displays the user's blocked users, communities, and instances. class UserSettingsBlockPage extends StatefulWidget { diff --git a/lib/src/features/user/presentation/pages/user_settings_page.dart b/lib/src/features/user/presentation/pages/user_settings_page.dart index 476ec78cb..137aada89 100644 --- a/lib/src/features/user/presentation/pages/user_settings_page.dart +++ b/lib/src/features/user/presentation/pages/user_settings_page.dart @@ -1,625 +1,618 @@ -import "dart:async"; -import "dart:convert"; -import "dart:io"; - -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -import "package:flutter_bloc/flutter_bloc.dart"; -import "package:flutter_file_dialog/flutter_file_dialog.dart"; -import "package:html/parser.dart"; -import "package:path_provider/path_provider.dart"; -import 'package:markdown/markdown.dart' hide Text; - -import "package:thunder/src/core/enums/threadiverse_platform.dart"; -import "package:thunder/src/core/enums/post_sort_type.dart"; -import "package:thunder/src/core/models/thunder_local_user.dart"; -import "package:thunder/src/core/models/thunder_my_user.dart"; -import "package:thunder/src/core/models/thunder_site_response.dart"; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import "package:thunder/src/core/enums/enums.dart"; -import "package:thunder/src/core/enums/local_settings.dart"; -import "package:thunder/src/features/settings/settings.dart"; -import "package:thunder/src/shared/dialogs.dart"; -import "package:thunder/src/shared/snackbar.dart"; -import "package:thunder/src/shared/sort_picker.dart"; -import "package:thunder/src/app/widgets/thunder_icons.dart"; -import "package:thunder/src/features/user/user.dart"; -import "package:thunder/src/shared/utils/bottom_sheet_list_picker.dart"; -import "package:thunder/src/shared/utils/constants.dart"; -import "package:thunder/src/shared/utils/error_messages.dart"; -import "package:thunder/src/app/utils/global_context.dart"; -import "package:thunder/src/shared/utils/links.dart"; -import "package:thunder/src/app/utils/navigation.dart"; - -/// A widget that displays the user's account settings. These settings are synchronized with the instance and should be preferred over the app settings. -class UserSettingsPage extends StatefulWidget { - /// The setting to be highlighted when searching - final LocalSettings? settingToHighlight; - - const UserSettingsPage({super.key, this.settingToHighlight}); - - @override - State createState() => _UserSettingsPageState(); -} - -class _UserSettingsPageState extends State { - /// Text controller for the user's display name - TextEditingController displayNameTextController = TextEditingController(); - - /// Text controller for the profile bio - TextEditingController bioTextController = TextEditingController(); - - /// Text controller for the user's email - TextEditingController emailTextController = TextEditingController(); - - /// Text controller for the user's matrix id - TextEditingController matrixUserTextController = TextEditingController(); - - GlobalKey settingToHighlightKey = GlobalKey(); - LocalSettings? settingToHighlight; - - @override - void initState() { - super.initState(); - context.read().add(const GetUserSettingsEvent()); - - if (widget.settingToHighlight != null) { - setState(() => settingToHighlight = widget.settingToHighlight); - - // Need some delay to finish building, even though we're in a post-frame callback. - Timer(const Duration(milliseconds: 500), () { - if (settingToHighlightKey.currentContext != null) { - // Ensure that the selected setting is visible on the screen - Scrollable.ensureVisible( - settingToHighlightKey.currentContext!, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - ); - } - - // Give time for the highlighting to appear, then turn it off - Timer(const Duration(seconds: 1), () { - setState(() => settingToHighlight = null); - }); - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = GlobalContext.l10n; - - final account = context.read().state.account; - - // TODO: Add support for Piefed account settings - if (account.platform == ThreadiversePlatform.piefed) { - return Scaffold( - appBar: AppBar(title: Text(l10n.accountSettings)), - body: const Center(child: Text("This feature is not yet available.")), - ); - } - - return PopScope( - onPopInvokedWithResult: (didPop, result) { - if (didPop) context.read().add(FetchProfileSettings()); - }, - child: Scaffold( - body: SafeArea( - top: false, - child: BlocListener( - listener: (context, state) { - if (!context.mounted) return; - context.read().add(const ResetUserSettingsEvent()); - context.read().add(const GetUserSettingsEvent()); - }, - child: BlocConsumer( - listener: (context, state) { - if (state.status == UserSettingsStatus.failure) { - showSnackbar(state.errorMessage ?? l10n.unexpectedError); - } - }, - builder: (context, state) { - ThunderSiteResponse? siteResponse = state.siteResponse; - - ThunderMyUser? myUser = siteResponse?.myUser; - ThunderLocalUser? localUser = myUser?.localUserView.localUser; - ThunderUser? person = myUser?.localUserView.person; - - return CustomScrollView( - physics: state.status == UserSettingsStatus.notLoggedIn ? const NeverScrollableScrollPhysics() : null, - slivers: [ - SliverAppBar( - pinned: true, - floating: true, - centerTitle: false, - toolbarHeight: APP_BAR_HEIGHT, - title: Text(l10n.accountSettings), - actions: [ - IconButton( - icon: const Icon(Icons.people_alt_rounded), - onPressed: () => showProfileModalSheet(context), - ), - ], - ), - switch (state.status) { - UserSettingsStatus.notLoggedIn => const SliverFillRemaining(hasScrollBody: false, child: AccountPlaceholder()), - UserSettingsStatus.initial => const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: CircularProgressIndicator(), - ), - ), - _ => SliverList.list( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const UserIndicator(), - IconButton( - icon: const Icon(Icons.logout_rounded), - onPressed: () => showProfileModalSheet(context, showLogoutDialog: true), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(top: 0, bottom: 8.0, left: 16.0, right: 16.0), - child: Text( - l10n.userSettingDescription, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - color: theme.colorScheme.onSurface.withValues(alpha: 0.75), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text(l10n.general, style: theme.textTheme.titleMedium), - ), - SettingsListTile( - icon: Icons.person_rounded, - description: l10n.displayName, - subtitle: person?.displayName?.isNotEmpty == true ? person?.displayName : l10n.noDisplayNameSet, - widget: const Padding(padding: EdgeInsets.all(20.0)), - onTap: () { - displayNameTextController.text = person?.displayName ?? ""; - showThunderDialog( - context: context, - title: l10n.displayName, - contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( - controller: displayNameTextController, - decoration: InputDecoration(hintText: l10n.displayName), - ), - primaryButtonText: l10n.save, - onPrimaryButtonPressed: (dialogContext, _) { - context.read().add(UpdateUserSettingsEvent(displayName: displayNameTextController.text)); - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountDisplayName, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.note_rounded, - description: l10n.profileBio, - subtitle: person?.bio?.isNotEmpty == true ? parse(markdownToHtml(person?.bio ?? "")).documentElement?.text.trim() : l10n.noProfileBioSet, - subtitleMaxLines: 1, - widget: const Padding(padding: EdgeInsets.all(20.0)), - onTap: () { - bioTextController.text = person?.bio ?? ""; - showThunderDialog( - context: context, - title: l10n.profileBio, - contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( - controller: bioTextController, - minLines: 8, - maxLines: 8, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: l10n.profileBio, - ), - ), - primaryButtonText: l10n.save, - onPrimaryButtonPressed: (dialogContext, _) { - context.read().add(UpdateUserSettingsEvent(bio: bioTextController.text)); - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountProfileBio, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.email_rounded, - description: l10n.email, - subtitle: localUser?.email?.isNotEmpty == true ? localUser?.email : l10n.noEmailSet, - widget: const Padding(padding: EdgeInsets.all(20.0)), - onTap: () { - emailTextController.text = localUser?.email ?? ""; - showThunderDialog( - context: context, - title: l10n.email, - contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( - controller: emailTextController, - decoration: InputDecoration(hintText: l10n.email), - keyboardType: TextInputType.emailAddress, - ), - primaryButtonText: l10n.save, - onPrimaryButtonPressed: (dialogContext, _) { - context.read().add(UpdateUserSettingsEvent(email: emailTextController.text)); - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountEmail, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.person_rounded, - description: l10n.matrixUser, - subtitle: person?.matrixUserId?.isNotEmpty == true ? person?.matrixUserId : l10n.noMatrixUserSet, - widget: const Padding(padding: EdgeInsets.all(20.0)), - onTap: () { - matrixUserTextController.text = person?.matrixUserId ?? ""; - showThunderDialog( - context: context, - title: l10n.matrixUser, - contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( - controller: matrixUserTextController, - decoration: const InputDecoration(hintText: "@user:instance"), - ), - primaryButtonText: l10n.save, - onPrimaryButtonPressed: (dialogContext, _) { - context.read().add(UpdateUserSettingsEvent(matrixUserId: matrixUserTextController.text)); - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountMatrixUser, - highlightedSetting: settingToHighlight, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text(l10n.feedSettings, style: theme.textTheme.titleMedium), - ), - Padding( - padding: const EdgeInsets.only(top: 0, bottom: 8.0, left: 16.0, right: 16.0), - child: Text( - l10n.settingOverrideLabel, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w400, - color: theme.colorScheme.onSurface.withValues(alpha: 0.75), - ), - ), - ), - ListOption( - description: l10n.defaultFeedType, - value: ListPickerItem(label: localUser?.defaultListingType?.value ?? "", icon: Icons.feed, payload: localUser?.defaultListingType), - options: [ - ListPickerItem(icon: Icons.view_list_rounded, label: FeedListType.subscribed.value, payload: FeedListType.subscribed), - ListPickerItem(icon: Icons.home_rounded, label: FeedListType.all.value, payload: FeedListType.all), - ListPickerItem(icon: Icons.grid_view_rounded, label: FeedListType.local.value, payload: FeedListType.local), - ], - icon: Icons.filter_alt_rounded, - onChanged: (value) async => context.read().add(UpdateUserSettingsEvent(defaultFeedListType: value.payload)), - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountDefaultFeedType, - highlightedSetting: settingToHighlight, - ), - ListOption( - description: l10n.defaultFeedSortType, - value: ListPickerItem( - label: localUser?.defaultSortType?.name ?? "", - icon: Icons.local_fire_department_rounded, - payload: localUser?.defaultSortType, - ), - options: [...getDefaultPostSortTypeItems(account: account), ...getTopPostSortTypeItems(account: account)], - icon: Icons.sort_rounded, - onChanged: (_) async {}, - isBottomModalScrollControlled: true, - customListPicker: SortPicker( - account: account, - title: l10n.defaultFeedSortType, - onSelect: (value) async { - context.read().add(UpdateUserSettingsEvent(defaultPostSortType: value.payload)); - }, - previouslySelected: localUser?.defaultSortType, - ), - valueDisplay: Row( - children: [ - Icon(allPostSortTypeItems.firstWhere((item) => item.payload == localUser?.defaultSortType).icon, size: 13), - const SizedBox(width: 4), - Text( - allPostSortTypeItems.firstWhere((item) => item.payload == localUser?.defaultSortType).label, - style: theme.textTheme.titleSmall, - ), - ], - ), - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountDefaultFeedSortType, - highlightedSetting: settingToHighlight, - ), - ToggleOption( - description: l10n.showNsfwContent, - value: localUser?.showNsfw, - iconEnabled: Icons.no_adult_content, - iconDisabled: Icons.no_adult_content, - onToggle: (bool value) => context.read().add(UpdateUserSettingsEvent(showNsfw: value)), - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountShowNsfwContent, - highlightedSetting: settingToHighlight, - ), - ToggleOption( - description: l10n.showScores, - value: localUser?.showScores, - iconEnabled: Icons.onetwothree_rounded, - iconDisabled: Icons.onetwothree_rounded, - onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showScores: value))}, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountShowScores, - highlightedSetting: settingToHighlight, - ), - ToggleOption( - description: l10n.showReadPosts, - value: localUser?.showReadPosts, - iconEnabled: Icons.fact_check_rounded, - iconDisabled: Icons.fact_check_outlined, - onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showReadPosts: value))}, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountShowReadPosts, - highlightedSetting: settingToHighlight, - ), - ToggleOption( - description: l10n.bot, - value: person?.botAccount, - iconEnabled: Thunder.robot, - iconDisabled: Thunder.robot, - iconSpacing: 14.0, - onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(botAccount: value))}, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountIsBot, - highlightedSetting: settingToHighlight, - ), - ToggleOption( - description: l10n.showBotAccounts, - value: localUser?.showBotAccounts, - iconEnabled: Thunder.robot, - iconDisabled: Thunder.robot, - iconSpacing: 14.0, - onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showBotAccounts: value))}, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountShowBotAccounts, - highlightedSetting: settingToHighlight, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text(l10n.contentManagement, style: theme.textTheme.titleMedium), - ), - SettingsListTile( - icon: Icons.language_rounded, - description: l10n.discussionLanguages, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountLanguages), - highlightKey: settingToHighlightKey, - setting: LocalSettings.discussionLanguages, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.block_rounded, - description: l10n.blockSettingLabel, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountBlocks), - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountBlocks, - highlightedSetting: settingToHighlight, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.importExportSettings, style: theme.textTheme.titleMedium), - Text(l10n.importExportLemmyAccountSettingsSubtitle), - ], - ), - ), - SettingsListTile( - icon: Icons.file_download_rounded, - description: l10n.exportLemmyAccountSettingsDescription, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () async { - dynamic exportSettings; - try { - final account = context.read().state.account; - exportSettings = await AccountRepositoryImpl(account: account).exportSettings(); - } catch (e) { - // Catch rate-limit errors - showSnackbar(getExceptionErrorMessage(e)); - return; - } - - try { - final String initialFilePath = (await getApplicationDocumentsDirectory()).path; - // Use the same naming convention as the web UI - String initialFileName = 'lemmy_user_settings_${DateTime.now().toUtc().toIso8601String().replaceAll(":", "").replaceAll("-", "")}.json'; - final filePath = '$initialFilePath/$initialFileName'; - - final File file = File(filePath); - await file.writeAsString(jsonEncode(exportSettings)); - - final String? savedFilePath = await FlutterFileDialog.saveFile( - params: SaveFileDialogParams( - mimeTypesFilter: ['application/json'], - sourceFilePath: filePath, - fileName: initialFileName, - ), - ); - - if (savedFilePath?.isNotEmpty == true) { - showSnackbar(l10n.accountSettingsExportedSuccessfully(savedFilePath!)); - } else { - showSnackbar(l10n.errorSavingAccountSettings); - } - } catch (e) { - showSnackbar('${l10n.errorSavingAccountSettings} $e'); - } - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountExportSettings, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.file_upload_rounded, - description: l10n.importLemmyAccountSettingsDescription, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () async { - String importSettings; - - try { - final filePath = await FlutterFileDialog.pickFile( - params: const OpenFileDialogParams( - fileExtensionsFilter: ['json'], - ), - ); - - if (filePath != null) { - importSettings = await File(filePath).readAsString(); - } else { - showSnackbar(l10n.errorLoadingAccountSettings); - return; - } - } catch (e) { - if (e is FormatException) { - showSnackbar(l10n.errorParsingJson); - } else if ((e as PlatformException?)?.code == "invalid_file_extension") { - showSnackbar(l10n.youMustSelectAJsonFile); - } else { - showSnackbar('${l10n.errorLoadingAccountSettings} $e'); - } - return; - } - - try { - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = context.read().state.account; - final success = await AccountRepositoryImpl(account: account).importSettings(importSettings); - - if (success) { - showSnackbar(l10n.accountSettingsImportedSuccessfully); - - // Reload the current page we're on to reflect changes to account settings - context.read().add(const ResetUserSettingsEvent()); - context.read().add(const GetUserSettingsEvent()); - } else { - showSnackbar(l10n.errorImportingAccountSettings); - } - } catch (e) { - showSnackbar(getExceptionErrorMessage(e)); - } - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountImportSettings, - highlightedSetting: settingToHighlight, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text(l10n.dangerZone, style: theme.textTheme.titleMedium), - ), - SettingsListTile( - icon: Icons.password, - description: l10n.changePassword, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () async { - showThunderDialog( - context: context, - title: l10n.changePassword, - contentText: l10n.changePasswordWarning, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - secondaryButtonText: l10n.cancel, - onPrimaryButtonPressed: (dialogContext, _) async { - if (context.mounted) { - Navigator.of(context).pop(); - final account = context.read().state.account; - - handleLink(context, url: "https://${account.instance}/settings"); - } - }, - primaryButtonText: l10n.confirm, - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountChangePassword, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.delete_forever_rounded, - description: l10n.deleteAccount, - widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), - onTap: () async { - showThunderDialog( - context: context, - title: l10n.deleteAccount, - contentText: l10n.deleteAccountDescription, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - primaryButtonText: l10n.confirm, - onPrimaryButtonPressed: (dialogContext, _) async { - if (context.mounted) { - Navigator.of(context).pop(); - final account = context.read().state.account; - - handleLink(context, url: "https://${account.instance}/settings"); - } - }, - ); - }, - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountDeleteAccount, - highlightedSetting: settingToHighlight, - ), - SettingsListTile( - icon: Icons.hide_image_rounded, - description: l10n.manageMedia, - widget: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), - onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountMedia), - highlightKey: settingToHighlightKey, - setting: LocalSettings.accountManageMedia, - highlightedSetting: settingToHighlight, - ), - const SizedBox(height: 100.0), - ], - ), - } - ], - ); - }, - ), - ), - ), - ), - ); - } -} +import "dart:async"; +import "dart:convert"; +import "dart:io"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_file_dialog/flutter_file_dialog.dart"; +import "package:html/parser.dart"; +import "package:path_provider/path_provider.dart"; +import 'package:markdown/markdown.dart' hide Text; + +import "package:thunder/src/foundation/primitives/models/thunder_local_user.dart"; +import "package:thunder/src/foundation/primitives/models/thunder_site_response.dart"; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/account/account.dart'; +import "package:thunder/src/foundation/primitives/enums/enums.dart"; +import "package:thunder/src/features/settings/settings.dart"; +import "package:thunder/src/shared/sort_picker.dart"; +import "package:thunder/src/features/user/user.dart"; +import "package:thunder/src/foundation/config/app_constants.dart"; +import "package:thunder/src/foundation/networking/error_message_utils.dart"; +import "package:thunder/src/foundation/config/global_context.dart"; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import "package:thunder/src/app/shell/navigation/navigation_utils.dart"; +import 'package:thunder/packages/ui/ui.dart' show ListPickerItem, Thunder, showSnackbar, showThunderDialog; + +/// A widget that displays the user's account settings. These settings are synchronized with the instance and should be preferred over the app settings. +class UserSettingsPage extends StatefulWidget { + /// The setting to be highlighted when searching + final LocalSettings? settingToHighlight; + + const UserSettingsPage({super.key, this.settingToHighlight}); + + @override + State createState() => _UserSettingsPageState(); +} + +class _UserSettingsPageState extends State { + /// Text controller for the user's display name + TextEditingController displayNameTextController = TextEditingController(); + + /// Text controller for the profile bio + TextEditingController bioTextController = TextEditingController(); + + /// Text controller for the user's email + TextEditingController emailTextController = TextEditingController(); + + /// Text controller for the user's matrix id + TextEditingController matrixUserTextController = TextEditingController(); + + GlobalKey settingToHighlightKey = GlobalKey(); + LocalSettings? settingToHighlight; + + @override + void initState() { + super.initState(); + context.read().add(const GetUserSettingsEvent()); + + if (widget.settingToHighlight != null) { + setState(() => settingToHighlight = widget.settingToHighlight); + + // Need some delay to finish building, even though we're in a post-frame callback. + Timer(const Duration(milliseconds: 500), () { + if (settingToHighlightKey.currentContext != null) { + // Ensure that the selected setting is visible on the screen + Scrollable.ensureVisible( + settingToHighlightKey.currentContext!, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + } + + // Give time for the highlighting to appear, then turn it off + Timer(const Duration(seconds: 1), () { + setState(() => settingToHighlight = null); + }); + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + final account = context.read().state.account; + + // TODO: Add support for Piefed account settings + if (account.platform == ThreadiversePlatform.piefed) { + return Scaffold( + appBar: AppBar(title: Text(l10n.accountSettings)), + body: const Center(child: Text("This feature is not yet available.")), + ); + } + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) context.read().add(FetchProfileSettings()); + }, + child: Scaffold( + body: SafeArea( + top: false, + child: BlocListener( + listener: (context, state) { + if (!context.mounted) return; + context.read().add(const ResetUserSettingsEvent()); + context.read().add(const GetUserSettingsEvent()); + }, + child: BlocConsumer( + listener: (context, state) { + if (state.status == UserSettingsStatus.failure) { + showSnackbar(state.errorMessage ?? l10n.unexpectedError); + } + }, + builder: (context, state) { + ThunderSiteResponse? siteResponse = state.siteResponse; + + ThunderMyUser? myUser = siteResponse?.myUser; + ThunderLocalUser? localUser = myUser?.localUserView.localUser; + ThunderUser? person = myUser?.localUserView.person; + + return CustomScrollView( + physics: state.status == UserSettingsStatus.notLoggedIn ? const NeverScrollableScrollPhysics() : null, + slivers: [ + SliverAppBar( + pinned: true, + floating: true, + centerTitle: false, + toolbarHeight: APP_BAR_HEIGHT, + title: Text(l10n.accountSettings), + actions: [ + IconButton( + icon: const Icon(Icons.people_alt_rounded), + onPressed: () => showProfileModalSheet(context), + ), + ], + ), + switch (state.status) { + UserSettingsStatus.notLoggedIn => const SliverFillRemaining(hasScrollBody: false, child: AccountPlaceholder()), + UserSettingsStatus.initial => const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator(), + ), + ), + _ => SliverList.list( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const UserIndicator(), + IconButton( + icon: const Icon(Icons.logout_rounded), + onPressed: () => showProfileModalSheet(context, showLogoutDialog: true), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 0, bottom: 8.0, left: 16.0, right: 16.0), + child: Text( + l10n.userSettingDescription, + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurface.withValues(alpha: 0.75), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text(l10n.general, style: theme.textTheme.titleMedium), + ), + SettingsListTile( + icon: Icons.person_rounded, + description: l10n.displayName, + subtitle: person?.displayName?.isNotEmpty == true ? person?.displayName : l10n.noDisplayNameSet, + widget: const Padding(padding: EdgeInsets.all(20.0)), + onTap: () { + displayNameTextController.text = person?.displayName ?? ""; + showThunderDialog( + context: context, + title: l10n.displayName, + contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( + controller: displayNameTextController, + decoration: InputDecoration(hintText: l10n.displayName), + ), + primaryButtonText: l10n.save, + onPrimaryButtonPressed: (dialogContext, _) { + context.read().add(UpdateUserSettingsEvent(displayName: displayNameTextController.text)); + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountDisplayName, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.note_rounded, + description: l10n.profileBio, + subtitle: person?.bio?.isNotEmpty == true ? parse(markdownToHtml(person?.bio ?? "")).documentElement?.text.trim() : l10n.noProfileBioSet, + subtitleMaxLines: 1, + widget: const Padding(padding: EdgeInsets.all(20.0)), + onTap: () { + bioTextController.text = person?.bio ?? ""; + showThunderDialog( + context: context, + title: l10n.profileBio, + contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( + controller: bioTextController, + minLines: 8, + maxLines: 8, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: l10n.profileBio, + ), + ), + primaryButtonText: l10n.save, + onPrimaryButtonPressed: (dialogContext, _) { + context.read().add(UpdateUserSettingsEvent(bio: bioTextController.text)); + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountProfileBio, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.email_rounded, + description: l10n.email, + subtitle: localUser?.email?.isNotEmpty == true ? localUser?.email : l10n.noEmailSet, + widget: const Padding(padding: EdgeInsets.all(20.0)), + onTap: () { + emailTextController.text = localUser?.email ?? ""; + showThunderDialog( + context: context, + title: l10n.email, + contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( + controller: emailTextController, + decoration: InputDecoration(hintText: l10n.email), + keyboardType: TextInputType.emailAddress, + ), + primaryButtonText: l10n.save, + onPrimaryButtonPressed: (dialogContext, _) { + context.read().add(UpdateUserSettingsEvent(email: emailTextController.text)); + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountEmail, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.person_rounded, + description: l10n.matrixUser, + subtitle: person?.matrixUserId?.isNotEmpty == true ? person?.matrixUserId : l10n.noMatrixUserSet, + widget: const Padding(padding: EdgeInsets.all(20.0)), + onTap: () { + matrixUserTextController.text = person?.matrixUserId ?? ""; + showThunderDialog( + context: context, + title: l10n.matrixUser, + contentWidgetBuilder: (setPrimaryButtonEnabled) => TextField( + controller: matrixUserTextController, + decoration: const InputDecoration(hintText: "@user:instance"), + ), + primaryButtonText: l10n.save, + onPrimaryButtonPressed: (dialogContext, _) { + context.read().add(UpdateUserSettingsEvent(matrixUserId: matrixUserTextController.text)); + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountMatrixUser, + highlightedSetting: settingToHighlight, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text(l10n.feedSettings, style: theme.textTheme.titleMedium), + ), + Padding( + padding: const EdgeInsets.only(top: 0, bottom: 8.0, left: 16.0, right: 16.0), + child: Text( + l10n.settingOverrideLabel, + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurface.withValues(alpha: 0.75), + ), + ), + ), + ListOption( + description: l10n.defaultFeedType, + value: ListPickerItem(label: localUser?.defaultListingType?.value ?? "", icon: Icons.feed, payload: localUser?.defaultListingType), + options: [ + ListPickerItem(icon: Icons.view_list_rounded, label: FeedListType.subscribed.value, payload: FeedListType.subscribed), + ListPickerItem(icon: Icons.home_rounded, label: FeedListType.all.value, payload: FeedListType.all), + ListPickerItem(icon: Icons.grid_view_rounded, label: FeedListType.local.value, payload: FeedListType.local), + ], + icon: Icons.filter_alt_rounded, + onChanged: (value) async => context.read().add(UpdateUserSettingsEvent(defaultFeedListType: value.payload)), + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountDefaultFeedType, + highlightedSetting: settingToHighlight, + ), + ListOption( + description: l10n.defaultFeedSortType, + value: ListPickerItem( + label: localUser?.defaultSortType?.name ?? "", + icon: Icons.local_fire_department_rounded, + payload: localUser?.defaultSortType, + ), + options: [...getDefaultPostSortTypeItems(account: account), ...getTopPostSortTypeItems(account: account)], + icon: Icons.sort_rounded, + onChanged: (_) async {}, + isBottomModalScrollControlled: true, + customListPicker: SortPicker( + account: account, + title: l10n.defaultFeedSortType, + onSelect: (value) async { + context.read().add(UpdateUserSettingsEvent(defaultPostSortType: value.payload)); + }, + previouslySelected: localUser?.defaultSortType, + ), + valueDisplay: Row( + children: [ + Icon(allPostSortTypeItems.firstWhere((item) => item.payload == localUser?.defaultSortType).icon, size: 13), + const SizedBox(width: 4), + Text( + allPostSortTypeItems.firstWhere((item) => item.payload == localUser?.defaultSortType).label, + style: theme.textTheme.titleSmall, + ), + ], + ), + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountDefaultFeedSortType, + highlightedSetting: settingToHighlight, + ), + ToggleOption( + description: l10n.showNsfwContent, + value: localUser?.showNsfw, + iconEnabled: Icons.no_adult_content, + iconDisabled: Icons.no_adult_content, + onToggle: (bool value) => context.read().add(UpdateUserSettingsEvent(showNsfw: value)), + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountShowNsfwContent, + highlightedSetting: settingToHighlight, + ), + ToggleOption( + description: l10n.showScores, + value: localUser?.showScores, + iconEnabled: Icons.onetwothree_rounded, + iconDisabled: Icons.onetwothree_rounded, + onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showScores: value))}, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountShowScores, + highlightedSetting: settingToHighlight, + ), + ToggleOption( + description: l10n.showReadPosts, + value: localUser?.showReadPosts, + iconEnabled: Icons.fact_check_rounded, + iconDisabled: Icons.fact_check_outlined, + onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showReadPosts: value))}, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountShowReadPosts, + highlightedSetting: settingToHighlight, + ), + ToggleOption( + description: l10n.bot, + value: person?.botAccount, + iconEnabled: Thunder.robot, + iconDisabled: Thunder.robot, + iconSpacing: 14.0, + onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(botAccount: value))}, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountIsBot, + highlightedSetting: settingToHighlight, + ), + ToggleOption( + description: l10n.showBotAccounts, + value: localUser?.showBotAccounts, + iconEnabled: Thunder.robot, + iconDisabled: Thunder.robot, + iconSpacing: 14.0, + onToggle: (bool value) => {context.read().add(UpdateUserSettingsEvent(showBotAccounts: value))}, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountShowBotAccounts, + highlightedSetting: settingToHighlight, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text(l10n.contentManagement, style: theme.textTheme.titleMedium), + ), + SettingsListTile( + icon: Icons.language_rounded, + description: l10n.discussionLanguages, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountLanguages), + highlightKey: settingToHighlightKey, + setting: LocalSettings.discussionLanguages, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.block_rounded, + description: l10n.blockSettingLabel, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountBlocks), + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountBlocks, + highlightedSetting: settingToHighlight, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.importExportSettings, style: theme.textTheme.titleMedium), + Text(l10n.importExportLemmyAccountSettingsSubtitle), + ], + ), + ), + SettingsListTile( + icon: Icons.file_download_rounded, + description: l10n.exportLemmyAccountSettingsDescription, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () async { + dynamic exportSettings; + try { + final account = context.read().state.account; + exportSettings = await AccountRepositoryImpl(account: account).exportSettings(); + } catch (e) { + // Catch rate-limit errors + showSnackbar(getExceptionErrorMessage(e)); + return; + } + + try { + final String initialFilePath = (await getApplicationDocumentsDirectory()).path; + // Use the same naming convention as the web UI + String initialFileName = 'lemmy_user_settings_${DateTime.now().toUtc().toIso8601String().replaceAll(":", "").replaceAll("-", "")}.json'; + final filePath = '$initialFilePath/$initialFileName'; + + final File file = File(filePath); + await file.writeAsString(jsonEncode(exportSettings)); + + final String? savedFilePath = await FlutterFileDialog.saveFile( + params: SaveFileDialogParams( + mimeTypesFilter: ['application/json'], + sourceFilePath: filePath, + fileName: initialFileName, + ), + ); + + if (savedFilePath?.isNotEmpty == true) { + showSnackbar(l10n.accountSettingsExportedSuccessfully(savedFilePath!)); + } else { + showSnackbar(l10n.errorSavingAccountSettings); + } + } catch (e) { + showSnackbar('${l10n.errorSavingAccountSettings} $e'); + } + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountExportSettings, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.file_upload_rounded, + description: l10n.importLemmyAccountSettingsDescription, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () async { + String importSettings; + + try { + final filePath = await FlutterFileDialog.pickFile( + params: const OpenFileDialogParams( + fileExtensionsFilter: ['json'], + ), + ); + + if (filePath != null) { + importSettings = await File(filePath).readAsString(); + } else { + showSnackbar(l10n.errorLoadingAccountSettings); + return; + } + } catch (e) { + if (e is FormatException) { + showSnackbar(l10n.errorParsingJson); + } else if ((e as PlatformException?)?.code == "invalid_file_extension") { + showSnackbar(l10n.youMustSelectAJsonFile); + } else { + showSnackbar('${l10n.errorLoadingAccountSettings} $e'); + } + return; + } + + try { + final l10n = AppLocalizations.of(GlobalContext.context)!; + final account = context.read().state.account; + final success = await AccountRepositoryImpl(account: account).importSettings(importSettings); + + if (success) { + showSnackbar(l10n.accountSettingsImportedSuccessfully); + + // Reload the current page we're on to reflect changes to account settings + context.read().add(const ResetUserSettingsEvent()); + context.read().add(const GetUserSettingsEvent()); + } else { + showSnackbar(l10n.errorImportingAccountSettings); + } + } catch (e) { + showSnackbar(getExceptionErrorMessage(e)); + } + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountImportSettings, + highlightedSetting: settingToHighlight, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text(l10n.dangerZone, style: theme.textTheme.titleMedium), + ), + SettingsListTile( + icon: Icons.password, + description: l10n.changePassword, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () async { + showThunderDialog( + context: context, + title: l10n.changePassword, + contentText: l10n.changePasswordWarning, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + secondaryButtonText: l10n.cancel, + onPrimaryButtonPressed: (dialogContext, _) async { + if (context.mounted) { + Navigator.of(context).pop(); + final account = context.read().state.account; + + handleLink(context, url: "https://${account.instance}/settings"); + } + }, + primaryButtonText: l10n.confirm, + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountChangePassword, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.delete_forever_rounded, + description: l10n.deleteAccount, + widget: const SizedBox(height: 42.0, child: Icon(Icons.chevron_right_rounded)), + onTap: () async { + showThunderDialog( + context: context, + title: l10n.deleteAccount, + contentText: l10n.deleteAccountDescription, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.confirm, + onPrimaryButtonPressed: (dialogContext, _) async { + if (context.mounted) { + Navigator.of(context).pop(); + final account = context.read().state.account; + + handleLink(context, url: "https://${account.instance}/settings"); + } + }, + ); + }, + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountDeleteAccount, + highlightedSetting: settingToHighlight, + ), + SettingsListTile( + icon: Icons.hide_image_rounded, + description: l10n.manageMedia, + widget: const SizedBox( + height: 42.0, + child: Icon(Icons.chevron_right_rounded), + ), + onTap: () => navigateToSettingPage(context, LocalSettings.settingsPageAccountMedia), + highlightKey: settingToHighlightKey, + setting: LocalSettings.accountManageMedia, + highlightedSetting: settingToHighlight, + ), + const SizedBox(height: 100.0), + ], + ), + } + ], + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart b/lib/src/features/user/presentation/state/user_settings_bloc.dart similarity index 57% rename from lib/src/features/user/presentation/bloc/user_settings_bloc.dart rename to lib/src/features/user/presentation/state/user_settings_bloc.dart index 300c1fddc..6f6c7f2a2 100644 --- a/lib/src/features/user/presentation/bloc/user_settings_bloc.dart +++ b/lib/src/features/user/presentation/state/user_settings_bloc.dart @@ -1,351 +1,480 @@ -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; -import 'package:stream_transform/stream_transform.dart'; - -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/models/models.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/error_messages.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -part 'user_settings_event.dart'; -part 'user_settings_state.dart'; - -const throttleDuration = Duration(seconds: 1); -const timeout = Duration(seconds: 5); - -EventTransformer throttleDroppable(Duration duration) { - return (events, mapper) => droppable().call(events.throttle(duration), mapper); -} - -class UserSettingsBloc extends Bloc { - Account account; - - late InstanceRepository instanceRepository; - late SearchRepository searchRepository; - late CommunityRepository communityRepository; - late AccountRepository accountRepository; - late UserRepository userRepository; - - UserSettingsBloc({required this.account}) : super(const UserSettingsState()) { - instanceRepository = InstanceRepositoryImpl(account: account); - searchRepository = SearchRepositoryImpl(account: account); - communityRepository = CommunityRepositoryImpl(account: account); - accountRepository = AccountRepositoryImpl(account: account); - userRepository = UserRepositoryImpl(account: account); - - on( - _resetUserSettingsEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _getUserSettingsEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _updateUserSettingsEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _getUserBlocksEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _unblockInstanceEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _unblockCommunityEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _unblockPersonEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _listMediaEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _deleteMediaEvent, - // Do not use any transformer, because a throttleDroppable will only process the first request and restartable will only process the last. - ); - on( - _findMediaUsagesEvent, - ); - } - - Future _resetUserSettingsEvent(ResetUserSettingsEvent event, emit) async { - return emit(state.copyWith(status: UserSettingsStatus.initial)); - } - - Future _getUserSettingsEvent(GetUserSettingsEvent event, emit) async { - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - try { - final getSiteResponse = await instanceRepository.info(); - - return emit( - state.copyWith( - status: UserSettingsStatus.success, - siteResponse: getSiteResponse, - ), - ); - } catch (e) { - return emit(state.copyWith( - status: UserSettingsStatus.failure, - errorMessage: getErrorMessage(GlobalContext.context, e.toString()), - )); - } - } - - Future _updateUserSettingsEvent(UpdateUserSettingsEvent event, emit) async { - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - ThunderSiteResponse? originalGetSiteResponse = state.siteResponse; - if (originalGetSiteResponse == null) emit(state.copyWith(status: UserSettingsStatus.failure)); - - try { - // Optimistically update settings - ThunderLocalUser localUser = state.siteResponse!.myUser!.localUserView.localUser.copyWith( - email: event.email ?? state.siteResponse!.myUser!.localUserView.localUser.email, - showReadPosts: event.showReadPosts ?? state.siteResponse!.myUser!.localUserView.localUser.showReadPosts, - showScores: event.showScores ?? state.siteResponse!.myUser!.localUserView.localUser.showScores, - showBotAccounts: event.showBotAccounts ?? state.siteResponse!.myUser!.localUserView.localUser.showBotAccounts, - showNsfw: event.showNsfw ?? state.siteResponse!.myUser!.localUserView.localUser.showNsfw, - defaultListingType: event.defaultFeedListType ?? state.siteResponse!.myUser!.localUserView.localUser.defaultListingType, - defaultSortType: event.defaultPostSortType ?? state.siteResponse!.myUser!.localUserView.localUser.defaultSortType, - ); - - ThunderSiteResponse updatedGetSiteResponse = state.siteResponse!.copyWith( - myUser: state.siteResponse!.myUser!.copyWith( - localUserView: state.siteResponse!.myUser!.localUserView.copyWith( - person: state.siteResponse!.myUser!.localUserView.person.copyWith( - botAccount: event.botAccount ?? state.siteResponse!.myUser!.localUserView.person.botAccount, - bio: event.bio ?? state.siteResponse!.myUser!.localUserView.person.bio, - displayName: event.displayName ?? state.siteResponse!.myUser!.localUserView.person.displayName, - matrixUserId: event.matrixUserId ?? state.siteResponse!.myUser!.localUserView.person.matrixUserId, - ), - localUser: localUser, - ), - discussionLanguages: event.discussionLanguages ?? state.siteResponse!.discussionLanguages, - ), - ); - - emit(state.copyWith(status: UserSettingsStatus.success, siteResponse: updatedGetSiteResponse)); - emit(state.copyWith(status: UserSettingsStatus.updating)); - - await accountRepository.saveSettings( - bio: event.bio, - email: event.email, - matrixUserId: event.matrixUserId, - displayName: event.displayName, - defaultFeedListType: event.defaultFeedListType, - defaultPostSortType: event.defaultPostSortType, - showNsfw: event.showNsfw, - showReadPosts: event.showReadPosts, - showScores: event.showScores, - botAccount: event.botAccount, - showBotAccounts: event.showBotAccounts, - discussionLanguages: event.discussionLanguages, - ); - - return emit(state.copyWith(status: UserSettingsStatus.success)); - } catch (e) { - return emit(state.copyWith( - status: UserSettingsStatus.failure, - siteResponse: originalGetSiteResponse, - errorMessage: getErrorMessage(GlobalContext.context, e.toString()), - )); - } - } - - Future _getUserBlocksEvent(GetUserBlocksEvent event, emit) async { - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - try { - final getSiteResponse = await instanceRepository.info(); - - final personBlocks = getSiteResponse.myUser!.personBlocks..sort((a, b) => a.name.compareTo(b.name)); - final communityBlocks = getSiteResponse.myUser!.communityBlocks..sort((a, b) => a.name.compareTo(b.name)); - final instanceBlocks = getSiteResponse.myUser!.instanceBlocks.map((instanceBlockView) => instanceBlockView.instance).toList()..sort((a, b) => a['domain'].compareTo(b['domain'])); - - return emit(state.copyWith( - status: (state.instanceBeingBlocked != 0 && (instanceBlocks.any((instance) => instance['id'] == state.instanceBeingBlocked) ?? false)) ? UserSettingsStatus.revert : UserSettingsStatus.success, - personBlocks: personBlocks, - communityBlocks: communityBlocks, - instanceBlocks: instanceBlocks, - )); - } catch (e) { - return emit(state.copyWith(status: UserSettingsStatus.failure, errorMessage: getErrorMessage(GlobalContext.context, e.toString()))); - } - } - - Future _unblockInstanceEvent(UnblockInstanceEvent event, emit) async { - emit(state.copyWith(status: UserSettingsStatus.blocking, instanceBeingBlocked: event.instanceId, personBeingBlocked: 0, communityBeingBlocked: 0)); - - try { - await instanceRepository.block(event.instanceId, !event.unblock); - - emit(state.copyWith( - status: state.status, - instanceBeingBlocked: event.instanceId, - personBeingBlocked: 0, - communityBeingBlocked: 0, - )); - - return add(const GetUserBlocksEvent()); - } catch (e) { - return emit(state.copyWith(status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, errorMessage: getErrorMessage(GlobalContext.context, e.toString()))); - } - } - - Future _unblockCommunityEvent(UnblockCommunityEvent event, emit) async { - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - emit(state.copyWith(status: UserSettingsStatus.blocking, communityBeingBlocked: event.communityId, personBeingBlocked: 0, instanceBeingBlocked: 0)); - - try { - final community = await communityRepository.block(event.communityId, !event.unblock); - - List updatedCommunityBlocks; - if (event.unblock) { - updatedCommunityBlocks = state.communityBlocks.where((community) => community.id != event.communityId).toList()..sort((a, b) => a.name.compareTo(b.name)); - } else { - updatedCommunityBlocks = (state.communityBlocks + [community])..sort((a, b) => a.name.compareTo(b.name)); - } - - return emit(state.copyWith( - status: event.unblock ? UserSettingsStatus.successBlock : UserSettingsStatus.revert, - communityBlocks: updatedCommunityBlocks, - communityBeingBlocked: event.communityId, - personBeingBlocked: 0, - )); - } catch (e) { - return emit(state.copyWith(status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, errorMessage: getErrorMessage(GlobalContext.context, e.toString()))); - } - } - - Future _unblockPersonEvent(UnblockPersonEvent event, emit) async { - emit(state.copyWith(status: UserSettingsStatus.blocking, personBeingBlocked: event.personId, communityBeingBlocked: 0, instanceBeingBlocked: 0)); - - try { - final user = await userRepository.block(event.personId, !event.unblock); - - List updatedPersonBlocks; - if (event.unblock) { - updatedPersonBlocks = state.personBlocks.where((person) => person.id != event.personId).toList()..sort((a, b) => a.name.compareTo(b.name)); - } else { - updatedPersonBlocks = (state.personBlocks + [user])..sort((a, b) => a.name.compareTo(b.name)); - } - - return emit(state.copyWith( - status: event.unblock ? UserSettingsStatus.successBlock : UserSettingsStatus.revert, - personBlocks: updatedPersonBlocks, - personBeingBlocked: event.personId, - communityBeingBlocked: 0, - )); - } catch (e) { - return emit(state.copyWith(status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, errorMessage: getErrorMessage(GlobalContext.context, e.toString()))); - } - } - - Future _listMediaEvent(ListMediaEvent event, emit) async { - emit(state.copyWith(status: UserSettingsStatus.listingMedia)); - - try { - int page = 1; - final images = >[]; - - while (true) { - final response = await accountRepository.media(page: page); - final imagesList = response['images'] as List?; - - if (imagesList == null || imagesList.isEmpty) break; - - images.addAll(imagesList.whereType>()); - page++; - } - - return emit(state.copyWith(status: UserSettingsStatus.succeededListingMedia, images: images)); - } catch (e) { - return emit(state.copyWith(status: UserSettingsStatus.failedListingMedia, errorMessage: getExceptionErrorMessage(e))); - } - } - - Future _deleteMediaEvent(DeleteMediaEvent event, emit) async { - emit(state.copyWith(status: UserSettingsStatus.deletingMedia)); - - try { - // Optimistically remove the media from the list - state.images?.removeWhere((localImageView) => localImageView['local_image']['pictrs_alias'] == event.id); - - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - await accountRepository.deleteImage(file: event.id, token: event.deleteToken); - - return emit(state.copyWith(status: UserSettingsStatus.succeededListingMedia, images: state.images)); - } catch (e) { - return emit( - state.copyWith( - status: UserSettingsStatus.failedListingMedia, - errorMessage: AppLocalizations.of(GlobalContext.context)!.errorDeletingImage(getExceptionErrorMessage(e)), - ), - ); - } - } - - Future _findMediaUsagesEvent(FindMediaUsagesEvent event, emit) async { - emit(state.copyWith(status: UserSettingsStatus.searchingMedia)); - - try { - final account = await fetchActiveProfile(); - String url = Uri.https(account.instance, 'pictrs/image/${event.id}').toString(); - - final postsResponse = await searchRepository.search(query: url, type: MetaSearchType.posts); - final postsByUrlResponse = await searchRepository.search(query: url, type: MetaSearchType.url); - - List posts = postsResponse['posts']; - List postsByUrl = postsByUrlResponse['posts']; - - // De-dup posts found by body and URL - posts.addAll(postsByUrl.where((postByUrl) => !posts.any((post) => post.id == postByUrl.id))); - - final response = await searchRepository.search(query: url, type: MetaSearchType.comments); - final List comments = response['comments']; - - return emit(state.copyWith( - status: UserSettingsStatus.succeededSearchingMedia, - imageSearchPosts: await parsePosts(posts), - imageSearchComments: comments, - )); - } catch (e) { - return emit( - state.copyWith( - status: UserSettingsStatus.failedListingMedia, - errorMessage: getExceptionErrorMessage(e), - ), - ); - } - } -} +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/features/user/domain/utils/user_media_utils.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; + +part 'user_settings_event.dart'; +part 'user_settings_state.dart'; + +const throttleDuration = Duration(seconds: 1); +const timeout = Duration(seconds: 5); + +EventTransformer throttleDroppable(Duration duration) { + return (events, mapper) => droppable().call(events.throttle(duration), mapper); +} + +class UserSettingsBloc extends Bloc { + final Account account; + + final InstanceRepository instanceRepository; + final SearchRepository searchRepository; + final CommunityRepository communityRepository; + final AccountRepository accountRepository; + final UserRepository userRepository; + final ActiveAccountProvider _activeAccountProvider; + final LocalizationService _localizationService; + + UserSettingsBloc({ + required this.account, + required this.instanceRepository, + required this.searchRepository, + required this.communityRepository, + required this.accountRepository, + required this.userRepository, + required ActiveAccountProvider activeAccountProvider, + required LocalizationService localizationService, + }) : _activeAccountProvider = activeAccountProvider, + _localizationService = localizationService, + super(const UserSettingsState()) { + on( + _resetUserSettingsEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _getUserSettingsEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _updateUserSettingsEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _getUserBlocksEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _unblockInstanceEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _unblockCommunityEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _unblockPersonEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _listMediaEvent, + transformer: throttleDroppable(throttleDuration), + ); + on( + _deleteMediaEvent, + // Do not use any transformer, because a throttleDroppable will only process the first request and restartable will only process the last. + ); + on( + _findMediaUsagesEvent, + ); + } + + Future _resetUserSettingsEvent(ResetUserSettingsEvent event, emit) async { + return emit( + state.copyWith( + status: UserSettingsStatus.initial, + errorMessage: '', + errorReason: null, + ), + ); + } + + Future _getUserSettingsEvent(GetUserSettingsEvent event, emit) async { + try { + final l10n = _localizationService.l10n; + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: UserSettingsStatus.notLoggedIn, + errorMessage: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + } + + final getSiteResponse = await instanceRepository.info(); + + return emit( + state.copyWith( + status: UserSettingsStatus.success, + siteResponse: getSiteResponse, + errorReason: null, + ), + ); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: UserSettingsStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _updateUserSettingsEvent(UpdateUserSettingsEvent event, emit) async { + final originalGetSiteResponse = state.siteResponse; + try { + final l10n = _localizationService.l10n; + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: UserSettingsStatus.notLoggedIn, + errorMessage: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + } + + if (originalGetSiteResponse == null) { + return emit(state.copyWith( + status: UserSettingsStatus.failure, + errorMessage: l10n.unexpectedError, + errorReason: AppErrorReason.validation( + message: l10n.unexpectedError, + ), + )); + } + + // Optimistically update settings + ThunderLocalUser localUser = state.siteResponse!.myUser!.localUserView.localUser.copyWith( + email: event.email ?? state.siteResponse!.myUser!.localUserView.localUser.email, + showReadPosts: event.showReadPosts ?? state.siteResponse!.myUser!.localUserView.localUser.showReadPosts, + showScores: event.showScores ?? state.siteResponse!.myUser!.localUserView.localUser.showScores, + showBotAccounts: event.showBotAccounts ?? state.siteResponse!.myUser!.localUserView.localUser.showBotAccounts, + showNsfw: event.showNsfw ?? state.siteResponse!.myUser!.localUserView.localUser.showNsfw, + defaultListingType: event.defaultFeedListType ?? state.siteResponse!.myUser!.localUserView.localUser.defaultListingType, + defaultSortType: event.defaultPostSortType ?? state.siteResponse!.myUser!.localUserView.localUser.defaultSortType, + ); + + ThunderSiteResponse updatedGetSiteResponse = state.siteResponse!.copyWith( + myUser: state.siteResponse!.myUser!.copyWith( + localUserView: state.siteResponse!.myUser!.localUserView.copyWith( + person: state.siteResponse!.myUser!.localUserView.person.copyWith( + botAccount: event.botAccount ?? state.siteResponse!.myUser!.localUserView.person.botAccount, + bio: event.bio ?? state.siteResponse!.myUser!.localUserView.person.bio, + displayName: event.displayName ?? state.siteResponse!.myUser!.localUserView.person.displayName, + matrixUserId: event.matrixUserId ?? state.siteResponse!.myUser!.localUserView.person.matrixUserId, + ), + localUser: localUser, + ), + discussionLanguages: event.discussionLanguages ?? state.siteResponse!.discussionLanguages, + ), + ); + + emit(state.copyWith( + status: UserSettingsStatus.success, + siteResponse: updatedGetSiteResponse, + errorReason: null, + )); + emit(state.copyWith(status: UserSettingsStatus.updating, errorReason: null)); + + await accountRepository.saveSettings( + bio: event.bio, + email: event.email, + matrixUserId: event.matrixUserId, + displayName: event.displayName, + defaultFeedListType: event.defaultFeedListType, + defaultPostSortType: event.defaultPostSortType, + showNsfw: event.showNsfw, + showReadPosts: event.showReadPosts, + showScores: event.showScores, + botAccount: event.botAccount, + showBotAccounts: event.showBotAccounts, + discussionLanguages: event.discussionLanguages, + ); + + return emit(state.copyWith(status: UserSettingsStatus.success, errorReason: null)); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: UserSettingsStatus.failure, + siteResponse: originalGetSiteResponse, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _getUserBlocksEvent(GetUserBlocksEvent event, emit) async { + try { + final l10n = _localizationService.l10n; + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: UserSettingsStatus.notLoggedIn, + errorMessage: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + } + + final getSiteResponse = await instanceRepository.info(); + + final personBlocks = getSiteResponse.myUser!.personBlocks..sort((a, b) => a.name.compareTo(b.name)); + final communityBlocks = getSiteResponse.myUser!.communityBlocks..sort((a, b) => a.name.compareTo(b.name)); + final instanceBlocks = getSiteResponse.myUser!.instanceBlocks.map((instanceBlockView) => instanceBlockView.instance).toList()..sort((a, b) => a['domain'].compareTo(b['domain'])); + + return emit(state.copyWith( + status: (state.instanceBeingBlocked != 0 && instanceBlocks.any((instance) => instance['id'] == state.instanceBeingBlocked)) ? UserSettingsStatus.revert : UserSettingsStatus.success, + personBlocks: personBlocks, + communityBlocks: communityBlocks, + instanceBlocks: instanceBlocks, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: UserSettingsStatus.failure, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _unblockInstanceEvent(UnblockInstanceEvent event, emit) async { + emit(state.copyWith(status: UserSettingsStatus.blocking, instanceBeingBlocked: event.instanceId, personBeingBlocked: 0, communityBeingBlocked: 0)); + + try { + await instanceRepository.block(event.instanceId, !event.unblock); + + emit(state.copyWith( + status: state.status, + instanceBeingBlocked: event.instanceId, + personBeingBlocked: 0, + communityBeingBlocked: 0, + )); + + return add(const GetUserBlocksEvent()); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _unblockCommunityEvent(UnblockCommunityEvent event, emit) async { + try { + final l10n = _localizationService.l10n; + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: UserSettingsStatus.notLoggedIn, + errorMessage: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + } + + emit(state.copyWith(status: UserSettingsStatus.blocking, communityBeingBlocked: event.communityId, personBeingBlocked: 0, instanceBeingBlocked: 0)); + + final community = await communityRepository.block(event.communityId, !event.unblock); + + List updatedCommunityBlocks; + if (event.unblock) { + updatedCommunityBlocks = state.communityBlocks.where((community) => community.id != event.communityId).toList()..sort((a, b) => a.name.compareTo(b.name)); + } else { + updatedCommunityBlocks = (state.communityBlocks + [community])..sort((a, b) => a.name.compareTo(b.name)); + } + + return emit(state.copyWith( + status: event.unblock ? UserSettingsStatus.successBlock : UserSettingsStatus.revert, + communityBlocks: updatedCommunityBlocks, + communityBeingBlocked: event.communityId, + personBeingBlocked: 0, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _unblockPersonEvent(UnblockPersonEvent event, emit) async { + emit(state.copyWith(status: UserSettingsStatus.blocking, personBeingBlocked: event.personId, communityBeingBlocked: 0, instanceBeingBlocked: 0)); + + try { + final user = await userRepository.block(event.personId, !event.unblock); + + List updatedPersonBlocks; + if (event.unblock) { + updatedPersonBlocks = state.personBlocks.where((person) => person.id != event.personId).toList()..sort((a, b) => a.name.compareTo(b.name)); + } else { + updatedPersonBlocks = (state.personBlocks + [user])..sort((a, b) => a.name.compareTo(b.name)); + } + + return emit(state.copyWith( + status: event.unblock ? UserSettingsStatus.successBlock : UserSettingsStatus.revert, + personBlocks: updatedPersonBlocks, + personBeingBlocked: event.personId, + communityBeingBlocked: 0, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: event.unblock ? UserSettingsStatus.failure : UserSettingsStatus.failedRevert, + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _listMediaEvent(ListMediaEvent event, emit) async { + emit(state.copyWith(status: UserSettingsStatus.listingMedia)); + + try { + int page = 1; + final images = >[]; + + while (true) { + final response = await accountRepository.media(page: page); + if (response.isEmpty) break; + + images.addAll(response.images); + page++; + } + + return emit(state.copyWith( + status: UserSettingsStatus.succeededListingMedia, + images: images, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit(state.copyWith( + status: UserSettingsStatus.failedListingMedia, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + )); + } + } + + Future _deleteMediaEvent(DeleteMediaEvent event, emit) async { + emit(state.copyWith(status: UserSettingsStatus.deletingMedia)); + + try { + // Optimistically remove the media from the list + final images = removeImageByAlias( + images: state.images ?? const [], + alias: event.id, + ); + + final l10n = _localizationService.l10n; + final account = await _activeAccountProvider.getActiveAccount(); + if (account.anonymous) { + return emit(state.copyWith( + status: UserSettingsStatus.notLoggedIn, + errorMessage: l10n.userNotLoggedIn, + errorReason: AppErrorReason.notLoggedIn(message: l10n.userNotLoggedIn), + )); + } + + await accountRepository.deleteImage(file: event.id, token: event.deleteToken); + + return emit(state.copyWith( + status: UserSettingsStatus.succeededListingMedia, + images: images, + errorReason: null, + )); + } catch (e) { + final message = _localizationService.l10n.errorDeletingImage(getExceptionErrorMessage(e)); + return emit( + state.copyWith( + status: UserSettingsStatus.failedListingMedia, + errorMessage: message, + errorReason: AppErrorReason.actionFailed( + message: message, + details: e.toString(), + ), + ), + ); + } + } + + Future _findMediaUsagesEvent(FindMediaUsagesEvent event, emit) async { + emit(state.copyWith(status: UserSettingsStatus.searchingMedia)); + + try { + final account = await _activeAccountProvider.getActiveAccount(); + String url = Uri.https(account.instance, 'pictrs/image/${event.id}').toString(); + + final postsResponse = await searchRepository.search(query: url, type: MetaSearchType.posts); + final postsByUrlResponse = await searchRepository.search(query: url, type: MetaSearchType.url); + + List posts = postsResponse.posts; + List postsByUrl = postsByUrlResponse.posts; + + // De-dup posts found by body and URL + posts = mergeUniquePosts( + primary: posts, + secondary: postsByUrl, + ); + + final response = await searchRepository.search(query: url, type: MetaSearchType.comments); + final List comments = response.comments; + + return emit(state.copyWith( + status: UserSettingsStatus.succeededSearchingMedia, + imageSearchPosts: await parsePosts(posts), + imageSearchComments: comments, + errorReason: null, + )); + } catch (e) { + final message = getExceptionErrorMessage(e); + return emit( + state.copyWith( + status: UserSettingsStatus.failedListingMedia, + errorMessage: message, + errorReason: AppErrorReason.unexpected( + message: message, + details: e.toString(), + ), + ), + ); + } + } +} diff --git a/lib/src/features/user/presentation/bloc/user_settings_event.dart b/lib/src/features/user/presentation/state/user_settings_event.dart similarity index 79% rename from lib/src/features/user/presentation/bloc/user_settings_event.dart rename to lib/src/features/user/presentation/state/user_settings_event.dart index 8f7db53cf..7204aefbb 100644 --- a/lib/src/features/user/presentation/bloc/user_settings_event.dart +++ b/lib/src/features/user/presentation/state/user_settings_event.dart @@ -4,7 +4,7 @@ abstract class UserSettingsEvent extends Equatable { const UserSettingsEvent(); @override - List get props => []; + List get props => []; } class ResetUserSettingsEvent extends UserSettingsEvent { @@ -66,6 +66,9 @@ class UpdateUserSettingsEvent extends UserSettingsEvent { this.showBotAccounts, this.discussionLanguages, }); + + @override + List get props => [displayName, bio, email, matrixUserId, defaultFeedListType, defaultPostSortType, showNsfw, showReadPosts, showScores, botAccount, showBotAccounts, discussionLanguages]; } class GetUserBlocksEvent extends UserSettingsEvent { @@ -77,6 +80,9 @@ class UnblockInstanceEvent extends UserSettingsEvent { final bool unblock; const UnblockInstanceEvent({required this.instanceId, this.unblock = true}); + + @override + List get props => [instanceId, unblock]; } class UnblockCommunityEvent extends UserSettingsEvent { @@ -84,6 +90,9 @@ class UnblockCommunityEvent extends UserSettingsEvent { final bool unblock; const UnblockCommunityEvent({required this.communityId, this.unblock = true}); + + @override + List get props => [communityId, unblock]; } class UnblockPersonEvent extends UserSettingsEvent { @@ -91,6 +100,9 @@ class UnblockPersonEvent extends UserSettingsEvent { final bool unblock; const UnblockPersonEvent({required this.personId, this.unblock = true}); + + @override + List get props => [personId, unblock]; } class ListMediaEvent extends UserSettingsEvent { @@ -102,10 +114,16 @@ class DeleteMediaEvent extends UserSettingsEvent { final String id; const DeleteMediaEvent({required this.deleteToken, required this.id}); + + @override + List get props => [deleteToken, id]; } class FindMediaUsagesEvent extends UserSettingsEvent { final String id; const FindMediaUsagesEvent({required this.id}); + + @override + List get props => [id]; } diff --git a/lib/src/features/user/presentation/bloc/user_settings_state.dart b/lib/src/features/user/presentation/state/user_settings_state.dart similarity index 63% rename from lib/src/features/user/presentation/bloc/user_settings_state.dart rename to lib/src/features/user/presentation/state/user_settings_state.dart index 83528760e..f7df0e8bd 100644 --- a/lib/src/features/user/presentation/bloc/user_settings_state.dart +++ b/lib/src/features/user/presentation/state/user_settings_state.dart @@ -1,97 +1,106 @@ -part of 'user_settings_bloc.dart'; - -enum UserSettingsStatus { - initial, - updating, - success, - blocking, - successBlock, - failure, - revert, - failedRevert, - notLoggedIn, - listingMedia, - failedListingMedia, - succeededListingMedia, - deletingMedia, - searchingMedia, - succeededSearchingMedia, -} - -class UserSettingsState extends Equatable { - const UserSettingsState({ - this.status = UserSettingsStatus.initial, - this.personBlocks = const [], - this.communityBlocks = const [], - this.instanceBlocks = const [], - this.personBeingBlocked = 0, - this.communityBeingBlocked = 0, - this.instanceBeingBlocked = 0, - this.siteResponse, - this.errorMessage = '', - this.images, - this.imageSearchPosts, - this.imageSearchComments, - }); - - final UserSettingsStatus status; - - final List personBlocks; - final List communityBlocks; - final List> instanceBlocks; - - final int personBeingBlocked; - final int communityBeingBlocked; - final int instanceBeingBlocked; - - final ThunderSiteResponse? siteResponse; - - final String? errorMessage; - final List>? images; - final List? imageSearchPosts; - final List? imageSearchComments; - - UserSettingsState copyWith({ - required UserSettingsStatus status, - List? personBlocks, - List? communityBlocks, - List>? instanceBlocks, - int? personBeingBlocked, - int? communityBeingBlocked, - int? instanceBeingBlocked, - ThunderSiteResponse? siteResponse, - String? errorMessage, - List>? images, - List? imageSearchPosts, - List? imageSearchComments, - }) { - return UserSettingsState( - status: status, - personBlocks: personBlocks ?? this.personBlocks, - communityBlocks: communityBlocks ?? this.communityBlocks, - instanceBlocks: instanceBlocks ?? this.instanceBlocks, - personBeingBlocked: personBeingBlocked ?? this.personBeingBlocked, - communityBeingBlocked: communityBeingBlocked ?? this.communityBeingBlocked, - instanceBeingBlocked: instanceBeingBlocked ?? this.instanceBeingBlocked, - siteResponse: siteResponse ?? this.siteResponse, - errorMessage: errorMessage ?? this.errorMessage, - images: images ?? this.images, - imageSearchPosts: imageSearchPosts ?? this.imageSearchPosts, - imageSearchComments: imageSearchComments ?? this.imageSearchComments, - ); - } - - @override - List get props => [ - status, - personBlocks, - communityBlocks, - instanceBlocks, - personBeingBlocked, - communityBeingBlocked, - instanceBeingBlocked, - siteResponse, - errorMessage, - images, - ]; -} +part of 'user_settings_bloc.dart'; + +const _userSettingsUnset = Object(); + +enum UserSettingsStatus { + initial, + updating, + success, + blocking, + successBlock, + failure, + revert, + failedRevert, + notLoggedIn, + listingMedia, + failedListingMedia, + succeededListingMedia, + deletingMedia, + searchingMedia, + succeededSearchingMedia, +} + +class UserSettingsState extends Equatable { + const UserSettingsState({ + this.status = UserSettingsStatus.initial, + this.personBlocks = const [], + this.communityBlocks = const [], + this.instanceBlocks = const [], + this.personBeingBlocked = 0, + this.communityBeingBlocked = 0, + this.instanceBeingBlocked = 0, + this.siteResponse, + this.errorMessage = '', + this.errorReason, + this.images, + this.imageSearchPosts, + this.imageSearchComments, + }); + + final UserSettingsStatus status; + + final List personBlocks; + final List communityBlocks; + final List> instanceBlocks; + + final int personBeingBlocked; + final int communityBeingBlocked; + final int instanceBeingBlocked; + + final ThunderSiteResponse? siteResponse; + + final String? errorMessage; + final AppErrorReason? errorReason; + final List>? images; + final List? imageSearchPosts; + final List? imageSearchComments; + + UserSettingsState copyWith({ + UserSettingsStatus? status, + List? personBlocks, + List? communityBlocks, + List>? instanceBlocks, + int? personBeingBlocked, + int? communityBeingBlocked, + int? instanceBeingBlocked, + Object? siteResponse = _userSettingsUnset, + Object? errorMessage = _userSettingsUnset, + Object? errorReason = _userSettingsUnset, + Object? images = _userSettingsUnset, + Object? imageSearchPosts = _userSettingsUnset, + Object? imageSearchComments = _userSettingsUnset, + }) { + return UserSettingsState( + status: status ?? this.status, + personBlocks: personBlocks ?? this.personBlocks, + communityBlocks: communityBlocks ?? this.communityBlocks, + instanceBlocks: instanceBlocks ?? this.instanceBlocks, + personBeingBlocked: personBeingBlocked ?? this.personBeingBlocked, + communityBeingBlocked: communityBeingBlocked ?? this.communityBeingBlocked, + instanceBeingBlocked: instanceBeingBlocked ?? this.instanceBeingBlocked, + siteResponse: identical(siteResponse, _userSettingsUnset) ? this.siteResponse : siteResponse as ThunderSiteResponse?, + errorMessage: identical(errorMessage, _userSettingsUnset) ? this.errorMessage : errorMessage as String?, + errorReason: identical(errorReason, _userSettingsUnset) ? this.errorReason : errorReason as AppErrorReason?, + images: identical(images, _userSettingsUnset) ? this.images : images as List>?, + imageSearchPosts: identical(imageSearchPosts, _userSettingsUnset) ? this.imageSearchPosts : imageSearchPosts as List?, + imageSearchComments: identical(imageSearchComments, _userSettingsUnset) ? this.imageSearchComments : imageSearchComments as List?, + ); + } + + @override + List get props => [ + status, + personBlocks, + communityBlocks, + instanceBlocks, + personBeingBlocked, + communityBeingBlocked, + instanceBeingBlocked, + siteResponse, + errorMessage, + errorReason, + images, + imageSearchPosts, + imageSearchComments, + ]; +} diff --git a/lib/src/features/user/presentation/utils/restore_user.dart b/lib/src/features/user/presentation/utils/restore_user.dart deleted file mode 100644 index 4528e9875..000000000 --- a/lib/src/features/user/presentation/utils/restore_user.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/features/account/account.dart'; - -/// Restores the previous user that was selected in the app, if it has changed. -/// Useful to call after invoking a page that may change the currently selected user. -void restoreUser(BuildContext context, Account? originalUser) { - final Account newUser = context.read().state.account; - - if (originalUser != null && originalUser.id != newUser.id) { - context.read().add(SwitchProfile(accountId: originalUser.id, reload: false)); - } -} diff --git a/lib/src/features/user/presentation/utils/user_groups.dart b/lib/src/features/user/presentation/utils/user_group_utils.dart similarity index 90% rename from lib/src/features/user/presentation/utils/user_groups.dart rename to lib/src/features/user/presentation/utils/user_group_utils.dart index 93073adf2..cf4006755 100644 --- a/lib/src/features/user/presentation/utils/user_groups.dart +++ b/lib/src/features/user/presentation/utils/user_group_utils.dart @@ -1,92 +1,91 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; - -/// Fetches the user groups for a givencomment. -List getCommentUserGroups(ThunderComment comment, Account account) { - final groups = []; - - if (comment.creator?.botAccount == true) groups.add(UserType.bot); - if (comment.creatorIsModerator == true) groups.add(UserType.moderator); - if (comment.creatorIsAdmin == true) groups.add(UserType.admin); - if (comment.post?.creatorId == comment.creatorId) groups.add(UserType.op); - if (comment.creatorId == account.userId) groups.add(UserType.self); - - final now = DateTime.now(); - final published = comment.creator?.published; - - final isUserBirthday = published != null && published.month == now.month && published.day == now.day; - if (isUserBirthday) groups.add(UserType.birthday); - - return groups; -} - -/// Fetches the user group color based on the given [userGroups]. -/// -/// If the user is in multiple groups, the color is based on order of precedence. -/// The order is: OP > Self > Admin > Moderator > Bot -Color? fetchUserGroupColor(BuildContext context, List userGroups) { - final theme = Theme.of(context); - final bool darkTheme = context.read().state.useDarkTheme; - - Color? color; - - if (userGroups.contains(UserType.op)) { - color = UserType.op.color; - } else if (userGroups.contains(UserType.self)) { - color = UserType.self.color; - } else if (userGroups.contains(UserType.admin)) { - color = UserType.admin.color; - } else if (userGroups.contains(UserType.moderator)) { - color = UserType.moderator.color; - } else if (userGroups.contains(UserType.bot)) { - color = UserType.bot.color; - } else if (userGroups.contains(UserType.birthday)) { - color = UserType.birthday.color; - } - - if (color != null) { - // Blend with theme - color = Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.35), color); - - // Lighten for light mode - if (!darkTheme) { - color = HSLColor.fromColor(color).withLightness(0.85).toColor(); - } - } - - return color; -} - -/// Fetches the user group descriptor based on the given [userGroups]. -/// -/// If the user is in multiple groups, the descriptor will contain all of them. -String fetchUserGroupDescriptor(List userGroups, DateTime? created) { - List descriptors = []; - String descriptor = ''; - - final l10n = AppLocalizations.of(GlobalContext.context)!; - - if (userGroups.contains(UserType.op)) descriptors.add(l10n.originalPoster); - if (userGroups.contains(UserType.self)) descriptors.add(l10n.me); - if (userGroups.contains(UserType.admin)) descriptors.add(l10n.admin); - if (userGroups.contains(UserType.moderator)) descriptors.add(l10n.moderator(1)); - if (userGroups.contains(UserType.bot)) descriptors.add(l10n.bot); - if (descriptors.isNotEmpty) descriptor = ' (${descriptors.join(', ')})'; - - if (userGroups.contains(UserType.birthday) && created != null) { - int yearsOld = DateTime.now().year - created.year; - descriptor += '\n${l10n.accountBirthday( - yearsOld == 0 ? '(${l10n.createdToday})' : '(${l10n.xYearsOld(yearsOld, yearsOld)})', - )}'; - } - - return descriptor; -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/account/account.dart'; + +/// Fetches the user groups for a givencomment. +List getCommentUserGroups(ThunderComment comment, Account account) { + final groups = []; + + if (comment.creator?.botAccount == true) groups.add(UserType.bot); + if (comment.creatorIsModerator == true) groups.add(UserType.moderator); + if (comment.creatorIsAdmin == true) groups.add(UserType.admin); + if (comment.post?.creatorId == comment.creatorId) groups.add(UserType.op); + if (comment.creatorId == account.userId) groups.add(UserType.self); + + final now = DateTime.now(); + final published = comment.creator?.published; + + final isUserBirthday = published != null && published.month == now.month && published.day == now.day; + if (isUserBirthday) groups.add(UserType.birthday); + + return groups; +} + +/// Fetches the user group color based on the given [userGroups]. +/// +/// If the user is in multiple groups, the color is based on order of precedence. +/// The order is: OP > Self > Admin > Moderator > Bot +Color? fetchUserGroupColor(BuildContext context, List userGroups) { + final theme = Theme.of(context); + final bool darkTheme = context.read().state.useDarkTheme; + + Color? color; + + if (userGroups.contains(UserType.op)) { + color = UserType.op.color; + } else if (userGroups.contains(UserType.self)) { + color = UserType.self.color; + } else if (userGroups.contains(UserType.admin)) { + color = UserType.admin.color; + } else if (userGroups.contains(UserType.moderator)) { + color = UserType.moderator.color; + } else if (userGroups.contains(UserType.bot)) { + color = UserType.bot.color; + } else if (userGroups.contains(UserType.birthday)) { + color = UserType.birthday.color; + } + + if (color != null) { + // Blend with theme + color = Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.35), color); + + // Lighten for light mode + if (!darkTheme) { + color = HSLColor.fromColor(color).withLightness(0.85).toColor(); + } + } + + return color; +} + +/// Fetches the user group descriptor based on the given [userGroups]. +/// +/// If the user is in multiple groups, the descriptor will contain all of them. +String fetchUserGroupDescriptor(List userGroups, DateTime? created) { + List descriptors = []; + String descriptor = ''; + + final l10n = AppLocalizations.of(GlobalContext.context)!; + + if (userGroups.contains(UserType.op)) descriptors.add(l10n.originalPoster); + if (userGroups.contains(UserType.self)) descriptors.add(l10n.me); + if (userGroups.contains(UserType.admin)) descriptors.add(l10n.admin); + if (userGroups.contains(UserType.moderator)) descriptors.add(l10n.moderator(1)); + if (userGroups.contains(UserType.bot)) descriptors.add(l10n.bot); + if (descriptors.isNotEmpty) descriptor = ' (${descriptors.join(', ')})'; + + if (userGroups.contains(UserType.birthday) && created != null) { + int yearsOld = DateTime.now().year - created.year; + descriptor += '\n${l10n.accountBirthday( + yearsOld == 0 ? '(${l10n.createdToday})' : '(${l10n.xYearsOld(yearsOld, yearsOld)})', + )}'; + } + + return descriptor; +} diff --git a/lib/src/features/user/presentation/utils/logout_dialog.dart b/lib/src/features/user/presentation/utils/user_session_utils.dart similarity index 67% rename from lib/src/features/user/presentation/utils/logout_dialog.dart rename to lib/src/features/user/presentation/utils/user_session_utils.dart index ef7e405c7..407ed0395 100644 --- a/lib/src/features/user/presentation/utils/logout_dialog.dart +++ b/lib/src/features/user/presentation/utils/user_session_utils.dart @@ -1,35 +1,45 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/dialogs.dart'; - -Future showLogOutDialog(BuildContext context) async { - final AppLocalizations l10n = AppLocalizations.of(context)!; - - bool result = false; - await showThunderDialog( - context: context, - customBuilder: (alertDialog) => BlocProvider.value( - value: context.read(), - child: alertDialog, - ), - title: l10n.confirmLogOutTitle, - contentText: l10n.confirmLogOutBody, - onSecondaryButtonPressed: (dialogContext) { - result = false; - Navigator.of(dialogContext).pop(); - }, - secondaryButtonText: l10n.cancel, - onPrimaryButtonPressed: (dialogContext, _) { - result = true; - dialogContext.read().add(RemoveProfile(accountId: dialogContext.read().state.account.id)); - Navigator.of(dialogContext).pop(); - }, - primaryButtonText: l10n.logOut, - ); - - return result; -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; + +Future showLogOutDialog(BuildContext context) async { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + bool result = false; + await showThunderDialog( + context: context, + customBuilder: (alertDialog) => BlocProvider.value( + value: context.read(), + child: alertDialog, + ), + title: l10n.confirmLogOutTitle, + contentText: l10n.confirmLogOutBody, + onSecondaryButtonPressed: (dialogContext) { + result = false; + Navigator.of(dialogContext).pop(); + }, + secondaryButtonText: l10n.cancel, + onPrimaryButtonPressed: (dialogContext, _) { + result = true; + dialogContext.read().add(RemoveProfile(accountId: dialogContext.read().state.account.id)); + Navigator.of(dialogContext).pop(); + }, + primaryButtonText: l10n.logOut, + ); + + return result; +} + +/// Restores the previous user that was selected in the app, if it has changed. +/// Useful to call after invoking a page that may change the currently selected user. +void restoreUser(BuildContext context, Account? originalUser) { + final Account newUser = context.read().state.account; + + if (originalUser != null && originalUser.id != newUser.id) { + context.read().add(SwitchProfile(accountId: originalUser.id, reload: false)); + } +} diff --git a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart index 01e9a7497..329a6d929 100644 --- a/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart +++ b/lib/src/features/user/presentation/widgets/user_action_bottom_sheet.dart @@ -1,21 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/snackbar.dart'; import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; import 'package:thunder/src/shared/widgets/chips/user_chip.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/app/widgets/thunder_icons.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, Thunder, ThunderDivider, showSnackbar, showThunderDialog; /// Defines the actions that can be taken on a user enum UserBottomSheetAction { @@ -164,19 +160,25 @@ class _UserActionBottomSheetState extends State { case UserBottomSheetAction.unbanUserFromCommunity: Navigator.of(context).pop(); final user = await communityRepository.banUserFromCommunity(userId: widget.user.id, communityId: widget.communityId!, ban: false); - if (!user.banned) showSnackbar(l10n.unbannedUserFromCommunity(widget.user.displayNameOrName)); + if (!user.banned) { + showSnackbar(l10n.unbannedUserFromCommunity(widget.user.displayNameOrName)); + } widget.onAction(UserAction.banFromCommunity, null); break; case UserBottomSheetAction.addUserAsCommunityModerator: Navigator.of(context).pop(); final moderators = await communityRepository.addModerator(userId: widget.user.id, communityId: widget.communityId!, added: true); - if (moderators.where((m) => m.id == widget.user.id).isNotEmpty) showSnackbar(l10n.addedUserAsCommunityModerator(widget.user.displayNameOrName)); + if (moderators.where((m) => m.id == widget.user.id).isNotEmpty) { + showSnackbar(l10n.addedUserAsCommunityModerator(widget.user.displayNameOrName)); + } widget.onAction(UserAction.addModerator, null); break; case UserBottomSheetAction.removeUserAsCommunityModerator: Navigator.of(context).pop(); final moderators = await communityRepository.addModerator(userId: widget.user.id, communityId: widget.communityId!, added: false); - if (moderators.where((m) => m.id == widget.user.id).isEmpty) showSnackbar(l10n.removedUserAsCommunityModerator(widget.user.displayNameOrName)); + if (moderators.where((m) => m.id == widget.user.id).isEmpty) { + showSnackbar(l10n.removedUserAsCommunityModerator(widget.user.displayNameOrName)); + } widget.onAction(UserAction.addModerator, null); break; } @@ -197,7 +199,9 @@ class _UserActionBottomSheetState extends State { final communityRepository = CommunityRepositoryImpl(account: widget.account); final user = await communityRepository.banUserFromCommunity(userId: widget.user.id, communityId: widget.communityId!, ban: true, reason: controller.text, removeData: removeData); - if (user.banned) showSnackbar(l10n.successfullyBannedUser(widget.user.displayNameOrName)); + if (user.banned) { + showSnackbar(l10n.successfullyBannedUser(widget.user.displayNameOrName)); + } widget.onAction(UserAction.banFromCommunity, null); Navigator.of(dialogContext).pop(); diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header.dart b/lib/src/features/user/presentation/widgets/user_header/user_header.dart index 10da1b1e4..f3a4b8cea 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; - -import 'package:thunder/src/shared/images/image_preview.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/shared/icon_text.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ImagePreview; /// A widget that displays a user's header information and related actions. /// diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart index 7d04defd9..af8d978a5 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart @@ -3,16 +3,14 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; /// A widget that displays relevant actions for a user in a scrollable chip list. class UserHeaderActions extends StatelessWidget { @@ -213,37 +211,6 @@ class _FeedTypeActionChip extends StatelessWidget { ), ); } - - void _showFeedTypePicker(BuildContext context) { - final l10n = GlobalContext.l10n; - - HapticFeedback.mediumImpact(); - - showModalBottomSheet( - showDragHandle: true, - context: context, - isScrollControlled: true, - builder: (builderContext) => BottomSheetListPicker( - title: l10n.selectFeedType, - previouslySelected: feedType, - items: [ - ListPickerItem( - label: FeedTypeSubview.post.name, - icon: Icons.article_rounded, - payload: FeedTypeSubview.post, - ), - ListPickerItem( - label: FeedTypeSubview.comment.name, - icon: Icons.chat_rounded, - payload: FeedTypeSubview.comment, - ), - ], - onSelect: (selection) async { - onChangeFeedType(selection.payload); - }, - ), - ); - } } /// Action chip for user sorting options. diff --git a/lib/src/features/user/presentation/widgets/user_indicator.dart b/lib/src/features/user/presentation/widgets/user_indicator.dart index 507932cac..5f68c494a 100644 --- a/lib/src/features/user/presentation/widgets/user_indicator.dart +++ b/lib/src/features/user/presentation/widgets/user_indicator.dart @@ -4,10 +4,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; class UserIndicator extends StatefulWidget { /// The user to display. diff --git a/lib/src/features/user/presentation/widgets/user_information.dart b/lib/src/features/user/presentation/widgets/user_information.dart index bb574a93f..9c7472a43 100644 --- a/lib/src/features/user/presentation/widgets/user_information.dart +++ b/lib/src/features/user/presentation/widgets/user_information.dart @@ -5,14 +5,14 @@ import 'package:intl/intl.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/date_time.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; /// A widget that displays detailed information about a user. class UserInformation extends StatefulWidget { diff --git a/lib/src/features/user/presentation/widgets/user_label_chip.dart b/lib/src/features/user/presentation/widgets/user_label_chip.dart index 6d0eed655..5caa159e9 100644 --- a/lib/src/features/user/presentation/widgets/user_label_chip.dart +++ b/lib/src/features/user/presentation/widgets/user_label_chip.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; /// A widget that displays a user's label in a chip format. class UserLabelChip extends StatelessWidget { diff --git a/lib/src/features/user/presentation/widgets/user_list_entry.dart b/lib/src/features/user/presentation/widgets/user_list_entry.dart index 5ae21421d..a2ace5658 100644 --- a/lib/src/features/user/presentation/widgets/user_list_entry.dart +++ b/lib/src/features/user/presentation/widgets/user_list_entry.dart @@ -1,71 +1,72 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; - -/// A widget that can display a single user entry for use within a list (e.g., search page, instance explorer) -class UserListEntry extends StatelessWidget { - /// The user to display. - final ThunderUser user; - - /// The account to use for resolving the user, if different from the current instance. - final Account? resolutionAccount; - - const UserListEntry({super.key, required this.user, this.resolutionAccount}); - - @override - Widget build(BuildContext context) { - return Tooltip( - excludeFromSemantics: true, - message: '${user.displayNameOrName}\n${generateUserFullName( - context, - user.name, - user.displayName, - fetchInstanceNameFromUrl(user.actorId), - )}', - preferBelow: false, - child: ListTile( - leading: UserAvatar(user: user, radius: 25), - title: Text(user.displayNameOrName, overflow: TextOverflow.ellipsis), - subtitle: Row( - children: [ - Flexible( - child: UserFullNameWidget( - context, - user.name, - user.displayName, - fetchInstanceNameFromUrl(user.actorId), - // Override because we're showing display name above - useDisplayName: false, - ), - ), - ], - ), - onTap: () async { - int? userId = user.id; - - if (resolutionAccount != null) { - try { - final response = await SearchRepositoryImpl(account: resolutionAccount!).resolve(query: user.actorId); - - userId = response['user']?.id; - } catch (e) { - // If we can't find it, then we'll get a standard error message about personId being un-navigable - } - } - - if (context.mounted) { - navigateToFeedPage(context, feedType: FeedType.user, userId: userId); - } - }, - ), - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; + +/// A widget that can display a single user entry for use within a list (e.g., search page, instance explorer) +class UserListEntry extends StatelessWidget { + /// The user to display. + final ThunderUser user; + + /// The account to use for resolving the user, if different from the current instance. + final Account? resolutionAccount; + + const UserListEntry({super.key, required this.user, this.resolutionAccount}); + + @override + Widget build(BuildContext context) { + return Tooltip( + excludeFromSemantics: true, + message: '${user.displayNameOrName}\n${generateUserFullName( + context, + user.name, + user.displayName, + fetchInstanceNameFromUrl(user.actorId), + )}', + preferBelow: false, + child: ListTile( + leading: UserAvatar(user: user, radius: 25), + title: Text(user.displayNameOrName, overflow: TextOverflow.ellipsis), + subtitle: Row( + children: [ + Flexible( + child: UserFullNameWidget( + context, + user.name, + user.displayName, + fetchInstanceNameFromUrl(user.actorId), + // Override because we're showing display name above + useDisplayName: false, + ), + ), + ], + ), + onTap: () async { + int? userId = user.id; + + if (resolutionAccount != null) { + try { + final response = await SearchRepositoryImpl(account: resolutionAccount!).resolve(query: user.actorId); + + userId = response.user?.id; + } catch (e) { + // If we can't find it, then we'll get a standard error message about personId being un-navigable + } + } + + if (context.mounted) { + navigateToFeedPage(context, feedType: FeedType.user, userId: userId); + } + }, + ), + ); + } +} diff --git a/lib/src/features/user/presentation/widgets/user_selector.dart b/lib/src/features/user/presentation/widgets/user_selector.dart index e3443ce14..b06dc8cfc 100644 --- a/lib/src/features/user/presentation/widgets/user_selector.dart +++ b/lib/src/features/user/presentation/widgets/user_selector.dart @@ -1,400 +1,405 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -/// A widget that displays the currently selected user account with the ability to switch between accounts. -/// -/// This widget provides a method for switching between different user accounts and ensures that the -/// target community, post, or comment is federated to the new account's instance before allowing -/// the switch. If the content cannot be resolved on the new instance, the switch is blocked. -/// -/// **Usage Examples:** -/// -/// For creating a post in a community: -/// ```dart -/// UserSelector( -/// account: currentAccount, -/// onUserChanged: (account) => handleAccountChange(account), -/// communityActorId: community.actorId, -/// onCommunityChanged: (community) => handleCommunityChange(community), -/// ) -/// ``` -/// -/// For creating a comment on a post: -/// ```dart -/// UserSelector( -/// account: currentAccount, -/// onUserChanged: (account) => handleAccountChange(account), -/// postActorId: post.actorId, -/// onPostChanged: (post) => handlePostChange(post), -/// ) -/// ``` -class UserSelector extends StatefulWidget { - /// The currently selected account. - /// This is the account that will be displayed in the selector. - final Account account; - - /// Callback invoked when the user successfully switches to a different account. - /// - /// This callback is triggered after all federation checks have passed and the - /// new account has been confirmed to have access to the community, post, or comment. - /// - /// The [account] parameter contains the newly selected account. - final void Function(Account account)? onUserChanged; - - // ========== Community-related parameters ========== - // Used when the selector is being used in the context of a community (e.g., creating a post) - - /// The ActivityPub ID (actor ID) of the community to resolve when switching accounts. - /// - /// When provided, the widget will attempt to resolve this community on the new - /// account's instance before allowing the account switch. If the community cannot - /// be found on the new instance, the switch will be blocked. - final String? communityActorId; - - /// Callback invoked when the community is successfully resolved on the new account's instance. - /// - /// This callback receives the resolved [ThunderCommunity] that corresponds to - /// the [communityActorId] on the new instance. If the community cannot be resolved, - /// this callback will receive `null` and the account switch will be blocked. - /// - /// Required when [communityActorId] is provided. - final void Function(ThunderCommunity? community)? onCommunityChanged; - - // ========== Post-related parameters ========== - // Used when the selector is being used in the context of a post (e.g., creating a comment to a post) - - /// The ActivityPub ID (actor ID) of the post to resolve when switching accounts. - /// - /// When provided, the widget will attempt to resolve this post on the new - /// account's instance before allowing the account switch. Unlike communities, - /// posts must be successfully resolved or the switch will be blocked. - /// - /// Used in conjunction with [onPostChanged]. - final String? postActorId; - - /// Callback invoked when the post is successfully resolved on the new account's instance. - /// - /// This callback receives the resolved [ThunderPost] that corresponds to - /// the [postActorId] on the new instance. This callback will only be invoked - /// if the post is successfully resolved - failed resolution blocks the account switch. - /// - /// Required when [postActorId] is provided. - final void Function(ThunderPost post)? onPostChanged; - - // ========== Parent comment-related parameters ========== - // Used when replying to a specific comment - - /// The ActivityPub ID (actor ID) of the parent comment to resolve when switching accounts. - /// - /// When provided, the widget will attempt to resolve this comment on the new - /// account's instance before allowing the account switch. The comment must be - /// successfully resolved or the switch will be blocked. - /// - /// Used in conjunction with [onParentCommentChanged]. - final String? parentCommentActorId; - - /// Callback invoked when the parent comment is successfully resolved on the new account's instance. - /// - /// This callback receives the resolved [ThunderComment] that corresponds to - /// the [parentCommentActorId] on the new instance. This callback will only be invoked - /// if the comment is successfully resolved - failed resolution blocks the account switch. - /// - /// Required when [parentCommentActorId] is provided. - final void Function(ThunderComment parentComment)? onParentCommentChanged; - - /// Whether account switching is enabled. - /// - /// When `false`, the selector displays the current account but disables the ability - /// to switch to a different account. This is useful during operations like editing - /// where changing accounts would be inappropriate. - /// - /// Defaults to `true`. - final bool enableAccountSwitching; - - const UserSelector({ - super.key, - required this.account, - this.onUserChanged, - this.communityActorId, - this.onCommunityChanged, - this.postActorId, - this.onPostChanged, - this.parentCommentActorId, - this.onParentCommentChanged, - this.enableAccountSwitching = true, - }); - - @override - State createState() => _UserSelectorState(); -} - -class _UserSelectorState extends State { - /// The current user details for the selected account - ThunderUser? _user; - - /// Whether the widget is currently loading user data - bool _isLoading = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadUserData(widget.account)); - } - - @override - void didUpdateWidget(UserSelector oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.account.id != widget.account.id) _loadUserData(widget.account); - } - - /// Loads user data for the specified account - Future _loadUserData(Account? account) async { - if (_isLoading) return; - setState(() => _isLoading = true); - - try { - final targetAccount = account ?? widget.account; - final username = targetAccount.username; - - if (username == null) { - setState(() { - _user = null; - _isLoading = false; - }); - return; - } - - final response = await UserRepositoryImpl(account: targetAccount).getUser(username: username); - final user = response?['user'] as ThunderUser?; - - if (!mounted) return; - - setState(() { - _user = user; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - - setState(() { - _user = null; - _isLoading = false; - }); - - debugPrint('Failed to load user data: $e'); - } - } - - /// Initiates the account switching process - Future _switchProfile() async { - if (!widget.enableAccountSwitching) return; - - final newAccount = await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => _UserProfileSelector(widget.account), - ); - - if (newAccount == null || !mounted || widget.account.id == newAccount.id) return; - - final resolvedItems = await _performAccountSwitch(newAccount); - if (resolvedItems != null) { - await _loadUserData(newAccount); - _invokeCallbacks(newAccount, resolvedItems); - } - } - - /// Performs federation checks and resolves content on the new account's instance - Future?> _performAccountSwitch(Account newAccount) async { - final l10n = GlobalContext.l10n; - - try { - ThunderCommunity? community; - ThunderPost? post; - ThunderComment? parentComment; - - // Resolve community if needed - if (widget.communityActorId?.isNotEmpty == true) { - community = await _resolveCommunity(newAccount, widget.communityActorId!); - if (community == null) { - showSnackbar(l10n.unableToFindCommunityOnInstance); - return null; - } - } - - // Resolve post if needed - if (widget.postActorId?.isNotEmpty == true) { - post = await _resolvePost(newAccount, widget.postActorId!); - if (post == null) { - showSnackbar(l10n.accountSwitchPostNotFound(newAccount.instance)); - return null; - } - } - - // Resolve parent comment if needed - if (widget.parentCommentActorId?.isNotEmpty == true) { - parentComment = await _resolveParentComment(newAccount, widget.parentCommentActorId!); - if (parentComment == null) { - showSnackbar(l10n.accountSwitchParentCommentNotFound(newAccount.instance)); - return null; - } - } - - return { - 'community': community, - 'post': post, - 'parentComment': parentComment, - }; - } catch (e) { - showSnackbar(e.toString()); - return null; - } - } - - /// Resolves a community on the new account's instance - Future _resolveCommunity(Account account, String actorId) async { - try { - final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); - return response['community']; - } catch (e) { - debugPrint('Failed to resolve community: $e'); - return null; - } - } - - /// Resolves a post on the new account's instance - Future _resolvePost(Account account, String actorId) async { - try { - final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); - if (response['post'] == null) return null; - - final parsedPosts = await parsePosts([response['post']]); - return parsedPosts.isNotEmpty ? parsedPosts.first : null; - } catch (e) { - debugPrint('Failed to resolve post: $e'); - return null; - } - } - - /// Resolves a parent comment on the new account's instance - Future _resolveParentComment(Account account, String actorId) async { - try { - final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); - return response['comment']; - } catch (e) { - debugPrint('Failed to resolve parent comment: $e'); - return null; - } - } - - /// Invokes the appropriate callbacks after a successful account switch - void _invokeCallbacks(Account newAccount, Map resolvedItems) { - widget.onUserChanged?.call(newAccount); - - if (widget.communityActorId != null) { - widget.onCommunityChanged?.call(resolvedItems['community']); - } - if (widget.postActorId != null && resolvedItems['post'] != null) { - widget.onPostChanged?.call(resolvedItems['post'] as ThunderPost); - } - if (widget.parentCommentActorId != null && resolvedItems['parentComment'] != null) { - widget.onParentCommentChanged?.call(resolvedItems['parentComment'] as ThunderComment); - } - } - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: const Offset(-8.0, 0), - child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(50.0)), - onTap: widget.enableAccountSwitching ? _switchProfile : null, - child: Padding( - padding: const EdgeInsets.only(left: 8.0, top: 4.0, bottom: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - UserIndicator(user: _user), - if (widget.enableAccountSwitching) const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - ); - } -} - -/// Modal bottom sheet widget for selecting user accounts -class _UserProfileSelector extends StatefulWidget { - /// The current account - final Account account; - - const _UserProfileSelector(this.account); - - @override - State<_UserProfileSelector> createState() => _UserProfileSelectorState(); -} - -class _UserProfileSelectorState extends State<_UserProfileSelector> { - /// The list of available user accounts - List _accounts = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadAccounts()); - } - - /// Loads all available user accounts - Future _loadAccounts() async { - try { - final accounts = await Account.accounts().then((accounts) => accounts.where((account) => account.id != widget.account.id).toList()); - - if (!mounted) return; - setState(() => _accounts = accounts); - } catch (e) { - if (!mounted) return; - setState(() => _accounts = []); - debugPrint('Failed to load accounts: $e'); - } - } - - @override - Widget build(BuildContext context) { - final l10n = GlobalContext.l10n; - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text(l10n.account(2), style: theme.textTheme.titleLarge), - ), - _accounts.isEmpty - ? Center(child: Text(l10n.noAccountsAdded)) - : ListView.builder( - shrinkWrap: true, - itemCount: _accounts.length, - itemBuilder: (context, index) { - final account = _accounts[index]; - return ListTile( - title: Text(account.username ?? '-', style: theme.textTheme.titleMedium), - subtitle: Text(account.instance), - onTap: () => Navigator.of(context).pop(account), - ); - }, - ), - ], - ); - } -} +import 'package:flutter/material.dart'; + +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/search/search.dart'; + +import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show showSnackbar; + +/// A widget that displays the currently selected user account with the ability to switch between accounts. +/// +/// This widget provides a method for switching between different user accounts and ensures that the +/// target community, post, or comment is federated to the new account's instance before allowing +/// the switch. If the content cannot be resolved on the new instance, the switch is blocked. +/// +/// **Usage Examples:** +/// +/// For creating a post in a community: +/// ```dart +/// UserSelector( +/// account: currentAccount, +/// onUserChanged: (account) => handleAccountChange(account), +/// communityActorId: community.actorId, +/// onCommunityChanged: (community) => handleCommunityChange(community), +/// ) +/// ``` +/// +/// For creating a comment on a post: +/// ```dart +/// UserSelector( +/// account: currentAccount, +/// onUserChanged: (account) => handleAccountChange(account), +/// postActorId: post.actorId, +/// onPostChanged: (post) => handlePostChange(post), +/// ) +/// ``` +class UserSelector extends StatefulWidget { + /// The currently selected account. + /// This is the account that will be displayed in the selector. + final Account account; + + /// Callback invoked when the user successfully switches to a different account. + /// + /// This callback is triggered after all federation checks have passed and the + /// new account has been confirmed to have access to the community, post, or comment. + /// + /// The [account] parameter contains the newly selected account. + final void Function(Account account)? onUserChanged; + + // ========== Community-related parameters ========== + // Used when the selector is being used in the context of a community (e.g., creating a post) + + /// The ActivityPub ID (actor ID) of the community to resolve when switching accounts. + /// + /// When provided, the widget will attempt to resolve this community on the new + /// account's instance before allowing the account switch. If the community cannot + /// be found on the new instance, the switch will be blocked. + final String? communityActorId; + + /// Callback invoked when the community is successfully resolved on the new account's instance. + /// + /// This callback receives the resolved [ThunderCommunity] that corresponds to + /// the [communityActorId] on the new instance. If the community cannot be resolved, + /// this callback will receive `null` and the account switch will be blocked. + /// + /// Required when [communityActorId] is provided. + final void Function(ThunderCommunity? community)? onCommunityChanged; + + // ========== Post-related parameters ========== + // Used when the selector is being used in the context of a post (e.g., creating a comment to a post) + + /// The ActivityPub ID (actor ID) of the post to resolve when switching accounts. + /// + /// When provided, the widget will attempt to resolve this post on the new + /// account's instance before allowing the account switch. Unlike communities, + /// posts must be successfully resolved or the switch will be blocked. + /// + /// Used in conjunction with [onPostChanged]. + final String? postActorId; + + /// Callback invoked when the post is successfully resolved on the new account's instance. + /// + /// This callback receives the resolved [ThunderPost] that corresponds to + /// the [postActorId] on the new instance. This callback will only be invoked + /// if the post is successfully resolved - failed resolution blocks the account switch. + /// + /// Required when [postActorId] is provided. + final void Function(ThunderPost post)? onPostChanged; + + // ========== Parent comment-related parameters ========== + // Used when replying to a specific comment + + /// The ActivityPub ID (actor ID) of the parent comment to resolve when switching accounts. + /// + /// When provided, the widget will attempt to resolve this comment on the new + /// account's instance before allowing the account switch. The comment must be + /// successfully resolved or the switch will be blocked. + /// + /// Used in conjunction with [onParentCommentChanged]. + final String? parentCommentActorId; + + /// Callback invoked when the parent comment is successfully resolved on the new account's instance. + /// + /// This callback receives the resolved [ThunderComment] that corresponds to + /// the [parentCommentActorId] on the new instance. This callback will only be invoked + /// if the comment is successfully resolved - failed resolution blocks the account switch. + /// + /// Required when [parentCommentActorId] is provided. + final void Function(ThunderComment parentComment)? onParentCommentChanged; + + /// Whether account switching is enabled. + /// + /// When `false`, the selector displays the current account but disables the ability + /// to switch to a different account. This is useful during operations like editing + /// where changing accounts would be inappropriate. + /// + /// Defaults to `true`. + final bool enableAccountSwitching; + + const UserSelector({ + super.key, + required this.account, + this.onUserChanged, + this.communityActorId, + this.onCommunityChanged, + this.postActorId, + this.onPostChanged, + this.parentCommentActorId, + this.onParentCommentChanged, + this.enableAccountSwitching = true, + }); + + @override + State createState() => _UserSelectorState(); +} + +class _UserSelectorState extends State { + /// The current user details for the selected account + ThunderUser? _user; + + /// Whether the widget is currently loading user data + bool _isLoading = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadUserData(widget.account)); + } + + @override + void didUpdateWidget(UserSelector oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.account.id != widget.account.id) { + _loadUserData(widget.account); + } + } + + /// Loads user data for the specified account + Future _loadUserData(Account? account) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + try { + final targetAccount = account ?? widget.account; + final username = targetAccount.username; + + if (username == null) { + setState(() { + _user = null; + _isLoading = false; + }); + return; + } + + final response = await UserRepositoryImpl(account: targetAccount).getUser(username: username); + final user = response?['user'] as ThunderUser?; + + if (!mounted) return; + + setState(() { + _user = user; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + setState(() { + _user = null; + _isLoading = false; + }); + + debugPrint('Failed to load user data: $e'); + } + } + + /// Initiates the account switching process + Future _switchProfile() async { + if (!widget.enableAccountSwitching) return; + + final newAccount = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => _UserProfileSelector(widget.account), + ); + + if (newAccount == null || !mounted || widget.account.id == newAccount.id) { + return; + } + + final resolvedItems = await _performAccountSwitch(newAccount); + if (resolvedItems != null) { + await _loadUserData(newAccount); + _invokeCallbacks(newAccount, resolvedItems); + } + } + + /// Performs federation checks and resolves content on the new account's instance + Future?> _performAccountSwitch(Account newAccount) async { + final l10n = GlobalContext.l10n; + + try { + ThunderCommunity? community; + ThunderPost? post; + ThunderComment? parentComment; + + // Resolve community if needed + if (widget.communityActorId?.isNotEmpty == true) { + community = await _resolveCommunity(newAccount, widget.communityActorId!); + if (community == null) { + showSnackbar(l10n.unableToFindCommunityOnInstance); + return null; + } + } + + // Resolve post if needed + if (widget.postActorId?.isNotEmpty == true) { + post = await _resolvePost(newAccount, widget.postActorId!); + if (post == null) { + showSnackbar(l10n.accountSwitchPostNotFound(newAccount.instance)); + return null; + } + } + + // Resolve parent comment if needed + if (widget.parentCommentActorId?.isNotEmpty == true) { + parentComment = await _resolveParentComment(newAccount, widget.parentCommentActorId!); + if (parentComment == null) { + showSnackbar(l10n.accountSwitchParentCommentNotFound(newAccount.instance)); + return null; + } + } + + return { + 'community': community, + 'post': post, + 'parentComment': parentComment, + }; + } catch (e) { + showSnackbar(e.toString()); + return null; + } + } + + /// Resolves a community on the new account's instance + Future _resolveCommunity(Account account, String actorId) async { + try { + final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); + return response.community; + } catch (e) { + debugPrint('Failed to resolve community: $e'); + return null; + } + } + + /// Resolves a post on the new account's instance + Future _resolvePost(Account account, String actorId) async { + try { + final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); + if (response.post == null) return null; + + final parsedPosts = await parsePosts([response.post!]); + return parsedPosts.isNotEmpty ? parsedPosts.first : null; + } catch (e) { + debugPrint('Failed to resolve post: $e'); + return null; + } + } + + /// Resolves a parent comment on the new account's instance + Future _resolveParentComment(Account account, String actorId) async { + try { + final response = await SearchRepositoryImpl(account: account).resolve(query: actorId); + return response.comment; + } catch (e) { + debugPrint('Failed to resolve parent comment: $e'); + return null; + } + } + + /// Invokes the appropriate callbacks after a successful account switch + void _invokeCallbacks(Account newAccount, Map resolvedItems) { + widget.onUserChanged?.call(newAccount); + + if (widget.communityActorId != null) { + widget.onCommunityChanged?.call(resolvedItems['community']); + } + if (widget.postActorId != null && resolvedItems['post'] != null) { + widget.onPostChanged?.call(resolvedItems['post'] as ThunderPost); + } + if (widget.parentCommentActorId != null && resolvedItems['parentComment'] != null) { + widget.onParentCommentChanged?.call(resolvedItems['parentComment'] as ThunderComment); + } + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: const Offset(-8.0, 0), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(50.0)), + onTap: widget.enableAccountSwitching ? _switchProfile : null, + child: Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0, bottom: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UserIndicator(user: _user), + if (widget.enableAccountSwitching) const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ); + } +} + +/// Modal bottom sheet widget for selecting user accounts +class _UserProfileSelector extends StatefulWidget { + /// The current account + final Account account; + + const _UserProfileSelector(this.account); + + @override + State<_UserProfileSelector> createState() => _UserProfileSelectorState(); +} + +class _UserProfileSelectorState extends State<_UserProfileSelector> { + /// The list of available user accounts + List _accounts = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadAccounts()); + } + + /// Loads all available user accounts + Future _loadAccounts() async { + try { + final accounts = await Account.accounts().then((accounts) => accounts.where((account) => account.id != widget.account.id).toList()); + + if (!mounted) return; + setState(() => _accounts = accounts); + } catch (e) { + if (!mounted) return; + setState(() => _accounts = []); + debugPrint('Failed to load accounts: $e'); + } + } + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text(l10n.account(2), style: theme.textTheme.titleLarge), + ), + _accounts.isEmpty + ? Center(child: Text(l10n.noAccountsAdded)) + : ListView.builder( + shrinkWrap: true, + itemCount: _accounts.length, + itemBuilder: (context, index) { + final account = _accounts[index]; + return ListTile( + title: Text(account.username ?? '-', style: theme.textTheme.titleMedium), + subtitle: Text(account.instance), + onTap: () => Navigator.of(context).pop(account), + ); + }, + ), + ], + ); + } +} diff --git a/lib/src/features/user/user.dart b/lib/src/features/user/user.dart index abc491743..5c7a8c2f0 100644 --- a/lib/src/features/user/user.dart +++ b/lib/src/features/user/user.dart @@ -1,5 +1,6 @@ export 'domain/enums/user_action.dart'; -export 'presentation/bloc/user_settings_bloc.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_my_user.dart'; +export 'presentation/state/user_settings_bloc.dart'; export 'presentation/pages/media_management_page.dart'; export 'presentation/pages/user_settings_block_page.dart'; export 'presentation/pages/user_settings_page.dart'; @@ -11,9 +12,8 @@ export 'presentation/widgets/user_information.dart'; export 'presentation/widgets/user_label_chip.dart'; export 'presentation/widgets/user_list_entry.dart'; export 'presentation/widgets/user_selector.dart'; -export 'presentation/utils/logout_dialog.dart'; -export 'presentation/utils/restore_user.dart'; -export 'presentation/utils/user_groups.dart'; -export 'data/models/thunder_user.dart'; +export 'presentation/utils/user_group_utils.dart'; +export 'presentation/utils/user_session_utils.dart'; +export 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; export 'data/models/user_label.dart'; export 'data/repositories/user_repository.dart'; diff --git a/lib/src/core/config/app_config.dart b/lib/src/foundation/config/app_config.dart similarity index 100% rename from lib/src/core/config/app_config.dart rename to lib/src/foundation/config/app_config.dart diff --git a/lib/src/shared/utils/constants.dart b/lib/src/foundation/config/app_constants.dart similarity index 88% rename from lib/src/shared/utils/constants.dart rename to lib/src/foundation/config/app_constants.dart index 7fcccf36e..08fd7c0fc 100644 --- a/lib/src/shared/utils/constants.dart +++ b/lib/src/foundation/config/app_constants.dart @@ -2,12 +2,7 @@ import 'dart:ui'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/foundation/primitives/enums/enums.dart'; const FeedListType DEFAULT_LISTING_TYPE = FeedListType.all; diff --git a/lib/src/foundation/config/config.dart b/lib/src/foundation/config/config.dart new file mode 100644 index 000000000..7d37dc6e8 --- /dev/null +++ b/lib/src/foundation/config/config.dart @@ -0,0 +1,2 @@ +export 'app_config.dart'; +export 'app_constants.dart'; diff --git a/lib/src/app/utils/global_context.dart b/lib/src/foundation/config/global_context.dart similarity index 97% rename from lib/src/app/utils/global_context.dart rename to lib/src/foundation/config/global_context.dart index 266bc25bd..3a7e481eb 100644 --- a/lib/src/app/utils/global_context.dart +++ b/lib/src/foundation/config/global_context.dart @@ -4,6 +4,7 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; class GlobalContext { static GlobalKey scaffoldMessengerKey = GlobalKey(); + static BuildContext get context => scaffoldMessengerKey.currentContext!; static AppLocalizations get l10n => AppLocalizations.of(context)!; } diff --git a/lib/src/features/account/data/models/account.dart b/lib/src/foundation/contracts/account.dart similarity index 97% rename from lib/src/features/account/data/models/account.dart rename to lib/src/foundation/contracts/account.dart index 4c477e83e..c98197a83 100644 --- a/lib/src/features/account/data/models/account.dart +++ b/lib/src/foundation/contracts/account.dart @@ -2,9 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; -import 'package:thunder/src/core/database/database.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/main.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; class Account { /// The internal id of the account in the database diff --git a/lib/src/foundation/contracts/active_account_provider.dart b/lib/src/foundation/contracts/active_account_provider.dart new file mode 100644 index 000000000..eda0a939b --- /dev/null +++ b/lib/src/foundation/contracts/active_account_provider.dart @@ -0,0 +1,5 @@ +import 'package:thunder/src/foundation/contracts/account.dart'; + +abstract class ActiveAccountProvider { + Future getActiveAccount(); +} diff --git a/lib/src/foundation/contracts/connectivity_service.dart b/lib/src/foundation/contracts/connectivity_service.dart new file mode 100644 index 000000000..cbe33e3cc --- /dev/null +++ b/lib/src/foundation/contracts/connectivity_service.dart @@ -0,0 +1,14 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; + +abstract class ConnectivityService { + Stream> get onConnectivityChanged; +} + +class DefaultConnectivityService implements ConnectivityService { + DefaultConnectivityService({Connectivity? connectivity}) : _connectivity = connectivity ?? Connectivity(); + + final Connectivity _connectivity; + + @override + Stream> get onConnectivityChanged => _connectivity.onConnectivityChanged; +} diff --git a/lib/src/foundation/contracts/contracts.dart b/lib/src/foundation/contracts/contracts.dart new file mode 100644 index 000000000..1c5479cd0 --- /dev/null +++ b/lib/src/foundation/contracts/contracts.dart @@ -0,0 +1,10 @@ +export 'active_account_provider.dart'; +export 'account.dart'; +export 'connectivity_service.dart'; +export 'deep_link_service.dart'; +export 'localization_service.dart'; +export 'notification_service.dart'; +export 'platform_detection_service.dart'; +export 'preferences_store.dart'; +export 'version_checker.dart'; +export 'web_controller.dart'; diff --git a/lib/src/foundation/contracts/deep_link_service.dart b/lib/src/foundation/contracts/deep_link_service.dart new file mode 100644 index 000000000..333a0906c --- /dev/null +++ b/lib/src/foundation/contracts/deep_link_service.dart @@ -0,0 +1,14 @@ +import 'package:app_links/app_links.dart'; + +abstract class DeepLinkService { + Stream get uriLinkStream; +} + +class AppLinksDeepLinkService implements DeepLinkService { + AppLinksDeepLinkService({AppLinks? appLinks}) : _appLinks = appLinks ?? AppLinks(); + + final AppLinks _appLinks; + + @override + Stream get uriLinkStream => _appLinks.uriLinkStream; +} diff --git a/lib/src/foundation/contracts/localization_service.dart b/lib/src/foundation/contracts/localization_service.dart new file mode 100644 index 000000000..014130ed6 --- /dev/null +++ b/lib/src/foundation/contracts/localization_service.dart @@ -0,0 +1,13 @@ +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; + +abstract class LocalizationService { + AppLocalizations get l10n; +} + +class GlobalContextLocalizationService implements LocalizationService { + const GlobalContextLocalizationService(); + + @override + AppLocalizations get l10n => GlobalContext.l10n; +} diff --git a/lib/src/foundation/contracts/notification_service.dart b/lib/src/foundation/contracts/notification_service.dart new file mode 100644 index 000000000..8b3231314 --- /dev/null +++ b/lib/src/foundation/contracts/notification_service.dart @@ -0,0 +1,12 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +abstract class NotificationService { + Stream get notifications; +} + +class StreamNotificationService implements NotificationService { + StreamNotificationService(this.notifications); + + @override + final Stream notifications; +} diff --git a/lib/src/foundation/contracts/platform_detection_service.dart b/lib/src/foundation/contracts/platform_detection_service.dart new file mode 100644 index 000000000..6b4fbd978 --- /dev/null +++ b/lib/src/foundation/contracts/platform_detection_service.dart @@ -0,0 +1,3 @@ +abstract class PlatformDetectionService { + Future?> detectPlatform(String instance); +} diff --git a/lib/src/foundation/contracts/preferences_store.dart b/lib/src/foundation/contracts/preferences_store.dart new file mode 100644 index 000000000..571c7cee0 --- /dev/null +++ b/lib/src/foundation/contracts/preferences_store.dart @@ -0,0 +1,50 @@ +import 'package:thunder/src/foundation/primitives/enums/local_settings.dart'; +import 'package:thunder/src/foundation/persistence/preferences.dart'; + +abstract class PreferencesStore { + T? getLocalSetting(LocalSettings setting); + + void setSetting(LocalSettings setting, Object value); + + void removeSetting(LocalSettings setting); + + String? getString(String key); + + Future setString(String key, String value); + + Future remove(String key); +} + +class UserPreferencesStore implements PreferencesStore { + const UserPreferencesStore(); + + @override + T? getLocalSetting(LocalSettings setting) { + return UserPreferences.getLocalSetting(setting); + } + + @override + void setSetting(LocalSettings setting, Object value) { + UserPreferences.setSetting(setting, value); + } + + @override + void removeSetting(LocalSettings setting) { + UserPreferences.removeSetting(setting); + } + + @override + String? getString(String key) { + return UserPreferences.instance.preferences.getString(key); + } + + @override + Future setString(String key, String value) { + return UserPreferences.instance.preferences.setString(key, value); + } + + @override + Future remove(String key) { + return UserPreferences.instance.preferences.remove(key); + } +} diff --git a/lib/src/foundation/contracts/version_checker.dart b/lib/src/foundation/contracts/version_checker.dart new file mode 100644 index 000000000..3ea634af2 --- /dev/null +++ b/lib/src/foundation/contracts/version_checker.dart @@ -0,0 +1,15 @@ +import 'package:thunder/src/foundation/primitives/models/version.dart'; +import 'package:thunder/src/foundation/utils/check_github_update.dart' as update_checker; + +abstract class VersionChecker { + Future fetchLatestVersion(); +} + +class GithubVersionChecker implements VersionChecker { + const GithubVersionChecker(); + + @override + Future fetchLatestVersion() { + return update_checker.fetchVersion(); + } +} diff --git a/lib/src/foundation/contracts/web_controller.dart b/lib/src/foundation/contracts/web_controller.dart new file mode 100644 index 000000000..fb900c718 --- /dev/null +++ b/lib/src/foundation/contracts/web_controller.dart @@ -0,0 +1,11 @@ +/// Defines an interface which can perform web controlling operations +abstract interface class IWebController { + Future canGoBack(); + Future canGoForward(); + Future goBack(); + Future goForward(); + Future reload(); + Future getTitle(); + Future currentUrl(); + Future loadRequest(Uri uri); +} diff --git a/lib/src/core/network/api_exception.dart b/lib/src/foundation/errors/api_exception.dart similarity index 100% rename from lib/src/core/network/api_exception.dart rename to lib/src/foundation/errors/api_exception.dart diff --git a/lib/src/foundation/errors/app_error_reason.dart b/lib/src/foundation/errors/app_error_reason.dart new file mode 100644 index 000000000..3c2391297 --- /dev/null +++ b/lib/src/foundation/errors/app_error_reason.dart @@ -0,0 +1,79 @@ +import 'package:equatable/equatable.dart'; + +enum AppErrorCategory { + actionFailed, + notLoggedIn, + validation, + network, + timeout, + unexpected, +} + +class AppErrorReason extends Equatable { + const AppErrorReason({ + required this.category, + required this.message, + this.details, + }); + + const AppErrorReason.actionFailed({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.actionFailed, + message: message, + details: details, + ); + + const AppErrorReason.notLoggedIn({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.notLoggedIn, + message: message, + details: details, + ); + + const AppErrorReason.validation({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.validation, + message: message, + details: details, + ); + + const AppErrorReason.network({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.network, + message: message, + details: details, + ); + + const AppErrorReason.timeout({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.timeout, + message: message, + details: details, + ); + + const AppErrorReason.unexpected({ + required String message, + String? details, + }) : this( + category: AppErrorCategory.unexpected, + message: message, + details: details, + ); + + final AppErrorCategory category; + final String message; + final String? details; + + @override + List get props => [category, message, details]; +} diff --git a/lib/src/foundation/errors/errors.dart b/lib/src/foundation/errors/errors.dart new file mode 100644 index 000000000..248bc66b4 --- /dev/null +++ b/lib/src/foundation/errors/errors.dart @@ -0,0 +1,2 @@ +export 'app_error_reason.dart'; +export 'api_exception.dart'; diff --git a/lib/src/foundation/foundation.dart b/lib/src/foundation/foundation.dart new file mode 100644 index 000000000..6145a4c3e --- /dev/null +++ b/lib/src/foundation/foundation.dart @@ -0,0 +1,7 @@ +export 'config/config.dart'; +export 'contracts/contracts.dart'; +export 'errors/errors.dart'; +export 'networking/networking.dart'; +export 'persistence/persistence.dart'; +export 'primitives/primitives.dart'; +export 'utils/utils.dart'; diff --git a/lib/src/core/network/api_client_factory.dart b/lib/src/foundation/networking/api_client_factory.dart similarity index 82% rename from lib/src/core/network/api_client_factory.dart rename to lib/src/foundation/networking/api_client_factory.dart index 2b047ac61..11e73826b 100644 --- a/lib/src/core/network/api_client_factory.dart +++ b/lib/src/foundation/networking/api_client_factory.dart @@ -1,13 +1,13 @@ import 'package:http/http.dart' as http; import 'package:version/version.dart'; -import 'package:thunder/src/core/cache/platform_version_cache.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/core/network/lemmy/lemmy_v3_api_client.dart'; -import 'package:thunder/src/core/network/lemmy/lemmy_v4_api_client.dart'; -import 'package:thunder/src/core/network/piefed/piefed_api_client.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/utils/cache/platform_version_cache.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/networking/lemmy/lemmy_v3_api_client.dart'; +import 'package:thunder/src/foundation/networking/lemmy/lemmy_v4_api_client.dart'; +import 'package:thunder/src/foundation/networking/piefed/piefed_api_client.dart'; +import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; +import 'package:thunder/src/foundation/contracts/account.dart'; /// Factory for creating the appropriate API client based on platform and version. /// diff --git a/lib/src/core/network/base_api_client.dart b/lib/src/foundation/networking/base_api_client.dart similarity index 96% rename from lib/src/core/network/base_api_client.dart rename to lib/src/foundation/networking/base_api_client.dart index dc42ea5a7..d41068663 100644 --- a/lib/src/core/network/base_api_client.dart +++ b/lib/src/foundation/networking/base_api_client.dart @@ -4,9 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:version/version.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/update/check_github_update.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/errors/api_exception.dart'; +import 'package:thunder/src/foundation/utils/check_github_update.dart'; +import 'package:thunder/src/foundation/contracts/account.dart'; /// HTTP methods supported by the API. enum HttpMethod { get, post, put, delete } diff --git a/lib/src/shared/utils/error_messages.dart b/lib/src/foundation/networking/error_message_utils.dart similarity index 89% rename from lib/src/shared/utils/error_messages.dart rename to lib/src/foundation/networking/error_message_utils.dart index 6607197a7..3489bb080 100644 --- a/lib/src/shared/utils/error_messages.dart +++ b/lib/src/foundation/networking/error_message_utils.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/errors/api_exception.dart'; /// Generates a user-friendly error message from an exception (or any thrown object) String getExceptionErrorMessage(Object? e, {String? additionalInfo}) { diff --git a/lib/src/core/network/lemmy/base_lemmy_api_client.dart b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart similarity index 91% rename from lib/src/core/network/lemmy/base_lemmy_api_client.dart rename to lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart index a502aba2a..0c1633e4f 100644 --- a/lib/src/core/network/lemmy/base_lemmy_api_client.dart +++ b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart @@ -1,20 +1,20 @@ -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/network/base_api_client.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/feed_list_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/meta_search_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/search_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; +import 'package:thunder/src/foundation/networking/base_api_client.dart'; +import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// Base class for Lemmy API clients, containing shared parsing helpers. /// Endpoint implementations live in version-specific clients. @@ -305,6 +305,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli int? page, int? limit, bool? saved, + bool? includeContent, }) async { throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); } @@ -466,7 +467,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli // ============================================================= @override - Future> getModlog({ + Future> getModlog({ int? page, int? limit, ModlogActionType? modlogActionType, @@ -478,11 +479,11 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); } - /// Given a modlog event, return a normalized [ModlogEventItem]. - ModlogEventItem parseModlogEvent(ModlogActionType type, dynamic event) { + /// Given a modlog event, return a normalized [ModlogEvent]. + ModlogEvent parseModlogEvent(ModlogActionType type, dynamic event) { switch (type) { case ModlogActionType.modRemovePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -492,7 +493,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_remove_post']['removed'], ); case ModlogActionType.modLockPost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_lock_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -501,7 +502,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_lock_post']['locked'], ); case ModlogActionType.modFeaturePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_feature_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -510,7 +511,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_feature_post']['featured'], ); case ModlogActionType.modRemoveComment: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_comment']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -522,7 +523,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_remove_comment']['removed'], ); case ModlogActionType.modRemoveCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -531,7 +532,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_remove_community']['removed'], ); case ModlogActionType.modBanFromCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_ban_from_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -541,7 +542,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_ban_from_community']['banned'], ); case ModlogActionType.modBan: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_ban']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -550,7 +551,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: event['mod_ban']['banned'], ); case ModlogActionType.modAddCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_add_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -559,7 +560,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: !event['mod_add_community']['removed'], ); case ModlogActionType.modTransferCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_transfer_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -568,7 +569,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: true, ); case ModlogActionType.modAdd: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_add']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -576,7 +577,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: !event['mod_add']['removed'], ); case ModlogActionType.adminPurgePerson: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_person']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -584,7 +585,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: true, ); case ModlogActionType.adminPurgeCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_community']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -592,7 +593,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: true, ); case ModlogActionType.adminPurgePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_post']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -600,7 +601,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: true, ); case ModlogActionType.adminPurgeComment: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_comment']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -608,7 +609,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli actioned: true, ); case ModlogActionType.modHideCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_hide_community']['when'], admin: event['admin'] != null ? parseUser(event['admin']) : null, diff --git a/lib/src/core/network/lemmy/lemmy_v3_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart similarity index 90% rename from lib/src/core/network/lemmy/lemmy_v3_api_client.dart rename to lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart index 6110516f0..cb0ee3b2d 100644 --- a/lib/src/core/network/lemmy/lemmy_v3_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart @@ -2,25 +2,26 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/base_api_client.dart'; -import 'package:thunder/src/core/network/lemmy/base_lemmy_api_client.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/feed_list_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/meta_search_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/search_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; +import 'package:thunder/src/foundation/errors/api_exception.dart'; +import 'package:thunder/src/foundation/networking/base_api_client.dart'; +import 'package:thunder/src/foundation/networking/lemmy/base_lemmy_api_client.dart'; +import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// Lemmy API client for version 0.19.x (v3 API). /// @@ -109,12 +110,12 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { 'comment_id': commentId, }); - final posts = await parsePosts([parsePost(json['post_view'])]); + final post = parsePost(json['post_view']); final moderators = (json['moderators'] as List).map((mu) => parseUser(mu['moderator'])).toList(); final crossPosts = (json['cross_posts'] as List).map((cp) => parsePost(cp)).toList(); return ( - post: posts.first, + post: post, moderators: moderators, crossPosts: crossPosts, ); @@ -522,6 +523,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { int? page, int? limit, bool? saved, + bool? includeContent, }) async { final json = await request(HttpMethod.get, '$basePath/user', { 'person_id': userId, @@ -811,7 +813,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { // ============================================================= @override - Future> getModlog({ + Future> getModlog({ int? page, int? limit, ModlogActionType? modlogActionType, @@ -830,24 +832,24 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { 'comment_id': commentId, }); - List items = []; + List items = []; // Convert the response to a list of modlog events - List removedPosts = (response['removed_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemovePost, e)).toList(); - List lockedPosts = (response['locked_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modLockPost, e)).toList(); - List featuredPosts = (response['featured_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modFeaturePost, e)).toList(); - List removedComments = (response['removed_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveComment, e)).toList(); - List removedCommunities = (response['removed_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveCommunity, e)).toList(); - List bannedFromCommunity = (response['banned_from_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modBanFromCommunity, e)).toList(); - List banned = (response['banned'] as List).map((e) => parseModlogEvent(ModlogActionType.modBan, e)).toList(); - List addedToCommunity = (response['added_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modAddCommunity, e)).toList(); - List transferredToCommunity = (response['transferred_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modTransferCommunity, e)).toList(); - List added = (response['added'] as List).map((e) => parseModlogEvent(ModlogActionType.modAdd, e)).toList(); - List adminPurgedPersons = (response['admin_purged_persons'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePerson, e)).toList(); - List adminPurgedCommunities = (response['admin_purged_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeCommunity, e)).toList(); - List adminPurgedPosts = (response['admin_purged_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePost, e)).toList(); - List adminPurgedComments = (response['admin_purged_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeComment, e)).toList(); - List hiddenCommunities = (response['hidden_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modHideCommunity, e)).toList(); + List removedPosts = (response['removed_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemovePost, e)).toList(); + List lockedPosts = (response['locked_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modLockPost, e)).toList(); + List featuredPosts = (response['featured_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.modFeaturePost, e)).toList(); + List removedComments = (response['removed_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveComment, e)).toList(); + List removedCommunities = (response['removed_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modRemoveCommunity, e)).toList(); + List bannedFromCommunity = (response['banned_from_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modBanFromCommunity, e)).toList(); + List banned = (response['banned'] as List).map((e) => parseModlogEvent(ModlogActionType.modBan, e)).toList(); + List addedToCommunity = (response['added_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modAddCommunity, e)).toList(); + List transferredToCommunity = (response['transferred_to_community'] as List).map((e) => parseModlogEvent(ModlogActionType.modTransferCommunity, e)).toList(); + List added = (response['added'] as List).map((e) => parseModlogEvent(ModlogActionType.modAdd, e)).toList(); + List adminPurgedPersons = (response['admin_purged_persons'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePerson, e)).toList(); + List adminPurgedCommunities = (response['admin_purged_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeCommunity, e)).toList(); + List adminPurgedPosts = (response['admin_purged_posts'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgePost, e)).toList(); + List adminPurgedComments = (response['admin_purged_comments'] as List).map((e) => parseModlogEvent(ModlogActionType.adminPurgeComment, e)).toList(); + List hiddenCommunities = (response['hidden_communities'] as List).map((e) => parseModlogEvent(ModlogActionType.modHideCommunity, e)).toList(); items.addAll(removedPosts); items.addAll(lockedPosts); @@ -868,12 +870,12 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { return items; } - /// Given a modlog event, return a normalized [ModlogEventItem]. + /// Given a modlog event, return a normalized [ModlogEvent]. @override - ModlogEventItem parseModlogEvent(ModlogActionType type, dynamic event) { + ModlogEvent parseModlogEvent(ModlogActionType type, dynamic event) { switch (type) { case ModlogActionType.modRemovePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -883,7 +885,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_remove_post']['removed'], ); case ModlogActionType.modLockPost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_lock_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -892,7 +894,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_lock_post']['locked'], ); case ModlogActionType.modFeaturePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_feature_post']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -901,7 +903,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_feature_post']['featured'], ); case ModlogActionType.modRemoveComment: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_comment']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -913,7 +915,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_remove_comment']['removed'], ); case ModlogActionType.modRemoveCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_remove_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -922,7 +924,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_remove_community']['removed'], ); case ModlogActionType.modBanFromCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_ban_from_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -932,7 +934,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_ban_from_community']['banned'], ); case ModlogActionType.modBan: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_ban']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -941,7 +943,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: event['mod_ban']['banned'], ); case ModlogActionType.modAddCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_add_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -950,7 +952,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: !event['mod_add_community']['removed'], ); case ModlogActionType.modTransferCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_transfer_community']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -959,7 +961,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: true, ); case ModlogActionType.modAdd: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_add']['when_'], moderator: event['moderator'] != null ? parseUser(event['moderator']) : null, @@ -967,7 +969,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: !event['mod_add']['removed'], ); case ModlogActionType.adminPurgePerson: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_person']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -975,7 +977,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: true, ); case ModlogActionType.adminPurgeCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_community']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -983,7 +985,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: true, ); case ModlogActionType.adminPurgePost: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_post']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -991,7 +993,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: true, ); case ModlogActionType.adminPurgeComment: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['admin_purge_comment']['when_'], admin: event['admin'] != null ? parseUser(event['admin']) : null, @@ -999,7 +1001,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { actioned: true, ); case ModlogActionType.modHideCommunity: - return ModlogEventItem( + return ModlogEvent( type: type, dateTime: event['mod_hide_community']['when'], admin: event['admin'] != null ? parseUser(event['admin']) : null, diff --git a/lib/src/core/network/lemmy/lemmy_v4_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart similarity index 86% rename from lib/src/core/network/lemmy/lemmy_v4_api_client.dart rename to lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart index a7f7655ab..43f29077e 100644 --- a/lib/src/core/network/lemmy/lemmy_v4_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v4_api_client.dart @@ -1,9 +1,9 @@ -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/network/lemmy/base_lemmy_api_client.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; +import 'package:thunder/src/foundation/networking/lemmy/base_lemmy_api_client.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// Lemmy API client for version 1.0.0+ (v4 API). /// diff --git a/lib/src/foundation/networking/networking.dart b/lib/src/foundation/networking/networking.dart new file mode 100644 index 000000000..90d0dd9f6 --- /dev/null +++ b/lib/src/foundation/networking/networking.dart @@ -0,0 +1,4 @@ +export 'api_client_factory.dart'; +export 'base_api_client.dart'; +export 'error_message_utils.dart'; +export 'thunder_api_client.dart'; diff --git a/lib/src/core/network/piefed/piefed_api_client.dart b/lib/src/foundation/networking/piefed/piefed_api_client.dart similarity index 96% rename from lib/src/core/network/piefed/piefed_api_client.dart rename to lib/src/foundation/networking/piefed/piefed_api_client.dart index 5601c854c..77af1224d 100644 --- a/lib/src/core/network/piefed/piefed_api_client.dart +++ b/lib/src/foundation/networking/piefed/piefed_api_client.dart @@ -2,24 +2,25 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/core/network/api_exception.dart'; -import 'package:thunder/src/core/network/base_api_client.dart'; -import 'package:thunder/src/core/network/thunder_api_client.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/feed_list_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/meta_search_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/search_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; +import 'package:thunder/src/foundation/errors/api_exception.dart'; +import 'package:thunder/src/foundation/networking/base_api_client.dart'; +import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// PieFed API client for the `/api/alpha` endpoints. class PiefedApiClient extends BaseApiClient implements ThunderApiClient { @@ -102,11 +103,10 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { }); final post = ThunderPost.fromPiefedPostView(json['post_view']); - final posts = await parsePosts([post]); final moderators = (json['moderators'] as List).map((mu) => ThunderUser.fromPiefedUser(mu['moderator'])).toList(); return ( - post: posts.first, + post: post, moderators: moderators, crossPosts: [], // PieFed doesn't return cross posts ); @@ -770,6 +770,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { int? page, int? limit, bool? saved, + bool? includeContent, }) async { final json = await request(HttpMethod.get, '$basePath/user', { 'person_id': userId, @@ -778,6 +779,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { 'page': page, 'limit': limit, 'saved_only': saved, + 'include_content': includeContent, }); return ( @@ -1220,7 +1222,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { // ============================================================= @override - Future> getModlog({ + Future> getModlog({ int? page, int? limit, ModlogActionType? modlogActionType, diff --git a/lib/src/core/network/thunder_api_client.dart b/lib/src/foundation/networking/thunder_api_client.dart similarity index 90% rename from lib/src/core/network/thunder_api_client.dart rename to lib/src/foundation/networking/thunder_api_client.dart index 1ea325c08..fabcfb0d6 100644 --- a/lib/src/core/network/thunder_api_client.dart +++ b/lib/src/foundation/networking/thunder_api_client.dart @@ -1,18 +1,19 @@ -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/core/models/thunder_comment_report.dart'; -import 'package:thunder/src/core/models/thunder_post_report.dart'; -import 'package:thunder/src/core/models/thunder_private_message.dart'; -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/core/models/thunder_site_response.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/modlog/modlog.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/comment_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/feed_list_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/meta_search_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/search_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site_response.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; /// Response from getting a single post. typedef GetPostResponse = ({ @@ -276,6 +277,7 @@ abstract class ThunderApiClient { int? page, int? limit, bool? saved, + bool? includeContent, }); /// Block or unblock a user. @@ -401,7 +403,7 @@ abstract class ThunderApiClient { // ============================================================= /// Get modlog entries. - Future> getModlog({ + Future> getModlog({ int? page, int? limit, ModlogActionType? modlogActionType, diff --git a/lib/src/core/database/database.dart b/lib/src/foundation/persistence/database/database.dart similarity index 95% rename from lib/src/core/database/database.dart rename to lib/src/foundation/persistence/database/database.dart index f25b66b92..eda1d915f 100644 --- a/lib/src/core/database/database.dart +++ b/lib/src/foundation/persistence/database/database.dart @@ -3,10 +3,10 @@ import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; -import 'package:thunder/src/core/database/tables.dart'; -import 'package:thunder/src/core/database/type_converters.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/foundation/persistence/database/tables.dart'; +import 'package:thunder/src/foundation/persistence/database/type_converters.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; import 'database.steps.dart'; part 'database.g.dart'; diff --git a/lib/src/core/database/database.g.dart b/lib/src/foundation/persistence/database/database.g.dart similarity index 100% rename from lib/src/core/database/database.g.dart rename to lib/src/foundation/persistence/database/database.g.dart diff --git a/lib/src/core/database/database.steps.dart b/lib/src/foundation/persistence/database/database.steps.dart similarity index 100% rename from lib/src/core/database/database.steps.dart rename to lib/src/foundation/persistence/database/database.steps.dart diff --git a/lib/src/core/database/database_utils.dart b/lib/src/foundation/persistence/database/database_utils.dart similarity index 93% rename from lib/src/core/database/database_utils.dart rename to lib/src/foundation/persistence/database/database_utils.dart index db1cdb53c..68926437e 100644 --- a/lib/src/core/database/database_utils.dart +++ b/lib/src/foundation/persistence/database/database_utils.dart @@ -6,8 +6,8 @@ import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/contracts/account.dart'; /// Exports the database to a file. Future exportDatabase() async { diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v1.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v1.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v1.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v1.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v2.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v2.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v2.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v2.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v3.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v3.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v3.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v3.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v4.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v4.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v4.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v4.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v5.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v5.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v5.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v5.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v6.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v6.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v6.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v6.json diff --git a/lib/src/core/database/schemas/thunder/drift_schema_v7.json b/lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v7.json similarity index 100% rename from lib/src/core/database/schemas/thunder/drift_schema_v7.json rename to lib/src/foundation/persistence/database/schemas/thunder/drift_schema_v7.json diff --git a/lib/src/core/database/tables.dart b/lib/src/foundation/persistence/database/tables.dart similarity index 95% rename from lib/src/core/database/tables.dart rename to lib/src/foundation/persistence/database/tables.dart index 7a052e23e..5deb4580d 100644 --- a/lib/src/core/database/tables.dart +++ b/lib/src/foundation/persistence/database/tables.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; -import 'package:thunder/src/core/database/type_converters.dart'; +import 'package:thunder/src/foundation/persistence/database/type_converters.dart'; class Accounts extends Table { IntColumn get id => integer().autoIncrement()(); diff --git a/lib/src/core/database/type_converters.dart b/lib/src/foundation/persistence/database/type_converters.dart similarity index 77% rename from lib/src/core/database/type_converters.dart rename to lib/src/foundation/persistence/database/type_converters.dart index 70b7236f8..3983a23cd 100644 --- a/lib/src/core/database/type_converters.dart +++ b/lib/src/foundation/persistence/database/type_converters.dart @@ -1,7 +1,7 @@ import 'package:drift/drift.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/src/features/drafts/drafts.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/draft_type.dart'; class DraftTypeConverter extends TypeConverter { const DraftTypeConverter(); diff --git a/lib/src/foundation/persistence/database_provider.dart b/lib/src/foundation/persistence/database_provider.dart new file mode 100644 index 000000000..dc96950fa --- /dev/null +++ b/lib/src/foundation/persistence/database_provider.dart @@ -0,0 +1,7 @@ +import 'package:thunder/src/foundation/persistence/database/database.dart'; + +late AppDatabase database; + +void initializeDatabase() { + database = AppDatabase(); +} diff --git a/lib/src/foundation/persistence/persistence.dart b/lib/src/foundation/persistence/persistence.dart new file mode 100644 index 000000000..11934f810 --- /dev/null +++ b/lib/src/foundation/persistence/persistence.dart @@ -0,0 +1,6 @@ +export 'database/database.dart' hide Account, Favorite, LocalSubscription, UserLabel, Draft; +export 'database/database_utils.dart'; +export 'database/tables.dart'; +export 'database/type_converters.dart'; +export 'preferences.dart'; +export 'database_provider.dart'; diff --git a/lib/src/core/singletons/preferences.dart b/lib/src/foundation/persistence/preferences.dart similarity index 98% rename from lib/src/core/singletons/preferences.dart rename to lib/src/foundation/persistence/preferences.dart index c6da817c0..54ea30608 100644 --- a/lib/src/core/singletons/preferences.dart +++ b/lib/src/foundation/persistence/preferences.dart @@ -6,7 +6,7 @@ import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; +import 'package:thunder/src/foundation/primitives/enums/local_settings.dart'; /// A singleton class to manage user preferences using SharedPreferences. /// diff --git a/lib/src/core/enums/action_color.dart b/lib/src/foundation/primitives/enums/action_color.dart similarity index 100% rename from lib/src/core/enums/action_color.dart rename to lib/src/foundation/primitives/enums/action_color.dart diff --git a/lib/src/core/enums/browser_mode.dart b/lib/src/foundation/primitives/enums/browser_mode.dart similarity index 100% rename from lib/src/core/enums/browser_mode.dart rename to lib/src/foundation/primitives/enums/browser_mode.dart diff --git a/lib/src/core/enums/comment_sort_type.dart b/lib/src/foundation/primitives/enums/comment_sort_type.dart similarity index 81% rename from lib/src/core/enums/comment_sort_type.dart rename to lib/src/foundation/primitives/enums/comment_sort_type.dart index cc71ee95d..d6f6019da 100644 --- a/lib/src/core/enums/comment_sort_type.dart +++ b/lib/src/foundation/primitives/enums/comment_sort_type.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum CommentSortType { hot('Hot'), diff --git a/lib/src/core/enums/custom_theme_type.dart b/lib/src/foundation/primitives/enums/custom_theme_type.dart similarity index 100% rename from lib/src/core/enums/custom_theme_type.dart rename to lib/src/foundation/primitives/enums/custom_theme_type.dart diff --git a/lib/src/features/drafts/domain/enums/draft_type.dart b/lib/src/foundation/primitives/enums/draft_type.dart similarity index 100% rename from lib/src/features/drafts/domain/enums/draft_type.dart rename to lib/src/foundation/primitives/enums/draft_type.dart diff --git a/lib/src/foundation/primitives/enums/enums.dart b/lib/src/foundation/primitives/enums/enums.dart new file mode 100644 index 000000000..b1e5014d4 --- /dev/null +++ b/lib/src/foundation/primitives/enums/enums.dart @@ -0,0 +1,27 @@ +export 'action_color.dart'; +export 'browser_mode.dart'; +export 'comment_sort_type.dart'; +export 'custom_theme_type.dart'; +export 'draft_type.dart'; +export 'feed_card_divider_thickness.dart'; +export 'feed_list_type.dart'; +export 'font_scale.dart'; +export 'image_caching_mode.dart'; +export 'internet_connection_type.dart'; +export 'local_settings.dart'; +export 'media_type.dart'; +export 'meta_search_type.dart'; +export 'modlog_action_type.dart'; +export 'nested_comment_indicator.dart'; +export 'post_body_view_type.dart'; +export 'post_card_metadata_item.dart'; +export 'post_sort_type.dart'; +export 'search_sort_type.dart'; +export 'subscription_status.dart'; +export 'theme_type.dart'; +export 'threadiverse_platform.dart'; +export 'user_type.dart'; +export 'video_auto_play.dart'; +export 'video_playback_speed.dart'; +export 'video_player_mode.dart'; +export 'view_mode.dart'; diff --git a/lib/src/core/enums/feed_card_divider_thickness.dart b/lib/src/foundation/primitives/enums/feed_card_divider_thickness.dart similarity index 92% rename from lib/src/core/enums/feed_card_divider_thickness.dart rename to lib/src/foundation/primitives/enums/feed_card_divider_thickness.dart index 1bd2df3c6..539bf0726 100644 --- a/lib/src/core/enums/feed_card_divider_thickness.dart +++ b/lib/src/foundation/primitives/enums/feed_card_divider_thickness.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; enum FeedCardDividerThickness { compact, diff --git a/lib/src/core/enums/feed_list_type.dart b/lib/src/foundation/primitives/enums/feed_list_type.dart similarity index 86% rename from lib/src/core/enums/feed_list_type.dart rename to lib/src/foundation/primitives/enums/feed_list_type.dart index bbb7bdb97..eccc3e676 100644 --- a/lib/src/core/enums/feed_list_type.dart +++ b/lib/src/foundation/primitives/enums/feed_list_type.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum FeedListType { all('All'), diff --git a/lib/src/core/enums/font_scale.dart b/lib/src/foundation/primitives/enums/font_scale.dart similarity index 94% rename from lib/src/core/enums/font_scale.dart rename to lib/src/foundation/primitives/enums/font_scale.dart index 2bdcfa116..f0481e5bb 100644 --- a/lib/src/core/enums/font_scale.dart +++ b/lib/src/foundation/primitives/enums/font_scale.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; enum FontScale { small, diff --git a/lib/src/core/enums/image_caching_mode.dart b/lib/src/foundation/primitives/enums/image_caching_mode.dart similarity index 100% rename from lib/src/core/enums/image_caching_mode.dart rename to lib/src/foundation/primitives/enums/image_caching_mode.dart diff --git a/lib/src/core/enums/internet_connection_type.dart b/lib/src/foundation/primitives/enums/internet_connection_type.dart similarity index 100% rename from lib/src/core/enums/internet_connection_type.dart rename to lib/src/foundation/primitives/enums/internet_connection_type.dart diff --git a/lib/src/core/enums/local_settings.dart b/lib/src/foundation/primitives/enums/local_settings.dart similarity index 100% rename from lib/src/core/enums/local_settings.dart rename to lib/src/foundation/primitives/enums/local_settings.dart diff --git a/lib/src/core/enums/media_type.dart b/lib/src/foundation/primitives/enums/media_type.dart similarity index 100% rename from lib/src/core/enums/media_type.dart rename to lib/src/foundation/primitives/enums/media_type.dart diff --git a/lib/src/core/enums/meta_search_type.dart b/lib/src/foundation/primitives/enums/meta_search_type.dart similarity index 81% rename from lib/src/core/enums/meta_search_type.dart rename to lib/src/foundation/primitives/enums/meta_search_type.dart index 02e68582f..085c2c574 100644 --- a/lib/src/core/enums/meta_search_type.dart +++ b/lib/src/foundation/primitives/enums/meta_search_type.dart @@ -1,6 +1,6 @@ -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum MetaSearchType { all(searchType: 'All'), diff --git a/lib/src/features/modlog/domain/enums/modlog_action_type.dart b/lib/src/foundation/primitives/enums/modlog_action_type.dart similarity index 100% rename from lib/src/features/modlog/domain/enums/modlog_action_type.dart rename to lib/src/foundation/primitives/enums/modlog_action_type.dart diff --git a/lib/src/core/enums/nested_comment_indicator.dart b/lib/src/foundation/primitives/enums/nested_comment_indicator.dart similarity index 100% rename from lib/src/core/enums/nested_comment_indicator.dart rename to lib/src/foundation/primitives/enums/nested_comment_indicator.dart diff --git a/lib/src/core/enums/post_body_view_type.dart b/lib/src/foundation/primitives/enums/post_body_view_type.dart similarity index 100% rename from lib/src/core/enums/post_body_view_type.dart rename to lib/src/foundation/primitives/enums/post_body_view_type.dart diff --git a/lib/src/features/post/domain/enums/post_card_metadata_item.dart b/lib/src/foundation/primitives/enums/post_card_metadata_item.dart similarity index 100% rename from lib/src/features/post/domain/enums/post_card_metadata_item.dart rename to lib/src/foundation/primitives/enums/post_card_metadata_item.dart diff --git a/lib/src/core/enums/post_sort_type.dart b/lib/src/foundation/primitives/enums/post_sort_type.dart similarity index 91% rename from lib/src/core/enums/post_sort_type.dart rename to lib/src/foundation/primitives/enums/post_sort_type.dart index 3733d20ff..1e80cf8f4 100644 --- a/lib/src/core/enums/post_sort_type.dart +++ b/lib/src/foundation/primitives/enums/post_sort_type.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum PostSortType { active('Active'), diff --git a/lib/src/core/enums/search_sort_type.dart b/lib/src/foundation/primitives/enums/search_sort_type.dart similarity index 88% rename from lib/src/core/enums/search_sort_type.dart rename to lib/src/foundation/primitives/enums/search_sort_type.dart index a1e496050..d8efb0f52 100644 --- a/lib/src/core/enums/search_sort_type.dart +++ b/lib/src/foundation/primitives/enums/search_sort_type.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum SearchSortType { new_('New'), diff --git a/lib/src/core/enums/subscription_status.dart b/lib/src/foundation/primitives/enums/subscription_status.dart similarity index 82% rename from lib/src/core/enums/subscription_status.dart rename to lib/src/foundation/primitives/enums/subscription_status.dart index e069adc98..bd133553a 100644 --- a/lib/src/core/enums/subscription_status.dart +++ b/lib/src/foundation/primitives/enums/subscription_status.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; enum SubscriptionStatus { subscribed('Subscribed'), diff --git a/lib/src/core/enums/theme_type.dart b/lib/src/foundation/primitives/enums/theme_type.dart similarity index 100% rename from lib/src/core/enums/theme_type.dart rename to lib/src/foundation/primitives/enums/theme_type.dart diff --git a/lib/src/core/enums/threadiverse_platform.dart b/lib/src/foundation/primitives/enums/threadiverse_platform.dart similarity index 100% rename from lib/src/core/enums/threadiverse_platform.dart rename to lib/src/foundation/primitives/enums/threadiverse_platform.dart diff --git a/lib/src/core/enums/user_type.dart b/lib/src/foundation/primitives/enums/user_type.dart similarity index 100% rename from lib/src/core/enums/user_type.dart rename to lib/src/foundation/primitives/enums/user_type.dart diff --git a/lib/src/core/enums/video_auto_play.dart b/lib/src/foundation/primitives/enums/video_auto_play.dart similarity index 100% rename from lib/src/core/enums/video_auto_play.dart rename to lib/src/foundation/primitives/enums/video_auto_play.dart diff --git a/lib/src/core/enums/video_playback_speed.dart b/lib/src/foundation/primitives/enums/video_playback_speed.dart similarity index 100% rename from lib/src/core/enums/video_playback_speed.dart rename to lib/src/foundation/primitives/enums/video_playback_speed.dart diff --git a/lib/src/core/enums/video_player_mode.dart b/lib/src/foundation/primitives/enums/video_player_mode.dart similarity index 100% rename from lib/src/core/enums/video_player_mode.dart rename to lib/src/foundation/primitives/enums/video_player_mode.dart diff --git a/lib/src/core/enums/view_mode.dart b/lib/src/foundation/primitives/enums/view_mode.dart similarity index 100% rename from lib/src/core/enums/view_mode.dart rename to lib/src/foundation/primitives/enums/view_mode.dart diff --git a/lib/src/core/models/media.dart b/lib/src/foundation/primitives/models/media.dart similarity index 71% rename from lib/src/core/models/media.dart rename to lib/src/foundation/primitives/models/media.dart index 88fbb3f23..2fefb4ddf 100644 --- a/lib/src/core/models/media.dart +++ b/lib/src/foundation/primitives/models/media.dart @@ -1,5 +1,4 @@ -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/src/foundation/primitives/enums/media_type.dart'; /// The Media class represents information for a given media source. class Media { @@ -41,7 +40,7 @@ class Media { String? contentType; /// Gets the full-size image URL, if any - String? get imageUrl => isImageUrl(mediaUrl ?? '') ? mediaUrl : thumbnailUrl; + String? get imageUrl => _isImageUrl(mediaUrl ?? '') ? mediaUrl : thumbnailUrl; @override String toString() { @@ -59,3 +58,22 @@ class Media { '''; } } + +bool _isImageUrl(String url) { + final imageExtensions = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.bmp', + '.webp', + '.avif', + '@jpeg', + ]; + + final uri = Uri.tryParse(url); + if (uri == null) return false; + + final path = uri.path.toLowerCase(); + return imageExtensions.any(path.endsWith); +} diff --git a/lib/src/foundation/primitives/models/models.dart b/lib/src/foundation/primitives/models/models.dart new file mode 100644 index 000000000..7d2ca1dfb --- /dev/null +++ b/lib/src/foundation/primitives/models/models.dart @@ -0,0 +1,18 @@ +export 'media.dart'; +export 'modlog_event_item.dart'; +export 'parsed_link.dart'; +export 'thunder_comment.dart'; +export 'thunder_comment_report.dart'; +export 'thunder_community.dart'; +export 'thunder_instance_info.dart'; +export 'thunder_language.dart'; +export 'thunder_local_user.dart'; +export 'thunder_my_user.dart'; +export 'thunder_post.dart'; +export 'thunder_post_report.dart'; +export 'thunder_private_message.dart'; +export 'thunder_site.dart'; +export 'thunder_site_response.dart'; +export 'thunder_tagline.dart'; +export 'thunder_user.dart'; +export 'version.dart'; diff --git a/lib/src/foundation/primitives/models/modlog_event_item.dart b/lib/src/foundation/primitives/models/modlog_event_item.dart new file mode 100644 index 000000000..70dcc769f --- /dev/null +++ b/lib/src/foundation/primitives/models/modlog_event_item.dart @@ -0,0 +1,32 @@ +import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; + +/// Pure foundation DTO used by networking boundaries for modlog entries. +class ModlogEvent { + const ModlogEvent({ + required this.type, + required this.dateTime, + this.moderator, + this.admin, + this.reason, + this.user, + this.post, + this.comment, + this.community, + required this.actioned, + }); + + final ModlogActionType type; + final String dateTime; + final ThunderUser? moderator; + final ThunderUser? admin; + final String? reason; + final ThunderUser? user; + final ThunderPost? post; + final ThunderComment? comment; + final ThunderCommunity? community; + final bool actioned; +} diff --git a/lib/src/foundation/primitives/models/parsed_link.dart b/lib/src/foundation/primitives/models/parsed_link.dart new file mode 100644 index 000000000..8cdb549ca --- /dev/null +++ b/lib/src/foundation/primitives/models/parsed_link.dart @@ -0,0 +1,22 @@ +/// Result of parsing a link, containing the extracted value and source instance. +class ParsedLink { + /// The extracted value (community name, username, or ID as string) + final String value; + + /// The source instance from the URL + final String instance; + + const ParsedLink({required this.value, required this.instance}); + + /// Returns the value in qualified format: value@instance + String get qualified => '$value@$instance'; + + @override + String toString() => 'ParsedLink(value: $value, instance: $instance)'; + + @override + bool operator ==(Object other) => identical(this, other) || other is ParsedLink && runtimeType == other.runtimeType && value == other.value && instance == other.instance; + + @override + int get hashCode => value.hashCode ^ instance.hashCode; +} diff --git a/lib/src/features/comment/data/models/thunder_comment.dart b/lib/src/foundation/primitives/models/thunder_comment.dart similarity index 96% rename from lib/src/features/comment/data/models/thunder_comment.dart rename to lib/src/foundation/primitives/models/thunder_comment.dart index 7137179dc..57dda3307 100644 --- a/lib/src/features/comment/data/models/thunder_comment.dart +++ b/lib/src/foundation/primitives/models/thunder_comment.dart @@ -1,9 +1,9 @@ import 'package:equatable/equatable.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderComment extends Equatable { /// The comment's ID diff --git a/lib/src/core/models/thunder_comment_report.dart b/lib/src/foundation/primitives/models/thunder_comment_report.dart similarity index 92% rename from lib/src/core/models/thunder_comment_report.dart rename to lib/src/foundation/primitives/models/thunder_comment_report.dart index df37c4026..3fb25ec08 100644 --- a/lib/src/core/models/thunder_comment_report.dart +++ b/lib/src/foundation/primitives/models/thunder_comment_report.dart @@ -1,10 +1,10 @@ import 'package:collection/collection.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderCommentReport { /// The comment report's ID. diff --git a/lib/src/features/community/data/models/thunder_community.dart b/lib/src/foundation/primitives/models/thunder_community.dart similarity index 99% rename from lib/src/features/community/data/models/thunder_community.dart rename to lib/src/foundation/primitives/models/thunder_community.dart index 0ff4efc77..4511269cd 100644 --- a/lib/src/features/community/data/models/thunder_community.dart +++ b/lib/src/foundation/primitives/models/thunder_community.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; class ThunderCommunity extends Equatable { /// The community's ID diff --git a/lib/src/core/models/thunder_instance_info.dart b/lib/src/foundation/primitives/models/thunder_instance_info.dart similarity index 92% rename from lib/src/core/models/thunder_instance_info.dart rename to lib/src/foundation/primitives/models/thunder_instance_info.dart index 6f2ba4d13..dfeca9d2e 100644 --- a/lib/src/core/models/thunder_instance_info.dart +++ b/lib/src/foundation/primitives/models/thunder_instance_info.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; +import 'package:thunder/src/foundation/primitives/enums/threadiverse_platform.dart'; /// A class that holds metadata about an instance. class ThunderInstanceInfo { diff --git a/lib/src/core/models/thunder_language.dart b/lib/src/foundation/primitives/models/thunder_language.dart similarity index 100% rename from lib/src/core/models/thunder_language.dart rename to lib/src/foundation/primitives/models/thunder_language.dart diff --git a/lib/src/core/models/thunder_local_user.dart b/lib/src/foundation/primitives/models/thunder_local_user.dart similarity index 94% rename from lib/src/core/models/thunder_local_user.dart rename to lib/src/foundation/primitives/models/thunder_local_user.dart index 438d888b6..86d937938 100644 --- a/lib/src/core/models/thunder_local_user.dart +++ b/lib/src/foundation/primitives/models/thunder_local_user.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/feed_list_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; +import 'package:thunder/src/foundation/primitives/enums/feed_list_type.dart'; class ThunderLocalUser { /// The local user's email. diff --git a/lib/src/core/models/thunder_my_user.dart b/lib/src/foundation/primitives/models/thunder_my_user.dart similarity index 95% rename from lib/src/core/models/thunder_my_user.dart rename to lib/src/foundation/primitives/models/thunder_my_user.dart index c36ef921f..70fbe2d14 100644 --- a/lib/src/core/models/thunder_my_user.dart +++ b/lib/src/foundation/primitives/models/thunder_my_user.dart @@ -1,6 +1,6 @@ -import 'package:thunder/src/core/models/thunder_local_user.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_local_user.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderLocalUserView { /// The local user data. diff --git a/lib/src/features/post/data/models/thunder_post.dart b/lib/src/foundation/primitives/models/thunder_post.dart similarity index 97% rename from lib/src/features/post/data/models/thunder_post.dart rename to lib/src/foundation/primitives/models/thunder_post.dart index 9af2130ce..216744f45 100644 --- a/lib/src/features/post/data/models/thunder_post.dart +++ b/lib/src/foundation/primitives/models/thunder_post.dart @@ -1,9 +1,9 @@ import 'package:equatable/equatable.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/core/models/media.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/models/media.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderPost extends Equatable { /// The post's ID diff --git a/lib/src/core/models/thunder_post_report.dart b/lib/src/foundation/primitives/models/thunder_post_report.dart similarity index 88% rename from lib/src/core/models/thunder_post_report.dart rename to lib/src/foundation/primitives/models/thunder_post_report.dart index d9e57c524..cce30fa10 100644 --- a/lib/src/core/models/thunder_post_report.dart +++ b/lib/src/foundation/primitives/models/thunder_post_report.dart @@ -1,6 +1,6 @@ -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderPostReport { /// The post report's ID. diff --git a/lib/src/core/models/thunder_private_message.dart b/lib/src/foundation/primitives/models/thunder_private_message.dart similarity index 96% rename from lib/src/core/models/thunder_private_message.dart rename to lib/src/foundation/primitives/models/thunder_private_message.dart index b9281c632..ab5278a8f 100644 --- a/lib/src/core/models/thunder_private_message.dart +++ b/lib/src/foundation/primitives/models/thunder_private_message.dart @@ -1,4 +1,4 @@ -import 'package:thunder/src/features/user/user.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; class ThunderPrivateMessage { /// The private message's ID. diff --git a/lib/src/core/models/thunder_site.dart b/lib/src/foundation/primitives/models/thunder_site.dart similarity index 100% rename from lib/src/core/models/thunder_site.dart rename to lib/src/foundation/primitives/models/thunder_site.dart diff --git a/lib/src/core/models/thunder_site_response.dart b/lib/src/foundation/primitives/models/thunder_site_response.dart similarity index 89% rename from lib/src/core/models/thunder_site_response.dart rename to lib/src/foundation/primitives/models/thunder_site_response.dart index b5a4d4cc6..2580f6dd8 100644 --- a/lib/src/core/models/thunder_site_response.dart +++ b/lib/src/foundation/primitives/models/thunder_site_response.dart @@ -1,7 +1,7 @@ -import 'package:thunder/src/core/models/thunder_site.dart'; -import 'package:thunder/src/core/models/thunder_my_user.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; -import 'package:thunder/src/core/models/thunder_tagline.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_my_user.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_language.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_tagline.dart'; class ThunderSiteResponse { /// The site view containing site information. diff --git a/lib/src/core/models/thunder_tagline.dart b/lib/src/foundation/primitives/models/thunder_tagline.dart similarity index 100% rename from lib/src/core/models/thunder_tagline.dart rename to lib/src/foundation/primitives/models/thunder_tagline.dart diff --git a/lib/src/features/user/data/models/thunder_user.dart b/lib/src/foundation/primitives/models/thunder_user.dart similarity index 100% rename from lib/src/features/user/data/models/thunder_user.dart rename to lib/src/foundation/primitives/models/thunder_user.dart diff --git a/lib/src/core/models/version.dart b/lib/src/foundation/primitives/models/version.dart similarity index 100% rename from lib/src/core/models/version.dart rename to lib/src/foundation/primitives/models/version.dart diff --git a/lib/src/foundation/primitives/primitives.dart b/lib/src/foundation/primitives/primitives.dart new file mode 100644 index 000000000..776be9638 --- /dev/null +++ b/lib/src/foundation/primitives/primitives.dart @@ -0,0 +1,2 @@ +export 'enums/enums.dart'; +export 'models/models.dart'; diff --git a/lib/src/shared/utils/cache.dart b/lib/src/foundation/utils/cache/image_cache_utils.dart similarity index 100% rename from lib/src/shared/utils/cache.dart rename to lib/src/foundation/utils/cache/image_cache_utils.dart diff --git a/lib/src/core/cache/image_dimension_cache.dart b/lib/src/foundation/utils/cache/image_dimension_cache.dart similarity index 100% rename from lib/src/core/cache/image_dimension_cache.dart rename to lib/src/foundation/utils/cache/image_dimension_cache.dart diff --git a/lib/src/core/cache/platform_version_cache.dart b/lib/src/foundation/utils/cache/platform_version_cache.dart similarity index 100% rename from lib/src/core/cache/platform_version_cache.dart rename to lib/src/foundation/utils/cache/platform_version_cache.dart diff --git a/lib/src/core/update/check_github_update.dart b/lib/src/foundation/utils/check_github_update.dart similarity index 93% rename from lib/src/core/update/check_github_update.dart rename to lib/src/foundation/utils/check_github_update.dart index a47b41cca..15c9924ce 100644 --- a/lib/src/core/update/check_github_update.dart +++ b/lib/src/foundation/utils/check_github_update.dart @@ -1,10 +1,10 @@ -import 'package:thunder/src/core/config/app_config.dart' as globals; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/app_config.dart' as globals; +import 'package:thunder/src/foundation/config/global_context.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:thunder/src/core/models/version.dart'; +import 'package:thunder/src/foundation/primitives/models/version.dart'; import 'package:version/version.dart' as version_parser; import 'package:thunder/l10n/generated/app_localizations.dart'; diff --git a/lib/src/shared/utils/debounce.dart b/lib/src/foundation/utils/debounce_utils.dart similarity index 100% rename from lib/src/shared/utils/debounce.dart rename to lib/src/foundation/utils/debounce_utils.dart diff --git a/lib/src/shared/utils/date_time.dart b/lib/src/foundation/utils/formatting_utils.dart similarity index 79% rename from lib/src/shared/utils/date_time.dart rename to lib/src/foundation/utils/formatting_utils.dart index 951a262e8..400af551c 100644 --- a/lib/src/shared/utils/date_time.dart +++ b/lib/src/foundation/utils/formatting_utils.dart @@ -1,3 +1,8 @@ +import 'package:intl/intl.dart'; + +final NumberFormat _compactFormatter = NumberFormat.compact(); +final NumberFormat _longFormatter = NumberFormat.decimalPatternDigits(decimalDigits: 0); + /// Given an integer which represents the epoch time, format it to a given string based on the time that has passed since the specified epoch time. /// /// The list of string representation -> meaning is as follows: @@ -34,3 +39,7 @@ String formatTimeToString({required String dateTime}) { return '${durationInYears.toStringAsFixed(0)}y'; } } + +String formatNumberToK(int number) => _compactFormatter.format(number); + +String formatLongNumber(int number) => _longFormatter.format(number); diff --git a/lib/src/shared/utils/link_utils.dart b/lib/src/foundation/utils/threadiverse_link_parser_utils.dart similarity index 92% rename from lib/src/shared/utils/link_utils.dart rename to lib/src/foundation/utils/threadiverse_link_parser_utils.dart index b71419cc2..5c5dd2ddf 100644 --- a/lib/src/shared/utils/link_utils.dart +++ b/lib/src/foundation/utils/threadiverse_link_parser_utils.dart @@ -1,32 +1,9 @@ +import 'package:thunder/src/foundation/primitives/models/parsed_link.dart'; + /// Pure link parsing utilities for Lemmy and PieFed URLs. /// /// These functions extract community names, usernames, post IDs, and comment IDs /// from platform-specific URL formats without any Flutter dependencies. -library; - -/// Result of parsing a link, containing the extracted value and source instance. -class ParsedLink { - /// The extracted value (community name, username, or ID as string) - final String value; - - /// The source instance from the URL - final String instance; - - const ParsedLink({required this.value, required this.instance}); - - /// Returns the value in qualified format: value@instance - String get qualified => '$value@$instance'; - - @override - String toString() => 'ParsedLink(value: $value, instance: $instance)'; - - @override - bool operator ==(Object other) => identical(this, other) || other is ParsedLink && runtimeType == other.runtimeType && value == other.value && instance == other.instance; - - @override - int get hashCode => value.hashCode ^ instance.hashCode; -} - // ============================================================================ // Lemmy URL Patterns // ============================================================================ diff --git a/lib/src/foundation/utils/utils.dart b/lib/src/foundation/utils/utils.dart new file mode 100644 index 000000000..714e99fa1 --- /dev/null +++ b/lib/src/foundation/utils/utils.dart @@ -0,0 +1,4 @@ +export 'cache/image_cache_utils.dart'; +export 'cache/image_dimension_cache.dart'; +export 'cache/platform_version_cache.dart'; +export 'utils_internal.dart'; diff --git a/lib/src/foundation/utils/utils_internal.dart b/lib/src/foundation/utils/utils_internal.dart new file mode 100644 index 000000000..588aec758 --- /dev/null +++ b/lib/src/foundation/utils/utils_internal.dart @@ -0,0 +1,3 @@ +export 'debounce_utils.dart'; +export 'formatting_utils.dart'; +export 'threadiverse_link_parser_utils.dart'; diff --git a/lib/src/shared/full_name_widgets.dart b/lib/src/shared/full_name_widgets.dart deleted file mode 100644 index 30f27eab4..000000000 --- a/lib/src/shared/full_name_widgets.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:auto_size_text/auto_size_text.dart'; - -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; - -/// A customizable [Text] widget which displays the given name and instance based on the user preferences. -/// -/// If special badges/indicators are needed, use [UserChip] instead. -class UserFullNameWidget extends StatelessWidget { - final BuildContext? outerContext; - final String? name; - final String? displayName; - final String? instance; - final FullNameSeparator? userSeparator; - final NameThickness? userNameThickness; - final NameColor? userNameColor; - final NameThickness? instanceNameThickness; - final NameColor? instanceNameColor; - final TextStyle? textStyle; - final bool includeInstance; - final FontScale? fontScale; - final bool autoSize; - final Color? Function(Color?)? transformColor; - final bool? useDisplayName; - - const UserFullNameWidget( - this.outerContext, - this.name, - this.displayName, - this.instance, { - super.key, - this.userSeparator, - this.userNameThickness, - this.userNameColor, - this.instanceNameThickness, - this.instanceNameColor, - this.textStyle, - this.includeInstance = true, - this.fontScale, - this.autoSize = false, - this.transformColor, - this.useDisplayName, - }) : assert(outerContext != null || - (userSeparator != null && userNameThickness != null && userNameColor != null && instanceNameThickness != null && instanceNameColor != null && useDisplayName != null)), - assert(outerContext != null || textStyle != null); - - @override - Widget build(BuildContext context) { - final prefix = generateUserFullNamePrefix(outerContext, name, displayName, userSeparator: userSeparator, useDisplayName: useDisplayName); - final suffix = generateUserFullNameSuffix(outerContext, instance, userSeparator: userSeparator); - - final userNameThickness = this.userNameThickness ?? outerContext!.read().state.userFullNameUserNameThickness; - final userNameColor = this.userNameColor ?? outerContext!.read().state.userFullNameUserNameColor; - final instanceNameThickness = this.instanceNameThickness ?? outerContext!.read().state.userFullNameInstanceNameThickness; - final instanceNameColor = this.instanceNameColor ?? outerContext!.read().state.userFullNameInstanceNameColor; - - final textStyle = this.textStyle ?? Theme.of(outerContext!).textTheme.bodyMedium; - final transformColor = this.transformColor ?? (color) => color; - - TextSpan textSpan = TextSpan( - children: [ - TextSpan( - text: prefix, - style: textStyle!.copyWith( - fontWeight: userNameThickness.toWeight(), - color: transformColor(userNameColor.color == NameColor.defaultColor ? textStyle.color : userNameColor.toColor(context)), - fontSize: - outerContext == null ? null : MediaQuery.textScalerOf(context).scale((textStyle.fontSize ?? textStyle.fontSize!) * (fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor)), - ), - ), - if (includeInstance == true) - TextSpan( - text: suffix, - style: textStyle.copyWith( - fontWeight: instanceNameThickness.toWeight(), - color: transformColor(instanceNameColor.color == NameColor.defaultColor ? textStyle.color : instanceNameColor.toColor(context)), - fontSize: - outerContext == null ? null : MediaQuery.textScalerOf(context).scale((textStyle.fontSize ?? textStyle.fontSize!) * (fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor)), - ), - ), - ], - ); - - return autoSize - ? AutoSizeText.rich( - softWrap: false, - maxLines: 1, - overflow: TextOverflow.fade, - style: textStyle, - textSpan, - ) - : Text.rich( - softWrap: false, - overflow: TextOverflow.fade, - style: textStyle, - textScaler: TextScaler.noScaling, - textSpan, - ); - } -} - -/// A customizable [Text] widget which displays the given name and instance based on the user preferences. -/// -/// If special badges/indicators are needed, use [CommunityChip] instead. -class CommunityFullNameWidget extends StatelessWidget { - final BuildContext? outerContext; - final String? name; - final String? displayName; - final String? instance; - final FullNameSeparator? communitySeparator; - final NameThickness? communityNameThickness; - final NameColor? communityNameColor; - final NameThickness? instanceNameThickness; - final NameColor? instanceNameColor; - final TextStyle? textStyle; - final bool includeInstance; - final FontScale? fontScale; - final bool autoSize; - final Color? Function(Color?)? transformColor; - final bool? useDisplayName; - - const CommunityFullNameWidget( - this.outerContext, - this.name, - this.displayName, - this.instance, { - super.key, - this.communitySeparator, - this.communityNameThickness, - this.communityNameColor, - this.instanceNameThickness, - this.instanceNameColor, - this.textStyle, - this.includeInstance = true, - this.fontScale, - this.autoSize = false, - this.transformColor, - this.useDisplayName, - }) : assert(outerContext != null || - (communitySeparator != null && communityNameThickness != null && communityNameColor != null && instanceNameThickness != null && instanceNameColor != null && useDisplayName != null)), - assert(outerContext != null || textStyle != null); - - @override - Widget build(BuildContext context) { - String prefix = generateCommunityFullNamePrefix(outerContext, name, displayName, communitySeparator: communitySeparator, useDisplayName: useDisplayName); - String suffix = generateCommunityFullNameSuffix(outerContext, instance, communitySeparator: communitySeparator); - NameThickness communityNameThickness = this.communityNameThickness ?? outerContext!.read().state.communityFullNameCommunityNameThickness; - NameColor communityNameColor = this.communityNameColor ?? outerContext!.read().state.communityFullNameCommunityNameColor; - NameThickness instanceNameThickness = this.instanceNameThickness ?? outerContext!.read().state.communityFullNameInstanceNameThickness; - NameColor instanceNameColor = this.instanceNameColor ?? outerContext!.read().state.communityFullNameInstanceNameColor; - TextStyle? textStyle = this.textStyle ?? Theme.of(outerContext!).textTheme.bodyMedium; - Color? Function(Color?) transformColor = this.transformColor ?? (color) => color; - - TextSpan textSpan = TextSpan( - children: [ - TextSpan( - text: prefix, - style: textStyle!.copyWith( - fontWeight: communityNameThickness.toWeight(), - color: transformColor(communityNameColor.color == NameColor.defaultColor ? textStyle.color : communityNameColor.toColor(context)), - fontSize: - outerContext == null ? null : MediaQuery.textScalerOf(context).scale((textStyle.fontSize ?? textStyle.fontSize!) * (fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor)), - ), - ), - if (includeInstance == true) - TextSpan( - text: suffix, - style: textStyle.copyWith( - fontWeight: instanceNameThickness.toWeight(), - color: transformColor(instanceNameColor.color == NameColor.defaultColor ? textStyle.color : instanceNameColor.toColor(context)), - fontSize: - outerContext == null ? null : MediaQuery.textScalerOf(context).scale((textStyle.fontSize ?? textStyle.fontSize!) * (fontScale?.textScaleFactor ?? FontScale.base.textScaleFactor)), - ), - ), - ], - ); - - return autoSize - ? AutoSizeText.rich( - softWrap: false, - maxLines: 1, - overflow: TextOverflow.fade, - style: textStyle, - textSpan, - ) - : Text.rich( - softWrap: false, - overflow: TextOverflow.fade, - style: textStyle, - textScaler: TextScaler.noScaling, - textSpan, - ); - } -} diff --git a/lib/src/shared/gesture_fab.dart b/lib/src/shared/gesture_fab.dart index 7bceed454..efd5ab069 100644 --- a/lib/src/shared/gesture_fab.dart +++ b/lib/src/shared/gesture_fab.dart @@ -6,8 +6,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; /// Enum to distinguish between feed and post FABs enum FabType { feed, post } diff --git a/lib/src/shared/utils/swipe.dart b/lib/src/shared/gestures/swipe_utils.dart similarity index 96% rename from lib/src/shared/utils/swipe.dart rename to lib/src/shared/gestures/swipe_utils.dart index c8a00fa39..214ae953e 100644 --- a/lib/src/shared/utils/swipe.dart +++ b/lib/src/shared/gestures/swipe_utils.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; -import 'package:thunder/src/app/cubits/gesture_preferences_cubit/gesture_preferences_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; DismissDirection determinePostSwipeDirection({ required bool isUserLoggedIn, diff --git a/lib/src/shared/icon_text.dart b/lib/src/shared/icon_text.dart index d40c15c86..2d8b92320 100644 --- a/lib/src/shared/icon_text.dart +++ b/lib/src/shared/icon_text.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; /// Creates a widget that displays an icon followed by text. /// diff --git a/lib/src/shared/image_preview.dart b/lib/src/shared/image_preview.dart index 3e3c04bd6..3c97993da 100644 --- a/lib/src/shared/image_preview.dart +++ b/lib/src/shared/image_preview.dart @@ -6,11 +6,11 @@ import 'package:flutter/material.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/core/enums/image_caching_mode.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/shared/theme/color_utils.dart'; -import 'package:thunder/src/shared/utils/media/image.dart'; +import 'package:thunder/src/features/content/presentation/widgets/media/media_utils.dart'; class ImagePreview extends StatefulWidget { final String? url; diff --git a/lib/src/shared/input_dialogs.dart b/lib/src/shared/input_dialogs.dart index 4bd5d7ddd..b4f3bb351 100644 --- a/lib/src/shared/input_dialogs.dart +++ b/lib/src/shared/input_dialogs.dart @@ -1,542 +1,540 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:collection/collection.dart'; - -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; -import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/subscription_status.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/dialogs.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/shared/marquee_widget.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/shared/utils/numbers.dart'; - -/// Shows a dialog which allows typing/search for a user -void showUserInputDialog( - BuildContext context, { - required String title, - required Account account, - required void Function(ThunderUser) onUserSelected, -}) async { - final l10n = GlobalContext.l10n; - - Future onSubmitted({ThunderUser? payload, String? value}) async { - if (payload == null && value == null) return null; - - if (payload != null) { - onUserSelected(payload); - Navigator.of(context).pop(); - return null; - } - - // Normalize the username - final normalizedUsername = await getLemmyUser(value!); - - if (normalizedUsername != null) { - try { - final response = await UserRepositoryImpl(account: account).getUser(username: normalizedUsername); - final user = response!['user']; - - onUserSelected(user); - Navigator.of(context).pop(); - return null; - } catch (e) { - return l10n.unableToFindUser; - } - } - - return l10n.unableToFindUser; - } - - showInputDialog( - context: context, - title: title, - inputLabel: l10n.username, - onSubmitted: onSubmitted, - getSuggestions: (query) => getUserSuggestions(context, query: query, account: account), - suggestionBuilder: (payload) => buildUserSuggestionWidget(context, payload), - ); -} - -Future> getUserSuggestions( - BuildContext context, { - required String query, - required Account account, -}) async { - if (query.isEmpty) return []; - - final response = await SearchRepositoryImpl(account: account).search( - query: query, - type: MetaSearchType.users, - limit: 20, - ); - - return response['users']; -} - -Widget buildUserSuggestionWidget(BuildContext context, ThunderUser payload, {void Function(ThunderUser)? onSelected}) { - return Tooltip( - message: generateUserFullName( - context, - payload.name, - payload.displayName, - fetchInstanceNameFromUrl(payload.actorId), - ), - preferBelow: false, - child: InkWell( - onTap: onSelected == null ? null : () => onSelected(payload), - child: ListTile( - leading: UserAvatar(user: payload), - title: Text(payload.displayNameOrName, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Semantics( - excludeSemantics: true, - child: Marquee( - animationDuration: const Duration(seconds: 2), - backDuration: const Duration(seconds: 2), - pauseDuration: const Duration(seconds: 1), - child: UserFullNameWidget( - context, - payload.name, - payload.displayName, - fetchInstanceNameFromUrl(payload.actorId), - // Override because we're showing display name above - useDisplayName: false, - ), - ), - ), - ), - ), - ); -} - -/// Shows a dialog which allows typing/search for a community. -/// Given an [account], the dialog will show subscriptions and favorites of that account. -/// -/// When searching for communities, it will use the provided [account]'s instance. -void showCommunityInputDialog( - BuildContext context, { - required String title, - required Account account, - required void Function(ThunderCommunity community) onCommunitySelected, - List? emptySuggestions, -}) async { - final l10n = GlobalContext.l10n; - - List? favoritedCommunities; - - try { - // Fetch subscriptions from the given account - final favorites = await Favorite.favorites(account.id); - final subscriptions = await AccountRepositoryImpl(account: account).subscriptions(); - favoritedCommunities = subscriptions.where((community) => favorites.any((favorite) => favorite.communityId == community.id)).toList(); - - emptySuggestions ??= prioritizeFavorites(subscriptions, favoritedCommunities); - } catch (e) { - // If this is unavailable, continue - } - - Future onSubmitted({ThunderCommunity? payload, String? value}) async { - if (payload == null && value == null) return null; - - if (payload != null) { - onCommunitySelected(payload); - Navigator.of(context).pop(); - return null; - } - - // Normalize the community name - final normalizedCommunity = await getLemmyCommunity(value!); - - if (normalizedCommunity != null) { - try { - final response = await CommunityRepositoryImpl(account: account).getCommunity(name: normalizedCommunity); - final community = response['community']; - - onCommunitySelected(community); - Navigator.of(context).pop(); - return null; - } catch (e) { - return l10n.unableToFindCommunity; - } - } - - return l10n.unableToFindCommunity; - } - - showInputDialog( - context: context, - title: title, - inputLabel: l10n.community, - onSubmitted: onSubmitted, - getSuggestions: (query) => getCommunitySuggestions(context, query: query, account: account, emptySuggestions: emptySuggestions, favoritedCommunities: favoritedCommunities), - suggestionBuilder: (payload) => buildCommunitySuggestionWidget(context, payload), - ); -} - -Future> getCommunitySuggestions( - BuildContext context, { - required String query, - required Account account, - List? favoritedCommunities, - List? emptySuggestions, -}) async { - if (query.isEmpty) return emptySuggestions ?? []; - - final response = await SearchRepositoryImpl(account: account).search( - query: query, - type: MetaSearchType.communities, - limit: 20, - sort: SearchSortType.topAll, - ); - - return prioritizeFavorites(response['communities'], favoritedCommunities) ?? []; -} - -Widget buildCommunitySuggestionWidget(BuildContext context, ThunderCommunity payload, {void Function(ThunderCommunity)? onSelected}) { - final l10n = GlobalContext.l10n; - - return Tooltip( - message: generateCommunityFullName( - context, - payload.name, - payload.title, - fetchInstanceNameFromUrl(payload.actorId), - ), - preferBelow: false, - child: InkWell( - onTap: onSelected == null ? null : () => onSelected(payload), - child: ListTile( - leading: CommunityAvatar(community: payload), - title: Text(payload.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Semantics( - excludeSemantics: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Marquee( - animationDuration: const Duration(seconds: 2), - backDuration: const Duration(seconds: 2), - pauseDuration: const Duration(seconds: 1), - child: CommunityFullNameWidget( - context, - payload.name, - payload.title, - fetchInstanceNameFromUrl(payload.actorId), - // Override because we're showing display name above - useDisplayName: false, - ), - ), - if (payload.subscribed != null && payload.subscribers != null) ...[ - Row( - children: [ - Icon(Icons.people_rounded, size: 16.0), - SizedBox(width: 5.0), - Text(formatNumberToK(payload.subscribers ?? -1)), - Text(' · ${switch (payload.subscribed) { - SubscriptionStatus.pending => l10n.pending, - SubscriptionStatus.subscribed => l10n.subscribed, - SubscriptionStatus.notSubscribed => '', - _ => '', - }}'), - if (_getFavoriteStatus(context, payload)) ...[ - Text(' · '), - Icon(Icons.star_rounded, size: 15.0), - ], - ], - ) - ], - ], - ), - ), - ), - ), - ); -} - -/// Checks whether the current community is a favorite of the current user -bool _getFavoriteStatus(BuildContext context, ThunderCommunity community) { - final state = context.read().state; - return state.favorites.any((c) => c.id == community.id); -} - -/// Shows a dialog which allows typing/search for an instance -void showInstanceInputDialog( - BuildContext context, { - required String title, - required void Function(Map) onInstanceSelected, - Iterable>? emptySuggestions, -}) async { - Account? account = await fetchActiveProfile(); - - final federatedInstances = await InstanceRepositoryImpl(account: account).federated(); - final linkedInstances = federatedInstances['linked']; - - Future onSubmitted({Map? payload, String? value}) async { - if (payload != null) { - onInstanceSelected(payload); - Navigator.of(context).pop(); - } else if (value != null) { - final Map? instance = linkedInstances.firstWhereOrNull((Map instance) => instance['domain'] == value); - - if (instance != null) { - onInstanceSelected(instance); - Navigator.of(context).pop(); - } else { - return AppLocalizations.of(context)!.unableToFindInstance; - } - } - - return null; - } - - if (context.mounted) { - showInputDialog>( - context: context, - title: title, - inputLabel: AppLocalizations.of(context)!.instance(1), - onSubmitted: onSubmitted, - getSuggestions: (query) => getInstanceSuggestions(query, linkedInstances), - suggestionBuilder: (payload) => buildInstanceSuggestionWidget(payload, context: context), - ); - } -} - -Future>> getInstanceSuggestions(String query, List>? emptySuggestions) async { - if (query.isEmpty) { - return []; - } - - List> filteredInstances = emptySuggestions?.where((Map instance) => instance['domain'].contains(query)).toList() ?? []; - return filteredInstances; -} - -Widget buildInstanceSuggestionWidget(Map payload, {void Function(Map)? onSelected, BuildContext? context}) { - final theme = Theme.of(context!); - - return Tooltip( - message: '${payload['domain']}', - preferBelow: false, - child: InkWell( - onTap: onSelected == null ? null : () => onSelected(payload), - child: ListTile( - leading: CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - maxRadius: 16.0, - child: Text( - payload['domain'][0].toUpperCase(), - semanticsLabel: '', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16.0, - ), - ), - ), - title: Text( - payload['domain'], - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ); -} - -/// Shows a dialog which allows typing/search for an language -void showLanguageInputDialog(BuildContext context, - {required String title, required void Function(ThunderLanguage) onLanguageSelected, Iterable? excludedLanguageIds, Iterable? emptySuggestions}) async { - ProfileState state = context.read().state; - final AppLocalizations l10n = AppLocalizations.of(context)!; - - List languages = [ThunderLanguage(id: -1, code: '', name: l10n.noLanguage), ...(state.siteResponse?.allLanguages ?? [])]; - languages = languages.where((language) { - if (excludedLanguageIds != null && excludedLanguageIds.isNotEmpty) { - return !excludedLanguageIds.contains(language.id); - } - return true; - }).toList(); - - Future onSubmitted({ThunderLanguage? payload, String? value}) async { - if (payload != null) { - onLanguageSelected(payload); - Navigator.of(context).pop(); - } else if (value != null) { - final ThunderLanguage? language = languages.firstWhereOrNull((ThunderLanguage language) => language.name.toLowerCase().contains(value.toLowerCase())); - - if (language != null) { - onLanguageSelected(language); - Navigator.of(context).pop(); - } else { - return AppLocalizations.of(context)!.unableToFindLanguage; - } - } - - return null; - } - - if (context.mounted) { - showInputDialog( - context: context, - title: title, - inputLabel: AppLocalizations.of(context)!.language, - onSubmitted: onSubmitted, - getSuggestions: (query) => getLanguageSuggestions(context, query, languages), - suggestionBuilder: (payload) => buildLanguageSuggestionWidget(payload, context: context), - ); - } -} - -Future> getLanguageSuggestions(BuildContext context, String query, List? emptySuggestions) async { - final Locale currentLocale = Localizations.localeOf(context); - - final ThunderLanguage? currentLanguage = emptySuggestions?.firstWhereOrNull((ThunderLanguage l) => l.code == currentLocale.languageCode); - if (currentLanguage != null && (emptySuggestions?.length ?? 0) >= 2) { - emptySuggestions = emptySuggestions?.toList() - ?..remove(currentLanguage) - ..insert(2, currentLanguage); - } - - if (query.isEmpty) { - return emptySuggestions ?? []; - } - - List filteredLanguages = emptySuggestions?.where((ThunderLanguage language) => language.name.toLowerCase().contains(query.toLowerCase())).toList() ?? []; - return filteredLanguages; -} - -Widget buildLanguageSuggestionWidget(ThunderLanguage payload, {void Function(ThunderLanguage)? onSelected, BuildContext? context}) { - return Tooltip( - message: payload.name, - preferBelow: false, - child: InkWell( - onTap: onSelected == null ? null : () => onSelected(payload), - child: ListTile( - title: Text( - payload.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ); -} - -/// Shows a dialog which allows typing/search for a keyword -void showKeywordInputDialog(BuildContext context, {required String title, required void Function(String) onKeywordSelected}) async { - final l10n = AppLocalizations.of(context)!; - - Future onSubmitted({String? payload, String? value}) async { - String? formattedPayload = payload?.trim(); - String? formattedValue = value?.trim(); - - if (formattedPayload != null && formattedPayload.isNotEmpty) { - onKeywordSelected(formattedPayload); - Navigator.of(context).pop(); - } else if (formattedValue != null && formattedValue.isNotEmpty) { - onKeywordSelected(formattedValue); - Navigator.of(context).pop(); - } - - return null; - } - - if (context.mounted) { - showInputDialog( - context: context, - title: title, - inputLabel: l10n.addKeywordFilter, - onSubmitted: onSubmitted, - getSuggestions: (query) => [], - suggestionBuilder: (payload) => Container(), - ); - } -} - -/// Shows a dialog which takes input and offers suggestions -void showInputDialog({ - required BuildContext context, - required String title, - required String inputLabel, - required Future Function({T? payload, String? value}) onSubmitted, - required FutureOr?> Function(String query) getSuggestions, - required Widget Function(T payload) suggestionBuilder, -}) async { - final textController = TextEditingController(); - // Capture our content widget's setState function so we can call it outside the widget - StateSetter? contentWidgetSetState; - String? contentWidgetError; - - await showThunderDialog( - context: context, - title: title, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - secondaryButtonText: AppLocalizations.of(context)!.cancel, - primaryButtonInitialEnabled: false, - onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) async { - setPrimaryButtonEnabled(false); - final String? submitError = await onSubmitted(value: textController.text); - contentWidgetSetState?.call(() => contentWidgetError = submitError); - }, - primaryButtonText: AppLocalizations.of(context)!.ok, - // Use a stateful widget for the content so we can update the error message - contentWidgetBuilder: (setPrimaryButtonEnabled) => StatefulBuilder(builder: (context, setState) { - contentWidgetSetState = setState; - return SizedBox( - width: min(MediaQuery.of(context).size.width, 700), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TypeAheadField( - controller: textController, - builder: (context, controller, focusNode) => TextField( - controller: controller, - focusNode: focusNode, - onChanged: (value) { - setPrimaryButtonEnabled(value.trim().isNotEmpty); - setState(() => contentWidgetError = null); - }, - autofocus: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: inputLabel, - errorText: contentWidgetError, - ), - onSubmitted: (text) async { - setPrimaryButtonEnabled(false); - final String? submitError = await onSubmitted(value: text); - setState(() => contentWidgetError = submitError); - }, - ), - suggestionsCallback: getSuggestions, - itemBuilder: (context, payload) => suggestionBuilder(payload), - onSelected: (payload) async { - setPrimaryButtonEnabled(false); - final String? submitError = await onSubmitted(payload: payload); - setState(() => contentWidgetError = submitError); - }, - hideOnEmpty: true, - hideOnLoading: true, - hideOnError: true, - ), - ], - ), - ); - }), - ); -} +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:collection/collection.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/community/api.dart'; +import 'package:thunder/src/features/instance/api.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/search/api.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; + +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/shared/marquee_widget.dart'; +import 'package:thunder/src/features/user/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/foundation/utils/utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show showThunderDialog; + +/// Shows a dialog which allows typing/search for a user +void showUserInputDialog( + BuildContext context, { + required String title, + required Account account, + required void Function(ThunderUser) onUserSelected, +}) async { + final l10n = GlobalContext.l10n; + + Future onSubmitted({ThunderUser? payload, String? value}) async { + if (payload == null && value == null) return null; + + if (payload != null) { + onUserSelected(payload); + Navigator.of(context).pop(); + return null; + } + + // Normalize the username + final normalizedUsername = await getLemmyUser(value!); + + if (normalizedUsername != null) { + try { + final response = await UserRepositoryImpl(account: account).getUser(username: normalizedUsername); + final user = response!['user']; + + onUserSelected(user); + Navigator.of(context).pop(); + return null; + } catch (e) { + return l10n.unableToFindUser; + } + } + + return l10n.unableToFindUser; + } + + showInputDialog( + context: context, + title: title, + inputLabel: l10n.username, + onSubmitted: onSubmitted, + getSuggestions: (query) => getUserSuggestions(context, query: query, account: account), + suggestionBuilder: (payload) => buildUserSuggestionWidget(context, payload), + ); +} + +Future> getUserSuggestions( + BuildContext context, { + required String query, + required Account account, +}) async { + if (query.isEmpty) return []; + + final response = await SearchRepositoryImpl(account: account).search( + query: query, + type: MetaSearchType.users, + limit: 20, + ); + + return response.users; +} + +Widget buildUserSuggestionWidget(BuildContext context, ThunderUser payload, {void Function(ThunderUser)? onSelected}) { + return Tooltip( + message: generateUserFullName( + context, + payload.name, + payload.displayName, + fetchInstanceNameFromUrl(payload.actorId), + ), + preferBelow: false, + child: InkWell( + onTap: onSelected == null ? null : () => onSelected(payload), + child: ListTile( + leading: UserAvatar(user: payload), + title: Text(payload.displayNameOrName, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Semantics( + excludeSemantics: true, + child: Marquee( + animationDuration: const Duration(seconds: 2), + backDuration: const Duration(seconds: 2), + pauseDuration: const Duration(seconds: 1), + child: UserFullNameWidget( + context, + payload.name, + payload.displayName, + fetchInstanceNameFromUrl(payload.actorId), + // Override because we're showing display name above + useDisplayName: false, + ), + ), + ), + ), + ), + ); +} + +/// Shows a dialog which allows typing/search for a community. +/// Given an [account], the dialog will show subscriptions and favorites of that account. +/// +/// When searching for communities, it will use the provided [account]'s instance. +void showCommunityInputDialog( + BuildContext context, { + required String title, + required Account account, + required void Function(ThunderCommunity community) onCommunitySelected, + List? emptySuggestions, +}) async { + final l10n = GlobalContext.l10n; + + List? favoritedCommunities; + + try { + // Fetch subscriptions from the given account + final favorites = await Favorite.favorites(account.id); + final subscriptions = await AccountRepositoryImpl(account: account).subscriptions(); + favoritedCommunities = subscriptions.where((community) => favorites.any((favorite) => favorite.communityId == community.id)).toList(); + + emptySuggestions ??= prioritizeFavorites(subscriptions, favoritedCommunities); + } catch (e) { + // If this is unavailable, continue + } + + Future onSubmitted({ThunderCommunity? payload, String? value}) async { + if (payload == null && value == null) return null; + + if (payload != null) { + onCommunitySelected(payload); + Navigator.of(context).pop(); + return null; + } + + // Normalize the community name + final normalizedCommunity = await getLemmyCommunity(value!); + + if (normalizedCommunity != null) { + try { + final response = await CommunityRepositoryImpl(account: account).getCommunity(name: normalizedCommunity); + final community = response.community; + + onCommunitySelected(community); + Navigator.of(context).pop(); + return null; + } catch (e) { + return l10n.unableToFindCommunity; + } + } + + return l10n.unableToFindCommunity; + } + + showInputDialog( + context: context, + title: title, + inputLabel: l10n.community, + onSubmitted: onSubmitted, + getSuggestions: (query) => getCommunitySuggestions(context, query: query, account: account, emptySuggestions: emptySuggestions, favoritedCommunities: favoritedCommunities), + suggestionBuilder: (payload) => buildCommunitySuggestionWidget(context, payload), + ); +} + +Future> getCommunitySuggestions( + BuildContext context, { + required String query, + required Account account, + List? favoritedCommunities, + List? emptySuggestions, +}) async { + if (query.isEmpty) return emptySuggestions ?? []; + + final response = await SearchRepositoryImpl(account: account).search( + query: query, + type: MetaSearchType.communities, + limit: 20, + sort: SearchSortType.topAll, + ); + + return prioritizeFavorites(response.communities, favoritedCommunities) ?? []; +} + +Widget buildCommunitySuggestionWidget(BuildContext context, ThunderCommunity payload, {void Function(ThunderCommunity)? onSelected}) { + final l10n = GlobalContext.l10n; + + return Tooltip( + message: generateCommunityFullName( + context, + payload.name, + payload.title, + fetchInstanceNameFromUrl(payload.actorId), + ), + preferBelow: false, + child: InkWell( + onTap: onSelected == null ? null : () => onSelected(payload), + child: ListTile( + leading: CommunityAvatar(community: payload), + title: Text(payload.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Semantics( + excludeSemantics: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Marquee( + animationDuration: const Duration(seconds: 2), + backDuration: const Duration(seconds: 2), + pauseDuration: const Duration(seconds: 1), + child: CommunityFullNameWidget( + context, + payload.name, + payload.title, + fetchInstanceNameFromUrl(payload.actorId), + // Override because we're showing display name above + useDisplayName: false, + ), + ), + if (payload.subscribed != null && payload.subscribers != null) ...[ + Row( + children: [ + Icon(Icons.people_rounded, size: 16.0), + SizedBox(width: 5.0), + Text(formatNumberToK(payload.subscribers ?? -1)), + Text(' · ${switch (payload.subscribed) { + SubscriptionStatus.pending => l10n.pending, + SubscriptionStatus.subscribed => l10n.subscribed, + SubscriptionStatus.notSubscribed => '', + _ => '', + }}'), + if (_getFavoriteStatus(context, payload)) ...[ + Text(' · '), + Icon(Icons.star_rounded, size: 15.0), + ], + ], + ) + ], + ], + ), + ), + ), + ), + ); +} + +/// Checks whether the current community is a favorite of the current user +bool _getFavoriteStatus(BuildContext context, ThunderCommunity community) { + final state = context.read().state; + return state.favorites.any((c) => c.id == community.id); +} + +/// Shows a dialog which allows typing/search for an instance +void showInstanceInputDialog( + BuildContext context, { + required String title, + required void Function(Map) onInstanceSelected, + Iterable>? emptySuggestions, +}) async { + Account? account = await fetchActiveProfile(); + + final federatedInstances = await InstanceRepositoryImpl(account: account).federated(); + final linkedInstances = federatedInstances['linked']; + + Future onSubmitted({Map? payload, String? value}) async { + if (payload != null) { + onInstanceSelected(payload); + Navigator.of(context).pop(); + } else if (value != null) { + final Map? instance = linkedInstances.firstWhereOrNull((Map instance) => instance['domain'] == value); + + if (instance != null) { + onInstanceSelected(instance); + Navigator.of(context).pop(); + } else { + return AppLocalizations.of(context)!.unableToFindInstance; + } + } + + return null; + } + + if (context.mounted) { + showInputDialog>( + context: context, + title: title, + inputLabel: AppLocalizations.of(context)!.instance(1), + onSubmitted: onSubmitted, + getSuggestions: (query) => getInstanceSuggestions(query, linkedInstances), + suggestionBuilder: (payload) => buildInstanceSuggestionWidget(payload, context: context), + ); + } +} + +Future>> getInstanceSuggestions(String query, List>? emptySuggestions) async { + if (query.isEmpty) { + return []; + } + + List> filteredInstances = emptySuggestions?.where((Map instance) => instance['domain'].contains(query)).toList() ?? []; + return filteredInstances; +} + +Widget buildInstanceSuggestionWidget(Map payload, {void Function(Map)? onSelected, BuildContext? context}) { + final theme = Theme.of(context!); + + return Tooltip( + message: '${payload['domain']}', + preferBelow: false, + child: InkWell( + onTap: onSelected == null ? null : () => onSelected(payload), + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.secondaryContainer, + maxRadius: 16.0, + child: Text( + payload['domain'][0].toUpperCase(), + semanticsLabel: '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16.0, + ), + ), + ), + title: Text( + payload['domain'], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); +} + +/// Shows a dialog which allows typing/search for an language +void showLanguageInputDialog(BuildContext context, + {required String title, required void Function(ThunderLanguage) onLanguageSelected, Iterable? excludedLanguageIds, Iterable? emptySuggestions}) async { + ProfileState state = context.read().state; + final AppLocalizations l10n = AppLocalizations.of(context)!; + + List languages = [ThunderLanguage(id: -1, code: '', name: l10n.noLanguage), ...(state.siteResponse?.allLanguages ?? [])]; + languages = languages.where((language) { + if (excludedLanguageIds != null && excludedLanguageIds.isNotEmpty) { + return !excludedLanguageIds.contains(language.id); + } + return true; + }).toList(); + + Future onSubmitted({ThunderLanguage? payload, String? value}) async { + if (payload != null) { + onLanguageSelected(payload); + Navigator.of(context).pop(); + } else if (value != null) { + final ThunderLanguage? language = languages.firstWhereOrNull((ThunderLanguage language) => language.name.toLowerCase().contains(value.toLowerCase())); + + if (language != null) { + onLanguageSelected(language); + Navigator.of(context).pop(); + } else { + return AppLocalizations.of(context)!.unableToFindLanguage; + } + } + + return null; + } + + if (context.mounted) { + showInputDialog( + context: context, + title: title, + inputLabel: AppLocalizations.of(context)!.language, + onSubmitted: onSubmitted, + getSuggestions: (query) => getLanguageSuggestions(context, query, languages), + suggestionBuilder: (payload) => buildLanguageSuggestionWidget(payload, context: context), + ); + } +} + +Future> getLanguageSuggestions(BuildContext context, String query, List? emptySuggestions) async { + final Locale currentLocale = Localizations.localeOf(context); + + final ThunderLanguage? currentLanguage = emptySuggestions?.firstWhereOrNull((ThunderLanguage l) => l.code == currentLocale.languageCode); + if (currentLanguage != null && (emptySuggestions?.length ?? 0) >= 2) { + emptySuggestions = emptySuggestions?.toList() + ?..remove(currentLanguage) + ..insert(2, currentLanguage); + } + + if (query.isEmpty) { + return emptySuggestions ?? []; + } + + List filteredLanguages = emptySuggestions?.where((ThunderLanguage language) => language.name.toLowerCase().contains(query.toLowerCase())).toList() ?? []; + return filteredLanguages; +} + +Widget buildLanguageSuggestionWidget(ThunderLanguage payload, {void Function(ThunderLanguage)? onSelected, BuildContext? context}) { + return Tooltip( + message: payload.name, + preferBelow: false, + child: InkWell( + onTap: onSelected == null ? null : () => onSelected(payload), + child: ListTile( + title: Text( + payload.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); +} + +/// Shows a dialog which allows typing/search for a keyword +void showKeywordInputDialog(BuildContext context, {required String title, required void Function(String) onKeywordSelected}) async { + final l10n = AppLocalizations.of(context)!; + + Future onSubmitted({String? payload, String? value}) async { + String? formattedPayload = payload?.trim(); + String? formattedValue = value?.trim(); + + if (formattedPayload != null && formattedPayload.isNotEmpty) { + onKeywordSelected(formattedPayload); + Navigator.of(context).pop(); + } else if (formattedValue != null && formattedValue.isNotEmpty) { + onKeywordSelected(formattedValue); + Navigator.of(context).pop(); + } + + return null; + } + + if (context.mounted) { + showInputDialog( + context: context, + title: title, + inputLabel: l10n.addKeywordFilter, + onSubmitted: onSubmitted, + getSuggestions: (query) => [], + suggestionBuilder: (payload) => Container(), + ); + } +} + +/// Shows a dialog which takes input and offers suggestions +void showInputDialog({ + required BuildContext context, + required String title, + required String inputLabel, + required Future Function({T? payload, String? value}) onSubmitted, + required FutureOr?> Function(String query) getSuggestions, + required Widget Function(T payload) suggestionBuilder, +}) async { + final textController = TextEditingController(); + // Capture our content widget's setState function so we can call it outside the widget + StateSetter? contentWidgetSetState; + String? contentWidgetError; + + await showThunderDialog( + context: context, + title: title, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + secondaryButtonText: AppLocalizations.of(context)!.cancel, + primaryButtonInitialEnabled: false, + onPrimaryButtonPressed: (dialogContext, setPrimaryButtonEnabled) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(value: textController.text); + contentWidgetSetState?.call(() => contentWidgetError = submitError); + }, + primaryButtonText: AppLocalizations.of(context)!.ok, + // Use a stateful widget for the content so we can update the error message + contentWidgetBuilder: (setPrimaryButtonEnabled) => StatefulBuilder(builder: (context, setState) { + contentWidgetSetState = setState; + return SizedBox( + width: min(MediaQuery.of(context).size.width, 700), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TypeAheadField( + controller: textController, + builder: (context, controller, focusNode) => TextField( + controller: controller, + focusNode: focusNode, + onChanged: (value) { + setPrimaryButtonEnabled(value.trim().isNotEmpty); + setState(() => contentWidgetError = null); + }, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: inputLabel, + errorText: contentWidgetError, + ), + onSubmitted: (text) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(value: text); + setState(() => contentWidgetError = submitError); + }, + ), + suggestionsCallback: getSuggestions, + itemBuilder: (context, payload) => suggestionBuilder(payload), + onSelected: (payload) async { + setPrimaryButtonEnabled(false); + final String? submitError = await onSubmitted(payload: payload); + setState(() => contentWidgetError = submitError); + }, + hideOnEmpty: true, + hideOnLoading: true, + hideOnError: true, + ), + ], + ), + ); + }), + ); +} diff --git a/lib/src/shared/language_selector.dart b/lib/src/shared/language_selector.dart index d3268f220..2b7000f8a 100644 --- a/lib/src/shared/language_selector.dart +++ b/lib/src/shared/language_selector.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; // Package imports import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/models/thunder_language.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; // Project imports -import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/account/api.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; /// Creates a widget which displays a preview of a pre-selected language, with the ability to change the selected language diff --git a/lib/src/shared/link_information.dart b/lib/src/shared/link_information.dart index 1a160c29f..183e60a05 100644 --- a/lib/src/shared/link_information.dart +++ b/lib/src/shared/link_information.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; // Project imports -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/src/shared/links/widgets/link_bottom_sheet.dart'; /// A widget that displays information about a link, including the link's media type if applicable. /// diff --git a/lib/src/shared/links/links.dart b/lib/src/shared/links/links.dart new file mode 100644 index 000000000..cd8ba0662 --- /dev/null +++ b/lib/src/shared/links/links.dart @@ -0,0 +1 @@ +export 'widgets/link_bottom_sheet.dart'; diff --git a/lib/src/shared/links/widgets/link_bottom_sheet.dart b/lib/src/shared/links/widgets/link_bottom_sheet.dart new file mode 100644 index 000000000..dd208e9e8 --- /dev/null +++ b/lib/src/shared/links/widgets/link_bottom_sheet.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:link_preview_generator/link_preview_generator.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show PickerItem; + +void handleLinkLongPress(BuildContext context, String text, String? url, {LinkBottomSheetPage initialPage = LinkBottomSheetPage.general, void Function(String)? customNavigation}) { + HapticFeedback.mediumImpact(); + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) => LinkBottomSheet( + text: text, + url: url, + initialPage: initialPage, + customNavigation: customNavigation, + ), + ); +} + +enum LinkBottomSheetPage { + general, + alternateLinks, +} + +class LinkBottomSheet extends StatefulWidget { + final String? url; + final String text; + final LinkBottomSheetPage initialPage; + final void Function(String)? customNavigation; + + const LinkBottomSheet({ + super.key, + required this.text, + required this.url, + this.initialPage = LinkBottomSheetPage.general, + this.customNavigation, + }); + + @override + State createState() => _LinkBottomSheetState(); +} + +class _LinkBottomSheetState extends State { + LinkBottomSheetPage? page; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppLocalizations l10n = AppLocalizations.of(context)!; + + bool isValidUrl = widget.url?.startsWith('http') ?? false; + + return SingleChildScrollView( + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + alignment: Alignment.bottomCenter, + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10, right: 10), + child: Material( + borderRadius: BorderRadius.circular(50), + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: (page ?? widget.initialPage) == LinkBottomSheetPage.general ? null : () => setState(() => page = LinkBottomSheetPage.general), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + if ((page ?? widget.initialPage) != LinkBottomSheetPage.general) ...[ + const Icon(Icons.chevron_left, size: 30), + const SizedBox(width: 12), + ], + Text( + switch (page ?? widget.initialPage) { + LinkBottomSheetPage.alternateLinks => l10n.alternateSources, + _ => l10n.linkActions, + }, + style: theme.textTheme.titleLarge, + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 10), + if (isValidUrl && (page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ + Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: LinkPreviewGenerator( + link: widget.url!, + placeholderWidget: const CircularProgressIndicator(), + linkPreviewStyle: LinkPreviewStyle.large, + cacheDuration: Duration.zero, + onTap: null, + bodyTextOverflow: TextOverflow.fade, + graphicFit: BoxFit.scaleDown, + removeElevation: true, + backgroundColor: theme.dividerColor.withValues(alpha: 0.25), + borderRadius: 10, + useDefaultOnTap: false, + ), + ), + const SizedBox(height: 10), + ], + Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: Container( + decoration: BoxDecoration( + color: theme.dividerColor.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(widget.url!), + ), + ), + ), + const SizedBox(height: 10), + if ((page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ + PickerItem( + label: l10n.open, + icon: Icons.language, + onSelected: () => handleLinkTap(context, widget.text, widget.url), + ), + PickerItem( + label: l10n.copy, + icon: Icons.copy_rounded, + onSelected: () => Clipboard.setData(ClipboardData(text: widget.url ?? widget.text)), + ), + PickerItem( + label: l10n.share, + icon: Icons.share_rounded, + onSelected: () => SharePlus.instance.share(ShareParams( + text: widget.url ?? widget.text, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )), + ), + PickerItem( + label: l10n.alternateSources, + icon: Icons.link_rounded, + onSelected: () => setState(() => page = LinkBottomSheetPage.alternateLinks), + trailingIcon: Icons.chevron_right_rounded, + ), + ], + if ((page ?? widget.initialPage) == LinkBottomSheetPage.alternateLinks) + ...generateAlternateSources(widget.url ?? widget.text).map((alternateSource) { + return PickerItem( + label: alternateSource.sourceName, + subtitle: alternateSource.link, + icon: Icons.archive_rounded, + onSelected: () { + if (widget.customNavigation != null) { + widget.customNavigation!.call(alternateSource.link); + } else { + handleLink(context, url: alternateSource.link); + } + + Navigator.of(context).pop(); + }, + trailingIcon: Icons.chevron_right_rounded, + ); + }), + const SizedBox(height: 40.0), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/shared/markdown/markdown_spoiler.dart b/lib/src/shared/markdown/markdown_spoiler.dart deleted file mode 100644 index 0d4f0ff2a..000000000 --- a/lib/src/shared/markdown/markdown_spoiler.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:expandable/expandable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:markdown/markdown.dart' as md; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/utils/colors.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; - -/// Note: This is currently disabled as this is not an officially supported Lemmy Markdown syntax -/// -/// A Markdown Extension to handle spoiler tags on Lemmy. This extends the [md.InlineSyntax] -/// to allow for inline parsing of text for a given spoiler tag. -/// -/// It parses the following syntax for a spoiler: -/// -/// ``` -/// :::spoiler spoiler_body::: -/// :::spoiler spoiler_body ::: -/// ::: spoiler spoiler_body ::: -/// ``` -/// -/// It does not capture this syntax properly: -/// ``` -/// ::: spoiler spoiler_body::: -/// ``` -class SpoilerInlineSyntax extends md.InlineSyntax { - static const String _pattern = r'(:::\s?spoiler\s(.*?)\s?:::)'; - - SpoilerInlineSyntax() : super(_pattern); - - @override - bool onMatch(md.InlineParser parser, Match match) { - final body = match[2]!; - - // Create a custom Node which will be used to render the spoiler in [SpoilerElementBuilder] - final md.Node spoiler = md.Element('span', [ - /// This is a workaround to allow us to parse the spoiler title and body within the [SpoilerElementBuilder] - /// - /// If the title and body are passed as separate elements into the [spoiler] tag, it causes - /// the resulting [SpoilerWidget] to always show the second element. To work around this, the title and - /// body are placed together into a single node, separated by a ::: to distinguish the sections. - md.Element('spoiler', [ - md.UnparsedContent('_inline:::$body'), - ]), - ]); - - parser.addNode(spoiler); - return true; - } -} - -/// A Markdown Extension to handle spoiler tags on Lemmy. This extends the [md.BlockSyntax] -/// to allow for multi-line parsing of text for a given spoiler tag. -class SpoilerBlockSyntax extends md.BlockSyntax { - /// The pattern to match the end of a spoiler - /// This pattern checks for the following conditions: - /// - The line starts with 0-3 whitespace characters - /// - The line is followed by 3 or more colons - /// - The line ends without any other characters except optional whitespace - RegExp endPattern = RegExp(r'^\s{0,3}:{3,}\s*$'); - - /// The pattern to match the beginning of a spoiler - /// This pattern checks for the following conditions: - /// - The line starts with 0-3 whitespace characters - /// - The line is followed by 3 or more colons - /// - The line contains optional whitespace between the colons and "spoiler" - /// - The line contains some non-whitespace character after the spoiler keyword - @override - RegExp get pattern => RegExp(r'^\s{0,3}:{3,}\s*spoiler\s+(\S.*)$'); - - @override - bool canParse(md.BlockParser parser) { - return pattern.hasMatch(parser.current.content); - } - - /// Parses the block of text for the given spoiler. This will fetch the title and the body of the spoiler. - @override - md.Node parse(md.BlockParser parser) { - final Match? match = pattern.firstMatch(parser.current.content); - final String? title = match?.group(1)?.trim(); - - parser.advance(); // Move to the next line - - final List body = []; - - // Accumulate lines of the body until the closing pattern - while (!parser.isDone) { - // Stop parsing if the current line is one of the following: - if (endPattern.hasMatch(parser.current.content)) { - parser.advance(); - break; - } else { - body.add(parser.current.content); - parser.advance(); - } - } - - // Create a custom Node which will be used to render the spoiler in [SpoilerElementBuilder] - final md.Node spoiler = md.Element('p', [ - /// This is a workaround to allow us to parse the spoiler title and body within the [SpoilerElementBuilder] - /// - /// If the title and body are passed as separate elements into the [spoiler] tag, it causes - /// the resulting [SpoilerWidget] to always show the second element. To work around this, the title and - /// body are placed together into a single node, separated by a :::/-/::: to distinguish the sections. - md.Element('spoiler', [ - md.Text('${title ?? '_block'}:::/-/:::${body.join('\n')}'), - ]), - ]); - - return spoiler; - } -} - -/// Creates a [MarkdownElementBuilder] that renders the custom spoiler tag defined in [SpoilerSyntax]. -/// -/// This breaks down the combined title/body and creates the resulting [SpoilerWidget] -class SpoilerElementBuilder extends MarkdownElementBuilder { - SpoilerElementBuilder(); - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - String rawText = element.textContent; - List parts = rawText.split(':::/-/:::'); - - if (parts.length < 2) { - // An invalid spoiler format - return Container(); - } - - String? title = parts[0].trim(); - String? body = parts[1].trim(); - return SpoilerWidget(title: title, body: body); - } -} - -/// Creates a widget that toggles the visibility of the given [body] -class SpoilerWidget extends StatefulWidget { - final String? title; - final String? body; - - const SpoilerWidget({super.key, this.title, this.body}); - - @override - State createState() => _SpoilerWidgetState(); -} - -class _SpoilerWidgetState extends State { - final ExpandableController expandableController = ExpandableController(initialExpanded: false); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final theme = Theme.of(context); - final contentFontSizeScale = context.read().state.contentFontSizeScale; - - return Container( - decoration: BoxDecoration( - color: getBackgroundColor(context), - borderRadius: const BorderRadius.all(Radius.elliptical(5, 5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSpoilerHeader(l10n, theme, contentFontSizeScale), - _buildSpoilerContent(contentFontSizeScale), - ], - ), - ); - } - - Widget _buildSpoilerHeader(AppLocalizations l10n, ThemeData theme, FontScale contentFontSizeScale) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: const BorderRadius.all(Radius.elliptical(5, 5)), - onTap: () { - expandableController.toggle(); - setState(() {}); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - expandableController.expanded ? Icons.expand_more_rounded : Icons.chevron_right_rounded, - semanticLabel: expandableController.expanded ? l10n.collapseSpoiler : l10n.expandSpoiler, - size: 20, - ), - const SizedBox(width: 5), - Expanded( - child: ScalableText( - widget.title ?? l10n.spoiler, - fontScale: contentFontSizeScale, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildSpoilerContent(FontScale contentFontSizeScale) { - return Expandable( - controller: expandableController, - collapsed: const SizedBox.shrink(), - expanded: Padding( - padding: const EdgeInsets.only(left: 4, right: 4, bottom: 4), - child: CommonMarkdownBody( - body: widget.body ?? '', - isComment: true, - ), - ), - ); - } -} diff --git a/lib/src/shared/markdown/markdown_subsuperscript.dart b/lib/src/shared/markdown/markdown_subsuperscript.dart deleted file mode 100644 index 6d727b1d3..000000000 --- a/lib/src/shared/markdown/markdown_subsuperscript.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:markdown/markdown.dart' as md; - -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; - -enum CustomMarkdownType { superscript, subscript } - -/// A Markdown Extension to handle subscript tags on Lemmy. This extends the [md.InlineSyntax] -/// to allow for inline parsing of text for a given subscript tag. -/// -/// It parses the following syntax for a subscript: -/// -/// ``` -/// ~subscript~ and text~subscript~ -/// ``` -/// -/// It does not capture this syntax properly (parity with Lemmy UI): -/// ``` -/// ~subscript with space~ and text~subscript with space~ -/// ``` -class SubscriptInlineSyntax extends md.InlineSyntax { - SubscriptInlineSyntax() : super(r'~([^~\s]+)~'); - - @override - bool onMatch(md.InlineParser parser, Match match) { - parser.addNode(md.Element.text("sub", match[1]!)); - return true; - } -} - -/// A Markdown Extension to handle superscript tags on Lemmy. This extends the [md.InlineSyntax] -/// to allow for inline parsing of text for a given superscript tag. -/// -/// It parses the following syntax for a superscript: -/// ``` -/// ^superscript^ and text^superscript^ -/// ``` -/// -/// It does not capture this syntax properly (parity with Lemmy UI): -/// ``` -/// ^superscript with space^ and text^superscript with space^ -/// ``` -class SuperscriptInlineSyntax extends md.InlineSyntax { - SuperscriptInlineSyntax() : super(r'\^([^\s^]+)\^'); - - @override - bool onMatch(md.InlineParser parser, Match match) { - parser.addNode(md.Element.text("sup", match[1]!)); - return true; - } -} - -/// Creates a [MarkdownElementBuilder] that renders the custom subscript tag defined in [SubscriptInlineSyntax]. -class SubscriptElementBuilder extends MarkdownElementBuilder { - @override - Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final String textContent = element.textContent; - - return SuperscriptSubscriptWidget(text: textContent, type: CustomMarkdownType.subscript); - } -} - -/// Creates a [MarkdownElementBuilder] that renders the custom superscript tag defined in [SuperscriptInlineSyntax].. -class SuperscriptElementBuilder extends MarkdownElementBuilder { - @override - Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final String textContent = element.textContent; - - return SuperscriptSubscriptWidget(text: textContent, type: CustomMarkdownType.superscript); - } -} - -/// Creates a widget that displays the given [text] in the given [type] (superscript or subscript). -/// -/// Note: There seems to be an issue with rendering both superscript and subscript at the if they are in the same line. -/// For example: `This is a text^subscript^ and this is a text~superscript~`. -/// In this case, the subscript is not rendered correctly. -class SuperscriptSubscriptWidget extends StatelessWidget { - /// The text for the superscript or subscript - final String text; - - /// Whether the text is superscript or subscript - final CustomMarkdownType type; - - const SuperscriptSubscriptWidget({super.key, required this.text, required this.type}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final contentFontSizeScale = context.read().state.contentFontSizeScale; - - return RichText( - text: TextSpan( - children: [ - WidgetSpan( - child: Transform.translate( - offset: Offset(0.0, type == CustomMarkdownType.subscript ? 3.0 : -5.0), - child: ScalableText( - text, - fontScale: contentFontSizeScale, - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/shared/utils/video_player/src/thunder_video_player.dart b/lib/src/shared/media/widgets/thunder_video_player.dart similarity index 94% rename from lib/src/shared/utils/video_player/src/thunder_video_player.dart rename to lib/src/shared/media/widgets/thunder_video_player.dart index 5a3ffa483..678191799 100644 --- a/lib/src/shared/utils/video_player/src/thunder_video_player.dart +++ b/lib/src/shared/media/widgets/thunder_video_player.dart @@ -7,15 +7,12 @@ import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:video_player/video_player.dart'; -import 'package:thunder/src/core/enums/video_playback_speed.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/internet_connection_type.dart'; -import 'package:thunder/src/core/enums/video_auto_play.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/network_checker_cubit/network_checker_cubit.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/app/state/network_checker_cubit/network_checker_cubit.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem, showSnackbar; class ThunderVideoPlayer extends StatefulWidget { const ThunderVideoPlayer({ @@ -96,7 +93,9 @@ class _ThunderVideoPlayerState extends State { timer?.cancel(); // Show video controls - if (!isVideoControlsVisible) setState(() => isVideoControlsVisible = true); + if (!isVideoControlsVisible) { + setState(() => isVideoControlsVisible = true); + } } if (_videoPlayerController.value.hasError) { diff --git a/lib/src/shared/utils/video_player/src/thunder_youtube_player.dart b/lib/src/shared/media/widgets/thunder_youtube_player.dart similarity index 92% rename from lib/src/shared/utils/video_player/src/thunder_youtube_player.dart rename to lib/src/shared/media/widgets/thunder_youtube_player.dart index ba8350345..6b3ea8e67 100644 --- a/lib/src/shared/utils/video_player/src/thunder_youtube_player.dart +++ b/lib/src/shared/media/widgets/thunder_youtube_player.dart @@ -7,13 +7,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart' as ypf; import 'package:youtube_player_iframe/youtube_player_iframe.dart'; -import 'package:thunder/src/app/cubits/network_checker_cubit/network_checker_cubit.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/internet_connection_type.dart'; -import 'package:thunder/src/core/enums/video_auto_play.dart'; -import 'package:thunder/src/app/thunder.dart'; -import 'package:thunder/src/shared/utils/links.dart'; +import 'package:thunder/src/app/state/network_checker_cubit/network_checker_cubit.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/app/shell/navigation/link_navigation_utils.dart'; class ThunderYoutubePlayer extends StatefulWidget { const ThunderYoutubePlayer({super.key, required this.videoUrl, this.postId}); diff --git a/lib/src/shared/media/widgets/video_player.dart b/lib/src/shared/media/widgets/video_player.dart new file mode 100644 index 000000000..897c87b7f --- /dev/null +++ b/lib/src/shared/media/widgets/video_player.dart @@ -0,0 +1,2 @@ +export 'thunder_video_player.dart'; +export 'thunder_youtube_player.dart'; diff --git a/lib/src/shared/persistent_header.dart b/lib/src/shared/persistent_header.dart deleted file mode 100644 index 1fa11b3f8..000000000 --- a/lib/src/shared/persistent_header.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; - -/// A widget which can be used in a [CustomScrollView] via a [SliverPersistentHeader] -/// to pin a widget to the top (like the AppBar) -class PersistentHeader extends SliverPersistentHeaderDelegate { - final Widget child; - - PersistentHeader({required this.child}); - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - final ThemeData theme = Theme.of(context); - - return Column( - children: [ - Container( - color: theme.colorScheme.surface, - width: double.infinity, - height: 56.0, - child: child, - ), - const Divider( - height: 0, - thickness: 1, - ), - ], - ); - } - - @override - double get maxExtent => 57.0; - - @override - double get minExtent => 57.0; - - @override - bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) { - return true; - } -} diff --git a/lib/src/shared/reply_to_preview_actions.dart b/lib/src/shared/reply_to_preview_actions.dart index 1743f8af2..ef98fe84b 100644 --- a/lib/src/shared/reply_to_preview_actions.dart +++ b/lib/src/shared/reply_to_preview_actions.dart @@ -1,77 +1,76 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/shared/divider.dart'; -import 'package:thunder/src/shared/icon_text.dart'; -import 'package:thunder/src/shared/snackbar.dart'; - -/// Defines a widget which provides action buttons for the preview of a post or comment when replying -/// -/// For example, actions to view the original source or copy the text to the clipboard. -class ReplyToPreviewActions extends StatelessWidget { - /// The text to be copied to the clipboard. - final String text; - - /// Whether to show the source text or the markdown text. - final bool viewSource; - - /// Whether the view source is toggled or not. - final void Function()? onViewSourceToggled; - - const ReplyToPreviewActions({ - super.key, - required this.text, - required this.viewSource, - required this.onViewSourceToggled, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - - return Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), - child: Material( - color: Colors.transparent, - child: Column( - children: [ - const ThunderDivider(sliver: false, padding: false), - Row( - spacing: 12.0, - children: [ - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onViewSourceToggled, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), - child: IconText( - padding: 5.0, - icon: Icon(Icons.edit_document, size: 15.0), - text: viewSource ? l10n.viewOriginal : l10n.viewSource, - ), - ), - ), - InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () async { - await Clipboard.setData(ClipboardData(text: text)); - showSnackbar(l10n.copiedToClipboard); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), - child: IconText( - padding: 5.0, - icon: Icon(Icons.copy_rounded, size: 15.0), - text: l10n.copyText, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/shared/icon_text.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderDivider, showSnackbar; + +/// Defines a widget which provides action buttons for the preview of a post or comment when replying +/// +/// For example, actions to view the original source or copy the text to the clipboard. +class ReplyToPreviewActions extends StatelessWidget { + /// The text to be copied to the clipboard. + final String text; + + /// Whether to show the source text or the markdown text. + final bool viewSource; + + /// Whether the view source is toggled or not. + final void Function()? onViewSourceToggled; + + const ReplyToPreviewActions({ + super.key, + required this.text, + required this.viewSource, + required this.onViewSourceToggled, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), + child: Material( + color: Colors.transparent, + child: Column( + children: [ + const ThunderDivider(sliver: false, padding: false), + Row( + spacing: 12.0, + children: [ + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onViewSourceToggled, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), + child: IconText( + padding: 5.0, + icon: Icon(Icons.edit_document, size: 15.0), + text: viewSource ? l10n.viewOriginal : l10n.viewSource, + ), + ), + ), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () async { + await Clipboard.setData(ClipboardData(text: text)); + showSnackbar(l10n.copiedToClipboard); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), + child: IconText( + padding: 5.0, + icon: Icon(Icons.copy_rounded, size: 15.0), + text: l10n.copyText, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/shared/share/advanced_share_sheet.dart b/lib/src/shared/share/advanced_share_sheet.dart index aea73b66a..bbccd4a1f 100644 --- a/lib/src/shared/share/advanced_share_sheet.dart +++ b/lib/src/shared/share/advanced_share_sheet.dart @@ -1,404 +1,402 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:screenshot/screenshot.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:thunder/src/core/enums/local_settings.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/image_preview.dart'; - -class AdvancedShareSheetOptions { - AdvancedShareSheetOptions({ - this.includePostLink = true, - this.includeExternalLink = false, - this.includeImage = true, - this.includeText = false, - this.includeTitle = false, - this.includeCommnity = false, - }); - - bool includePostLink; - bool includeExternalLink; - bool includeImage; - bool includeText; - bool includeTitle; - bool includeCommnity; - - Map toJson() => { - 'includePostLink': includePostLink, - 'includeExternalLink': includeExternalLink, - 'includeImage': includeImage, - 'includeText': includeText, - 'includeTitle': includeTitle, - 'includeCommnity': includeCommnity, - }; - - static AdvancedShareSheetOptions fromJson(Map json) => AdvancedShareSheetOptions( - includePostLink: json['includePostLink'], - includeExternalLink: json['includeExternalLink'], - includeImage: json['includeImage'], - includeText: json['includeText'], - includeTitle: json['includeTitle'], - includeCommnity: json['includeCommnity'], - ); -} - -bool _hasImage(ThunderPost post) => post.media.isNotEmpty && post.media.first.thumbnailUrl != null; - -bool _hasText(ThunderPost post) => post.body?.isNotEmpty == true; - -bool _hasExternalLink(ThunderPost post) => post.media.first.mediaType != MediaType.text; - -bool _canShare(AdvancedShareSheetOptions options, ThunderPost post) { - return options.includePostLink || (options.includeExternalLink && _hasExternalLink(post)) || _canShareImage(options, post); -} - -bool _canShareImage(AdvancedShareSheetOptions options, ThunderPost post) { - return (options.includeImage && _hasImage(post)) || _isImageCustomized(options, post); -} - -bool _isImageCustomized(AdvancedShareSheetOptions options, ThunderPost post) { - return options.includeTitle || options.includeCommnity || (options.includeText && _hasText(post)) || (options.includeImage && _hasImage(post) && (options.includeTitle || options.includeCommnity)); -} - -Future generateShareImage(BuildContext context, AdvancedShareSheetOptions options, ThunderPost post) async { - Uint8List result = Uint8List(0); - ScreenshotController screenshotController = ScreenshotController(); - - // This little trick allows the images we generate to be taller than the viewport - // (which is otherwise the default size in the screenshot package) without having a render overflow. - final FlutterView? view = View.maybeOf(context); - final Size? viewSize = view == null ? null : view.physicalSize / view.devicePixelRatio; - final Size? targetSize = viewSize == null ? null : Size(viewSize.width, 999); - - result = await screenshotController.captureFromWidget( - targetSize: targetSize, - pixelRatio: 4, - Container( - color: Colors.white, - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (options.includeTitle) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - post.name, - textAlign: TextAlign.left, - style: const TextStyle(color: Colors.black, fontSize: 20), - ), - ), - const SizedBox(height: 10), - ], - if (options.includeImage && _hasImage(post)) - Image.network( - post.media.first.thumbnailUrl!, - ), - if (options.includeText && post.body?.isNotEmpty == true) ...[ - if (_hasImage(post)) const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Text( - post.body!, - style: const TextStyle(color: Colors.black), - ), - ), - ), - ], - if (options.includeCommnity) ...[ - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: Text( - post.community!.actorId, - style: const TextStyle(color: Colors.black, fontSize: 10), - ), - ), - ], - ], - ), - ), - ), - ); - - return result; -} - -void showAdvancedShareSheet(BuildContext context, ThunderPost post) async { - final ThemeData theme = Theme.of(context); - - String? optionsJson = UserPreferences.getLocalSetting(LocalSettings.advancedShareOptions); - AdvancedShareSheetOptions options = optionsJson != null ? AdvancedShareSheetOptions.fromJson(jsonDecode(optionsJson)) : AdvancedShareSheetOptions(); - - bool isDownloading = false; - bool isGeneratingImage = true; - - if (context.mounted) { - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return FutureBuilder( - future: generateShareImage(context, options, post), - builder: (context, snapshot) { - if (!_isImageCustomized(options, post) || snapshot.connectionState == ConnectionState.done) { - isGeneratingImage = false; - } - - return AnimatedSize( - duration: const Duration(milliseconds: 250), - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(left: 14, right: 14, bottom: 30), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - AppLocalizations.of(context)!.preview, - style: theme.textTheme.titleLarge, - ), - ), - ), - if (!_canShare(options, post)) - Text( - AppLocalizations.of(context)!.nothingToShare, - style: theme.textTheme.bodyMedium?.copyWith(fontStyle: FontStyle.italic), - ), - if (!_isImageCustomized(options, post) && options.includeImage && _hasImage(post)) - ImagePreview( - url: post.media.first.thumbnailUrl.toString(), - isExpandable: true, - isComment: true, - showFullHeightImages: true, - altText: post.media.first.altText, - ), - if (_isImageCustomized(options, post)) - snapshot.hasData && !isGeneratingImage - ? ImagePreview( - bytes: snapshot.data!, - isExpandable: true, - isComment: true, - showFullHeightImages: true, - ) - : const CircularProgressIndicator(), - if (options.includePostLink) - Text( - post.apId, - style: theme.textTheme.bodyMedium?.copyWith(decoration: TextDecoration.underline), - ), - if (options.includeExternalLink && _hasExternalLink(post)) - Text( - post.media.first.originalUrl!, - style: theme.textTheme.bodyMedium?.copyWith(decoration: TextDecoration.underline), - ), - const SizedBox(height: 20), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - AppLocalizations.of(context)!.image, - style: theme.textTheme.titleLarge, - ), - ), - ), - ToggleOption( - description: AppLocalizations.of(context)!.includeTitle, - iconEnabled: Icons.title_rounded, - iconDisabled: Icons.title_rounded, - value: options.includeTitle, - onToggle: (_) => setState(() { - isGeneratingImage = true; - options.includeTitle = !options.includeTitle; - }), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - if (_hasImage(post)) - ToggleOption( - description: AppLocalizations.of(context)!.includeImage, - iconEnabled: Icons.image_rounded, - iconDisabled: Icons.image_rounded, - value: options.includeImage, - onToggle: (_) => setState(() { - isGeneratingImage = true; - options.includeImage = !options.includeImage; - }), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - if (_hasText(post)) - ToggleOption( - description: AppLocalizations.of(context)!.includeText, - iconEnabled: Icons.comment_rounded, - iconDisabled: Icons.comment_rounded, - value: options.includeText, - onToggle: (_) => setState(() { - isGeneratingImage = true; - options.includeText = !options.includeText; - }), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - ToggleOption( - description: AppLocalizations.of(context)!.includeCommunity, - iconEnabled: Icons.people_rounded, - iconDisabled: Icons.people_rounded, - value: options.includeCommnity, - onToggle: (_) => setState(() { - isGeneratingImage = true; - options.includeCommnity = !options.includeCommnity; - }), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - const SizedBox(height: 20), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - AppLocalizations.of(context)!.link(0), - style: theme.textTheme.titleLarge, - ), - ), - ), - ToggleOption( - description: AppLocalizations.of(context)!.includePostLink, - iconEnabled: Icons.link_rounded, - iconDisabled: Icons.link_rounded, - value: options.includePostLink, - onToggle: (_) => setState(() => options.includePostLink = !options.includePostLink), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - if (_hasExternalLink(post)) - ToggleOption( - description: AppLocalizations.of(context)!.includeExternalLink, - iconEnabled: Icons.link_rounded, - iconDisabled: Icons.link_rounded, - value: options.includeExternalLink, - onToggle: (_) => setState(() => options.includeExternalLink = !options.includeExternalLink), - highlightKey: null, - setting: null, - highlightedSetting: null, - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(AppLocalizations.of(context)!.cancel), - ), - const SizedBox(width: 5), - FilledButton( - onPressed: _canShare(options, post) && !isGeneratingImage - ? () async { - // Save the share settings - UserPreferences.setSetting(LocalSettings.advancedShareOptions, jsonEncode(options.toJson())); - - // Generate the text to share - String? text; - if (options.includePostLink) { - text = post.apId; - } - if (options.includeExternalLink && _hasExternalLink(post)) { - text == null ? text = post.media.first.originalUrl! : text = '$text\n${post.media.first.originalUrl!}'; - } - - // Do the actual sharing - if (_canShareImage(options, post)) { - if (_isImageCustomized(options, post)) { - SharePlus.instance.share(ShareParams( - files: [XFile.fromData(snapshot.data!, mimeType: 'image/jpeg')], - text: text, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } else { - setState(() => isDownloading = true); - final File file = await DefaultCacheManager().getSingleFile(post.media.first.thumbnailUrl!); - setState(() => isDownloading = false); - SharePlus.instance.share(ShareParams( - files: [XFile(file.path)], - text: text, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } - } else if (text != null) { - SharePlus.instance.share(ShareParams( - text: text, - sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), - )); - } - - if (context.mounted) { - Navigator.of(context).pop(); - } - } - : null, - child: Stack( - children: [ - if (isDownloading) - const Positioned.fill( - child: Align( - alignment: Alignment.center, - child: SizedBox( - width: 15, - height: 15, - child: CircularProgressIndicator( - color: Colors.white, - ), - ), - ), - ), - Text( - AppLocalizations.of(context)!.share, - style: TextStyle(color: isDownloading ? Colors.transparent : null), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); - }, - ); - } -} +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/persistence/persistence.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/shared/image_preview.dart'; + +class AdvancedShareSheetOptions { + AdvancedShareSheetOptions({ + this.includePostLink = true, + this.includeExternalLink = false, + this.includeImage = true, + this.includeText = false, + this.includeTitle = false, + this.includeCommnity = false, + }); + + bool includePostLink; + bool includeExternalLink; + bool includeImage; + bool includeText; + bool includeTitle; + bool includeCommnity; + + Map toJson() => { + 'includePostLink': includePostLink, + 'includeExternalLink': includeExternalLink, + 'includeImage': includeImage, + 'includeText': includeText, + 'includeTitle': includeTitle, + 'includeCommnity': includeCommnity, + }; + + static AdvancedShareSheetOptions fromJson(Map json) => AdvancedShareSheetOptions( + includePostLink: json['includePostLink'], + includeExternalLink: json['includeExternalLink'], + includeImage: json['includeImage'], + includeText: json['includeText'], + includeTitle: json['includeTitle'], + includeCommnity: json['includeCommnity'], + ); +} + +bool _hasImage(ThunderPost post) => post.media.isNotEmpty && post.media.first.thumbnailUrl != null; + +bool _hasText(ThunderPost post) => post.body?.isNotEmpty == true; + +bool _hasExternalLink(ThunderPost post) => post.media.first.mediaType != MediaType.text; + +bool _canShare(AdvancedShareSheetOptions options, ThunderPost post) { + return options.includePostLink || (options.includeExternalLink && _hasExternalLink(post)) || _canShareImage(options, post); +} + +bool _canShareImage(AdvancedShareSheetOptions options, ThunderPost post) { + return (options.includeImage && _hasImage(post)) || _isImageCustomized(options, post); +} + +bool _isImageCustomized(AdvancedShareSheetOptions options, ThunderPost post) { + return options.includeTitle || options.includeCommnity || (options.includeText && _hasText(post)) || (options.includeImage && _hasImage(post) && (options.includeTitle || options.includeCommnity)); +} + +Future generateShareImage(BuildContext context, AdvancedShareSheetOptions options, ThunderPost post) async { + Uint8List result = Uint8List(0); + ScreenshotController screenshotController = ScreenshotController(); + + // This little trick allows the images we generate to be taller than the viewport + // (which is otherwise the default size in the screenshot package) without having a render overflow. + final FlutterView? view = View.maybeOf(context); + final Size? viewSize = view == null ? null : view.physicalSize / view.devicePixelRatio; + final Size? targetSize = viewSize == null ? null : Size(viewSize.width, 999); + + result = await screenshotController.captureFromWidget( + targetSize: targetSize, + pixelRatio: 4, + Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (options.includeTitle) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + post.name, + textAlign: TextAlign.left, + style: const TextStyle(color: Colors.black, fontSize: 20), + ), + ), + const SizedBox(height: 10), + ], + if (options.includeImage && _hasImage(post)) + Image.network( + post.media.first.thumbnailUrl!, + ), + if (options.includeText && post.body?.isNotEmpty == true) ...[ + if (_hasImage(post)) const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Text( + post.body!, + style: const TextStyle(color: Colors.black), + ), + ), + ), + ], + if (options.includeCommnity) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: Text( + post.community!.actorId, + style: const TextStyle(color: Colors.black, fontSize: 10), + ), + ), + ], + ], + ), + ), + ), + ); + + return result; +} + +void showAdvancedShareSheet(BuildContext context, ThunderPost post) async { + final ThemeData theme = Theme.of(context); + + String? optionsJson = UserPreferences.getLocalSetting(LocalSettings.advancedShareOptions); + AdvancedShareSheetOptions options = optionsJson != null ? AdvancedShareSheetOptions.fromJson(jsonDecode(optionsJson)) : AdvancedShareSheetOptions(); + + bool isDownloading = false; + bool isGeneratingImage = true; + + if (context.mounted) { + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return FutureBuilder( + future: generateShareImage(context, options, post), + builder: (context, snapshot) { + if (!_isImageCustomized(options, post) || snapshot.connectionState == ConnectionState.done) { + isGeneratingImage = false; + } + + return AnimatedSize( + duration: const Duration(milliseconds: 250), + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 14, right: 14, bottom: 30), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + AppLocalizations.of(context)!.preview, + style: theme.textTheme.titleLarge, + ), + ), + ), + if (!_canShare(options, post)) + Text( + AppLocalizations.of(context)!.nothingToShare, + style: theme.textTheme.bodyMedium?.copyWith(fontStyle: FontStyle.italic), + ), + if (!_isImageCustomized(options, post) && options.includeImage && _hasImage(post)) + ImagePreview( + url: post.media.first.thumbnailUrl.toString(), + isExpandable: true, + isComment: true, + showFullHeightImages: true, + altText: post.media.first.altText, + ), + if (_isImageCustomized(options, post)) + snapshot.hasData && !isGeneratingImage + ? ImagePreview( + bytes: snapshot.data!, + isExpandable: true, + isComment: true, + showFullHeightImages: true, + ) + : const CircularProgressIndicator(), + if (options.includePostLink) + Text( + post.apId, + style: theme.textTheme.bodyMedium?.copyWith(decoration: TextDecoration.underline), + ), + if (options.includeExternalLink && _hasExternalLink(post)) + Text( + post.media.first.originalUrl!, + style: theme.textTheme.bodyMedium?.copyWith(decoration: TextDecoration.underline), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + AppLocalizations.of(context)!.image, + style: theme.textTheme.titleLarge, + ), + ), + ), + ToggleOption( + description: AppLocalizations.of(context)!.includeTitle, + iconEnabled: Icons.title_rounded, + iconDisabled: Icons.title_rounded, + value: options.includeTitle, + onToggle: (_) => setState(() { + isGeneratingImage = true; + options.includeTitle = !options.includeTitle; + }), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + if (_hasImage(post)) + ToggleOption( + description: AppLocalizations.of(context)!.includeImage, + iconEnabled: Icons.image_rounded, + iconDisabled: Icons.image_rounded, + value: options.includeImage, + onToggle: (_) => setState(() { + isGeneratingImage = true; + options.includeImage = !options.includeImage; + }), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + if (_hasText(post)) + ToggleOption( + description: AppLocalizations.of(context)!.includeText, + iconEnabled: Icons.comment_rounded, + iconDisabled: Icons.comment_rounded, + value: options.includeText, + onToggle: (_) => setState(() { + isGeneratingImage = true; + options.includeText = !options.includeText; + }), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + ToggleOption( + description: AppLocalizations.of(context)!.includeCommunity, + iconEnabled: Icons.people_rounded, + iconDisabled: Icons.people_rounded, + value: options.includeCommnity, + onToggle: (_) => setState(() { + isGeneratingImage = true; + options.includeCommnity = !options.includeCommnity; + }), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + AppLocalizations.of(context)!.link(0), + style: theme.textTheme.titleLarge, + ), + ), + ), + ToggleOption( + description: AppLocalizations.of(context)!.includePostLink, + iconEnabled: Icons.link_rounded, + iconDisabled: Icons.link_rounded, + value: options.includePostLink, + onToggle: (_) => setState(() => options.includePostLink = !options.includePostLink), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + if (_hasExternalLink(post)) + ToggleOption( + description: AppLocalizations.of(context)!.includeExternalLink, + iconEnabled: Icons.link_rounded, + iconDisabled: Icons.link_rounded, + value: options.includeExternalLink, + onToggle: (_) => setState(() => options.includeExternalLink = !options.includeExternalLink), + highlightKey: null, + setting: null, + highlightedSetting: null, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + const SizedBox(width: 5), + FilledButton( + onPressed: _canShare(options, post) && !isGeneratingImage + ? () async { + // Save the share settings + UserPreferences.setSetting(LocalSettings.advancedShareOptions, jsonEncode(options.toJson())); + + // Generate the text to share + String? text; + if (options.includePostLink) { + text = post.apId; + } + if (options.includeExternalLink && _hasExternalLink(post)) { + text == null ? text = post.media.first.originalUrl! : text = '$text\n${post.media.first.originalUrl!}'; + } + + // Do the actual sharing + if (_canShareImage(options, post)) { + if (_isImageCustomized(options, post)) { + SharePlus.instance.share(ShareParams( + files: [XFile.fromData(snapshot.data!, mimeType: 'image/jpeg')], + text: text, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + } else { + setState(() => isDownloading = true); + final File file = await DefaultCacheManager().getSingleFile(post.media.first.thumbnailUrl!); + setState(() => isDownloading = false); + SharePlus.instance.share(ShareParams( + files: [XFile(file.path)], + text: text, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + } + } else if (text != null) { + SharePlus.instance.share(ShareParams( + text: text, + sharePositionOrigin: Rect.fromLTWH(0, 0, 1, 1), + )); + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + } + : null, + child: Stack( + children: [ + if (isDownloading) + const Positioned.fill( + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ), + ), + Text( + AppLocalizations.of(context)!.share, + style: TextStyle(color: isDownloading ? Colors.transparent : null), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/shared/share/share_action_bottom_sheet.dart b/lib/src/shared/share/share_action_bottom_sheet.dart index e4f33493f..320b5ec47 100644 --- a/lib/src/shared/share/share_action_bottom_sheet.dart +++ b/lib/src/shared/share/share_action_bottom_sheet.dart @@ -5,14 +5,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/post/api.dart'; import 'package:thunder/src/shared/share/advanced_share_sheet.dart'; -import 'package:thunder/src/shared/bottom_sheet_action.dart'; -import 'package:thunder/src/shared/snackbar.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetAction, showSnackbar; /// Defines the actions that can be taken on a post when sharing enum ShareBottomSheetAction { diff --git a/lib/src/shared/sort_picker.dart b/lib/src/shared/sort_picker.dart index 9d35067f0..362284a60 100644 --- a/lib/src/shared/sort_picker.dart +++ b/lib/src/shared/sort_picker.dart @@ -1,12 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/core/enums/search_sort_type.dart'; -import 'package:thunder/src/shared/picker_item.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/features/account/api.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/packages/ui/ui.dart' show BottomSheetListPicker, ListPickerItem, PickerItem; // ============================================================================ // Post Sort Type Items diff --git a/lib/src/shared/utils/colors.dart b/lib/src/shared/theme/color_utils.dart similarity index 82% rename from lib/src/shared/utils/colors.dart rename to lib/src/shared/theme/color_utils.dart index b1de9fc0b..8596f5e64 100644 --- a/lib/src/shared/utils/colors.dart +++ b/lib/src/shared/theme/color_utils.dart @@ -1,29 +1,29 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; - -/// Gets a tinted background color that looks good in light and dark mode -Color getBackgroundColor(BuildContext context) { - final useDarkTheme = context.read().state.useDarkTheme; - return useDarkTheme ? DARK_THEME_BACKGROUND_COLOR : LIGHT_THEME_BACKGROUND_COLOR; -} - -/// Retrieves the color based on the depth of the comment in the comment tree -Color getCommentLevelColor(BuildContext context, int level) { - // TODO: make this themeable - List colors = [ - Colors.red.shade300, - Colors.orange.shade300, - Colors.yellow.shade300, - Colors.green.shade300, - Colors.blue.shade300, - Colors.indigo.shade300, - ]; - - final theme = Theme.of(context); - - return Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), colors[level]); -} +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/foundation/config/config.dart'; + +/// Gets a tinted background color that looks good in light and dark mode +Color getBackgroundColor(BuildContext context) { + final useDarkTheme = context.read().state.useDarkTheme; + return useDarkTheme ? DARK_THEME_BACKGROUND_COLOR : LIGHT_THEME_BACKGROUND_COLOR; +} + +/// Retrieves the color based on the depth of the comment in the comment tree +Color getCommentLevelColor(BuildContext context, int level) { + // TODO: make this themeable + List colors = [ + Colors.red.shade300, + Colors.orange.shade300, + Colors.yellow.shade300, + Colors.green.shade300, + Colors.blue.shade300, + Colors.indigo.shade300, + ]; + + final theme = Theme.of(context); + + return Color.alphaBlend(theme.colorScheme.primary.withValues(alpha: 0.4), colors[level]); +} diff --git a/lib/src/shared/utils/media/video.dart b/lib/src/shared/utils/media/video.dart deleted file mode 100644 index 574af2a87..000000000 --- a/lib/src/shared/utils/media/video.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:youtube_player_flutter/youtube_player_flutter.dart'; - -import 'package:thunder/src/core/enums/video_player_mode.dart'; -import 'package:thunder/src/app/cubits/video_preferences_cubit/video_preferences_cubit.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/shared/utils/video_player/video_player.dart'; - -bool isVideoUrl(String url) { - List videoExtensions = [ - "mp4", - "avi", - "mkv", - "mov", - "wmv", - "flv", - "webm", - "ogg", - "ogv", - "3gp", - "mpeg", - "mpg", - "m4v", - "ts", - "vob", - ]; - - // YouTube url - String? youtubeVideoId = YoutubePlayer.convertUrlToId(url); - - // Get the file extension from the URL - String fileExtension = url.split('.').last.toLowerCase(); - - // Check if the file extension is in the list of video extensions - return videoExtensions.contains(fileExtension) || (youtubeVideoId?.isNotEmpty ?? false); -} - -void showVideoPlayer(BuildContext context, {String? url, int? postId}) { - if (url == null) return; - - String? videoId = YoutubePlayer.convertUrlToId(url); - - final videoPlayerMode = context.read().state.videoPlayerMode; - - switch (videoPlayerMode) { - case VideoPlayerMode.inApp: - Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - transitionDuration: const Duration(milliseconds: 100), - reverseTransitionDuration: const Duration(milliseconds: 50), - pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { - return (videoId != null) ? ThunderYoutubePlayer(videoUrl: url, postId: postId) : ThunderVideoPlayer(videoUrl: url, postId: postId); - }, - transitionsBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { - return Align(child: FadeTransition(opacity: animation, child: child)); - }, - ), - ); - break; - case VideoPlayerMode.externalPlayer: - handleVideoLink(context, url: url); - case VideoPlayerMode.customTabs: - handleVideoLink(context, url: url); - } -} diff --git a/lib/src/shared/utils/numbers.dart b/lib/src/shared/utils/numbers.dart deleted file mode 100644 index b9de36a8e..000000000 --- a/lib/src/shared/utils/numbers.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:intl/intl.dart'; - -final NumberFormat _compactFormatter = NumberFormat.compact(); -final NumberFormat _longFormatter = NumberFormat.decimalPatternDigits(decimalDigits: 0); - -String formatNumberToK(int number) => _compactFormatter.format(number); - -String formatLongNumber(int number) => _longFormatter.format(number); diff --git a/lib/src/shared/utils/text_input_formatter.dart b/lib/src/shared/utils/text_input_formatter.dart deleted file mode 100644 index 22ddd8957..000000000 --- a/lib/src/shared/utils/text_input_formatter.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/services.dart'; - -class LowerCaseTextFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { - return TextEditingValue( - text: newValue.text.toLowerCase(), - selection: newValue.selection, - ); - } -} diff --git a/lib/src/shared/utils/video_player/video_player.dart b/lib/src/shared/utils/video_player/video_player.dart deleted file mode 100644 index 2c8773f64..000000000 --- a/lib/src/shared/utils/video_player/video_player.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'src/thunder_video_player.dart'; -export 'src/thunder_youtube_player.dart'; diff --git a/lib/src/shared/widgets/avatars/community_avatar.dart b/lib/src/shared/widgets/avatars/community_avatar.dart deleted file mode 100644 index d0203dc7f..000000000 --- a/lib/src/shared/widgets/avatars/community_avatar.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:cached_network_image/cached_network_image.dart'; - -/// A community avatar. Displays the associated community icon if available. -/// -/// Otherwise, displays the first letter of the community title (display name). -/// If no title is available, displays the first letter of the community name. -class CommunityAvatar extends StatelessWidget { - /// The community information to display - final ThunderCommunity community; - - /// The radius of the avatar. Defaults to 12 - final double radius; - - /// Whether to show the community status (locked) - final bool showCommunityStatus; - - /// The size of the thumbnail's height - final int? thumbnailSize; - - /// The image format to request from the instance - final String? format; - - const CommunityAvatar({ - super.key, - required this.community, - this.radius = 12.0, - this.showCommunityStatus = false, - this.thumbnailSize, - this.format, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - CircleAvatar placeholderIcon = CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - maxRadius: radius, - child: Text( - community.titleOrName.isNotEmpty ? community.titleOrName[0].toUpperCase() : '', - semanticsLabel: '', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: radius), - ), - ); - - if (community.icon?.isNotEmpty != true) return placeholderIcon; - - Map queryParameters = {}; - if (thumbnailSize != null) queryParameters['thumbnail'] = thumbnailSize.toString(); - if (format != null) queryParameters['format'] = format; - - Uri imageUri = Uri.parse(community.icon!); - - // Only set pictrs query parameters if the image URL is a pictrs URL and the image is not being proxied - if (imageUri.path.contains('/pictrs/image/') && queryParameters.isNotEmpty) { - imageUri = Uri.https(imageUri.host, imageUri.path, queryParameters); - } - - return CachedNetworkImage( - imageUrl: imageUri.toString(), - imageBuilder: (context, imageProvider) { - return Stack( - children: [ - CircleAvatar(backgroundColor: Colors.transparent, foregroundImage: imageProvider, maxRadius: radius), - if (community.postingRestrictedToMods && showCommunityStatus) - Positioned( - bottom: -2.0, - right: -2.0, - child: Tooltip( - message: l10n.onlyModsCanPostInCommunity, - child: Container( - padding: const EdgeInsets.all(4.0), - decoration: BoxDecoration(color: theme.colorScheme.surface, shape: BoxShape.circle), - child: Icon(Icons.lock, color: theme.colorScheme.error, size: 18.0, semanticLabel: l10n.onlyModsCanPostInCommunity), - ), - ), - ), - ], - ); - }, - placeholder: (context, url) => placeholderIcon, - errorWidget: (context, url, error) => placeholderIcon, - ); - } -} diff --git a/lib/src/shared/widgets/avatars/instance_avatar.dart b/lib/src/shared/widgets/avatars/instance_avatar.dart deleted file mode 100644 index 3b9f21e52..000000000 --- a/lib/src/shared/widgets/avatars/instance_avatar.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; - -import 'package:thunder/src/core/models/models.dart'; - -/// An instance avatar. Displays the associated instance icon if available. -/// -/// Otherwise, displays the first letter of the instance's name. -class InstanceAvatar extends StatelessWidget { - final ThunderInstanceInfo instance; - final double radius; - - const InstanceAvatar({super.key, required this.instance, this.radius = 16.0}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - CircleAvatar placeholderIcon = CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - maxRadius: radius, - child: Text( - instance.name?.isNotEmpty == true - ? instance.name![0].toUpperCase() - : instance.domain?.isNotEmpty == true - ? instance.domain![0].toUpperCase() - : '', - semanticsLabel: '', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: radius), - ), - ); - - if (instance.icon?.isNotEmpty != true) return placeholderIcon; - - return CachedNetworkImage( - imageUrl: instance.icon!, - imageBuilder: (context, imageProvider) { - return CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: imageProvider, - maxRadius: radius, - ); - }, - placeholder: (context, url) => placeholderIcon, - errorWidget: (context, url, error) => placeholderIcon, - ); - } -} diff --git a/lib/src/shared/widgets/avatars/user_avatar.dart b/lib/src/shared/widgets/avatars/user_avatar.dart deleted file mode 100644 index 1089afb47..000000000 --- a/lib/src/shared/widgets/avatars/user_avatar.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:cached_network_image/cached_network_image.dart'; - -import 'package:thunder/src/features/user/user.dart'; - -/// A user avatar. Displays the associated user icon if available. -/// -/// Otherwise, displays the first letter of the user's display name. -/// If no display name is available, displays the first letter of the user's username. -class UserAvatar extends StatelessWidget { - /// The user information to display - final ThunderUser user; - - /// The radius of the avatar. Defaults to 16 - final double radius; - - /// The size of the thumbnail's height - final int? thumbnailSize; - - /// The image format to request from the instance - final String? format; - - const UserAvatar({ - super.key, - required this.user, - this.radius = 16.0, - this.thumbnailSize, - this.format, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - CircleAvatar placeholderIcon = CircleAvatar( - backgroundColor: theme.colorScheme.secondaryContainer, - maxRadius: radius, - child: Text( - user.displayNameOrName[0].toUpperCase(), - semanticsLabel: '', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: radius), - ), - ); - - if (user.avatar?.isNotEmpty != true) return placeholderIcon; - - Map queryParameters = {}; - if (thumbnailSize != null) queryParameters['thumbnail'] = thumbnailSize.toString(); - if (format != null) queryParameters['format'] = format; - - Uri imageUri = Uri.parse(user.avatar!); - - // Only set pictrs query parameters if the image URL is a pictrs URL and the image is not being proxied - if (imageUri.path.contains('/pictrs/image/') && queryParameters.isNotEmpty) { - imageUri = Uri.https(imageUri.host, imageUri.path, queryParameters); - debugPrint('imageUri with pictrs: $imageUri'); - } - - return CachedNetworkImage( - imageUrl: imageUri.toString(), - imageBuilder: (context, imageProvider) { - return CircleAvatar(backgroundColor: Colors.transparent, foregroundImage: imageProvider, maxRadius: radius); - }, - placeholder: (context, url) => placeholderIcon, - errorWidget: (context, url, error) => placeholderIcon, - ); - } -} diff --git a/lib/src/shared/widgets/chips/community_chip.dart b/lib/src/shared/widgets/chips/community_chip.dart index b27521b21..e4ccdb724 100644 --- a/lib/src/shared/widgets/chips/community_chip.dart +++ b/lib/src/shared/widgets/chips/community_chip.dart @@ -1,15 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/community_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; /// A chip which displays the given community and instance information. /// diff --git a/lib/src/shared/widgets/chips/user_chip.dart b/lib/src/shared/widgets/chips/user_chip.dart index 7338a382f..6acf90871 100644 --- a/lib/src/shared/widgets/chips/user_chip.dart +++ b/lib/src/shared/widgets/chips/user_chip.dart @@ -2,18 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; -import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/shared/widgets/avatars/user_avatar.dart'; -import 'package:thunder/src/shared/full_name_widgets.dart'; -import 'package:thunder/src/app/cubits/comment_preferences_cubit/comment_preferences_cubit.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; -import 'package:thunder/src/app/widgets/thunder_icons.dart'; -import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/avatars/user_avatar.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/full_name_widgets.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/features/user/api.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.dart'; +import 'package:thunder/src/app/shell/navigation/navigation_utils.dart'; +import 'package:thunder/packages/ui/ui.dart' show Thunder; /// A chip which displays the given user and instance information. Additionally, it renders special chips for special users. /// diff --git a/lib/src/shared/widgets/comment_navigator_fab.dart b/lib/src/shared/widgets/comment_navigator_fab.dart index 3de4b1d11..aa601221a 100644 --- a/lib/src/shared/widgets/comment_navigator_fab.dart +++ b/lib/src/shared/widgets/comment_navigator_fab.dart @@ -4,8 +4,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/features/comment/api.dart'; +import 'package:thunder/src/features/settings/api.dart'; class CommentNavigatorFab extends StatefulWidget { /// The [ScrollController] for the scrollable list diff --git a/lib/src/shared/widgets/media/media_type_badge.dart b/lib/src/shared/widgets/media/media_type_badge.dart index 3254cfbb9..2fad06260 100644 --- a/lib/src/shared/widgets/media/media_type_badge.dart +++ b/lib/src/shared/widgets/media/media_type_badge.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/core/enums/media_type.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/settings/api.dart'; /// Base representation of a media type badge. Holds the icon and color. class MediaTypeBadgeItem { diff --git a/lib/src/shared/widgets/media/media_view_text.dart b/lib/src/shared/widgets/media/media_view_text.dart index 26c6f8e2c..e0d7abe8e 100644 --- a/lib/src/shared/widgets/media/media_view_text.dart +++ b/lib/src/shared/widgets/media/media_view_text.dart @@ -6,8 +6,8 @@ import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:html/parser.dart'; import 'package:markdown/markdown.dart' hide Text; -import 'package:thunder/src/core/enums/view_mode.dart'; -import 'package:thunder/src/features/notification/notification.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/notification/api.dart'; /// Creates a [MediaViewText] widget which displays a preview of the text content of a post. /// diff --git a/lib/src/shared/widgets/multi_action_dismissible.dart b/lib/src/shared/widgets/multi_action_dismissible.dart index 333cf26fb..22d960a5e 100644 --- a/lib/src/shared/widgets/multi_action_dismissible.dart +++ b/lib/src/shared/widgets/multi_action_dismissible.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:thunder/src/core/enums/swipe_action.dart'; +import 'package:thunder/src/features/settings/domain/swipe_action.dart'; typedef SwipeBackgroundBuilder = Widget Function( BuildContext context, diff --git a/lib/src/shared/widgets/text/selectable_text_modal.dart b/lib/src/shared/widgets/text/selectable_text_modal.dart index 5757ca686..3e704f7c5 100644 --- a/lib/src/shared/widgets/text/selectable_text_modal.dart +++ b/lib/src/shared/widgets/text/selectable_text_modal.dart @@ -1,173 +1,173 @@ -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/font_scale.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; -import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; -import 'package:thunder/src/shared/widgets/text/scalable_text.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; - -void showSelectableTextModal(BuildContext context, {String? title, required String text}) { - final l10n = GlobalContext.l10n; - final theme = Theme.of(context); - - final themePreferences = context.read().state; - final contentFontSizeScale = themePreferences.contentFontSizeScale; - final titleFontSizeScale = themePreferences.titleFontSizeScale; - - final textScrollController = ScrollController(); - final actionsScrollController = ScrollController(); - final focusNode = FocusNode(); - final selectableRegionKey = GlobalKey(); - - bool isAnythingSelected = false; - - final chipColor = theme.colorScheme.primaryContainer.withValues(alpha: 0.25); - - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (context) { - bool viewSource = false; - bool copySuccess = false; - return StatefulBuilder( - builder: (context, setState) { - return FractionallySizedBox( - heightFactor: 0.6, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - FadingEdgeScrollView.fromSingleChildScrollView( - gradientFractionOnStart: 0.1, - gradientFractionOnEnd: 0.1, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: actionsScrollController, - child: Row( - spacing: 10.0, - children: [ - ThunderActionChip( - backgroundColor: viewSource ? chipColor : null, - trailingIcon: viewSource ? Icons.close_rounded : null, - onPressed: () => setState(() => viewSource = !viewSource), - label: l10n.viewSource, - ), - ThunderActionChip( - onPressed: () => (selectableRegionKey.currentState as SelectableRegionState).selectAll(), - label: l10n.selectAll, - ), - ThunderActionChip( - backgroundColor: copySuccess ? chipColor : null, - trailingIcon: copySuccess ? Icons.check_rounded : null, - onPressed: isAnythingSelected - ? () async { - (selectableRegionKey.currentState as SelectableRegionState).copySelection(SelectionChangedCause.tap); - setState(() => copySuccess = true); - await Future.delayed(const Duration(seconds: 2)); - setState(() => copySuccess = false); - } - : null, - label: l10n.copySelected, - ), - ], - ), - ), - ), - const SizedBox(height: 24.0), - Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 26.0, right: 16.0), - child: FadingEdgeScrollView.fromSingleChildScrollView( - gradientFractionOnStart: 0.1, - gradientFractionOnEnd: 0.1, - child: SingleChildScrollView( - controller: textScrollController, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - SelectableRegion( - onSelectionChanged: (value) { - setState(() => isAnythingSelected = value != null); - }, - key: selectableRegionKey, - focusNode: focusNode, - // Note: material/cupertinoTextSelectionHandleControls will be deprecated eventually, - // but is still required in order to also use contextMenuBuilder. - // See https://github.com/flutter/flutter/issues/122421 for more info. - selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, - contextMenuBuilder: (context, selectableRegionState) { - // While this isn't strictly needed right now, it's here so that when we upgrade the Flutter version, we'll get "Share" for free. - // This comment canbe deleted at that time. - return AdaptiveTextSelectionToolbar.buttonItems( - buttonItems: selectableRegionState.contextMenuButtonItems, - anchors: selectableRegionState.contextMenuAnchors, - ); - }, - child: Column( - children: [ - if (title?.isNotEmpty == true) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - title!, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: MediaQuery.textScalerOf(context).scale(theme.textTheme.bodyMedium!.fontSize! * titleFontSizeScale.textScaleFactor), - ), - ), - ), - const SizedBox(height: 16), - ], - Align( - alignment: Alignment.centerLeft, - child: viewSource - ? ScalableText( - text, - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - fontScale: contentFontSizeScale, - ) - : CommonMarkdownBody( - body: text, - isComment: true, - ), - ), - ], - ), - ) - ], - ), - ), - ), - ), - ), - const SizedBox(height: 16.0), - Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 26.0, right: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.close), - ), - ], - ), - ), - const SizedBox(height: 24.0), - ], - ), - ); - }, - ); - }, - ); -} +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; +import 'package:thunder/src/features/content/presentation/widgets/common_markdown_body.dart'; +import 'package:thunder/src/features/identity/presentation/widgets/text/scalable_text.dart'; +import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderActionChip; + +void showSelectableTextModal(BuildContext context, {String? title, required String text}) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + final themePreferences = context.read().state; + final contentFontSizeScale = themePreferences.contentFontSizeScale; + final titleFontSizeScale = themePreferences.titleFontSizeScale; + + final textScrollController = ScrollController(); + final actionsScrollController = ScrollController(); + final focusNode = FocusNode(); + final selectableRegionKey = GlobalKey(); + + bool isAnythingSelected = false; + + final chipColor = theme.colorScheme.primaryContainer.withValues(alpha: 0.25); + + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + bool viewSource = false; + bool copySuccess = false; + return StatefulBuilder( + builder: (context, setState) { + return FractionallySizedBox( + heightFactor: 0.6, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + FadingEdgeScrollView.fromSingleChildScrollView( + gradientFractionOnStart: 0.1, + gradientFractionOnEnd: 0.1, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: actionsScrollController, + child: Row( + spacing: 10.0, + children: [ + ThunderActionChip( + backgroundColor: viewSource ? chipColor : null, + trailingIcon: viewSource ? Icons.close_rounded : null, + onPressed: () => setState(() => viewSource = !viewSource), + label: l10n.viewSource, + ), + ThunderActionChip( + onPressed: () => (selectableRegionKey.currentState as SelectableRegionState).selectAll(), + label: l10n.selectAll, + ), + ThunderActionChip( + backgroundColor: copySuccess ? chipColor : null, + trailingIcon: copySuccess ? Icons.check_rounded : null, + onPressed: isAnythingSelected + ? () async { + (selectableRegionKey.currentState as SelectableRegionState).copySelection(SelectionChangedCause.tap); + setState(() => copySuccess = true); + await Future.delayed(const Duration(seconds: 2)); + setState(() => copySuccess = false); + } + : null, + label: l10n.copySelected, + ), + ], + ), + ), + ), + const SizedBox(height: 24.0), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 26.0, right: 16.0), + child: FadingEdgeScrollView.fromSingleChildScrollView( + gradientFractionOnStart: 0.1, + gradientFractionOnEnd: 0.1, + child: SingleChildScrollView( + controller: textScrollController, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + SelectableRegion( + onSelectionChanged: (value) { + setState(() => isAnythingSelected = value != null); + }, + key: selectableRegionKey, + focusNode: focusNode, + // Note: material/cupertinoTextSelectionHandleControls will be deprecated eventually, + // but is still required in order to also use contextMenuBuilder. + // See https://github.com/flutter/flutter/issues/122421 for more info. + selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, + contextMenuBuilder: (context, selectableRegionState) { + // While this isn't strictly needed right now, it's here so that when we upgrade the Flutter version, we'll get "Share" for free. + // This comment canbe deleted at that time. + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors, + ); + }, + child: Column( + children: [ + if (title?.isNotEmpty == true) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + title!, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: MediaQuery.textScalerOf(context).scale(theme.textTheme.bodyMedium!.fontSize! * titleFontSizeScale.textScaleFactor), + ), + ), + ), + const SizedBox(height: 16), + ], + Align( + alignment: Alignment.centerLeft, + child: viewSource + ? ScalableText( + text, + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + fontScale: contentFontSizeScale, + ) + : CommonMarkdownBody( + body: text, + isComment: true, + ), + ), + ], + ), + ) + ], + ), + ), + ), + ), + ), + const SizedBox(height: 16.0), + Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 26.0, right: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + ), + ), + const SizedBox(height: 24.0), + ], + ), + ); + }, + ); + }, + ); +} diff --git a/lib/src/shared/widgets/webview.dart b/lib/src/shared/widgets/webview.dart index b2ae1f92f..358438862 100644 --- a/lib/src/shared/widgets/webview.dart +++ b/lib/src/shared/widgets/webview.dart @@ -5,16 +5,17 @@ import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; -import 'package:thunder/src/shared/utils/constants.dart'; -import 'package:thunder/src/shared/utils/links.dart'; -import 'package:thunder/src/shared/utils/web_utils.dart'; +import 'package:thunder/src/foundation/config/config.dart'; +import 'package:thunder/src/shared/links/widgets/link_bottom_sheet.dart'; +import 'package:thunder/src/foundation/contracts/contracts.dart'; +import 'package:thunder/src/shared/widgets/webview/custom_web_view_controller.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/packages/ui/ui.dart' show ThunderPopupMenuItem; class WebView extends StatefulWidget { final String url; diff --git a/lib/src/shared/utils/web_utils.dart b/lib/src/shared/widgets/webview/custom_web_view_controller.dart similarity index 66% rename from lib/src/shared/utils/web_utils.dart rename to lib/src/shared/widgets/webview/custom_web_view_controller.dart index 8fbe0e3af..37a54683e 100644 --- a/lib/src/shared/utils/web_utils.dart +++ b/lib/src/shared/widgets/webview/custom_web_view_controller.dart @@ -1,43 +1,33 @@ -import 'package:webview_flutter/webview_flutter.dart'; - -/// Defines an interface which can perform web controlling operations -abstract interface class IWebController { - Future canGoBack(); - Future canGoForward(); - Future goBack(); - Future goForward(); - Future reload(); - Future getTitle(); - Future currentUrl(); - Future loadRequest(Uri uri); -} - -class CustomWebViewController implements IWebController { - final WebViewController controller; - - CustomWebViewController.fromWebViewController(this.controller); - - @override - Future canGoBack() => controller.canGoBack(); - - @override - Future canGoForward() => controller.canGoForward(); - - @override - Future goBack() => controller.goBack(); - - @override - Future goForward() => controller.goForward(); - - @override - Future reload() => controller.reload(); - - @override - Future getTitle() => controller.getTitle(); - - @override - Future currentUrl() => controller.currentUrl(); - - @override - Future loadRequest(Uri uri) => controller.loadRequest(uri); -} +import 'package:webview_flutter/webview_flutter.dart'; + +import 'package:thunder/src/foundation/contracts/contracts.dart'; + +class CustomWebViewController implements IWebController { + final WebViewController controller; + + CustomWebViewController.fromWebViewController(this.controller); + + @override + Future canGoBack() => controller.canGoBack(); + + @override + Future canGoForward() => controller.canGoForward(); + + @override + Future goBack() => controller.goBack(); + + @override + Future goForward() => controller.goForward(); + + @override + Future reload() => controller.reload(); + + @override + Future getTitle() => controller.getTitle(); + + @override + Future currentUrl() => controller.currentUrl(); + + @override + Future loadRequest(Uri uri) => controller.loadRequest(uri); +} diff --git a/pubspec.lock b/pubspec.lock index c461fb11e..46e28515f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,10 +117,10 @@ packages: dependency: "direct main" description: name: bloc - sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "9.2.0" bloc_concurrency: dependency: "direct main" description: @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -141,10 +149,10 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.4" build_config: dependency: transitive description: @@ -165,10 +173,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.11.1" built_collection: dependency: transitive description: @@ -181,10 +189,10 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.3" cached_network_image: dependency: "direct main" description: @@ -213,10 +221,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -233,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -249,14 +265,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" collection: dependency: "direct main" description: @@ -289,14 +313,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" cross_file: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -341,26 +373,34 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" drift: dependency: "direct main" description: name: drift - sha256: "3669e1b68d7bffb60192ac6ba9fd2c0306804d7a00e5879f6364c69ecde53a7f" + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" url: "https://pub.dev" source: hosted - version: "2.30.0" + version: "2.31.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: afe4d1d2cfce6606c86f11a6196e974a2ddbfaa992956ce61e054c9b1899c769 + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" url: "https://pub.dev" source: hosted - version: "2.30.0" + version: "2.31.0" drift_flutter: dependency: "direct main" description: @@ -381,10 +421,10 @@ packages: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" exif: dependency: transitive description: @@ -437,10 +477,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -594,42 +634,42 @@ packages: dependency: "direct main" description: name: flutter_custom_tabs - sha256: "5921e323f70ccee553e01e09d873c2779c9280ebe3d4dd904c7ab1c7a0dbbb3a" + sha256: "8c3d92b074a8109f1dc76333c5ff8f87ea5896c118264c4b6b6e7d880058e820" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.5.0" flutter_custom_tabs_android: dependency: transitive description: name: flutter_custom_tabs_android - sha256: "925fc5e7d27372ee523962dcfcd4b77c0443549482c284dd7897b77815547621" + sha256: "84e6b856fae8ca347bf47156f2106aac5671441fd6b3c531d1b793d22d29dece" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.0" flutter_custom_tabs_ios: dependency: transitive description: name: flutter_custom_tabs_ios - sha256: "27988bf8f28aaa93643de6d09c372ff3b50f3adad376c172d66d4bc9f48a9db5" + sha256: "5f8bbfedad7ff60da4875d888085c05b1d0fd90acbfa4a624ae71b78c5cc6e37" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.5.0" flutter_custom_tabs_platform_interface: dependency: transitive description: name: flutter_custom_tabs_platform_interface - sha256: "54a6ff5cc7571cb266a47ade9f6f89d1980b9ed2dba18162a6d5300afc408449" + sha256: "2b66c541f3ca87fbf3e63808dbd41b28cb76f512acce5940f2468b33abe69031" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" flutter_custom_tabs_web: dependency: transitive description: name: flutter_custom_tabs_web - sha256: "236e035c73b6d3ef0a2f85cd8b6b815954e7559c9f9d50a15ed2e53a297b58b0" + sha256: d606b231859c79679c78dbf05293528a543e3e067c2893a3a5bed1ab9a8300bb url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" flutter_displaymode: dependency: "direct main" description: @@ -674,10 +714,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" flutter_inappwebview_ios: dependency: transitive description: @@ -786,34 +826,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad" url: "https://pub.dev" source: hosted - version: "19.5.0" + version: "20.1.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "7.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "10.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.1" flutter_localizations: dependency: transitive description: flutter @@ -893,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" gal: dependency: "direct main" description: @@ -925,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" html: dependency: "direct main" description: @@ -977,10 +1033,10 @@ packages: dependency: "direct main" description: name: image - sha256: "48c11d0943b93b6fb29103d956ff89aafeae48f6058a3939649be2093dcff0bf" + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.7.1" + version: "4.7.2" image_dimension_parser: dependency: "direct main" description: @@ -1002,10 +1058,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+13" image_picker_for_web: dependency: transitive description: @@ -1018,10 +1074,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+6" image_picker_linux: dependency: transitive description: @@ -1074,18 +1130,18 @@ packages: dependency: transitive description: name: jovial_misc - sha256: "4301011027d87b8b919cb862db84071a34448eadbb32cc8d40fe505424dfe69a" + sha256: "065b5240badae6b13472efdea28fffe8baf914a7831361469a95c6456d9b8dc8" url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.10.0" jovial_svg: dependency: "direct main" description: name: jovial_svg - sha256: "08dd24b800d48796c9c0227acb96eb00c6cacccb1d7de58d79fc924090049868" + sha256: "99e9c3afcf7371ae38083ad52de23677d6d751f46150c3c6ae842e009e84d9f0" url: "https://pub.dev" source: hosted - version: "1.1.28" + version: "1.1.29" js: dependency: transitive description: @@ -1098,10 +1154,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" l10n_esperanto: dependency: "direct main" description: @@ -1147,10 +1203,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -1180,18 +1236,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1208,6 +1264,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -1224,6 +1296,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" octo_image: dependency: transitive description: @@ -1276,10 +1364,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1508,10 +1596,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -1560,6 +1648,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1585,18 +1689,34 @@ packages: dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sprintf: dependency: transitive description: @@ -1633,10 +1753,10 @@ packages: dependency: "direct dev" description: name: sqflite_common_ffi - sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988" + sha256: "8d7b8749a516cbf6e9057f9b480b716ad14fc4f3d3873ca6938919cc626d9025" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.7+1" sqflite_darwin: dependency: transitive description: @@ -1673,10 +1793,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: "162435ede92bcc793ea939fdc0452eef0a73d11f8ed053b58a89792fba749da5" + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" url: "https://pub.dev" source: hosted - version: "0.42.1" + version: "0.43.1" stack_trace: dependency: transitive description: @@ -1733,14 +1853,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + url: "https://pub.dev" + source: hosted + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" + test_core: + dependency: transitive + description: + name: test_core + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + url: "https://pub.dev" + source: hosted + version: "0.6.15" timezone: dependency: transitive description: @@ -1817,10 +1953,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.0" url_launcher_linux: dependency: transitive description: @@ -1849,10 +1985,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -1905,10 +2041,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: e4d33b79a064498c6eb3a6a492b6a5012573d4943c28d566caf1a6c0840fe78d + sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5 url: "https://pub.dev" source: hosted - version: "2.8.8" + version: "2.9.3" video_player_platform_interface: dependency: transitive description: @@ -1945,10 +2081,10 @@ packages: dependency: transitive description: name: watcher - sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" web: dependency: transitive description: @@ -1973,14 +2109,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" webview_flutter: dependency: "direct main" description: name: webview_flutter - sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 url: "https://pub.dev" source: hosted - version: "4.13.0" + version: "4.13.1" webview_flutter_android: dependency: "direct main" description: @@ -2001,10 +2145,10 @@ packages: dependency: "direct main" description: name: webview_flutter_wkwebview - sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc + sha256: "0412b657a2828fb301e73509909e6ec02b77cd2b441ae9f77125d482b3ddf0e7" url: "https://pub.dev" source: hosted - version: "3.23.5" + version: "3.23.6" win32: dependency: transitive description: @@ -2063,4 +2207,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.38.1" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index ef6c0a09c..739225f95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: flutter_displaymode: ^0.7.0 flutter_file_dialog: ^3.0.2 flutter_keyboard_visibility: ^6.0.0 - flutter_local_notifications: ^19.2.1 + flutter_local_notifications: ^20.1.0 flutter_markdown: ^0.7.3+1 flutter_native_splash: ^2.4.7 flutter_sharing_intent: ^2.0.4 @@ -93,10 +93,12 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + bloc_test: ^10.0.0 build_runner: ^2.5.4 drift_dev: ^2.28.0 flutter_launcher_icons: ^0.14.1 flutter_lints: ^6.0.0 + mocktail: ^1.0.4 sqflite: ^2.4.0 sqflite_common_ffi: ^2.3.4 diff --git a/scripts/build.dart b/scripts/build.dart index 2f96872de..12ad4f51d 100644 --- a/scripts/build.dart +++ b/scripts/build.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print import 'dart:io'; -import 'package:thunder/src/core/config/app_config.dart'; +import 'package:thunder/src/foundation/config/app_config.dart'; /// This script automatically generates the release files for the current version, /// and stores the release files in /release directory. diff --git a/test/app/deep_links_cubit_test.dart b/test/app/deep_links_cubit_test.dart new file mode 100644 index 000000000..6eae40987 --- /dev/null +++ b/test/app/deep_links_cubit_test.dart @@ -0,0 +1,70 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/state/deep_links_cubit/deep_links_cubit.dart'; +import 'package:thunder/src/app/shell/routing/deep_link_enums.dart'; +import 'package:thunder/src/foundation/contracts/deep_link_service.dart'; +import 'package:thunder/src/foundation/contracts/localization_service.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; + +class _FakeDeepLinkService implements DeepLinkService { + _FakeDeepLinkService(this._stream); + + final Stream _stream; + + @override + Stream get uriLinkStream => _stream; +} + +class _MockLocalizationService extends Mock implements LocalizationService {} + +class _MockAppLocalizations extends Mock implements AppLocalizations {} + +void main() { + group('DeepLinksCubit', () { + late _MockLocalizationService localizationService; + late _MockAppLocalizations l10n; + + setUp(() { + localizationService = _MockLocalizationService(); + l10n = _MockAppLocalizations(); + when(() => localizationService.l10n).thenReturn(l10n); + when(() => l10n.uriNotSupported).thenReturn('URI not supported'); + }); + + blocTest( + 'emits success for user links', + build: () => DeepLinksCubit( + deepLinkService: _FakeDeepLinkService(const Stream.empty()), + localizationService: localizationService, + ), + act: (cubit) => cubit.getLinkType('https://lemmy.world/u/tester'), + expect: () => [ + isA() + .having((state) => state.deepLinkStatus, 'status', + DeepLinkStatus.success) + .having((state) => state.linkType, 'type', LinkType.user) + .having( + (state) => state.link, 'link', 'https://lemmy.world/u/tester'), + ], + ); + + blocTest( + 'emits typed validation error for unsupported links', + build: () => DeepLinksCubit( + deepLinkService: _FakeDeepLinkService(const Stream.empty()), + localizationService: localizationService, + ), + act: (cubit) => cubit.getLinkType('https://lemmy.world/random/path'), + expect: () => [ + isA() + .having((state) => state.deepLinkStatus, 'status', + DeepLinkStatus.unknown) + .having((state) => state.errorReason?.category, 'error category', + AppErrorCategory.validation), + ], + ); + }); +} diff --git a/test/app/state_copy_with_nullability_test.dart b/test/app/state_copy_with_nullability_test.dart new file mode 100644 index 000000000..be64038e2 --- /dev/null +++ b/test/app/state_copy_with_nullability_test.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/app/state/deep_links_cubit/deep_links_cubit.dart'; +import 'package:thunder/src/app/shell/routing/deep_link_enums.dart'; +import 'package:thunder/src/features/feed/api.dart'; +import 'package:thunder/src/features/notification/application/state/notifications_cubit.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; +import 'package:thunder/src/features/comment/presentation/state/create_comment_cubit.dart'; +import 'package:thunder/src/features/inbox/presentation/state/inbox_bloc.dart'; +import 'package:thunder/src/features/instance/presentation/state/instance_page_bloc.dart'; +import 'package:thunder/src/features/moderator/presentation/state/report_bloc.dart'; +import 'package:thunder/src/features/post/presentation/state/create_post_cubit.dart'; +import 'package:thunder/src/features/search/presentation/state/search_bloc.dart'; + +void main() { + group('Nullable copyWith semantics', () { + test('ThunderState preserves and clears nullable fields explicitly', () { + const state = ThunderState( + errorMessage: 'error', + appLanguageCode: 'en', + currentAnonymousInstance: 'lemmy.world', + ); + + final preserved = state.copyWith(status: ThunderStatus.success); + expect(preserved.errorMessage, 'error'); + expect(preserved.appLanguageCode, 'en'); + expect(preserved.currentAnonymousInstance, 'lemmy.world'); + + final cleared = state.copyWith( + errorMessage: null, + appLanguageCode: null, + currentAnonymousInstance: null, + ); + expect(cleared.errorMessage, isNull); + expect(cleared.appLanguageCode, isNull); + expect(cleared.currentAnonymousInstance, isNull); + expect(cleared.errorReason, isNull); + }); + + test('FeedUiState preserves dismiss IDs when unrelated fields update', () { + const state = FeedUiState( + dismissBlockedUserId: 10, + dismissBlockedCommunityId: 20, + dismissHiddenPostId: 30, + ); + + final preserved = state.copyWith(scrollId: 1); + expect(preserved.dismissBlockedUserId, 10); + expect(preserved.dismissBlockedCommunityId, 20); + expect(preserved.dismissHiddenPostId, 30); + + final cleared = state.copyWith( + dismissBlockedUserId: null, + dismissBlockedCommunityId: null, + dismissHiddenPostId: null, + ); + expect(cleared.dismissBlockedUserId, isNull); + expect(cleared.dismissBlockedCommunityId, isNull); + expect(cleared.dismissHiddenPostId, isNull); + }); + + test('NotificationsState preserves ID/account when toggling pending', () { + const state = NotificationsState( + status: NotificationsStatus.reply, + notificationId: 4, + accountId: 'acct-1', + ); + + final pending = state.copyWith(pending: true); + expect(pending.notificationId, 4); + expect(pending.accountId, 'acct-1'); + + final cleared = state.copyWith(notificationId: null, accountId: null); + expect(cleared.notificationId, isNull); + expect(cleared.accountId, isNull); + }); + + test('InboxState preserves and clears nullable fields explicitly', () { + const state = InboxState( + status: InboxStatus.failure, + errorMessage: 'failed', + inboxReplyMarkedAsRead: 9, + ); + + final preserved = state.copyWith(status: InboxStatus.loading); + expect(preserved.errorMessage, 'failed'); + expect(preserved.inboxReplyMarkedAsRead, 9); + + final cleared = state.copyWith( + status: InboxStatus.success, + errorMessage: null, + inboxReplyMarkedAsRead: null, + ); + expect(cleared.errorMessage, isNull); + expect(cleared.inboxReplyMarkedAsRead, isNull); + }); + + test('ReportState preserves and clears nullable fields explicitly', () { + const state = ReportState( + communityId: 42, + message: 'failed', + ); + + final preserved = state.copyWith(status: ReportStatus.fetching); + expect(preserved.communityId, 42); + expect(preserved.message, 'failed'); + + final cleared = state.copyWith(communityId: null, message: null); + expect(cleared.communityId, isNull); + expect(cleared.message, isNull); + }); + + test('Instance page states preserve and clear messages explicitly', () { + const typeState = InstanceTypeState( + message: 'type-error', + errorReason: AppErrorReason.unexpected(message: 'type-error'), + ); + final typePreserved = + typeState.copyWith(status: InstancePageStatus.loading); + expect(typePreserved.message, 'type-error'); + expect(typePreserved.errorReason, isNotNull); + + final typeCleared = typeState.copyWith(message: null, errorReason: null); + expect(typeCleared.message, isNull); + expect(typeCleared.errorReason, isNull); + + const pageState = InstancePageState( + message: 'page-error', + errorReason: AppErrorReason.unexpected(message: 'page-error'), + ); + final pagePreserved = + pageState.copyWith(status: InstancePageStatus.loading); + expect(pagePreserved.message, 'page-error'); + expect(pagePreserved.errorReason, isNotNull); + + final pageCleared = pageState.copyWith(message: null, errorReason: null); + expect(pageCleared.message, isNull); + expect(pageCleared.errorReason, isNull); + }); + + test('SearchState supports both explicit null and clear flags', () { + const state = SearchState( + message: 'error', + errorReason: AppErrorReason.unexpected(message: 'error'), + communityFilter: 1, + communityFilterName: 'c/thunder', + ); + + final preserved = state.copyWith(status: SearchStatus.loading); + expect(preserved.message, 'error'); + expect(preserved.communityFilter, 1); + + final explicitCleared = state.copyWith( + message: null, + errorReason: null, + communityFilter: null, + communityFilterName: null, + ); + expect(explicitCleared.message, isNull); + expect(explicitCleared.errorReason, isNull); + expect(explicitCleared.communityFilter, isNull); + expect(explicitCleared.communityFilterName, isNull); + + final flagCleared = state.copyWith(clearCommunityFilter: true); + expect(flagCleared.communityFilter, isNull); + expect(flagCleared.communityFilterName, isNull); + }); + + test('FeedPreferencesState preserves and clears nullable fields explicitly', + () { + final dateFormat = DateFormat.yMd(); + final state = FeedPreferencesState( + dateFormat: dateFormat, + feedCardDividerColor: const Color(0xff123456), + ); + + final preserved = state.copyWith(showHiddenPosts: true); + expect(identical(preserved.dateFormat, dateFormat), isTrue); + expect(preserved.feedCardDividerColor, const Color(0xff123456)); + + final cleared = state.copyWith( + dateFormat: null, + feedCardDividerColor: null, + ); + expect(cleared.dateFormat, isNull); + expect(cleared.feedCardDividerColor, isNull); + }); + + test('DeepLinksState preserves and clears nullable typed error explicitly', + () { + const state = DeepLinksState( + deepLinkStatus: DeepLinkStatus.error, + error: 'bad', + errorReason: AppErrorReason.validation(message: 'bad'), + ); + + final preserved = state.copyWith(linkType: LinkType.user); + expect(preserved.error, 'bad'); + expect(preserved.errorReason, isNotNull); + + final cleared = state.copyWith(error: null, errorReason: null); + expect(cleared.error, isNull); + expect(cleared.errorReason, isNull); + }); + + test('CreatePostState supports explicit null clear for typed errors', () { + const state = CreatePostState( + status: CreatePostStatus.error, + message: 'failed', + errorReason: AppErrorReason.actionFailed(message: 'failed'), + ); + + final preserved = state.copyWith(status: CreatePostStatus.loading); + expect(preserved.errorReason, isNotNull); + + final cleared = state.copyWith( + status: CreatePostStatus.initial, + message: null, + errorReason: null, + ); + expect(cleared.message, isNull); + expect(cleared.errorReason, isNull); + }); + + test('CreateCommentState supports explicit null clear for typed errors', + () { + const state = CreateCommentState( + status: CreateCommentStatus.error, + message: 'failed', + errorReason: AppErrorReason.actionFailed(message: 'failed'), + ); + + final preserved = state.copyWith(status: CreateCommentStatus.loading); + expect(preserved.errorReason, isNotNull); + + final cleared = state.copyWith( + status: CreateCommentStatus.initial, + message: null, + errorReason: null, + ); + expect(cleared.message, isNull); + expect(cleared.errorReason, isNull); + }); + }); +} diff --git a/test/app/thunder_bloc_test.dart b/test/app/thunder_bloc_test.dart new file mode 100644 index 000000000..f05aa292a --- /dev/null +++ b/test/app/thunder_bloc_test.dart @@ -0,0 +1,73 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; +import 'package:thunder/src/foundation/primitives/enums/local_settings.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; +import 'package:thunder/src/foundation/primitives/models/version.dart'; +import 'package:thunder/src/foundation/contracts/version_checker.dart'; + +import '../helpers/fake_preferences_store.dart'; + +class _FailingVersionChecker implements VersionChecker { + const _FailingVersionChecker(); + + @override + Future fetchLatestVersion() async { + throw Exception('network down'); + } +} + +class _SuccessVersionChecker implements VersionChecker { + const _SuccessVersionChecker(); + + @override + Future fetchLatestVersion() async { + return Version(version: '0.0.1'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + EquatableConfig.stringify = false; + + group('ThunderBloc', () { + blocTest( + 'emits typed error reason when initialization fails', + build: () => ThunderBloc( + preferencesStore: FakePreferencesStore(), + versionChecker: const _FailingVersionChecker(), + ), + act: (bloc) => bloc.add(InitializeAppEvent()), + expect: () => [ + isA() + .having((state) => state.status, 'status', ThunderStatus.failure) + .having((state) => state.errorReason?.category, 'category', + AppErrorCategory.unexpected), + ], + ); + + blocTest( + 'clears error reason on successful preference load', + build: () => ThunderBloc( + preferencesStore: FakePreferencesStore(settings: { + LocalSettings.currentAnonymousInstance: 'lemmy.world', + }), + versionChecker: const _SuccessVersionChecker(), + ), + seed: () => const ThunderState( + status: ThunderStatus.failure, + errorMessage: 'old', + errorReason: AppErrorReason.unexpected(message: 'old'), + ), + act: (bloc) => bloc.add(UserPreferencesChangeEvent()), + expect: () => [ + isA().having( + (state) => state.status, 'status', ThunderStatus.refreshing), + isA() + .having((state) => state.status, 'status', ThunderStatus.success) + .having((state) => state.errorReason, 'errorReason', isNull), + ], + ); + }); +} diff --git a/test/drift/thunder/migration_test.dart b/test/drift/thunder/migration_test.dart index f303bd49b..bbeeacd28 100644 --- a/test/drift/thunder/migration_test.dart +++ b/test/drift/thunder/migration_test.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:thunder/src/core/database/database.dart'; +import 'package:thunder/src/foundation/persistence/database/database.dart'; import 'generated/schema.dart'; import 'generated/schema_v3.dart' as v3; @@ -40,8 +40,26 @@ void main() { group('from v3 to v4', () { test('add custom_thumbnail to Drafts table', () async { // Add data to insert into the old database, and the expected rows after the migration. - final oldDraftsData = [v3.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; - final expectedNewDraftsData = [v4.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; + final oldDraftsData = [ + v3.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: 1, + replyId: 1, + title: 'title', + url: 'url', + body: 'body') + ]; + final expectedNewDraftsData = [ + v4.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: 1, + replyId: 1, + title: 'title', + url: 'url', + body: 'body') + ]; await verifier.testWithDataIntegrity( oldVersion: 3, @@ -49,8 +67,10 @@ void main() { createOld: v3.DatabaseAtV3.new, createNew: v4.DatabaseAtV4.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => batch.insertAll(oldDb.drafts, oldDraftsData), - validateItems: (newDb) async => expect(expectedNewDraftsData, await newDb.select(newDb.drafts).get()), + createItems: (batch, oldDb) => + batch.insertAll(oldDb.drafts, oldDraftsData), + validateItems: (newDb) async => expect( + expectedNewDraftsData, await newDb.select(newDb.drafts).get()), ); }); }); @@ -59,13 +79,31 @@ void main() { test('add list_index column and set list_index to id', () async { // Add data to insert into the old database, and the expected rows after the migration. final oldAccountsData = [ - v4.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1), - v4.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true), + v4.AccountsData( + id: 1, + username: 'thunder', + jwt: 'jwt', + instance: 'lemmy.thunderapp.dev', + anonymous: false, + userId: 1), + v4.AccountsData( + id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true), ]; final expectedNewAccountsData = [ - v5.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1), - v5.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2), + v5.AccountsData( + id: 1, + username: 'thunder', + jwt: 'jwt', + instance: 'lemmy.thunderapp.dev', + anonymous: false, + userId: 1, + listIndex: 1), + v5.AccountsData( + id: 2, + instance: 'lemmy.thunderapp.dev', + anonymous: true, + listIndex: 2), ]; await verifier.testWithDataIntegrity( @@ -74,8 +112,10 @@ void main() { createOld: v4.DatabaseAtV4.new, createNew: v5.DatabaseAtV5.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => batch.insertAll(oldDb.accounts, oldAccountsData), - validateItems: (newDb) async => expect(expectedNewAccountsData, await newDb.select(newDb.accounts).get()), + createItems: (batch, oldDb) => + batch.insertAll(oldDb.accounts, oldAccountsData), + validateItems: (newDb) async => expect(expectedNewAccountsData, + await newDb.select(newDb.accounts).get()), ); }); }); @@ -83,8 +123,26 @@ void main() { group('from v5 to v6', () { test('add alt_text column to Drafts table', () async { // Add data to insert into the old database, and the expected rows after the migration. - final oldDraftsData = [v5.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; - final expectedNewDraftsData = [v6.DraftsData(id: 1, draftType: 'postCreate', existingId: 1, replyId: 1, title: 'title', url: 'url', body: 'body')]; + final oldDraftsData = [ + v5.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: 1, + replyId: 1, + title: 'title', + url: 'url', + body: 'body') + ]; + final expectedNewDraftsData = [ + v6.DraftsData( + id: 1, + draftType: 'postCreate', + existingId: 1, + replyId: 1, + title: 'title', + url: 'url', + body: 'body') + ]; await verifier.testWithDataIntegrity( oldVersion: 5, @@ -92,23 +150,50 @@ void main() { createOld: v5.DatabaseAtV5.new, createNew: v6.DatabaseAtV6.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => batch.insertAll(oldDb.drafts, oldDraftsData), - validateItems: (newDb) async => expect(expectedNewDraftsData, await newDb.select(newDb.drafts).get()), + createItems: (batch, oldDb) => + batch.insertAll(oldDb.drafts, oldDraftsData), + validateItems: (newDb) async => expect( + expectedNewDraftsData, await newDb.select(newDb.drafts).get()), ); }); }); group('from v6 to v7', () { - test('add platform column to Accounts table and set platform to lemmy', () async { + test('add platform column to Accounts table and set platform to lemmy', + () async { // Add data to insert into the old database, and the expected rows after the migration. final oldAccountsData = [ - v6.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1), - v6.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2), + v6.AccountsData( + id: 1, + username: 'thunder', + jwt: 'jwt', + instance: 'lemmy.thunderapp.dev', + anonymous: false, + userId: 1, + listIndex: 1), + v6.AccountsData( + id: 2, + instance: 'lemmy.thunderapp.dev', + anonymous: true, + listIndex: 2), ]; final expectedNewAccountsData = [ - v7.AccountsData(id: 1, username: 'thunder', jwt: 'jwt', instance: 'lemmy.thunderapp.dev', anonymous: false, userId: 1, listIndex: 1, platform: 'lemmy'), - v7.AccountsData(id: 2, instance: 'lemmy.thunderapp.dev', anonymous: true, listIndex: 2, platform: 'lemmy'), + v7.AccountsData( + id: 1, + username: 'thunder', + jwt: 'jwt', + instance: 'lemmy.thunderapp.dev', + anonymous: false, + userId: 1, + listIndex: 1, + platform: 'lemmy'), + v7.AccountsData( + id: 2, + instance: 'lemmy.thunderapp.dev', + anonymous: true, + listIndex: 2, + platform: 'lemmy'), ]; await verifier.testWithDataIntegrity( @@ -117,8 +202,10 @@ void main() { createOld: v6.DatabaseAtV6.new, createNew: v7.DatabaseAtV7.new, openTestedDatabase: AppDatabase.new, - createItems: (batch, oldDb) => batch.insertAll(oldDb.accounts, oldAccountsData), - validateItems: (newDb) async => expect(expectedNewAccountsData, await newDb.select(newDb.accounts).get()), + createItems: (batch, oldDb) => + batch.insertAll(oldDb.accounts, oldAccountsData), + validateItems: (newDb) async => expect(expectedNewAccountsData, + await newDb.select(newDb.accounts).get()), ); }); }); diff --git a/test/features/account/profile_community_usecase_test.dart b/test/features/account/profile_community_usecase_test.dart new file mode 100644 index 000000000..f3710ee82 --- /dev/null +++ b/test/features/account/profile_community_usecase_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/account/domain/utils/profile_community_utils.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/user/user.dart'; + +class _MockThunderUser extends Mock implements ThunderUser {} + +ThunderCommunity _community(int id) { + return ThunderCommunity( + id: id, + name: 'c$id', + title: 'c$id', + removed: false, + published: DateTime.utc(2024, 1, 1), + deleted: false, + nsfw: false, + actorId: 'https://lemmy.world/c/c$id', + local: true, + hidden: false, + postingRestrictedToMods: false, + instanceId: 1, + visibility: 'Public', + ); +} + +void main() { + group('ProfileCommunityUsecase', () { + test('isSameUser returns true when user id matches account userId', () { + final user = _MockThunderUser(); + when(() => user.id).thenReturn(42); + + const account = Account( + id: '1', + index: 0, + instance: 'lemmy.world', + userId: 42, + ); + + expect( + isSameUser(user: user, account: account), + isTrue, + ); + }); + + test('filterFavorites keeps only subscribed favorite communities', () { + final subscriptions = [_community(1), _community(2), _community(3)]; + const favorites = [ + Favorite(id: 'a', communityId: 2, accountId: '1'), + Favorite(id: 'b', communityId: 4, accountId: '1'), + ]; + + final result = filterFavorites( + subscriptions: subscriptions, + favorites: favorites, + ); + + expect(result.map((community) => community.id), [2]); + }); + }); +} diff --git a/test/features/comment/comment_node_test.dart b/test/features/comment/comment_node_test.dart index 5b6f44e97..38adb7aa1 100644 --- a/test/features/comment/comment_node_test.dart +++ b/test/features/comment/comment_node_test.dart @@ -118,7 +118,9 @@ void main() { expect(root.replies[0].comment, equals(comment)); }); - test('insert does not add comment if it is not a direct child of this comment', () { + test( + 'insert does not add comment if it is not a direct child of this comment', + () { final root = CommentNode(); final comment = createMockComment(id: 1, path: '0.1'); @@ -132,7 +134,8 @@ void main() { expect(node.replies.length, equals(0)); }); - test('insert does not add comment if it has the same id as this comment', () { + test('insert does not add comment if it has the same id as this comment', + () { final root = CommentNode(); final comment = createMockComment(id: 1, path: '0.1'); @@ -154,11 +157,13 @@ void main() { test('insert replaces comment if it already exists', () { final root = CommentNode(); - final comment = createMockComment(id: 1, path: '0.1', content: 'Original'); + final comment = + createMockComment(id: 1, path: '0.1', content: 'Original'); final node = CommentNode(comment: comment); root.insert(node); - final updatedComment = createMockComment(id: 1, path: '0.1', content: 'Updated'); + final updatedComment = + createMockComment(id: 1, path: '0.1', content: 'Updated'); final updatedNode = CommentNode(comment: updatedComment); root.insert(updatedNode); diff --git a/test/features/comment/create_comment_cubit_test.dart b/test/features/comment/create_comment_cubit_test.dart new file mode 100644 index 000000000..fd475f766 --- /dev/null +++ b/test/features/comment/create_comment_cubit_test.dart @@ -0,0 +1,117 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/contracts/localization_service.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; + +class _MockCommentRepository extends Mock implements CommentRepository {} + +class _MockAccountRepository extends Mock implements AccountRepository {} + +class _MockThunderComment extends Mock implements ThunderComment {} + +class _MockLocalizationService extends Mock implements LocalizationService {} + +class _MockAppLocalizations extends Mock implements AppLocalizations {} + +void main() { + group('CreateCommentCubit', () { + late _MockCommentRepository commentRepository; + late _MockAccountRepository accountRepository; + late _MockLocalizationService localizationService; + late _MockAppLocalizations l10n; + + setUp(() { + commentRepository = _MockCommentRepository(); + accountRepository = _MockAccountRepository(); + localizationService = _MockLocalizationService(); + l10n = _MockAppLocalizations(); + when(() => localizationService.l10n).thenReturn(l10n); + when(() => l10n.userNotLoggedIn).thenReturn('User not logged in'); + }); + + blocTest( + 'emits not-logged-in typed error for anonymous image upload', + build: () => CreateCommentCubit( + account: const Account( + id: 'anon', + index: 0, + instance: 'lemmy.world', + anonymous: true, + ), + commentRepositoryFactory: (_) => commentRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ), + act: (cubit) => cubit.uploadImages(const ['a.png']), + expect: () => [ + isA() + .having((state) => state.status, 'status', + CreateCommentStatus.imageUploadFailure) + .having((state) => state.errorReason?.category, 'error category', + AppErrorCategory.notLoggedIn), + ], + ); + + blocTest( + 'emits typed actionFailed error when comment create fails', + build: () { + when(() => commentRepository.create( + postId: 12, + content: 'Hello', + parentId: null, + languageId: null, + )).thenThrow(Exception('failed')); + + return CreateCommentCubit( + account: const Account(id: '1', index: 0, instance: 'lemmy.world'), + commentRepositoryFactory: (_) => commentRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ); + }, + act: (cubit) => cubit.createOrEditComment(postId: 12, content: 'Hello'), + expect: () => [ + isA().having( + (state) => state.status, 'status', CreateCommentStatus.submitting), + isA() + .having((state) => state.status, 'status', CreateCommentStatus.error) + .having((state) => state.errorReason?.category, 'error category', + AppErrorCategory.actionFailed), + ], + ); + + blocTest( + 'emits success when comment create succeeds', + build: () { + final comment = _MockThunderComment(); + when(() => comment.id).thenReturn(88); + + when(() => commentRepository.create( + postId: 12, + content: 'Hello', + parentId: null, + languageId: null, + )).thenAnswer((_) async => comment); + + return CreateCommentCubit( + account: const Account(id: '1', index: 0, instance: 'lemmy.world'), + commentRepositoryFactory: (_) => commentRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ); + }, + act: (cubit) => cubit.createOrEditComment(postId: 12, content: 'Hello'), + expect: () => [ + isA().having( + (state) => state.status, 'status', CreateCommentStatus.submitting), + isA() + .having((state) => state.status, 'status', CreateCommentStatus.success) + .having((state) => state.errorReason, 'errorReason', isNull), + ], + ); + }); +} diff --git a/test/features/feed/feed_state_test.dart b/test/features/feed/feed_state_test.dart new file mode 100644 index 000000000..aed0f79a1 --- /dev/null +++ b/test/features/feed/feed_state_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/feed/presentation/state/feed_bloc.dart'; + +void main() { + test('FeedState.copyWith preserves excessiveApiCalls by default', () { + const state = FeedState(excessiveApiCalls: true); + + final updated = state.copyWith(status: FeedStatus.success); + + expect(updated.excessiveApiCalls, isTrue); + }); +} diff --git a/test/features/feed/feed_view_usecase_test.dart b/test/features/feed/feed_view_usecase_test.dart new file mode 100644 index 000000000..759d1e958 --- /dev/null +++ b/test/features/feed/feed_view_usecase_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/feed/domain/utils/feed_collection_utils.dart'; +import 'package:thunder/src/features/post/post.dart'; + +ThunderPost _post(int id) { + return ThunderPost( + id: id, + name: 'post-$id', + creatorId: 1, + communityId: 1, + removed: false, + locked: false, + published: DateTime(2024, 1, 1), + deleted: false, + nsfw: false, + apId: 'https://example.com/post/$id', + local: true, + languageId: 0, + featuredCommunity: false, + featuredLocal: false, + ); +} + +void main() { + group('FeedViewUsecase', () { + test('hidePostsByIds removes only matching post IDs', () { + final posts = [ + _post(1), + _post(2), + _post(3), + ]; + + final filtered = hidePostsByIds( + posts: posts, + postIds: {2, 99}, + ); + + expect(filtered.map((p) => p.id), [1, 3]); + expect(posts.map((p) => p.id), [1, 2, 3]); + }); + }); +} diff --git a/test/features/inbox/inbox_bloc_test.dart b/test/features/inbox/inbox_bloc_test.dart new file mode 100644 index 000000000..d6942a6fc --- /dev/null +++ b/test/features/inbox/inbox_bloc_test.dart @@ -0,0 +1,69 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thunder/src/foundation/contracts/localization_service.dart'; +import 'package:thunder/src/foundation/primitives/enums/comment_sort_type.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/inbox/inbox.dart'; +import 'package:thunder/src/features/notification/notification.dart'; + +class _MockCommentRepository extends Mock implements CommentRepository {} + +class _MockNotificationRepository extends Mock + implements NotificationRepository {} + +void main() { + group('InboxBloc', () { + late _MockCommentRepository commentRepository; + late _MockNotificationRepository notificationRepository; + + setUp(() { + commentRepository = _MockCommentRepository(); + notificationRepository = _MockNotificationRepository(); + }); + + blocTest( + 'increments only mention page on mention reset fetch', + build: () { + when(() => notificationRepository.mentions( + unread: true, + limit: 20, + sort: CommentSortType.new_, + page: 1)).thenAnswer((_) async => []); + when(() => notificationRepository.unreadNotificationsCount()) + .thenAnswer((_) async => const UnreadNotificationsCount( + privateMessages: 0, + mentions: 0, + replies: 0, + )); + + return InboxBloc( + account: const Account(id: '1', index: 0, instance: 'example.com'), + commentRepository: commentRepository, + notificationRepository: notificationRepository, + localizationService: const GlobalContextLocalizationService(), + ); + }, + act: (bloc) => bloc + .add(const GetInboxEvent(reset: true, inboxType: InboxType.mentions)), + expect: () => [ + isA() + .having((state) => state.status, 'status', InboxStatus.loading), + isA() + .having((state) => state.status, 'status', InboxStatus.success) + .having((state) => state.inboxMentionPage, 'mention page', 2) + .having((state) => state.inboxReplyPage, 'reply page', 1) + .having((state) => state.inboxPrivateMessagePage, + 'private message page', 1), + ], + verify: (_) { + verify(() => notificationRepository.mentions( + unread: true, + limit: 20, + sort: CommentSortType.new_, + page: 1)).called(1); + }, + ); + }); +} diff --git a/test/features/inbox/inbox_cleanup_usecase_test.dart b/test/features/inbox/inbox_cleanup_usecase_test.dart new file mode 100644 index 000000000..343b61dca --- /dev/null +++ b/test/features/inbox/inbox_cleanup_usecase_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/inbox/domain/utils/inbox_utils.dart'; + +ThunderComment _comment({ + required int id, + required String content, + required bool removed, + required bool deleted, +}) { + return ThunderComment( + id: id, + creatorId: 1, + postId: 1, + content: content, + removed: removed, + published: DateTime(2024, 1, 1), + deleted: deleted, + apId: 'https://example.com/comment/$id', + local: true, + path: '0.$id', + distinguished: false, + languageId: 0, + ); +} + +void main() { + group('InboxCleanupUsecase', () { + test('cleans deleted private messages', () { + final messages = [ + ThunderPrivateMessage( + id: 1, + content: 'hello', + deleted: false, + read: false, + published: DateTime(2024, 1, 1), + ), + ThunderPrivateMessage( + id: 2, + content: 'bye', + deleted: true, + read: false, + published: DateTime(2024, 1, 1), + ), + ]; + + final cleaned = cleanDeletedMessages(messages); + expect(cleaned.first.content, 'hello'); + expect(cleaned.last.content, '_deleted by creator_'); + }); + + test('cleans removed and deleted mentions', () { + final mentions = [ + _comment(id: 1, content: 'normal', removed: false, deleted: false), + _comment(id: 2, content: 'removed', removed: true, deleted: false), + _comment(id: 3, content: 'deleted', removed: false, deleted: true), + ]; + + final cleaned = cleanDeletedMentions(mentions); + expect(cleaned[0].content, 'normal'); + expect(cleaned[1].content, '_deleted by moderator_'); + expect(cleaned[2].content, '_deleted by creator_'); + }); + }); +} diff --git a/test/features/instance/instance_pagination_usecase_test.dart b/test/features/instance/instance_pagination_usecase_test.dart new file mode 100644 index 000000000..076254eaa --- /dev/null +++ b/test/features/instance/instance_pagination_usecase_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_utils.dart'; + +void main() { + group('InstancePaginationUsecase', () { + test('shouldSkipFetch skips non-reset requests while already loading', () { + expect( +shouldSkipFetch( + currentlyLoading: true, + page: 2, + ), + isTrue, + ); + expect( +shouldSkipFetch( + currentlyLoading: true, + page: 1, + ), + isFalse, + ); + }); + + test('previousItemsForPage resets on first page and preserves otherwise', () { + expect( +previousItemsForPage( + page: 1, + currentItems: const [1, 2], + ), + isEmpty, + ); + + expect( +previousItemsForPage( + page: 3, + currentItems: const [1, 2], + ), + [1, 2], + ); + }); + + test('hasReachedEnd matches fetch count boundaries', () { + expect( +hasReachedEnd( + fetchedCount: 0, + pageLimit: 30, + ), + isTrue, + ); + expect( +hasReachedEnd( + fetchedCount: 10, + pageLimit: 30, + ), + isTrue, + ); + expect( +hasReachedEnd( + fetchedCount: 30, + pageLimit: 30, + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/instance/instance_resolution_usecase_test.dart b/test/features/instance/instance_resolution_usecase_test.dart new file mode 100644 index 000000000..7a095e948 --- /dev/null +++ b/test/features/instance/instance_resolution_usecase_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/instance/domain/utils/instance_utils.dart'; + +void main() { + group('InstanceResolutionUsecase', () { + test('resolveInBatches aggregates non-null resolved values', () async { + final snapshots = >[]; + + final resolved = await resolveInBatches( + source: const [1, 2, 3, 4, 5], + batchSize: 2, + resolver: (value) async => value.isEven ? value * 10 : null, + onBatchResolved: (items) => snapshots.add(items), + ); + + expect(resolved, [20, 40]); + expect(snapshots, [ + [20], + [20, 40], + [20, 40], + ]); + }); + }); +} diff --git a/test/features/moderator/report_feed_usecase_test.dart b/test/features/moderator/report_feed_usecase_test.dart new file mode 100644 index 000000000..b4f1b1055 --- /dev/null +++ b/test/features/moderator/report_feed_usecase_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; +import 'package:thunder/src/features/moderator/domain/utils/report_utils.dart'; + +class _MockPostReport extends Mock implements ThunderPostReport {} + +class _MockCommentReport extends Mock implements ThunderCommentReport {} + +void main() { + group('ReportFeedUsecase', () { + test('shouldSkipPagination respects fetching and feed-end flags', () { + expect( + shouldSkipPagination( + isFetching: true, + hasReachedPostReportsEnd: false, + hasReachedCommentReportsEnd: false, + isPostFeed: true, + ), + isTrue, + ); + + expect( + shouldSkipPagination( + isFetching: false, + hasReachedPostReportsEnd: true, + hasReachedCommentReportsEnd: false, + isPostFeed: true, + ), + isTrue, + ); + + expect( + shouldSkipPagination( + isFetching: false, + hasReachedPostReportsEnd: false, + hasReachedCommentReportsEnd: true, + isPostFeed: false, + ), + isTrue, + ); + + expect( + shouldSkipPagination( + isFetching: false, + hasReachedPostReportsEnd: false, + hasReachedCommentReportsEnd: false, + isPostFeed: true, + ), + isFalse, + ); + }); + + test('append helpers preserve existing order and append incoming items', + () { + final postA = _MockPostReport(); + final postB = _MockPostReport(); + final commentA = _MockCommentReport(); + final commentB = _MockCommentReport(); + + final mergedPosts = appendPostReports( + current: [postA], + incoming: [postB], + ); + final mergedComments = appendCommentReports( + current: [commentA], + incoming: [commentB], + ); + + expect(mergedPosts, [postA, postB]); + expect(mergedComments, [commentA, commentB]); + }); + }); +} diff --git a/test/features/modlog/modlog_state_test.dart b/test/features/modlog/modlog_state_test.dart new file mode 100644 index 000000000..0d2c0af96 --- /dev/null +++ b/test/features/modlog/modlog_state_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; +import 'package:thunder/src/features/modlog/modlog.dart'; + +void main() { + group('ModlogState.errorReason', () { + test('returns null when message is null', () { + const state = ModlogState(); + expect(state.errorReason, isNull); + }); + + test('returns typed unexpected reason when message exists', () { + const state = ModlogState( + status: ModlogStatus.failure, + message: 'failed to fetch modlog', + ); + + expect(state.errorReason?.category, AppErrorCategory.unexpected); + expect(state.errorReason?.message, 'failed to fetch modlog'); + }); + }); +} diff --git a/test/features/post/collapsed_comments_usecase_test.dart b/test/features/post/collapsed_comments_usecase_test.dart new file mode 100644 index 000000000..2c9fb7c39 --- /dev/null +++ b/test/features/post/collapsed_comments_usecase_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/post/domain/utils/comment_state_utils.dart'; + +void main() { + group('CollapsedCommentsUsecase', () { + test('adds comment id when collapsed is true', () { + final updated = update( + current: const [1, 2], + commentId: 3, + collapsed: true, + ); + + expect(updated, [1, 2, 3]); + }); + + test('removes comment id when collapsed is false', () { + final updated = update( + current: const [1, 2, 3], + commentId: 2, + collapsed: false, + ); + + expect(updated, [1, 3]); + }); + }); +} diff --git a/test/features/post/create_post_cubit_test.dart b/test/features/post/create_post_cubit_test.dart new file mode 100644 index 000000000..5c35b0066 --- /dev/null +++ b/test/features/post/create_post_cubit_test.dart @@ -0,0 +1,127 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/foundation/contracts/localization_service.dart'; +import 'package:thunder/src/foundation/errors/app_error_reason.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/post/post.dart'; + +class _MockPostRepository extends Mock implements PostRepository {} + +class _MockAccountRepository extends Mock implements AccountRepository {} + +class _MockThunderPost extends Mock implements ThunderPost {} + +class _MockLocalizationService extends Mock implements LocalizationService {} + +class _MockAppLocalizations extends Mock implements AppLocalizations {} + +void main() { + group('CreatePostCubit', () { + late _MockPostRepository postRepository; + late _MockAccountRepository accountRepository; + late _MockLocalizationService localizationService; + late _MockAppLocalizations l10n; + + setUp(() { + postRepository = _MockPostRepository(); + accountRepository = _MockAccountRepository(); + localizationService = _MockLocalizationService(); + l10n = _MockAppLocalizations(); + when(() => localizationService.l10n).thenReturn(l10n); + when(() => l10n.userNotLoggedIn).thenReturn('User not logged in'); + }); + + blocTest( + 'emits not-logged-in typed error for anonymous image upload', + build: () => CreatePostCubit( + account: const Account( + id: 'anon', + index: 0, + instance: 'lemmy.world', + anonymous: true, + ), + postRepositoryFactory: (_) => postRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ), + act: (cubit) => cubit.uploadImages(const ['a.png']), + expect: () => [ + isA() + .having((state) => state.status, 'status', + CreatePostStatus.imageUploadFailure) + .having((state) => state.errorReason?.category, 'error category', + AppErrorCategory.notLoggedIn), + ], + ); + + blocTest( + 'emits typed actionFailed error when create fails', + build: () { + when(() => postRepository.create( + communityId: 1, + name: 'Title', + body: null, + url: null, + customThumbnail: null, + altText: null, + nsfw: null, + postIdBeingEdited: null, + languageId: null, + )).thenThrow(Exception('failed')); + + return CreatePostCubit( + account: const Account(id: '1', index: 0, instance: 'lemmy.world'), + postRepositoryFactory: (_) => postRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ); + }, + act: (cubit) => cubit.createOrEditPost(communityId: 1, name: 'Title'), + expect: () => [ + isA().having( + (state) => state.status, 'status', CreatePostStatus.submitting), + isA() + .having((state) => state.status, 'status', CreatePostStatus.error) + .having((state) => state.errorReason?.category, 'error category', + AppErrorCategory.actionFailed), + ], + ); + + blocTest( + 'emits success when create succeeds', + build: () { + final post = _MockThunderPost(); + when(() => post.id).thenReturn(101); + + when(() => postRepository.create( + communityId: 1, + name: 'Title', + body: null, + url: null, + customThumbnail: null, + altText: null, + nsfw: null, + postIdBeingEdited: null, + languageId: null, + )).thenAnswer((_) async => post); + + return CreatePostCubit( + account: const Account(id: '1', index: 0, instance: 'lemmy.world'), + postRepositoryFactory: (_) => postRepository, + accountRepositoryFactory: (_) => accountRepository, + localizationService: localizationService, + ); + }, + act: (cubit) => cubit.createOrEditPost(communityId: 1, name: 'Title'), + expect: () => [ + isA().having( + (state) => state.status, 'status', CreatePostStatus.submitting), + isA() + .having((state) => state.status, 'status', CreatePostStatus.success) + .having((state) => state.errorReason, 'errorReason', isNull), + ], + ); + }); +} diff --git a/test/features/post/post_navigation_state_test.dart b/test/features/post/post_navigation_state_test.dart new file mode 100644 index 000000000..d2599f78a --- /dev/null +++ b/test/features/post/post_navigation_state_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/post/presentation/state/post_navigation_cubit/post_navigation_cubit.dart'; + +void main() { + test( + 'PostNavigationState.copyWith preserves highlightedCommentId when omitted', + () { + const state = PostNavigationState(highlightedCommentId: 42); + + final updated = state.copyWith(navigateCommentIndex: 5); + + expect(updated.highlightedCommentId, 42); + expect(updated.navigateCommentIndex, 5); + }); +} diff --git a/test/features/user/user_media_usecase_test.dart b/test/features/user/user_media_usecase_test.dart new file mode 100644 index 000000000..6fb4e59f4 --- /dev/null +++ b/test/features/user/user_media_usecase_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/user/domain/utils/user_media_utils.dart'; + +ThunderPost _post(int id) { + return ThunderPost( + id: id, + name: 'post-$id', + creatorId: 1, + communityId: 1, + removed: false, + locked: false, + published: DateTime(2024, 1, 1), + deleted: false, + nsfw: false, + apId: 'https://example.com/post/$id', + local: true, + languageId: 0, + featuredCommunity: false, + featuredLocal: false, + ); +} + +void main() { + group('UserMediaUsecase', () { + test('removes images by alias', () { + final images = [ + { + 'local_image': {'pictrs_alias': 'a'}, + }, + { + 'local_image': {'pictrs_alias': 'b'}, + }, + ]; + + final updated = removeImageByAlias( + images: images, + alias: 'b', + ); + + expect(updated.length, 1); + expect(updated.first['local_image']['pictrs_alias'], 'a'); + }); + + test('merges posts without duplicates by ID', () { + final merged = mergeUniquePosts( + primary: [_post(1), _post(2)], + secondary: [_post(2), _post(3)], + ); + + expect(merged.map((post) => post.id), [1, 2, 3]); + }); + }); +} diff --git a/test/helpers/fake_preferences_store.dart b/test/helpers/fake_preferences_store.dart new file mode 100644 index 000000000..30d6316ec --- /dev/null +++ b/test/helpers/fake_preferences_store.dart @@ -0,0 +1,42 @@ +import 'package:thunder/src/foundation/contracts/preferences_store.dart'; +import 'package:thunder/src/foundation/primitives/enums/local_settings.dart'; + +class FakePreferencesStore implements PreferencesStore { + FakePreferencesStore({Map? settings}) + : _settings = Map.from(settings ?? const {}); + + final Map _settings; + final Map _strings = {}; + + @override + T? getLocalSetting(LocalSettings setting) { + return _settings[setting] as T?; + } + + @override + void setSetting(LocalSettings setting, Object value) { + _settings[setting] = value; + } + + @override + void removeSetting(LocalSettings setting) { + _settings.remove(setting); + } + + @override + String? getString(String key) { + return _strings[key]; + } + + @override + Future setString(String key, String value) async { + _strings[key] = value; + return true; + } + + @override + Future remove(String key) async { + _strings.remove(key); + return true; + } +} diff --git a/test/utils/link_utils_test.dart b/test/utils/link_utils_test.dart index b8f30c449..944877ac4 100644 --- a/test/utils/link_utils_test.dart +++ b/test/utils/link_utils_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:thunder/src/shared/utils/link_utils.dart'; +import 'package:thunder/src/foundation/primitives/models/parsed_link.dart'; +import 'package:thunder/src/foundation/utils/threadiverse_link_parser_utils.dart'; void main() { group('ParsedLink', () { @@ -41,7 +42,8 @@ void main() { test('returns null for user URLs', () { expect(parseLemmyCommunity('https://lemmy.world/u/darklightxi'), isNull); - expect(parseLemmyCommunity('https://lemmy.world/u/darklightxi@lemmy.ca'), isNull); + expect(parseLemmyCommunity('https://lemmy.world/u/darklightxi@lemmy.ca'), + isNull); }); test('returns null for @ mentions (users)', () { @@ -49,14 +51,20 @@ void main() { }); test('returns null for PieFed post URLs (/c/community/p/postId)', () { - expect(parseLemmyCommunity('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'), isNull); - expect(parseLemmyCommunity('https://piefed.social/c/thunder_app/p/1422697'), isNull); + expect( + parseLemmyCommunity( + 'https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'), + isNull); + expect( + parseLemmyCommunity('https://piefed.social/c/thunder_app/p/1422697'), + isNull); }); }); group('Lemmy User Parsing', () { test('parses full user URL with federation', () { - final result = parseLemmyUser('https://lemmy.world/u/darklightxi@lemmy.ca'); + final result = + parseLemmyUser('https://lemmy.world/u/darklightxi@lemmy.ca'); expect(result, isNotNull); expect(result!.value, 'darklightxi'); expect(result.instance, 'lemmy.ca'); @@ -95,7 +103,8 @@ void main() { }); test('returns null for PieFed comment URLs', () { - expect(parseLemmyPostId('https://piefed.social/post/123/comment/456'), isNull); + expect(parseLemmyPostId('https://piefed.social/post/123/comment/456'), + isNull); }); test('returns null for Lemmy new format comment URLs', () { @@ -125,7 +134,8 @@ void main() { group('PieFed Comment Parsing', () { test('parses PieFed comment URL (/post/123/comment/456)', () { - final result = parsePiefedCommentId('https://piefed.social/post/1663157/comment/9679172'); + final result = parsePiefedCommentId( + 'https://piefed.social/post/1663157/comment/9679172'); expect(result, isNotNull); expect(result!.value, '9679172'); expect(result.instance, 'piefed.social'); @@ -145,7 +155,8 @@ void main() { }); test('parses PieFed community URL', () { - final result = parseCommunity('https://piefed.social/c/news@lemmy.world'); + final result = + parseCommunity('https://piefed.social/c/news@lemmy.world'); expect(result?.qualified, 'news@lemmy.world'); }); }); @@ -157,7 +168,8 @@ void main() { }); test('parses PieFed user URL', () { - final result = parseUser('https://piefed.social/u/darklightxi@lemmy.ca'); + final result = + parseUser('https://piefed.social/u/darklightxi@lemmy.ca'); expect(result?.qualified, 'darklightxi@lemmy.ca'); }); }); @@ -176,14 +188,16 @@ void main() { }); test('returns null for comment URLs', () { - expect(parsePostId('https://piefed.social/post/123/comment/456'), isNull); + expect( + parsePostId('https://piefed.social/post/123/comment/456'), isNull); expect(parsePostId('https://lemmy.world/post/123/456'), isNull); }); }); group('parseCommentId', () { test('parses PieFed comment URL', () { - final result = parseCommentId('https://piefed.social/post/1663157/comment/9679172'); + final result = parseCommentId( + 'https://piefed.social/post/1663157/comment/9679172'); expect(result?.value, '9679172'); expect(result?.instance, 'piefed.social'); }); @@ -225,14 +239,18 @@ void main() { }); test('https://piefed.social/post/1663157/comment/9679172', () { - final result = parseCommentId('https://piefed.social/post/1663157/comment/9679172'); + final result = + parseCommentId('https://piefed.social/post/1663157/comment/9679172'); expect(result, isNotNull); expect(result!.value, '9679172'); expect(result.instance, 'piefed.social'); }); - test('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support (community post format)', () { - final result = parsePostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + test( + 'https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support (community post format)', + () { + final result = parsePostId( + 'https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); expect(result, isNotNull); expect(result!.value, '1422697'); expect(result.instance, 'piefed.social'); @@ -241,21 +259,24 @@ void main() { group('PieFed Community Post URL Format', () { test('parses /c/community/p/postId/slug format', () { - final result = parsePiefedPostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + final result = parsePiefedPostId( + 'https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); expect(result, isNotNull); expect(result!.value, '1422697'); expect(result.instance, 'piefed.social'); }); test('parses /c/community/p/postId format (no slug)', () { - final result = parsePiefedPostId('https://piefed.social/c/thunder_app/p/1422697'); + final result = + parsePiefedPostId('https://piefed.social/c/thunder_app/p/1422697'); expect(result, isNotNull); expect(result!.value, '1422697'); expect(result.instance, 'piefed.social'); }); test('unified parsePostId handles PieFed community post format', () { - final result = parsePostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + final result = parsePostId( + 'https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); expect(result, isNotNull); expect(result!.value, '1422697'); expect(result.instance, 'piefed.social'); diff --git a/test/utils/user_groups_test.dart b/test/utils/user_groups_test.dart index 6460b4b09..d49ccee5c 100644 --- a/test/utils/user_groups_test.dart +++ b/test/utils/user_groups_test.dart @@ -4,9 +4,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:thunder/src/core/enums/user_type.dart'; -import 'package:thunder/src/core/singletons/preferences.dart'; -import 'package:thunder/src/app/cubits/theme_preferences_cubit/theme_preferences_cubit.dart'; +import 'package:thunder/src/foundation/primitives/enums/user_type.dart'; +import 'package:thunder/src/foundation/contracts/preferences_store.dart'; +import 'package:thunder/src/foundation/persistence/preferences.dart'; +import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/user/user.dart'; import '../widgets/base_widget.dart'; @@ -19,31 +20,40 @@ void main() { }); group('Test user group logic', () { - testWidgets('fetchUsernameDescriptor returns empty string if user is in no groups', (tester) async { + testWidgets( + 'fetchUsernameDescriptor returns empty string if user is in no groups', + (tester) async { await tester.pumpWidget(const BaseWidget()); String result = fetchUserGroupDescriptor([], null); expect(result, ''); }); - testWidgets('fetchUsernameDescriptor returns correct string for a single group', (tester) async { + testWidgets( + 'fetchUsernameDescriptor returns correct string for a single group', + (tester) async { await tester.pumpWidget(const BaseWidget()); String result = fetchUserGroupDescriptor([UserType.admin], null); expect(result, ' (Admin)'); }); - testWidgets('fetchUsernameDescriptor returns correct string for multiple groups', (tester) async { + testWidgets( + 'fetchUsernameDescriptor returns correct string for multiple groups', + (tester) async { await tester.pumpWidget(const BaseWidget()); - String result = fetchUserGroupDescriptor([UserType.admin, UserType.moderator], null); + String result = + fetchUserGroupDescriptor([UserType.admin, UserType.moderator], null); expect(result, ' (Admin, Moderator)'); }); - testWidgets('fetchUsernameColor returns no color if user is in no groups', (tester) async { + testWidgets('fetchUsernameColor returns no color if user is in no groups', + (tester) async { await tester.pumpWidget(BaseWidget( child: BlocProvider( - create: (context) => ThemePreferencesCubit(), + create: (context) => ThemePreferencesCubit( + preferencesStore: const UserPreferencesStore()), child: Builder(builder: (context) { Color? color = fetchUserGroupColor(context, []); @@ -54,16 +64,21 @@ void main() { )); }); - testWidgets('fetchUsernameColor returns correct color if user is in a single group', (tester) async { + testWidgets( + 'fetchUsernameColor returns correct color if user is in a single group', + (tester) async { await tester.pumpWidget(BaseWidget( child: BlocProvider( - create: (context) => ThemePreferencesCubit(), + create: (context) => ThemePreferencesCubit( + preferencesStore: const UserPreferencesStore()), child: Builder(builder: (context) { final theme = Theme.of(context); Color? color = fetchUserGroupColor(context, [UserType.moderator]); Color? expectedColor = HSLColor.fromColor( - Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.35), UserType.moderator.color), + Color.alphaBlend( + theme.colorScheme.primaryContainer.withValues(alpha: 0.35), + UserType.moderator.color), ).withLightness(0.85).toColor(); expect(color, expectedColor); @@ -73,17 +88,23 @@ void main() { )); }); - testWidgets('fetchUsernameColor returns correct color if user is in multiple groups', (tester) async { + testWidgets( + 'fetchUsernameColor returns correct color if user is in multiple groups', + (tester) async { await tester.pumpWidget(BaseWidget( child: BlocProvider( - create: (context) => ThemePreferencesCubit(), + create: (context) => ThemePreferencesCubit( + preferencesStore: const UserPreferencesStore()), child: Builder(builder: (context) { final theme = Theme.of(context); // The order of precedence is op -> self -> admin -> moderator -> bot - Color? color = fetchUserGroupColor(context, [UserType.moderator, UserType.admin, UserType.self]); + Color? color = fetchUserGroupColor( + context, [UserType.moderator, UserType.admin, UserType.self]); Color? expectedColor = HSLColor.fromColor( - Color.alphaBlend(theme.colorScheme.primaryContainer.withValues(alpha: 0.35), UserType.self.color), + Color.alphaBlend( + theme.colorScheme.primaryContainer.withValues(alpha: 0.35), + UserType.self.color), ).withLightness(0.85).toColor(); expect(color, expectedColor); diff --git a/test/widgets/base_widget.dart b/test/widgets/base_widget.dart index ced8b8277..bc893a938 100644 --- a/test/widgets/base_widget.dart +++ b/test/widgets/base_widget.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; /// Base widget for simple tests which requires localization class BaseWidget extends StatelessWidget { From f98a4963672a6ae712516c8b8251d312f461d782 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Wed, 18 Feb 2026 11:25:00 -0800 Subject: [PATCH 2/2] fix: fix ci paths --- .github/workflows/ci.yml | 2 +- pubspec.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90380f0b5..07bdd787d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: id: output-globals-version uses: juliangruber/read-file-action@v1 with: - path: lib/src/core/config/app_config.dart + path: lib/src/foundation/config/app_config.dart # Get just the first line of the app_config.dart file - name: Get first line of app_config.dart file diff --git a/pubspec.lock b/pubspec.lock index 46e28515f..ae77c6e69 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -1236,18 +1236,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1857,26 +1857,26 @@ packages: dependency: transitive description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.15" + version: "0.6.12" timezone: dependency: transitive description: