From 69154d528de98a31776a925c50b9330d3c82656d Mon Sep 17 00:00:00 2001 From: Zeew Date: Thu, 17 Jul 2025 09:59:30 +0800 Subject: [PATCH] init --- .gitignore | 3 +- CHANGELOG.md | 716 +++++++ LICENSE | 25 + README.md | 387 ++++ README_ZH.md | 496 +++++ analysis_options.yaml | 4 + doc/DETAIL.md | 325 ++++ doc/FAST_INTEGRATED.md | 236 +++ doc/I18N.md | 172 ++ doc/I18N_en.md | 169 ++ doc/README.md | 8 + doc/get_start.md | 606 ++++++ doc/theme.md | 101 + doc/混合开发.md | 938 ++++++++++ doc/混合开发_en.md | 974 ++++++++++ doc/表情.md | 453 +++++ doc/表情_en.md | 457 +++++ doc/表情_国际中文.md | 454 +++++ images/add.svg | 22 + images/arrow_back.png | Bin 0 -> 1811 bytes images/card.svg | 16 + images/chat_permission_icon_camera.png | Bin 0 -> 3507 bytes images/chat_permission_icon_file.png | Bin 0 -> 2209 bytes images/chat_permission_icon_mic.png | Bin 0 -> 3678 bytes images/clear.png | Bin 0 -> 2952 bytes images/close.png | Bin 0 -> 4995 bytes images/copy_message.png | Bin 0 -> 1192 bytes images/custom-msg.svg | 23 + images/default_c2c_head.png | Bin 0 -> 1801 bytes images/default_group_head.png | Bin 0 -> 8426 bytes images/delete.png | Bin 0 -> 11825 bytes images/delete_emoji.png | Bin 0 -> 1707 bytes images/delete_message.png | Bin 0 -> 1084 bytes images/download.png | Bin 0 -> 5840 bytes images/excel.png | Bin 0 -> 1932 bytes images/face.svg | 26 + images/file.svg | 14 + images/folder_open.png | Bin 0 -> 440 bytes images/forward.png | Bin 0 -> 15015 bytes images/forward_message.png | Bin 0 -> 4356 bytes images/hands-free_off.png | Bin 0 -> 17888 bytes images/hands-free_on.png | Bin 0 -> 16297 bytes images/hangup.png | Bin 0 -> 7040 bytes images/image_icon.png | Bin 0 -> 1671 bytes images/keyboard.svg | 14 + images/logo_bottom.png | Bin 0 -> 17082 bytes images/merge_forward.png | Bin 0 -> 14438 bytes images/message_sending.png | Bin 0 -> 1776 bytes images/mic_off.png | Bin 0 -> 17439 bytes images/mic_on.png | Bin 0 -> 15334 bytes images/more.png | Bin 0 -> 788 bytes images/multi_message.png | Bin 0 -> 3220 bytes images/open_in_new.png | Bin 0 -> 374 bytes images/pdf.png | Bin 0 -> 3248 bytes images/photo.svg | 16 + images/play.png | Bin 0 -> 25798 bytes images/play_voice_receive.gif | Bin 0 -> 2861 bytes images/play_voice_send.gif | Bin 0 -> 2660 bytes images/poke.svg | 24 + images/ppt.png | Bin 0 -> 1838 bytes images/reply_message.png | Bin 0 -> 1539 bytes images/revoke_message.png | Bin 0 -> 3106 bytes images/screen.svg | 12 + images/svg/message_history.svg | 1 + images/svg/send_code.svg | 7 + images/svg/send_face.png | Bin 0 -> 9112 bytes images/svg/send_face.svg | 7 + images/svg/send_file.svg | 6 + images/svg/send_image.svg | 7 + images/svg/send_screenshot.svg | 4 + images/svg/send_video.svg | 6 + images/take_video.png | Bin 0 -> 1968 bytes images/translate.png | Bin 0 -> 6022 bytes images/txt.png | Bin 0 -> 929 bytes images/unknown.png | Bin 0 -> 1307 bytes images/video-call.svg | 18 + images/video_call.png | Bin 0 -> 1438 bytes images/video_call_self.png | Bin 0 -> 1462 bytes images/video_icon.png | Bin 0 -> 1502 bytes images/voice-call.svg | 14 + images/voice.svg | 23 + images/voice_call.png | Bin 0 -> 1581 bytes images/voice_receive.png | Bin 0 -> 2498 bytes images/voice_send.png | Bin 0 -> 2521 bytes images/word.png | Bin 0 -> 2614 bytes images/zip.png | Bin 0 -> 1009 bytes lib/base_widgets/tim_callback.dart | 20 + lib/base_widgets/tim_state.dart | 51 + lib/base_widgets/tim_stateless_widget.dart | 43 + lib/base_widgets/tim_ui_kit_base.dart | 8 + lib/base_widgets/tim_ui_kit_class.dart | 12 + lib/base_widgets/tim_ui_kit_state.dart | 39 + .../tim_ui_kit_statelesswidget.dart | 40 + .../life_cycle/add_friend_life_cycle.dart | 15 + .../life_cycle/add_group_life_cycle.dart | 14 + .../life_cycle/base_life_cycle.dart | 89 + .../life_cycle/block_list_life_cycle.dart | 12 + .../life_cycle/chat_life_cycle.dart | 54 + .../life_cycle/conversation_life_cycle.dart | 25 + .../life_cycle/friend_list_life_cycle.dart | 10 + .../life_cycle/group_profile_life_cycle.dart | 11 + .../life_cycle/new_contact_life_cycle.dart | 20 + .../life_cycle/profile_life_cycle.dart | 33 + .../tui_group_listener_model.dart | 173 ++ lib/business_logic/model/profile_model.dart | 12 + .../separate_models/tui_chat_model_tools.dart | 174 ++ .../tui_chat_separate_view_model.dart | 1398 ++++++++++++++ .../tui_group_profile_model.dart | 372 ++++ .../tui_profile_view_model.dart | 243 +++ .../view_models/tui_chat_global_model.dart | 1087 +++++++++++ .../tui_conversation_view_model.dart | 335 ++++ .../tui_friendship_view_model.dart | 266 +++ .../view_models/tui_search_view_model.dart | 149 ++ .../view_models/tui_self_info_view_model.dart | 25 + .../view_models/tui_setting_model.dart | 26 + .../conversation/conversation_services.dart | 39 + .../conversation_services_implements.dart | 123 ++ lib/data_services/core/core_services.dart | 106 ++ .../core/core_services_implements.dart | 420 +++++ lib/data_services/core/tim_uikit_config.dart | 55 + .../tim_uikit_wide_modal_operation_key.dart | 43 + .../core/web_support/uikit_web_support.dart | 4 + .../uikit_web_support_implement.dart | 12 + .../friendShip/friendship_services.dart | 94 + .../friendship_services_implements.dart | 311 +++ lib/data_services/group/group_services.dart | 121 ++ .../group/group_services_implement.dart | 321 ++++ .../message/message_service_implement.dart | 685 +++++++ .../message/message_services.dart | 220 +++ lib/data_services/services_locatar.dart | 55 + lib/i18n/strings.i18n.json | 1 + lib/i18n/strings_zh-Hans.i18n.json | 1 + lib/i18n/strings_zh-Hant.i18n.json | 702 +++++++ lib/import_proxy/general.dart | 4 + lib/import_proxy/import_proxy.dart | 11 + lib/import_proxy/platform/native_import.dart | 11 + lib/import_proxy/platform/web_import.dart | 10 + lib/tencent_cloud_chat_uikit.dart | 63 + lib/theme/color.dart | 37 + lib/theme/tui_theme.dart | 597 ++++++ lib/theme/tui_theme_view_model.dart | 16 + .../constants/history_message_constant.dart | 32 + lib/ui/constants/time.dart | 9 + .../controller/tim_uikit_chat_controller.dart | 378 ++++ .../tim_uikit_conversation_controller.dart | 75 + .../tim_uikit_profile_controller.dart | 119 ++ lib/ui/theme/tim_uikit_avatar_theme.dart | 1 + lib/ui/theme/tim_uikit_message_theme.dart | 29 + lib/ui/utils/common_utils.dart | 158 ++ lib/ui/utils/custom_sticker.dart | 9 + lib/ui/utils/error_message_converter.dart | 20 + lib/ui/utils/frame.dart | 55 + lib/ui/utils/logger.dart | 13 + lib/ui/utils/message.dart | 346 ++++ lib/ui/utils/optimize_utils.dart | 45 + lib/ui/utils/permission.dart | 366 ++++ lib/ui/utils/platform.dart | 61 + lib/ui/utils/route.dart | 24 + lib/ui/utils/screen_shot.dart | 105 ++ lib/ui/utils/screen_utils.dart | 63 + lib/ui/utils/sound_record.dart | 69 + lib/ui/utils/time_ago.dart | 111 ++ .../tim_uikit_add_friend.dart | 257 +++ .../tim_uikit_send_application.dart | 231 +++ .../TIMUIKitAddGroup/tim_uikit_add_group.dart | 301 +++ .../tim_uikit_send_application.dart | 188 ++ .../tim_uikit_black_list.dart | 153 ++ ...ikit_chat_history_message_list_tongue.dart | 63 + ...history_message_list_tongue_container.dart | 182 ++ .../TIMUIKitTongue/tim_uikit_tongue_item.dart | 104 + .../tim_uikit_chat_history_message_list.dart | 531 ++++++ ...ikit_chat_history_message_list_config.dart | 171 ++ ..._uikit_chat_history_message_list_item.dart | 1460 +++++++++++++++ .../tim_uikit_chat_message_tooltip.dart | 620 ++++++ ..._uikit_history_message_list_container.dart | 203 ++ .../tim_uikit_message_read_receipt.dart | 113 ++ .../TIMUIKItMessageList/utils.dart | 13 + .../TIMUIKitAppBar/tim_uikit_appbar.dart | 272 +++ .../tim_uikit_appbar_title.dart | 62 + .../message_reaction_emoji.dart | 36 + .../tim_uikit_message_reaction_detail.dart | 181 ++ ...m_uikit_message_reaction_select_emoji.dart | 93 + .../tim_uikit_message_reaction_show_item.dart | 198 ++ ...tim_uikit_message_reaction_show_panel.dart | 160 ++ .../tim_uikit_message_reaction_utils.dart | 56 + .../tim_uikit_message_reaction_wrapper.dart | 175 ++ .../TIMUIKitMessageItem/main.dart | 8 + .../tim_uikit_chat_custom_elem.dart | 60 + .../tim_uikit_chat_face_elem.dart | 65 + .../tim_uikit_chat_file_elem.dart | 472 +++++ .../tim_uikit_chat_file_icon.dart | 60 + .../tim_uikit_chat_group_tips_elem.dart | 52 + .../tim_uikit_chat_image_elem.dart | 638 +++++++ .../tim_uikit_chat_reply_elem.dart | 422 +++++ .../tim_uikit_chat_sound_elem.dart | 268 +++ .../tim_uikit_chat_text_elem.dart | 241 +++ .../tim_uikit_chat_text_translate_elem.dart | 190 ++ .../tim_uikit_chat_video_elem.dart | 285 +++ .../tim_uikit_chat_videoplayer.dart | 209 +++ .../tim_uikit_merger_message_elem.dart | 222 +++ .../TIMUIKitTextField/at_member_panel.dart | 125 ++ .../DefaultSpecialTextSpanBuilder.dart | 53 + .../special_text/emoji_text.dart | 186 ++ .../special_text/http_text.dart | 32 + .../TIMUIKitTextField/tim_uikit_at_text.dart | 213 +++ .../tim_uikit_call_invite_list.dart | 210 +++ .../tim_uikit_emoji_panel.dart | 90 + .../tim_uikit_more_panel.dart | 741 ++++++++ .../tim_uikit_send_sound_message.dart | 322 ++++ .../tim_uikit_text_field.dart | 1028 ++++++++++ .../tim_uikit_text_field_controller.dart | 50 + .../tim_uikit_text_field_layout/narrow.dart | 632 +++++++ .../tim_uikit_text_field_layout/wide.dart | 994 ++++++++++ lib/ui/views/TIMUIKitChat/tim_uikit_chat.dart | 703 +++++++ .../TIMUIKitChat/tim_uikit_chat_config.dart | 306 +++ .../tim_uikit_cloud_custom_data.dart | 70 + .../tim_uikit_multi_select_panel.dart | 317 ++++ .../TIMUIKitChat/tim_uikit_send_file.dart | 220 +++ .../TIMUIKitContact/tim_uikit_contact.dart | 91 + .../tim_ui_kit_conversation_total_unread.dart | 52 + .../tim_uikit_conversation.dart | 452 +++++ .../tim_uikit_conversation_draft_text.dart | 46 + .../tim_uikit_conversation_item.dart | 197 ++ .../tim_uikit_conversation_last_msg.dart | 242 +++ .../views/TIMUIKitGroup/tim_uikit_group.dart | 230 +++ .../tim_uikit_group_application_list.dart | 220 +++ ...tim_uikit_group_application_list_item.dart | 72 + .../group_member/tui_add_group_member.dart | 87 + .../group_member/tui_delete_group_member.dart | 131 ++ .../group_member/tui_group_member_list.dart | 140 ++ .../group_profile_widget.dart | 143 ++ .../shared_data_widget.dart | 19 + .../tim_uikit_group_profile.dart | 323 ++++ .../widgets/tim_ui_group_member_search.dart | 60 + .../widgets/tim_ui_group_profile_widget.dart | 80 + .../widgets/tim_ui_group_search_msg.dart | 57 + .../widgets/tim_uikit_group_add_opt.dart | 141 ++ .../widgets/tim_uikit_group_button_area.dart | 355 ++++ .../widgets/tim_uikit_group_detail_card.dart | 209 +++ .../widgets/tim_uikit_group_manage.dart | 817 ++++++++ .../widgets/tim_uikit_group_member_title.dart | 381 ++++ .../tim_uikit_group_message_disturb.dart | 31 + .../widgets/tim_uikit_group_name_card.dart | 145 ++ .../widgets/tim_uikit_group_notification.dart | 236 +++ .../tim_uikit_group_pin_conversation.dart | 27 + .../widgets/tim_uikit_group_type.dart | 71 + .../tim_uikit_new_contact.dart | 207 ++ .../tim_uikit_unread_count.dart | 45 + .../views/TIMUIKitProfile/profile_widget.dart | 179 ++ .../TIMUIKitProfile/tim_uikit_profile.dart | 403 ++++ .../widget/tim_uikit_operation_item.dart | 207 ++ .../tim_uikit_profile_userinfo_card.dart | 54 + ...im_uikit_profile_userinfo_card_narrow.dart | 98 + .../tim_uikit_profile_userinfo_card_wide.dart | 102 + .../widget/tim_uikit_profile_widget.dart | 395 ++++ .../pureUI/tim_uikit_search_folder.dart | 48 + .../pureUI/tim_uikit_search_indicator.dart | 112 ++ .../pureUI/tim_uikit_search_input.dart | 119 ++ .../pureUI/tim_uikit_search_item.dart | 120 ++ .../pureUI/tim_uikit_search_showAll.dart | 69 + .../TIMUIKitSearch/tim_uikit_search.dart | 179 ++ .../tim_uikit_search_friend.dart | 89 + .../tim_uikit_search_group.dart | 79 + .../tim_uikit_search_item_wide.dart | 109 ++ .../TIMUIKitSearch/tim_uikit_search_msg.dart | 81 + .../tim_uikit_search_msg_detail.dart | 265 +++ .../tim_uikit_search_not_support.dart | 62 + lib/ui/widgets/avatar.dart | 166 ++ lib/ui/widgets/az_list_view.dart | 115 ++ lib/ui/widgets/center_play_button.dart | 39 + lib/ui/widgets/column_menu.dart | 91 + lib/ui/widgets/contact_list.dart | 306 +++ .../widgets/customize_ball_pulse_header.dart | 240 +++ lib/ui/widgets/drag_widget.dart | 97 + lib/ui/widgets/emoji.dart | 21 + .../extended_wrap/extended_render_wrap.dart | 827 ++++++++ .../widgets/extended_wrap/extended_wrap.dart | 228 +++ lib/ui/widgets/forward_message_screen.dart | 204 ++ lib/ui/widgets/gestured_image.dart | 475 +++++ lib/ui/widgets/group_member_list.dart | 307 +++ lib/ui/widgets/image_hero.dart | 106 ++ lib/ui/widgets/image_screen.dart | 298 +++ lib/ui/widgets/keepalive_wrapper.dart | 36 + .../link_preview/common/extensions.dart | 8 + lib/ui/widgets/link_preview/common/utils.dart | 90 + .../link_preview/compiler/md_text.dart | 31 + .../link_preview/link_preview_entry.dart | 126 ++ .../models/link_preview_content.dart | 57 + .../link_preview/widgets/link_preview.dart | 79 + .../link_preview/widgets/link_text.dart | 187 ++ lib/ui/widgets/loading.dart | 50 + lib/ui/widgets/merger_message_screen.dart | 332 ++++ lib/ui/widgets/message_read_receipt.dart | 385 ++++ lib/ui/widgets/radio_button.dart | 68 + lib/ui/widgets/recent_conversation_list.dart | 180 ++ lib/ui/widgets/textSize.dart | 126 ++ lib/ui/widgets/text_input_bottom_sheet.dart | 248 +++ .../widgets/transimit_group_owner_select.dart | 165 ++ lib/ui/widgets/unread_message.dart | 50 + lib/ui/widgets/video_custom_control.dart | 437 +++++ lib/ui/widgets/video_screen.dart | 302 +++ lib/ui/widgets/wide_popup.dart | 399 ++++ pubspec.lock | 1666 +++++++++++++++++ pubspec.yaml | 133 ++ scan.js | 242 +++ test/tencent_cloud_chat_uikit_test.dart | 4 + 306 files changed, 49994 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_ZH.md create mode 100644 analysis_options.yaml create mode 100644 doc/DETAIL.md create mode 100644 doc/FAST_INTEGRATED.md create mode 100644 doc/I18N.md create mode 100644 doc/I18N_en.md create mode 100644 doc/README.md create mode 100644 doc/get_start.md create mode 100644 doc/theme.md create mode 100644 doc/混合开发.md create mode 100644 doc/混合开发_en.md create mode 100644 doc/表情.md create mode 100644 doc/表情_en.md create mode 100644 doc/表情_国际中文.md create mode 100644 images/add.svg create mode 100644 images/arrow_back.png create mode 100644 images/card.svg create mode 100644 images/chat_permission_icon_camera.png create mode 100644 images/chat_permission_icon_file.png create mode 100644 images/chat_permission_icon_mic.png create mode 100644 images/clear.png create mode 100644 images/close.png create mode 100644 images/copy_message.png create mode 100644 images/custom-msg.svg create mode 100644 images/default_c2c_head.png create mode 100644 images/default_group_head.png create mode 100644 images/delete.png create mode 100644 images/delete_emoji.png create mode 100644 images/delete_message.png create mode 100644 images/download.png create mode 100644 images/excel.png create mode 100644 images/face.svg create mode 100644 images/file.svg create mode 100644 images/folder_open.png create mode 100644 images/forward.png create mode 100644 images/forward_message.png create mode 100644 images/hands-free_off.png create mode 100644 images/hands-free_on.png create mode 100644 images/hangup.png create mode 100644 images/image_icon.png create mode 100644 images/keyboard.svg create mode 100644 images/logo_bottom.png create mode 100644 images/merge_forward.png create mode 100644 images/message_sending.png create mode 100644 images/mic_off.png create mode 100644 images/mic_on.png create mode 100644 images/more.png create mode 100644 images/multi_message.png create mode 100644 images/open_in_new.png create mode 100644 images/pdf.png create mode 100644 images/photo.svg create mode 100644 images/play.png create mode 100644 images/play_voice_receive.gif create mode 100644 images/play_voice_send.gif create mode 100644 images/poke.svg create mode 100644 images/ppt.png create mode 100644 images/reply_message.png create mode 100644 images/revoke_message.png create mode 100644 images/screen.svg create mode 100644 images/svg/message_history.svg create mode 100644 images/svg/send_code.svg create mode 100644 images/svg/send_face.png create mode 100644 images/svg/send_face.svg create mode 100644 images/svg/send_file.svg create mode 100644 images/svg/send_image.svg create mode 100644 images/svg/send_screenshot.svg create mode 100644 images/svg/send_video.svg create mode 100644 images/take_video.png create mode 100644 images/translate.png create mode 100644 images/txt.png create mode 100644 images/unknown.png create mode 100644 images/video-call.svg create mode 100644 images/video_call.png create mode 100644 images/video_call_self.png create mode 100644 images/video_icon.png create mode 100644 images/voice-call.svg create mode 100644 images/voice.svg create mode 100644 images/voice_call.png create mode 100644 images/voice_receive.png create mode 100644 images/voice_send.png create mode 100644 images/word.png create mode 100644 images/zip.png create mode 100644 lib/base_widgets/tim_callback.dart create mode 100644 lib/base_widgets/tim_state.dart create mode 100644 lib/base_widgets/tim_stateless_widget.dart create mode 100644 lib/base_widgets/tim_ui_kit_base.dart create mode 100644 lib/base_widgets/tim_ui_kit_class.dart create mode 100644 lib/base_widgets/tim_ui_kit_state.dart create mode 100644 lib/base_widgets/tim_ui_kit_statelesswidget.dart create mode 100644 lib/business_logic/life_cycle/add_friend_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/add_group_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/base_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/block_list_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/chat_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/conversation_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/friend_list_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/group_profile_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/new_contact_life_cycle.dart create mode 100644 lib/business_logic/life_cycle/profile_life_cycle.dart create mode 100644 lib/business_logic/listener_model/tui_group_listener_model.dart create mode 100644 lib/business_logic/model/profile_model.dart create mode 100644 lib/business_logic/separate_models/tui_chat_model_tools.dart create mode 100644 lib/business_logic/separate_models/tui_chat_separate_view_model.dart create mode 100644 lib/business_logic/separate_models/tui_group_profile_model.dart create mode 100644 lib/business_logic/separate_models/tui_profile_view_model.dart create mode 100644 lib/business_logic/view_models/tui_chat_global_model.dart create mode 100644 lib/business_logic/view_models/tui_conversation_view_model.dart create mode 100644 lib/business_logic/view_models/tui_friendship_view_model.dart create mode 100644 lib/business_logic/view_models/tui_search_view_model.dart create mode 100644 lib/business_logic/view_models/tui_self_info_view_model.dart create mode 100644 lib/business_logic/view_models/tui_setting_model.dart create mode 100644 lib/data_services/conversation/conversation_services.dart create mode 100644 lib/data_services/conversation/conversation_services_implements.dart create mode 100644 lib/data_services/core/core_services.dart create mode 100644 lib/data_services/core/core_services_implements.dart create mode 100644 lib/data_services/core/tim_uikit_config.dart create mode 100644 lib/data_services/core/tim_uikit_wide_modal_operation_key.dart create mode 100644 lib/data_services/core/web_support/uikit_web_support.dart create mode 100644 lib/data_services/core/web_support/uikit_web_support_implement.dart create mode 100644 lib/data_services/friendShip/friendship_services.dart create mode 100644 lib/data_services/friendShip/friendship_services_implements.dart create mode 100644 lib/data_services/group/group_services.dart create mode 100644 lib/data_services/group/group_services_implement.dart create mode 100644 lib/data_services/message/message_service_implement.dart create mode 100644 lib/data_services/message/message_services.dart create mode 100644 lib/data_services/services_locatar.dart create mode 100644 lib/i18n/strings.i18n.json create mode 100644 lib/i18n/strings_zh-Hans.i18n.json create mode 100644 lib/i18n/strings_zh-Hant.i18n.json create mode 100644 lib/import_proxy/general.dart create mode 100644 lib/import_proxy/import_proxy.dart create mode 100644 lib/import_proxy/platform/native_import.dart create mode 100644 lib/import_proxy/platform/web_import.dart create mode 100644 lib/tencent_cloud_chat_uikit.dart create mode 100644 lib/theme/color.dart create mode 100644 lib/theme/tui_theme.dart create mode 100644 lib/theme/tui_theme_view_model.dart create mode 100644 lib/ui/constants/history_message_constant.dart create mode 100644 lib/ui/constants/time.dart create mode 100644 lib/ui/controller/tim_uikit_chat_controller.dart create mode 100644 lib/ui/controller/tim_uikit_conversation_controller.dart create mode 100644 lib/ui/controller/tim_uikit_profile_controller.dart create mode 100644 lib/ui/theme/tim_uikit_avatar_theme.dart create mode 100644 lib/ui/theme/tim_uikit_message_theme.dart create mode 100644 lib/ui/utils/common_utils.dart create mode 100644 lib/ui/utils/custom_sticker.dart create mode 100644 lib/ui/utils/error_message_converter.dart create mode 100644 lib/ui/utils/frame.dart create mode 100644 lib/ui/utils/logger.dart create mode 100644 lib/ui/utils/message.dart create mode 100644 lib/ui/utils/optimize_utils.dart create mode 100644 lib/ui/utils/permission.dart create mode 100644 lib/ui/utils/platform.dart create mode 100644 lib/ui/utils/route.dart create mode 100644 lib/ui/utils/screen_shot.dart create mode 100644 lib/ui/utils/screen_utils.dart create mode 100644 lib/ui/utils/sound_record.dart create mode 100644 lib/ui/utils/time_ago.dart create mode 100644 lib/ui/views/TIMUIKitAddFriend/tim_uikit_add_friend.dart create mode 100644 lib/ui/views/TIMUIKitAddFriend/tim_uikit_send_application.dart create mode 100644 lib/ui/views/TIMUIKitAddGroup/tim_uikit_add_group.dart create mode 100644 lib/ui/views/TIMUIKitAddGroup/tim_uikit_send_application.dart create mode 100644 lib/ui/views/TIMUIKitBlackList/tim_uikit_black_list.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue_container.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_tongue_item.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_message_tooltip.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_history_message_list_container.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_message_read_receipt.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/utils.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar_title.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/message_reaction_emoji.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_detail.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_item.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_custom_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_group_tips_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_reply_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_translate_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_videoplayer.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/at_member_panel.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_at_text.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_call_invite_list.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_emoji_panel.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_more_panel.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_send_sound_message.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/narrow.dart create mode 100644 lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart create mode 100644 lib/ui/views/TIMUIKitChat/tim_uikit_chat.dart create mode 100644 lib/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart create mode 100644 lib/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart create mode 100644 lib/ui/views/TIMUIKitChat/tim_uikit_multi_select_panel.dart create mode 100644 lib/ui/views/TIMUIKitChat/tim_uikit_send_file.dart create mode 100644 lib/ui/views/TIMUIKitContact/tim_uikit_contact.dart create mode 100644 lib/ui/views/TIMUIKitConversation/tim_ui_kit_conversation_total_unread.dart create mode 100644 lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart create mode 100644 lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_draft_text.dart create mode 100644 lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart create mode 100644 lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_last_msg.dart create mode 100644 lib/ui/views/TIMUIKitGroup/tim_uikit_group.dart create mode 100644 lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list.dart create mode 100644 lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list_item.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/group_member/tui_add_group_member.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/group_member/tui_delete_group_member.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/group_member/tui_group_member_list.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/group_profile_widget.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/shared_data_widget.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/tim_uikit_group_profile.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_profile_widget.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_search_msg.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_add_opt.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_button_area.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_detail_card.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_member_title.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_message_disturb.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_name_card.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_pin_conversation.dart create mode 100644 lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_type.dart create mode 100644 lib/ui/views/TIMUIKitNewContact/tim_uikit_new_contact.dart create mode 100644 lib/ui/views/TIMUIKitNewContact/tim_uikit_unread_count.dart create mode 100644 lib/ui/views/TIMUIKitProfile/profile_widget.dart create mode 100644 lib/ui/views/TIMUIKitProfile/tim_uikit_profile.dart create mode 100644 lib/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart create mode 100644 lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card.dart create mode 100644 lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_narrow.dart create mode 100644 lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_wide.dart create mode 100644 lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_widget.dart create mode 100644 lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart create mode 100644 lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_indicator.dart create mode 100644 lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart create mode 100644 lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart create mode 100644 lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_friend.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_group.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_item_wide.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart create mode 100644 lib/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart create mode 100644 lib/ui/widgets/avatar.dart create mode 100644 lib/ui/widgets/az_list_view.dart create mode 100644 lib/ui/widgets/center_play_button.dart create mode 100644 lib/ui/widgets/column_menu.dart create mode 100644 lib/ui/widgets/contact_list.dart create mode 100644 lib/ui/widgets/customize_ball_pulse_header.dart create mode 100644 lib/ui/widgets/drag_widget.dart create mode 100644 lib/ui/widgets/emoji.dart create mode 100644 lib/ui/widgets/extended_wrap/extended_render_wrap.dart create mode 100644 lib/ui/widgets/extended_wrap/extended_wrap.dart create mode 100644 lib/ui/widgets/forward_message_screen.dart create mode 100644 lib/ui/widgets/gestured_image.dart create mode 100644 lib/ui/widgets/group_member_list.dart create mode 100644 lib/ui/widgets/image_hero.dart create mode 100644 lib/ui/widgets/image_screen.dart create mode 100644 lib/ui/widgets/keepalive_wrapper.dart create mode 100644 lib/ui/widgets/link_preview/common/extensions.dart create mode 100644 lib/ui/widgets/link_preview/common/utils.dart create mode 100644 lib/ui/widgets/link_preview/compiler/md_text.dart create mode 100644 lib/ui/widgets/link_preview/link_preview_entry.dart create mode 100644 lib/ui/widgets/link_preview/models/link_preview_content.dart create mode 100644 lib/ui/widgets/link_preview/widgets/link_preview.dart create mode 100644 lib/ui/widgets/link_preview/widgets/link_text.dart create mode 100644 lib/ui/widgets/loading.dart create mode 100644 lib/ui/widgets/merger_message_screen.dart create mode 100644 lib/ui/widgets/message_read_receipt.dart create mode 100644 lib/ui/widgets/radio_button.dart create mode 100644 lib/ui/widgets/recent_conversation_list.dart create mode 100644 lib/ui/widgets/textSize.dart create mode 100644 lib/ui/widgets/text_input_bottom_sheet.dart create mode 100644 lib/ui/widgets/transimit_group_owner_select.dart create mode 100644 lib/ui/widgets/unread_message.dart create mode 100644 lib/ui/widgets/video_custom_control.dart create mode 100644 lib/ui/widgets/video_screen.dart create mode 100644 lib/ui/widgets/wide_popup.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 scan.js create mode 100644 test/tencent_cloud_chat_uikit_test.dart diff --git a/.gitignore b/.gitignore index 2224cac..d965ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -155,4 +155,5 @@ firebase-debug.*.log *.temp *.bak *.backup -*~.nib \ No newline at end of file +*~.nib +/example diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..833b589 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,716 @@ +# 5.0.0 +* Migrate to Flutter 3.29.0. + +# 4.0.8 +* Use the OfflinePushInfo constructor, not the fromJson function. + +# 4.0.7 +* Fixed the issue that modifying your own adding friend permissions does not take effect. +* Fixed the setGroupInfo exception problem. + +# 4.0.6 +* Solve the updateSelfInfo exception problem. +* The success of calling the initSDK interface is determined by the code of the return value. Avoid inaccurate judgment when calling the interface multiple times. + +# 4.0.5 +* Upgrade tencent_cloud_chat_sdk to the minimum version 8.5.6864+6. +* Changed the SDK interface call from getConversationListByConversaionIds to getConversationListByConversationIds. + +# 4.0.4 +* Remove the import of tencent_im_base plugin. +* Upgrade tencent_cloud_chat_sdk to the minimum version 8.5.6864+4. +* Fix compilation issues in tim_uikit_group.dart, tui_group_listener_model.dart, tui_conversation_view_model.dart. + +# 4.0.3 +* Fix compilation issues on Flutter 3.27.1 + +# 4.0.2 +* Optimize the display of group notification page. +* Optimize the display of read receipt page. +* TIMUIKitChatController sendMessage interface supports isExcludedFromContentModeration parameter. +* Fixed abnormal playback and recording of videos on Huawei P30. +* Optimize the display of tips messages when they are too long. + +# 4.0.1 +* Upgraded the plugin tim_ui_kit_sticker_plugin to 4.0.1. +* Add the 'useTencentCloudChatStickerPackageOldKeys' parameter in StickerPanelConfig to control whether the emoticon is compatible with version 3.x. + +# 4.0.0 +## Breaking changes +* Upgraded the plugin tim_ui_kit_sticker_plugin to 4.0.0. +* Delete the isUseDefaultEmoji parameter in TIMUIKitChatConfig. +* Delete the isUseDefaultEmoji parameter in each widget. + +## Bug Fixes +* Solve the problem that showReplyMessage and showForwardMessage in ToolTipsConfig do not take effect after being set to false. + +# 3.1.0+2 +* Replace the flutter_slidable library with flutter_slidable_plus_plus to solve the compatibility issue of flutter 3.27.0 version. + +# 3.1.0+1 +* Upgrade the third-party library version to adapt to Android AGP 8.0. + +# 3.1.0 +## Bug Fixes +* The interface for deleting messages is changed to the interface for deleting cloud messages. +* C2C messages support read receipts +* Fix and optimize some issues + +# 3.0.0 +## Breaking Changes +* Migrated to Flutter 3.24.0 +## Bug Fixes +* Fix and optimize some issues + +# 2.7.2 +* Fix the issue where failed messages cannot be resent. +* Fix the issue where image messages that failed to send are not loaded using the local path. +* Fix the issue where the screen turns white after dissolving or leaving a group. +* Optimize the process of sending messages. +* Optimize the alignment of buttons in the long-press message menu. +* Limit the version range of the third-party library extended_image. + +# 2.7.1 +* Fixed the 'keepAspectRatio' parameter error. + +# 2.7.0 + +## Breaking Changes + +* Upgraded Low-Level Native Chat SDK to 8.0. + +# 2.6.0 + +## Breaking Changes + +* Migrated to Flutter 3.22. Support for Flutter 3.19 and earlier versions has been discontinued. + + + +# 2.5.1 + +## Improvements + +* Improved memory usage, enhancing performance. +* Improved the logger storage. + +# 2.5.0 + +## Breaking Changes + +* Migrated to Flutter 3.19. Support for Flutter 3.16 and earlier versions has been discontinued. + +## Notes + +* Starting from Flutter 3.19, it is recommended to apply Flutter's Gradle plugins using Gradle's declarative plugins {} block (also known as the Plugin DSL) ([see details](https://docs.flutter.dev/release/breaking-changes/flutter-gradle-plugin-apply)). +* In line with this, our sample app on the GitHub repo has also been migrated to this new approach. If you'd like to migrate to this new approach, please refer to our [sample app repo](https://github.com/TencentCloud/chat-demo-flutter). + +# 2.4.3 + +## Bug Fixes + +* Fixed an keyboard issue on Web. + +# 2.4.2 + +## Bug Fixes + +* Fixed an UI issue on Material3 mode. + +# 2.4.1 + +## Improvements + +* Enhanced stability for message reaction. + +## Bug Fixes + +* Fixed some bugs. + +# 2.4.0 + +## Breaking Changes + +* Migrated to Flutter 3.16. Support for Flutter 3.13 and earlier versions has been discontinued. +* Upgraded the minimum supported Android Gradle Plugin to 7.3 to meet Flutter requirements. + +# 2.3.3 + +## New Features + +* Added a new lifecycle hook, `messageListShouldMount`. + +## Bug Fixes + +* Fixed an issue on time tag creator. + +# 2.3.2 + +## Improvements + +* Enhanced message list performance. + +## Bug Fixes + +* Fixed an issue that prevented the group member addition/removal modal from closing. +* Addressed several other bugs. + +# 2.3.1 + +## Bug Fixes + +* Resolved an issue that prevented the clearing of history messages after deleting a conversation. +* Fixed an issue that prevented opening files sent by the user themselves on Android. + +# 2.3.0 + +## Breaking Changes + +* Upgraded and migrated to support Flutter 3.13. Support for Flutter 3.10 and earlier versions has been discontinued. + +## Recommendations + +* Customers who do not wish to upgrade to Flutter 3.13 are advised to continue using version 2.2.1 of our Chat UIKit. However, we strongly recommend upgrading to Flutter 3.13.0 as it includes numerous performance improvements and introduces cutting-edge features. + +# 2.2.1 + +## New Features + +* Introduced a new `groupMemberList` configuration in `TUIKitChat`; when specified, TUIKit will not load it automatically, optimizing network traffic usage. +* Added support for image copying on desktop platforms. + +## Bug Fixes + +* Fixed an issue preventing the removal of image loading status. +* Resolved a problem that prevented images from being saved to the device gallery. +* Addressed a potential issue causing the `mentionOtherMemberInGroup` function in `TIMUIKitChatController` to fail. +* Corrected an issue that could lead to improper image rendering. + +# 2.2.0 + +## New Features + +* Introduced a newly-designed set of Emoji image stickers, available for seamless integration within textual content, providing an enhanced user experience. +* Streamlined the implementation of stickers, removing the need for additional complex coding. Full functionality is enabled by default, with customization options available through the `stickerPanelConfig` configuration in `TIMUIKitChatConfig`. +* Extended support for rendering embedded image stickers within text messages when the `Markdown` parsing mode is activated, combining a rich, user-friendly experience with the ability to display formatted Markdown text. + +## Improvements + +* Enhanced group chat functionality on the Desktop, enabling mentions (`@` tag) to be inserted at any position within a composed message, rather than only at the end. Additionally, deleting `@` tags has been optimized. +* Maintained message sending permissions for the group owner and administrators during "mute all" scenarios. +* Enabled the use of a return `null` value for the `customHoverBar` to utilize the default. +* Refined the revoke button functionality for group administrators. +* Removed full-screen support for video previews on the Web and introduced an alternative "Open in New Window" button for an enlarged view. +* Implemented UIKit log recording to facilitate issue identification and troubleshooting. +* Introduced a delete button for the small PNG sticker selection panel on mobile devices, which previously was only available in the Unicode emoji selection panel. + +## Bug Fixes + +* Resolved an issue preventing photo capturing on devices running Android 12 or lower. +* Rectified display inaccuracies related to picture aspect ratios. +* Addressed several issues concerning voice and video calls. + +# 2.1.3+1 + +## New Features + +* Introduced [a new custom internationalization language scheme](https://www.tencentcloud.com/document/product/1047/52154?from=pub) that supports adding language packs, adding or modifying entries, and makes customizing i18n more accessible. This feature helps your app achieve a more convenient globalization process and easier customer acquisition worldwide. +* Provided a seamless experience for previewing large images and playing videos within desktop environments (applications and web) by avoiding frequent page transitions. Enhanced the user experience for image previews and video playback. Please note that video playback is currently supported only on the web and not in desktop applications. +* Supported to integrate with the new online customer service plugin (tencent_cloud_chat_customer_service_plugin). +* Added two new life cycle hooks, `messageDidSend` and `messageShouldMount` to `ChatLifeCycle`. + +## Improvements + +* Optimized the usage, interface, and interaction of the sticker panel. +* Enhanced mobile video playback interaction and UI. +* Refined the error prompt when sending a 0 KB file fails. +* Enabled users to close modals on desktop by clicking the bottom gray overlay area. +* Improved the UI and interaction of image and video messages in the message list. +* Added the ability to open self-sent file messages without downloading. +* Optimized the download status animation of file messages on the web. + +## Bug Fixes + +* Fixed an issue preventing mobile image previews from being dragged after zooming. +* Resolved an issue that might cause the message selection status not to be removed after canceling a message forward action. +* Addressed an issue that might cause the microphone usage not to end after sending a voice message, which means the microphone was not released. + +# 2.1.2 + +## New Features + +* Introduced a new message recall mode, which enables group administrators to recall any message from any group member. To enable this feature, set `isGroupAdminRecallEnabled` in `TIMUIKitChatConfig` to `true`. +* Added support for draft text functionality on the Web. Activate this feature by setting `isUseDraftOnWeb` in `TIMUIKitChatConfig` to `true`. Since the Chat SDK doesn't support this functionality, draft data will be stored in TUIKit's memory. Be aware that draft text will be lost upon refreshing the website. +* Enabled using the default message abstract text when `abstractMessageBuilder` returns `null`. + +## Improvements + +* The duration for video messages sent from the Web will no longer be displayed, as this type of video message does not contain an accurate video duration. +* Removed the hover color on the message input area on Desktop. +* Added auto-focus support for the message input area on Desktop. +* Enhanced the rendering of text messages in markdown mode, particularly for clickable link extraction and HTML tag handling. +* Limited the number of lines displayed for replied messages to a maximum of 2 lines to avoid occupying excessive space. +* Optimized the message replying process, ensuring that a message referencing another message can still display the replied message, even when it is too old. + +## Bug Fixes + +* Fixed an issue that could cause the profile page to display no data. +* Fixed an issue that could prevent the message sending button from being displayed after selecting an emoji on mobile Web. +* Fixed an issue that could prevent the message long-press menu from showing on mobile Web. +* Fixed an issue where editing a message would carry over to another conversation when switching between conversations. +* Fixed an issue that could prevent displaying the `Modal` on Desktop. +* Fixed an issue that caused the `iconImageAsset` from the `MessageToolTipItem` class to not work properly. + +# 2.1.0+2 + +## Improvements + +* Upgraded several dependencies to resolve conflicts with the Kotlin Gradle plugin. + +## Bug Fixes + +* Fixed an issue causing the message list to be displayed inaccurately when it contains a file without a suffix. + +# 2.1.0+1 + +## Improvements + +* Removed `disk_space` dependency as many customers reported difficulty in obtaining this dependency successfully. +* Replaced `fc_native_video_thumbnail_for_us` with its original version `fc_native_video_thumbnail`. + +## Bug Fixes + +* Fixed an issue where `universal_html` could be blocking the compilation. + +# 2.1.0 + +## Breaking Changes + +* Migrated to Flutter 3.10.0 and Dart 3.0.0, no longer supporting projects with Flutter < 3.10.0 and Dart < 3.0.0. +* Updated the minimum requirement for Android AGP to 7.0, projects with AGP < 7.0 are no longer supported. + +We highly recommend updating to these new versions for a better experience. + +## New Features + +* Added several methods to `TIMUIKitChatController`, including `hideAllBottomPanelOnMobile`, `mentionOtherMemberInGroup`, `setInputTextField`, and `getGroupMemberList`. Please refer to the corresponding annotations for usage. +* Added more parameter fields to the `TIMUIKitChatController`'s `sendMessage` method. For details, please refer to the corresponding annotations. +* Added `onSecondaryTapAvatar` to `TIMUIKitChat`, serving as callback trigger for secondary avatar clicks in the message list. +* Introduced `isUseMessageHoverBarOnDesktop` and `desktopMessageInputFieldLines` to `TIMUIKitChatConfig`. For usage details, please refer to the corresponding annotations. + +## Improvements + +* Enhanced performance and user experience when switching conversations on Desktop, including features like text field auto-focus and draft text. +* Enabled displaying correct new lines in markdown mode. +* Changed the order of members in the mentioned member selection panel: Group Owner => Group Administrator => Member, sorted based on the code units' first differing position in the member show names. +* Implemented auto-focus after clicking a member in the mentioned member selection panel. +* Added text field auto-focus when replying to a message. +* Updated other members' display names in at-tag messages to use `namecard`, followed by `nickname` and `userId`. +* Widened Desktop message input area's control bar. +* Replaced the default icon in Desktop's message input area from `png` to `svg` for better performance and clarity. `DesktopControlBarConfig` now supports defining `svgPath` for each item as well. +* Improved Web platform detection. +* Mentioning "all" or "at all" can now only be used by group owners and administrators. +* Supported returning null for each message item builder in `MessageItemBuilder` to use the default message widget. +* Enhanced group members filtering in the group member mentioned selection panel with case-insensitive fuzzy matching, leading to increased filtering accuracy. +* For security purposes, downloading files by `fetch` and `blob` in the Web now replaces previewing files in a new browser tab, whereas previewing images and videos is displayed in a new tab on the Web. +* Changed the default order in the message tooltip menu. +* Previewing images and videos is set to open in a new tab on the Web. +* Improved the ratio for sending video messages. + +## Bug Fixes + +* Fixed issues when enabling the section function in markdown mode with `inEnableTextSelection` set to `true`. +* Addressed an issue where the replied message was removed when selecting all text in the message and clicking backspace. +* Fixed an issue where Chinese characters could not be entered while replying to a message. +* Resolved some console errors during debugging. +* Fixed an issue with links not opening in markdown mode. +* Fixed an issue that caused two `Scrollbar`s to appear in the message input field on Desktop. +* Solved an issue that might cause incorrect layout when the app is launched. +* Addressed an issue where messages were directly sent when the Enter key was pressed while entering Chinese text. +* Fixed related issues with the mentioned member selection panel on Desktop. +* Resolved an issue where images couldn't be pasted directly into the message input area for sending on the Web. +* Fixed an issue where files couldn't be sent on the Web. +* Remedied an issue where media and files couldn't be opened when local downloaded resources were deleted; now, resources will automatically re-download. +* Fixed an issue that caused the `iconImageAsset` of the `MessageToolTipItem` config to head internally to this chat UIKit. +* Improved the downloading process of media and files by avoiding frequent calls to `setState`, thus preventing the entire project from re-rendering. + +# 2.0.0 + +If you are upgrading from version 1.7.0, please refer to the changelog of all 2.0.0-preview versions, ranging from preview.1 to preview.7. + +The main feature of this new 2.0.0 version is Desktop Support. Tencent Cloud Chat UIKit now supports all platforms, including iOS, Android, Web, Windows, and macOS, which has resulted in significant changes to the codebase. The UI has been improved to adapt to screens of various widths, with different layouts for both wide and narrow screens. + +In addition, there are some significant changes compared to version 2.0.0-preview.7. + +## New Features + +* Added drag and drop support for multiple files in `TIMUIKitChat`, allowing direct sending. +* Introduced functionality to open files or their containing folder (using `Finder` on `macOS` or `Explorer` on `Windows`) for file messages via the message operation tooltip menu on desktop. +* Implemented text selection and copying in messages on desktop. +* Added group joining application processing on Desktop. +* Introduced `isAutoReportRead` to `TIMUIKitChatConfig` for controlling read status reporting. + +## Improvements + +* Enhanced group members selection panel for mentioning someone in a group chat. +* Refined image display ratio on Desktop. +* The Reply or Quote button is now labeled as `Reply` when `isAtWhenReply` is set to true, and `Quote` otherwise. +* @ member tags can now be deleted at once. + +## Bug Fixes + +* Fixed UI layout issue causing the `translate` button to display on two lines. +* Addressed an issue causing the mute status not to change when switching to another conversation. +* Fixed several issues causing bugs when opening files. +* Resolved an issue causing secondary confirmation modal UI layout to be over-width on Desktop. +* Fixed an issue causing UI layout errors on the profile page. +* Addressed an issue where the `chatMessageItemFromSelfBgColor` configuration did not work. +* Fixed an issue preventing files from being opened when the path contained Chinese characters on Windows. +* Resolved an issue preventing images from being pasted and sent directly with Ctrl + V on Windows. +* Fixed an issue causing errors in the muting members list. + +# 2.0.0-preview.7 + +## New Features + +* Added `additionalMessageToolTips` to `ToolTipsConfig`. This new property allows developers to add additional message operation tooltip items, apart from the default ones. The previous `additionalItemBuilder` has been replaced by this new property. With `additionalMessageToolTips`, developers only need to specify the data for the tooltip items, rather than providing a whole widget. This makes usage easier, as you no longer need to worry about the UI display. +* Added `isPreloadMessagesAfterInit` to `TIMUIKitConfig`, allows determines whether TUIKit should preload some messages after initialization for faster message display. + +## Improvements + +* Message operation menu shows when long-pressing messages will not show if nothing operation item includes and do not use message sticker reaction module. +* Renamed `desktopMessageHoverBar` to `additionalDesktopMessageHoverBarItem` in `TIMUIKitChatConfig` to control only the addition of extra operation items displayed on the hover bar of messages on desktop (macOS, Windows, and desktop version of Web), without affecting the default ones. Previously, it controlled the entire message hover bar, including covering the default items. +* Renamed `showWideScreenModalFunc` to `showDesktopModalFunc` in `TIMUIKitConfig` for better clarity. +* Upgraded several dependencies to their latest versions, including `ffi` to ^2.0.1, `file_picker` to ^5.2.9 and `device_info_plus` to ^8.2.0. +* Added support for the new permission authorization schema on Android 13 and `targetSdkVersion` greater than 33. +* Corrected the `textHight` to `textHeight` in `TIMUIKitChatConfig`, and modified the default value to 1.3. + +## Bug Fixes + +* Fixed an issue where the `showVideoCall` and `showVoiceCall` configuration options were not working. +* Fixed potential `Windows` platform deployment prohibition issue. +* Fixed an issue that may cause `setLocalCustomData` to be triggered repeatedly. + +# 2.0.0-preview.6 + +## Improvements + +* Permission requests now feature a gray translucent overlay for secondary confirmations on first-time requests, which was reintroduced after being removed in version 2.0.0-preview.4. Additionally, the overlay can now be successfully hidden once the permission authorization is complete.". +* Time Divider on Message List: The default 12-hour display has been changed to a 24-hour display. +* Message translation now targets the language of TUIKit instead of relying on the system language directly. The language of TUIKit can be set as the system language automatically or defined by the user. For more information, please refer to this documentation: https://www.tencentcloud.com/document/product/1047/52154. +* Optimized the animation for message text input area. + +## Bug Fixes + +* Fixed an issue where the `Voice Call` and `Video Call` buttons were not working in group chat. +* Fixed several null-safety issues. +* Fixed a layout problem for the message operation menu when not using the message sticker reaction module. +* Addressed a problem where the time ago display was not correct on the conversation item. +* Fixed an issue where stickers could not be clicked in some cases. +* Resolved an overflow error that occurred when opening the sticker panel. + +# 2.0.0-preview.5 + +## New Features + +* New Chat Configuration: `isAllowLongPressAvatarToAt`. This option controls whether users are allowed to mention another user in the group by long-pressing on their avatar. + +## Improvements + +* Improved tool bar configuration on desktop: The tool bar can now be customized using `desktopControlBarConfig` for embedded default items and `additionalDesktopControlBarItems` for additional tool items. These configurations come from TIMUIKitChatConfig. +* Renamed the `wideMessageHoverBar` configuration option to `desktopMessageHoverBar` for better clarity. +* Eliminated the dependency on `fluttertoast`. All necessary customer reminders are now triggered through the `onTUIKitCallbackListener` info callback in your project. For more information, please see: https://www.tencentcloud.com/document/product/1047/50054#how-do-i-get-an-api-call-error.2Fflutter-layer-error.2Fpop-up-prompt-message.3F.3Ca-id.3D.22callback.22.3E.3C.2Fa.3E. +* Eliminated other six unnecessary dependency packages to reduce the size and improve performance. +* Improved the clarity of the `sendMessage` function in `TIMUIKitChatController` by replacing the use of `convID` to represent both `userID` and `groupID` with separate parameters. + +## Bug Fixes + +* Fixed an issue where the message operation menu may show inaccurately when the message is too long. +* Fixed a problem where the message operation menu had the potential to be too wide for certain types of messages, causing display issues. +* Corrected an issue where the button to remove group members was not functioning correctly. +* Addressed a problem where the message item could exceed the pixel limit and appear too wide. +* Fixed a bug where certain JSON decoding operations could potentially fail. +* Fixed an issue with sound messages on iOS devices playing only through earpiece instead of speaker by default. + +# 2.0.0-preview.4 + +## New Features + +* New Chat Configuration: `TIMUIKitChatConfig` now includes `offlinePushInfo`, which allows for customization of the entire `offlinePushInfo` for each message. This field has a higher priority than the previous separate configuration fields for this object. +* New Color Configuration: Added `appbarTextColor` and `appbarBgColor` to configure the color for the Appbar. Also added `selectPanelBgColor` and `selectPanelTextIconColor` to configure the color of the messages multi-select panel. + +## Improvements + +* Improved Group Management: Muting members on Work Group is now not allowed. +* Improved Avatar: Ensured that the avatar can be as small as possible while still covering the entire target box. +* Permission Requests: Removed the gray translucent overlay for secondary confirmations on first-time permission requests. + +## Bug Fixes + +* Fixed an issue where the color defined by `chatBgColor` could not cover the entire chat screen when messages did not cover the whole page. +* Fixed an issue where the history message list could not be scrolled in some cases. +* Fixed an issue where the ratio of sending messages was incorrect, resulting in the wrong position of the read status label on the left. +* Fixed an issue where loading messages could fail when the number of messages equaled the specified count. + +# 2.0.0-preview.3 + +## New Features + +* Integrated Callkit: The Calls button no longer needs to be added to `MorePanelConfig`. If `tencent_calls_uikit` is installed, the Video Call and Voice Call buttons will be displayed automatically. +* Paste Images on Desktop: Users can now paste an image on the text field on Desktop to send it. +* Screenshot Capture on Desktop: Users can now capture a screenshot on Desktop and send it. + +## Improvements + +* Improved Compatibility: The TUIKit is now compatible with Flutter versions 3.0.0 to 3.7.7. + +## Bug Fixes + +* Fixed an issue where the `businessID` type may not be correct. +* Fixed an issue where the `chatMessageItemFromSelfBgColor` configuration was not taking effect. + +# 2.0.0-preview.2 + +## New Features + +* Added support for opening files locally from file messages. + +# 2.0.0-preview.1 + +## New Features + +* Desktop Support: Tencent Cloud Chat UIKit now supports all platforms, including iOS, Android, Web, Windows, and macOS, resulting in significant changes to the codebase. The UI has been enhanced to adapt to screens of various widths, with different layouts for both wide and narrow screens. +* Information Copy: The ability to copy information, such as Group ID, from the screen has been added. + +## Improvements + +* Improved group management logic, with non-administrators no longer able to access the management interface. +* Optimized cursor positioning when sending messages. +* Improved and optimized scrollbar functionality. +* Enhanced clickable URL support in messages, with URLs now supporting both with and without the "https://" prefix. + +# 1.7.0+1 + +* Fix: An issue that caused errors on mentioning all members. + +# 1.7.0 + +* Addition: Support for quickly navigating to the first unread message in a group chat with more than 20 new unread messages, using the dynamic tongue located in the top right corner of the screen. This feature allows for swift movement through the messages, regardless of their quantity. +* Addition: Customize the border radius for all avatars is now supported. You can set the default avatar border radius using `defaultAvatarBorderRadius` in `TIMUIKitConfig`. +* Optimization: The delete button on the sticker sending panel has been improved for better usability. +* Optimization: Some English labels on the screen have been updated to better reflect local expressions. +* Fix: An issue causing errors when sending a large number of stickers has been resolved. +* Fix: Some errors that were occurring in the sticker panel have been addressed. + +# 1.6.2 + +* Optimization: Remove `fluttertoast`. +* Fix: An issue that caused errors when sending files without extensions. + +# 1.6.1 + +* Fix: A bug of muting someone in a group. +* Fix: A bug on Flutter 3.7.0. + +# 1.6.0 + +* Addition: `scrollToConversation` on `TIMUIKitConversationController`. You can now easily navigate to a specific conversation in the conversation list and move to the next unread conversation by double-clicking the tab bar, [refers to our sample app](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/conversation.dart). +* Optimization: The performance of the history message list while scrolling over a large distance. + +# 1.5.0+1 + +* Fix: An issue with video messages being oversize. + +# 1.5.0 + +* Addition: New configuration `defaultAvatarAssetPath` on global `TIMUIKitConfig` to define the default avatar. +* Addition: Supports Flutter 3.7.0. +* Fix: `chatBgColor` configuration. + +# 1.4.0 + +* Addition: Text translation. Long press the text messages to choose `Translate`, which can be turned off by `showTranslation` from `ToolTipsConfig`. +* Optimization: The long press pop-up location and keyboard pop-up event. + +# 1.3.2 + +* Fix: Text input field height, after choosing to mention someone. + +# 1.3.1 + +* Optimization: Improve performance. + +# 1.3.0 + +* Fix: A bug where group tips were not showing the nickname or remarks when transferring group owner. +* Optimization: Remove the confirmation pop-up before opening the file. + +# 1.2.0 + +* Fix: An issue where the input area was not showing when switching from recording to keyboard on `TIMUIKitChat`. +* Fix: An issue where only the first receiver could receive the merged multiple forward messages. +* Optimization: `MessageItemBuilder` can now be used for shows on the merger message screen. + +# 1.1.0 And 1.1.0+1 + +* Addition: We have added support for two new languages - Japanese and Korean. +* Addition: You can now add other languages apart from our default ones, such as English, Chinese (Simplified and Traditional), Japanese, and Korean. You can also modify the translations using the instructions provided in [this documentation](https://www.tencentcloud.com/document/product/1047/52154?from=pub). +* Addition: The sticker plug-in is now embedded in TUIKit by default. We support three types of stickers - Unicode Emoji, small image emoji, and big image stickers. You can refer to [this documentation](https://www.tencentcloud.com/document/product/1047/52227?from=pub) for optimized usage. +* Optimization: Themes are now more customizable. +* Optimization: We have optimized the animation of the input area, keyboard, sticker panel, and more panel. +* Optimization: You can now insert both Unicode and small image emojis at any position in text messages. +* Optimization: You can now preview profile avatars with a large image by clicking it, and copy UserIDs in profile. +* Optimization: We have improved several UI details, including `TIMUIKitAddFriend`, `TIMUIKitAddGroup`, `TIMUIKitGroupProfile`, and `TIMUIKitProfile`. +* Optimization: `TIMUIKitGroupProfile` and `TIMUIKitProfile` can now update automatically after the `ID` is changed. +* Optimization: We have added a new loading animation when downloading images or videos on `TIMUIKitGroupChat`. +* Fix: We have fixed some bugs. + +# 1.0.1 + +* Modification: Remove `groupTRTCTipsItemBuilder` from `MessageItemBuilder`, please use `customMessageItemBuilder` instead. +* Modification: Remove default rendering for calling messages, you can choose to use the default widgets, `CallMessageItem` and `GroupCallMessageItem`, from our call plugin `tim_ui_kit_calling_plugin` directly. Refer to the [Demo](https://github.com/TencentCloud/chat-demo-flutter/tree/main/lib/utils/custom_message/custom_message_element.dart). + +# 1.0.0 + +* Addition: We have added support for adding Flutter module to Native APP. For implementation details, please refer to [this documentation](https://www.tencentcloud.com/document/product/1047/51456?from=pub). +* Addition: You can now customize stickers and emojis for text messages. For more information, please refer to [this documentation](https://cloud.tencent.com/document/product/269/80882). +* Optimization: We have improved the loading duration for history message lists, especially those with lots of media and file messages. +* Optimization: More panel area now supports scrolling. +* Optimization: We have made loading latest messages when scrolling back to the bottom smoother. +* Modification: It is now required to provide the call record widget to `messageItemBuilder` => `customMessageItemBuilder` of `TIMUIKitChat`. You can choose to use the default widget, `CallMessageItem`, from our call plugin `tim_ui_kit_calling_plugin` directly. Please refer to the [Demo](https://github.com/TencentCloud/chat-demo-flutter/tree/main/lib/utils/custom_message/custom_message_element.dart). +* Fix: We have fixed the issue with the number of photos from the album on Android. +* Fix: We have fixed the issue with long text going out of bounds in the group profile info card. +* Fix: We have resolved some bugs. + +> **Please note that modifications are required for the second and sixth lines**. Otherwise, the modules for stickers/emojis/call records will not work. + +# 0.1.8 + +* Optimization: File batch downloading queue now allows clicking on multiple file messages at once. +* Optimization: Group list widgets are now automatically updated. +* Optimization: Camera capture now supports relatively lower performance devices and adjusts resolution automatically. +* Optimization: Supports customization of the color and text style of the app bar, especially on TIMUIKitChat widget. +* Fix: Friend remark or nickname no longer fails to show on group tips. +* Fix: Resolved a crash when playing videos. +* Fix: Several bugs. + +# 0.1.7 + +* Addition: Big and RAW images are now supported, especially for those captured from the latest version of iOS and iPhone 14 Pro series, with automatic compression and formatting before sending. +* Optimization: Improved performance and stability, especially for the history message list and launching. +* Optimization: Initializing the `TIMUIKitChat` is now an idempotent operation. +* Optimization: Loads the latest messages when scrolling back to the bottom. +* Optimization: Supports Flutter both 2.x and 3.x series. +* Fix: Resolved an issue with select photos permission. +* Fix: Several bugs. + +# 0.1.5 + +* Addition: Web support is now available, allowing TUIKit to be implemented on iOS/Android/Web platforms. +* Addition: Disk storage checking is now performed after login, with controls available in `config` of `init`. +* Addition: `timeDividerConfig`, `notificationAndroidSound`, `isSupportMarkdownForTextMessage`, and `onTapLink` are added to `TIMUIKitChatConfig`. +* Remove: The default Emoji list has been removed due to copyright issues. You can provide your own sticker list to the panel using [tim_ui_kit_sticker_plugin](https://pub.dev/packages/tim_ui_kit_sticker_plugin). +* Optimization: You can now choose to disable Markdown parsing for text messages. +* Optimization: You can now choose to disable shows for @ messages in the conversation list. +* Optimization: You can now return `null` for `notificationExt`/`notificationBody` in `TIMUIKitChatConfig` and `messageRowBuilder` in `MessageItemBuilder` to use default values based on your needs in a specific case. This means you can control whether to use customized settings based on the provided situation, without having to redefine the same logic as TUIKit in your code. +* Optimization: Supports multiple lines for text messages. +* Optimization: Rebuilt and improved the experience of `TIMUIKitChat`. Note that `TIMUIKitChatController` needs to be specified to `controller`, as shown in the [Demo](https://github.com/TencentCloud/tc-chat-demo-flutter/lib/src/chat.dart). +* Fix: Several bugs. + +# 0.1.3 + +* Addition: User inputting status is now available. +* Addition: Message reactions with emoji/stickers are now available. +* Addition: User online status is now available. + +# 0.1.2 + +* Upgrade: flutter_record_plugin_plus to version 0.0.4. + +# 0.1.1 + +* Addition: Lifecycle hooks are now available for the main widgets, referring to the parameter description for details. +* Addition: Mute status display is now available for group chat on the chat page. +* Addition: URL enrichment is now available for text messages. +* Addition: Callback for global information (Flutter Error, Tips for Reminds, API Error), and you can display toast as needed. +* Optimization: Image preview display has been improved. +* Rebuilt: TUIKitGroupProfile and TUIKitProfile have been simplified for ease of use. + +# 0.1.0-bugfix + +* Upgrade: Tencent IM Native SDK. + +# 0.1.0 + +* Addition: Atomization widgets for TIMUIKitChat. +* Addition: Updating the UI when the message has been modified. +* Addition: The application page for joining the group. +* Addition: `updateMessage` API, users can refresh the view after modifying the local message. +* Addition: Support for Traditional Chinese. +* Addition: Customization for conversation list item. + +# 0.0.9 + +* Addition: Offline push along with [tim_ui_kit_push_plugin](https://pub.dev/packages/tim_ui_kit_push_plugin). +* Adapt: Flutter 3.0.0. +* Optimization: Local preview of multimedia files. + +# 0.0.8 + +* Addition: Group read receipt module. +* Addition: Little tongue on the message list. +* Addition: Examples. +* Fix: Several bugs. + +# 0.0.7 + +* Fix: Several bugs. + +# 0.0.6 + +* Addition: New `sendMessage` method to the controller `TIMUIKitChatController` for TIMUIKitChat. +* Addition: Configuration for TIMUIKitChat, which can control the functions for TIMUIKitChat components. +* Support: Customized for more panel customized ability to TIMUIKitChat. +* Optimization: User authorization standardized. + +# 0.0.5 + +* Addition: Several new customized configs, includes, appBarConfig, morePanelConfig, and removed appBarActions config. +* Optimization: Image preview displaying. +* Upgrade: Tencent IM SDK. +* Fix: The issue of conversation item duplication for TIMUIKitConversation. + +# 0.0.4 + +* Optimization: TIMUIKitChat, especially for media files selector. +* Optimization: Previewing of image messages, video messages. +* Optimization: Theme color. +* Optimization: UI for search components. +* Upgrade: Tencent IM SDK. + +# 0.0.3 + +* Addition: TIMUIKitSearch and TIMUIKitSearchMsgDetail, supports searching both in conversation and globally. +* Addition: TIMUIKitAddFriend. +* Addition: TIMUIKitAddGroup. +* Addition: Theme style configuration. +* Optimization: Internationalization. + +# 0.0.2 + +* Optimization: TIMUIKitChat. +* Fix: Bugs on Internationalization. + +# 0.0.1 + +The first released of TUIKit for Flutter of Tencent Cloud IM, the component of the first phase includes: + +* TIMUIKitCore: The main entrance of the whole TUIKit. +* TIMUIKitConversation: Conversation list. +* TIMUIKitChat: Chat and historical message list. +* TIMUIKitProfile: User detail profile and relationship management. +* TIMUIKitGroupProfile: Group details and management. +* TIMUIKitGroup: Joined group list. +* TIMUIKitBlackList: Blocklist. +* TIMUIKitContact: Contacts list. +* TIMUIKitNewContact: New contact application list. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8482e60 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright © 2013-2023 Tencent Cloud. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fece51c --- /dev/null +++ b/README.md @@ -0,0 +1,387 @@ +# It is recommended to download the source code from [pub.dev](https://pub.dev/packages/tencent_cloud_chat_uikit/versions) + +## Product Introduction +You only need to integrate Chat SDK to easily gain chat, conversation, group capabilities, and you can also communicate with other products such as whiteboards through signaling messages. Chat can cover various business scenarios, support the access and use of various platforms, and fully meet the communication needs. + +## Check Out Our Sample Apps +This document introduces how to quickly run the Chat demo on the iOS platform. +[](https://www.youtube.com/watch?v=lawzmfW9vls) + +For the other platforms, please refer to document: +- [**chat-uikit-android**](https://github.com/TencentCloud/chat-uikit-android) +- [**chat-uikit-ios**](https://github.com/TencentCloud/chat-uikit-ios) +- [**chat-uikit-vue**](https://github.com/TencentCloud/chat-uikit-vue) +- [**chat-uikit-react**](https://github.com/TencentCloud/chat-uikit-react) +- [**chat-uikit-uniapp**](https://github.com/TencentCloud/chat-uikit-uniapp) +- [**chat-uikit-wechat**](https://github.com/TencentCloud/chat-uikit-wechat) + +## Introduction to TUIKit +Chat SDK comes with TUIKit, which is an official set of UI components that have chat business logic built-in. TUIKit includes components like conversation, chat, relationship chain, and group. +See [TUIKit Library Overview](https://trtc.io/document/50059?platform=flutter&product=chat&menulabel=uikit) for more details. + +Developers can use these UI components to quickly and easily add In-APP chat modules to their mobile applications. + + +Currently, Flutter TUIKit +contains the following main components: + +- TIMUIKitCore: Core entry +- TIMUIKitConversation: Conversation list +- TIMUIKitChat: Chat module, includes historical message list and message sending area, with some other features + like message reaction and URL preview, etc. +- TIMUIKitContact: Contacts list +- TIMUIKitProfile: User profile and relationship management +- TIMUIKitGroupProfile: Group profile and management +- TIMUIKitGroup: The list of group self joined +- TIMUIKitBlackList: The list of user been blocked +- TIMUIKitNewContact: New contacts application list +- TIMUIKitSearch: Search globally +- TIMUIKitSearchMsgDetail: Search in specific conversation + +In addition to these components, there are other useful components and widgets available to help +developers meet their business needs, such as group entry application list and group member list. + +## Compatible Platforms + +The platforms are compatible with the deployment of our Chat UIKit. + +- Android +- iOS +- Web (version 0.1.4 and later) +- Windows (version 2.0.0 and later) +- macOS (version 2.0.0 and later) + +## Get Started + +Please refer to [Run Demo](https://trtc.io/document/45907?platform=flutter&product=chat&menulabel=uikit) for a complete and detailed guide on getting started. + +## Directions + +The following guide describes how to quickly build a simple chat application using Flutter TUIKit. +Refer to the appendix if you want to learn about the details and parameters of each widget. + +### Step 0: Create two accounts for testing + +Sign up and log in to the [Chat console](https://console.trtc.io/). + +Create an application and enter it. Click Users and create two accounts. + +> The correct way to distribute `UserSig` is to integrate the calculation code for `UserSig` into your server and provide an application-oriented API. When `UserSig` is needed, your application can send a request to the business server for a dynamic `UserSig.` For more information, see [How do I calculate UserSig on the server?](https://trtc.io/document/34385?product=chat&menulabel=serverapis). + +### Step 1: Create a Flutter app and add permission configuration + +Create a Flutter app quickly by following the [Flutter documentation](https://docs.flutter.dev/get-started/install). + +TUIKit needs the permissions of shooting/album/recording/network for basic messaging functions. You need to declare these permissions manually to use the relevant capabilities normally. + +#### Android + +Open `android/app/src/main/AndroidManifest.xml` and add the following lines between `` and ``. + +```xml + + + + + + + + + + + +``` + +#### iOS + +Open `ios/Podfile` and add the following lines to the end of the file. + +```pod +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64' + config.build_settings['ENABLE_BITCODE'] = 'NO' + config.build_settings["ONLY_ACTIVE_ARCH"] = "NO" + end + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + 'PERMISSION_CAMERA=1', + 'PERMISSION_PHOTOS=1', + ] + end + end +end +``` + +### Step 2: Install dependencies + +Add `tencent_cloud_chat_uikit` under `dependencies` in the `pubspec.yaml` file, or run the following command: + +```shell +flutter pub add tencent_cloud_chat_uikit +``` + +It supports Android and iOS by default. If you also want to use it on the web, refer to the following guide. + +#### Web Support + +Version 0.1.4 or later is required to support web. + +If your existing Flutter project does not support web, run `flutter create .` in the project root directory to add web support. + +Install JavaScript dependencies to `web/` using `npm` or `yarn`. + +```shell +cd web + +npm init + +npm i tim-js-sdk + +npm i tim-upload-plugin +``` + +Open `web/index.html` and add the following two lines between `` and `` to import them. + +```html + + +``` + + + + +### Step 3: Initialize TUIKit + +Initialize TUIKit when your app starts. You only need to perform the initialization once for the project to start. + +Get the instance of TUIKit first using `TIMUIKitCore.getInstance()`, followed by initializing it with your `sdkAppID`. + +```dart +/// main.dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + +@override +void initState() { + _coreInstance.init( + sdkAppID: 0, // Replace 0 with the SDKAppID of your Tencent Cloud Chat application + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, + listener: V2TimSDKListener()); + super.initState(); +}} +``` + +> **You may also want to register a callback function for `onTUIKitCallbackListener` here. Refer to the appendix.** + +### Step 4: Get the signature and log in + +You can now log in one of the testing accounts generated in Step 0 to start the Tencent Cloud Chat module. + +Log in using `_coreInstance.login`. + +```dart +/// main.dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +_coreInstance.login(userID: userID, userSig: userSig); +``` + +Note: Importing UserSig to your application is only for debugging purposes and cannot be used for the release version. Before publishing your app, you should generate your UserSig from your server. Refer to: [Generate Signature](https://trtc.io/document/34385?product=chat&menulabel=serverapis). + +## Step 5. Implementing the conversation list page + +You can use the conversation (channel) list page as the homepage of your Chat module, which includes all conversations with users and groups that have chat records. + + + +You can create a `Conversation` class, with `TIMUIKitConversation` as its body, to render the conversation list. You only need to provide the `onTapItem` callback, which allows users to navigate to the Chat page for each conversation. In the next step, we'll introduce the `Chat` class. + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class Conversation extends StatelessWidget { + const Conversation({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + "Message", + style: TextStyle(color: Colors.black), + ), + ), + body: TIMUIKitConversation( + onTapItem: (selectedConv) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + Chat( + selectedConversation: selectedConv, + ), + )); + }, + ), + ); + } +} +``` +## Step 6. Implementing the chat page + +The chat page consists of the main message list and a message sending bar at the bottom. + + + +You can create a `Chat` class, with `TIMUIKitChat` as its body, to render the chat page. We recommend providing an `onTapAvatar` callback function to navigate to the profile page for the current contact, which we'll introduce in the next step. + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class Chat extends StatelessWidget { + final V2TimConversation selectedConversation; + + const Chat({Key? key, required this.selectedConversation}) : super(key: key); + + String? _getConvID() { + return selectedConversation.type == 1 + ? selectedConversation.userID + : selectedConversation.groupID; + } + + @override + Widget build(BuildContext context) { + return TIMUIKitChat( + conversationID: _getConvID() ?? '', + conversationType: selectedConversation.type ?? 1, + conversationShowName: selectedConversation.showName ?? "", + onTapAvatar: (_) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => UserProfile(userID: userID), + )); + }, + ); + } +``` +## Step 7. Implementing the user profile page + +This page shows the profile of a specific user and maintains the relationship between the current logged-in user and the other user. + + + +You can create a `UserProfile` class, with `TIMUIKitProfile` as its body, to render the user profile page. + +The only parameter you have to provide is `userID`, while this component automatically generates the profile and relationship maintenance page based on the existence of friendship. + +> **TIP**: Please use `profileWidgetBuilder` first to customize some profile widgets and determine their vertical sequence using `profileWidgetsOrder` if you want to customize this page. If this method cannot meet your business needs, you may consider using `builder` instead. + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class UserProfile extends StatelessWidget { + final String userID; + + const UserProfile({required this.userID, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + "Message", + style: TextStyle(color: Colors.black), + ), + ), + body: TIMUIKitProfile( + userID: widget.userID, + ), + ); + } +} +``` + +Now your app can send and receive messages, display the conversation list, and manage contact friendships. You can use other components from TUIKit to quickly and easily implement the complete Chat module. + +## FAQs + +#### Do I need to integrate Chat SDK after integrating TUIKit? + +No. You don't need to integrate Chat SDK again. If you want to use Chat SDK related APIs, you can +get them via `TIMUIKitCore.getSDKInstance()`. This method is recommended to ensure Chat SDK version +consistency. + +#### Why did force quit occur when I sent voice, image, file or other messages? + +Check whether you have enabled the **camera**, **mic**, **album**, or other related permissions. + +Refers to Step 1 above. + +#### What should I do if clicking Build And Run for an Android device triggers an error, stating no available device is found? + +Check that the device is not occupied by other resources. Alternatively, click Build to generate an +APK package, drag it to the simulator, and run it. + +#### What should I do if an error occurs during the first run for an iOS device? + +If an error occurs after the configuration, click **Product > Clean Build Folder** , clean the +product, and run `pod install` or `flutter run` again. + + +#### What should I do if an error occurs during debugging on a real iOS device when I am wearing an Apple Watch? + + +Turn on Airplane Mode on your Apple Watch, and go to **Settings > Bluetooth** on your iPhone to turn +off Bluetooth. + +Restart Xcode (if opened) and run `flutter run` again. + +#### What should I do when an error occurs on an Android device after TUIKit is imported into the application automatically generated by Flutter? + + +1. Open `android\app\src\main\AndroidManifest.xml` and + complete `xmlns:tools="http://schemas.android.com/tools" / android:label="@string/android_label" / tools:replace="android:label"` + as follows. + +```xml + + + +``` + +2. Open `android\app\build.gradle` and complete `minSdkVersion` and `targetSdkVersion` + in `defaultConfig`. + +```gradle +defaultConfig { + applicationId "" // Replace it with your Android package name + minSdkVersion 21 + targetSdkVersion 30 +} +``` + +--- + +## Recommended Resources + +For those who require real-time voice and video call capabilities alongside our Chat UIKit, +we highly recommend our dedicated voice and video call UI component package, [tencent\_calls\_uikit](https://pub.dev/packages/tencent_calls_uikit). +This robust and feature-rich package is specifically designed to complement our existing solution and seamlessly integrate with it, +providing a comprehensive, unified communication experience for your users. diff --git a/README_ZH.md b/README_ZH.md new file mode 100644 index 0000000..ecf475a --- /dev/null +++ b/README_ZH.md @@ -0,0 +1,496 @@ +[English](https://github.com/TencentCloud/tc-chat-uikit-flutter) | 简体中文 + +# Flutter TUIKit + +TUIKit 是基于 Chat SDK 实现的一套 UI 组件,其包含会话、聊天、搜索、关系链、群组、音视频通话等功能,基于 UI 组件您可以像搭积木一样快速搭建起自己的业务逻辑。 + +TUIKit 效果图如下所示: +![img](https://qcloudimg.tencent-cloud.cn/raw/f140dd76be01a65abfb7e6ba2bf50ed5.png) + +快速集成 TUIKit 组件请参考视频: +[](https://www.youtube.com/watch?v=lawzmfW9vls) + +TUIKit 主要 Widget 如下: +- TIMUIKitConversation 会话组件 +- TIMUIKitChat 聊天组件 +- TIMUIKitCore Core 组件 +- TIMUIKitProfile 个人详情组件 +- TIMUIKitGroupProfile 群组详情组件 +- TIMUIKitGroup 群组列表组件 +- TIMUIKitBlackList 黑名单列表组件 +- TIMUIKitContact 联系人组件 +- TIMUIKitNewContact 新的联系人 +- TIMUIKitSearch 搜索 + +快速使用 TUIKit 组件库建议阅读: +- [TUIKit 组件库](https://trtc.io/zh/document/50059?platform=flutter&product=chat) +- [集成 TUIKit](https://trtc.io/zh/document/58585?platform=flutter&product=chat&menulabel=uikit) + +## 国际化 + +我们默认提供 `简体中文` `繁体中文` `英语` 的语言支持;并允许开发者新增语言包,扩展多语言支持。 + +如果您需要使用国际化多语言能力,请参考 [Flutter TUIKit 国际化指南](https://docs.qq.com/doc/DSVN4aHVpZm1CSEhv?u=c927b5c7e9874f77b40b7549f3fffa57)。 + +## TIMUIKitCore + +[本部分详细文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitCore/) + +`TIMUIKitCore`提供两个静态方法`getInstance` 和 `getSDKInstance`。 + +- `getInstance`: 返回 `CoreServicesImpl` 实例。 +- `getSDKInstance`: 返回 SDK 实例。 + +`CoreServicesImpl` 为`TIMUIKit` 核心类,包含初始化、登录、登出、获取用户信息等方法。 + +基础用法如下,先初始化 SDK,再登录用户: + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +final V2TIMManager _sdkInstance = TIMUIKitCore.getSDKInstance(); + +// init +_coreInstance.init( + language: LanguageEnum?, // 初始指定使用语言,`简体中文` `繁体中文` `英语`。不填默认跟随系统语言。 + onTUIKitCallbackListener: ValueChanged, // TUIKit信息回调,包含SDK API错误信息/TUIKit界面相关提示信息/Flutter层报错。您可根据需要,选择性自定义展示给用户。详见下方说明 + sdkAppID: 0, // 控制台申请的sdkAppID + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, + listener: V2TimSDKListener()); +// unInit +_coreInstance.unInit(); + +// login +_coreInstance.login( + userID: 0, // 用户ID + userSig: "" // 参考官方文档userSig生成 +) +``` + +### `onTUIKitCallbackListener` 监听 + +该监听用于返回包括:SDK API 错误 / Flutter 报错 / 一些可能需要弹窗提示用户的场景信息。 + +通过`TIMCallbackType`确定类型。 + +#### SDK API 错误(`TIMCallbackType.API_ERROR`) + +该场景下,提供 SDK API 原生`errorMsg`及`errorCode`。 + +[错误码请参考该文档](https://trtc.io/zh/document/34348?platform=flutter&product=chat&menulabel=uikit) + +#### Flutter 报错(`TIMCallbackType.FLUTTER_ERROR`) + +该错误由监听 Flutter 原生抛出异常产生,提供错误发生时的`stackTrace`(来自`FlutterError.onError`)或`catchError`(来自 try-catch)。 + +#### 场景信息(`TIMCallbackType.INFO`) + +建议根据实际情况,将这些信息弹窗提示用户。具体弹窗规则和样式可由您决定。 + +提供`infoCode`场景码帮助您确定当前的场景,及默认的提示推荐语`infoRecommendText`。 + +您可直接弹窗我们的推荐语,也可根据场景码自定义推荐语。推荐语语言根据系统语言自适应或您指定的语言,请勿根据推荐语来判断场景。 + +场景码规则如下: + +场景码由七位数组成,前五位数确定场景发生的组件,后两位确定具体的场景表现。 + +| 场景码开头 | 对应的组件 | +| ---------- | ---------------------- | +| 66601 | `TIMUIKitAddFriend` | +| 66602 | `TIMUIKitAddGroup` | +| 66603 | `TIMUIKitBlackList` | +| 66604 | `TIMUIKitChat` | +| 66605 | `TIMUIKitContact` | +| 66606 | `TIMUIKitConversation` | +| 66607 | `TIMUIKitGroup` | +| 66608 | `TIMUIKitGroupProfile` | +| 66609 | `TIMUIKitNewContact` | +| 66610 | `TIMUIKitGroupProfile` | +| 66611 | `TIMUIKitNewContact` | +| 66612 | `TIMUIKitProfile` | +| 66613 | `TIMUIKitSearch` | +| 66614 | 通用组件 | + +全部场景码清单如下: + +| 场景码 `infoCode` | 推荐提示语 `infoRecommendText` | 场景描述 | +|------------------| ------------------------------------------------------------ | ------------------------------------------------------------ | +| 6660101(3.0.1废弃)| 好友申请已发送 | 用户申请添加其他用户为联系人 | +| 6660102(3.0.1废弃)| 该用户已是好友 | 用户申请添加其他已是好友的用户为好友时,触发 `onTapAlreadyFriendsItem` 回调 | +| 6660201 | 群申请已发送 | 用户申请加入需要管理员审批的群聊 | +| 6660202 | 您已是群成员 | 用户申请加群时,判断用户已经是当前群成员,触发 `onTapExistGroup` 回调 | +| 6660401(3.0.1废弃)| 无法定位到原消息 | 当用户需要跳转至@消息或者是引用消息时,在消息列表中查不到目标消息 | +| 6660402 | 视频保存成功 | 用户在消息列表,点开视频消息后,选择保存视频 | +| 6660403 | 视频保存失败 | 用户在消息列表,点开视频消息后,选择保存视频 | +| 6660404 | 说话时间太短 | 用户发送了过短的语音消息 | +| 6660405(3.0.1废弃)| 发送失败,视频不能大于 100MB | 用户试图发送大于 100MB 的视频 | +| 6660406 | 图片保存成功 | 用户在消息列表,点开图片大图后,选择保存图片 | +| 6660407 | 图片保存失败 | 用户在消息列表,点开图片大图后,选择保存图片 | +| 6660408 | 已复制 | 用户在弹窗内选择复制文字消息 | +| 6660409 | 暂未实现 | 用户在弹窗内选择非标功能 | +| 6660410 | 其他文件正在接收中 | 用户点击下载文件消息时,前序下载任务还未完成 | +| 6660411 | 正在接收中 | 用户点击下载文件消息 | +| 6660412 | 视频消息仅限 mp4 格式 | 用户发送了一条非 mp4 格式的视频消息 | +| 6660413 | 已加入待下载队列,其他文件下载中 | 已加入待下载队列,其他文件下载中 | +| 6661001 | 无网络连接,无法修改 | 当用户试图在无网络环境下,修改群资料 | +| 6661002 | 无网络连接,无法查看群成员 | 当用户试图在无网络环境下,修改群资料 | +| 6661003 | 成功取消管理员身份 | 用户将群内其他用户移除管理员 | +| 6661201 | 无网络连接,无法修改 | 当用户试图在无网络环境下,修改自己或联系人的资料 | +| 6661202(3.0.1废弃)| 好友添加成功 | 在资料页添加其他用户为好友,并自动添加成功,无需验证 | +| 6661203(3.0.1废弃)| 好友申请已发出 | 在资料页添加其他用户为好友,对方设置需要验证 | +| 6661204(3.0.1废弃)| 当前用户在黑名单 | 在资料页添加其他用户为好友,对方在自己的黑名单内 | +| 6661205(3.0.1废弃)| 好友添加失败 | 在资料页添加其他用户为好友,添加失败,可能是由于对方禁止加好友 | +| 6661206(3.0.1废弃)| 好友删除成功 | 在资料页删除其他用户为好友,成功 | +| 6661207(3.0.1废弃)| 好友删除失败 | 在资料页删除其他用户为好友,失败 | +| 6661401 | 输入不能为空 | 当用户在录入信息时,输入了空字符串 | +| 6661402(3.0.1废弃)| 请传入离开群组生命周期函数,提供返回首页或其他页面的导航方法 | 用户退出群或解散群时,为提供返回首页办法 | +| 6661403 | 设备存储空间不足,建议清理,以获得更好使用体验 | 在login成功后,会自动检测设备存储空间,如果不足1GB,会提示存储空间不足 | + +## TIMUIKitConversation + +`TIMUIKitConversation` 为会话组件,拉取用户会话列表,默认提供一套 UI,用户也可自定义会话条目。同时提供对应的`TIMUIKitConversationController`。 + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitConversation/) + + + +### TIMUIKitConversationController + +#### 方法 + +- **loadData(int count)**: + 加载会话列表, count 为单次加载数量 +- **reloadData(int count)**: + 重新加载会话列表, count 为单次加载数量 +- **pinConversation({required String conversationID, required bool isPinned})**: + 会话置顶 +- **clearHistoryMessage({required V2TimConversation conversation})**: + 清除指定会话消息 +- **deleteConversation({required String conversationID})**: + 删除指定会话 +- **setConversationListener({V2TimConversationListener? listener})**: + 添加会话监听器 +- **dipose()**: + 销毁 + +--- + +## TIMUIKitChat + +`TIMUIKitChat` 为聊天组件,提供消息列表的展示及消息发送的能力,同时支持自定义各种消息类型的展示。同时可结合 TIMUIKitChatController 实现消息的本地存储及消息预渲染。 +目前支持的消息解析: + +- 文本消息 +- 图片消息 +- 视频消息 +- 语音消息 +- 群消息 +- 合并消息 +- 文件消息 + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitChat/) + +![](https://qcloudimg.tencent-cloud.cn/raw/09b8b9b54fd0caa47069544343eba461.jpg) + +### TIMUIKitChatController + +#### 方法 + +- **clearHistory()**: 清除历史消息 +- **dispose()**:销毁 +- **sendMessage({required V2TimMessage messageInfo, String? receiverID, String? groupID, required ConvType convType})**:发送消息。根据 ConvType,receiverID/groupID 二选一传入。 +- **sendForwardMessage({required List conversationList,})**:逐条转发 +- **sendMergerMessage({ required List conversationList, required String title, required List abstractList, required BuildContext context, })**:合并转发 + +--- + +## TIMUIKitProfile + +`TIMUIKitProfile` 为用户详情展示。同时支持自定义添加操作项. + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitProfile/) + +![](https://qcloudimg.tencent-cloud.cn/raw/03e88da6f1d63f688d2a8ee446da43ff.png) + +### TIMUIKitProfileController + +- **pinedConversation(bool isPined, String convID)**: + 会话置顶, `isPined` 为是否置顶,`convID` 为需要置顶的会话 ID. +- **addUserToBlackList(bool shouldAdd, String userID)**: + 添加用户至黑名单, `shouldAdd`为是否需要添加至黑名单, `userID`为需要被添加到黑名单的用户. +- **changeFriendVerificationMethod(int allowType)**: + 更改好友验证方式, `0`为"同意任何用户添加好友"、`1`为"需要验证"、`2`为"拒绝任何人加好友". +- **updateRemarks(String userID, String remark)**: + 更新好友备注, `userID`为被更新的用户 ID, `remark`为备注. +- **loadData**: + 加载数据 +- **dispose()**: + 销毁 +- **addFriend(String userID)**: + 添加好友,`userID`为被添加好友的用户 ID. + +--- + +## TIMUIKitGroupProfile + +`TIMUIKitGroupProfile` 为群管理页面。同时支持自定义添加操作项. + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitGroupProfile/) + +`operationListBuilder` 及 `bottomOperationListBuilder` 主要给予用户可配置操作条目的能力,同时可结合子组件配合使用,可以自己选择搭配。 + +--- + +## TIMUIKitBlackList + +`TIMUIKitBlackList` 为黑名单列表。 + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitBlackList/) + +--- + +## TIMUIKitGroup + +`TIMUIKitGroup` 为群列表。 + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitGroup/) + +--- + +### TIMUIKitContact + +`TIMUIKitContact` 为联系人列表组件。 + +[详细参数及用法可参考此文档](https://comm.qq.com/im/doc/flutter/uikit-sdk-api/TIMUIKitContact/) + +--- + +### 本地搜索组件 + +`TIMUIKitSearch` 为全局搜索组件。全局搜索支持"联系人"/"群组"/"聊天记录"。 +`TIMUIKitSearchMsgDetail` 为会话内搜索组件,可搜索会话内聊天记录。 + +[详细用法可参考此文档](https://trtc.io/zh/document/50036?platform=flutter&product=chat&menulabel=uikit) + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +// 全局搜索 +TIMUIKitSearch( + onTapConversation: _handleOnConvItemTapedWithPlace, // Function(V2TimConversation, V2TimMessage? message), 跳转到特定conversation的特定message + onEnterSearchInConversation: (V2TimConversation conversation, String initKeyword){}, // 跳转至对应Conversation的会话内搜索,请手动跳转至TIMUIKitSearchMsg组件。 +); + +// 会话内搜索 +TIMUIKitSearchMsgDetail( + currentConversation: conversation!, + onTapConversation: onTapConversation, + keyword: initKeyword ?? "", + ); +``` + +--- + +### 如何自定义 TIMUIKitChat 组件 + +为扩展`TIMUIKitChat`组件的自定义能力,我们将该组件包含的基础子组件对外暴露,用户可根据业务去选择和使用基础子组件实现满足自身的业务。基础子组件包含如下: + +- `TIMUIKitAppBar` +- `TIMUIKitHistoryMessageList` +- `TIMUIKitHistoryMessageListItem` +- `TIMUIKitInputTextField` + +下文将对以上组件介绍及使用用例。 + +#### TIMUIKitAppBar + +该组件为`TIMUIKitChat`的 appbar 组件,用于自定义应用导航栏。相较于 flutter 默认的`appbar`, 该组件额外提供了`title`自适应`用户昵称, 群名称`改变而动态改变,主题色改变。具体参数如下: + +| name | type | desc | optional | +| -------------------- | ------ | ------------------------------------ | -------- | +| config | AppBar | flutter appbar, 具体使用参考官方文档 | 可选 | +| showTotalUnReadCount | bool | 显示会话总未读数, 默认为 true | 可选 | +| conversationID | String | 会话 ID | 可选 | +| conversationShowName | String | 会话名称 | 可选 | + +#### TIMUIKitHistoryMessageList + +该组件为消息列表渲染组件,提供消息自动拉取,自动加载更多,跳转到指定消息。 具体参数如下: + +| name | type | desc | optional | +| --------------------- | -------------------------------------------- | ---------------------------- | -------- | +| messageList | List | 消息列表,渲染数据源 | 必填 | +| tongueItemBuilder | TongueItemBuilder | 小舌头(回到底部)自定义构造器 | 可选 | +| groupAtInfoList | List | 艾特信息 | 可选 | +| itemBuilder | Widget Function(BuildContext, V2TimMessage?) | 消息构造器 | 可选 | +| controller | TIMUIKitHistoryMessageListController | 控制列表跳转,滚动 | 可选 | +| onLoadMore | Function | 加载更多 | 必填 | +| mainHistoryListConfig | ListView | 自定义 ListView | 可选 | + +#### TIMUIKitHistoryMessageListItem + +该组件为消息实例组件,可根据提供的消息渲染不通的消息类型,包含`文本消息`,`图片消息`, `文件消息`,`通话消息`, `语音消息`等。同时支持消息自定义,主题定制能力。 + +| name | type | desc | optional | +| -------------------------------- | ------------------ | ------------------------------------------------------------ | -------- | +| message | V2TimMessage | 消息实例 | 必填 | +| onTapForOthersPortrait | Function | 远端用户头像 tap 回调 | 可选 | +| onScrollToIndex | Function | TIMUIKitHistoryMessageListController 的 scrollToIndex 方法,用于回复消息点击跳转到指定消息 | 可选 | +| onScrollToIndexBegin | Function | TIMUIKitHistoryMessageListController 的 scrollToIndexBegin 方法,长消息长按位置矫正 | 可选 | +| onLongPressForOthersHeadPortrait | Function | 远端用户头像长按 | 可选 | +| messageItemBuilder | MessageItemBuilder | 消息自定义构造器 | 可选 | +| topRowBuilder | Function | 昵称所在行自定义 builder | 可选 | +| bottomRowBuilder | Function | 消息显示之下 builder | 可选 | +| showAvatar | bool | 是否显示头像 | 可选 | +| showNickName | bool | 是否显示用户昵称 | 可选 | +| showMessageSending | bool | 是否显示消息发送中状态 | 可选 | +| showMessageReadRecipt | bool | 是否显示消息已读 | 可选 | +| showGroupMessageReadRecipt | bool | 是否显示群消息已读 | 可选 | +| allowLongPress | bool | 是否允许消息长按 | 可选 | +| allowAvatarTap | bool | 是否允许头像 tap | 可选 | +| allowAtUserWhenReply | bool | 是否在回复消息中提示对方 | 可选 | +| onLongPress | Function | 消息长按回掉 | 可选 | +| toolTipsConfig | ToolTipsConfig | 消息长按 tool tips 配置 | 可选 | +| padding | double | 消息间的间距 | 可选 | +| textPadding | EdgeInsetsGeometry | 文本消息内边距 | 可选 | +| userAvatarBuilder | Function | 用户头像构造器 | 可选 | +| themeData | MessageThemeData | 消息主题配置,可自定义字体颜色,大小等 | 可选 | + +#### TIMUIKitInputTextField + +该组件为输入框组件,提供`文本消息`,`图片消息`,`语音消息`等发送能力。参数如下 + +| name | type | desc | optional | +| ------------------ | -------------------------------- | ---------------------------------- | -------- | +| conversationID | String | 会话 ID | 必填 | +| conversationType | String | 会话类型 | 必填 | +| initText | String | 初始化文本 | 可选 | +| scrollController | AutoScrollController | 用于发送消息时将消息列表滚动到底部 | 可选 | +| hintText | String | 提示文本 | 可选 | +| morePanelConfig | MorePanelConfig | 更多面板配置 | 可选 | +| showSendAudio | bool | 是否显示发送语音 | 可选 | +| showSendEmoji | bool | 是否显示发送表情 | 可选 | +| showMorePannel | bool | 是否显示更多面板 | 可选 | +| backgroundColor | Color | 背景色 | 可选 | +| controller | TIMUIKitInputTextFieldController | 控制器,可控制输入框文本 | 可选 | +| onChanged | Function | 文本改变回调事件 | 可选 | +| customStickerPanel | Function | 自定义表情 | 可选 | + +#### 如何使用基础组件 + +如下是一个完整的使用示例 + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/controller/tim_uikit_chat_controller.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_history_message_list.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart'; + +class Chat extends StatefulWidget { + final V2TimConversation selectedConversation; + final V2TimMessage? initFindingMsg; + + const ChatV2( + {Key? key, required this.selectedConversation, this.initFindingMsg}) + : super(key: key); + @override + State createState() => _ChatV2State(); +} + +class _ChatV2State extends State { + final TIMUIKitChatController _controller = TIMUIKitChatController(); + final TIMUIKitHistoryMessageListController _historyMessageListController = + TIMUIKitHistoryMessageListController(); + final TIMUIKitInputTextFieldController _textFieldController = + TIMUIKitInputTextFieldController(); + bool _haveMoreData = true; + String? _getConvID() { + return widget.selectedConversation.type == 1 + ? widget.selectedConversation.userID + : widget.selectedConversation.groupID; + } + + loadHistoryMessageList(String? lastMsgID, [int? count]) async { + if (_haveMoreData) { + _haveMoreData = await _controller.loadHistoryMessageList( + count: count ?? 20, + userID: widget.selectedConversation.userID, + groupID: widget.selectedConversation.groupID, + lastMsgID: lastMsgID); + } + } + + @override + Widget build(BuildContext context) { + return TIMUIKitChatProviderScope( + conversationID: _getConvID() ?? "", + conversationType: widget.selectedConversation.type ?? 0, + builder: (context, w) { + return GestureDetector( + onTap: () { + _textFieldController.hideAllPanel(); + }, + child: Scaffold( + appBar: TIMUIKitAppBar( + config: AppBar( + title: Text(widget.selectedConversation.showName ?? ""), + ), + ), + body: Column( + children: [ + Expanded( + child: TIMUIKitHistoryMessageListSelector( + builder: (context, messageList, w) { + return TIMUIKitHistoryMessageList( + controller: _historyMessageListController, + messageList: messageList, + onLoadMore: loadHistoryMessageList, + itemBuilder: (context, message) { + return TIMUIKitHistoryMessageListItem( + onScrollToIndex: + _historyMessageListController.scrollToIndex, + onScrollToIndexBegin: + _historyMessageListController.scrollToIndexBegin, + message: message!, + ); + }, + ); + }, + conversationID: _getConvID() ?? "", + )), + TIMUIKitInputTextField( + controller: _textFieldController, + conversationID: _getConvID() ?? "", + conversationType: widget.selectedConversation.type ?? 1, + scrollController: + _historyMessageListController.scrollController!, + ) + ], + ), + ), + ); + }, + ); + } +} + +``` + +在如上示例中需要注意的点: + +- 在使用基础组件时必须通过`TIMUIKitChatProviderScope`组件包裹, 他会根据传入的`conversationID` 及`conversationType` 拉取对应的历史消息.该组件提供是基于通过`MultiProvider` 实现,同时可注入自定义的`provider`.其目的在于基础组件能够消费到业务层数据,同时可通过`TIMUIKitChatController` 控制业务层数据达到数据触发视图渲染的目的。 +- 可以使用提供的`TIMUIKitAppBar`组件实现应用导航栏,同时也可根据业务的需要,自己实现 appBar. +- `TIMUIKitChatProviderScope`会加载历史消息到业务层, 通过`TIMUIKitHistoryMessageListSelector` 获取到业务层历史消息数据用于渲染,当历史消息数据发生改变时会触发渲染。 +- 通过`TIMUIKitHistoryMessageList` 结合 `TIMUIKitHistoryMessageListItem` 实现消息页面的渲染 +- `TIMUIKitInputTextField`实现发送消息 + +基础组件可根据业务需要自行更换以及组合。如若需要控制业务层数据,可通过`TIMUIKitChatController`提供的方法。 + +## 更多阅读 +集成更多高级功能建议阅读: + +- [本地搜索](https://trtc.io/zh/document/50036?platform=flutter&product=chat&menulabel=uikit) +- [离线推送](https://trtc.io/zh/document/50032?platform=flutter&product=chat&menulabel=uikit) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/doc/DETAIL.md b/doc/DETAIL.md new file mode 100644 index 0000000..2960c6e --- /dev/null +++ b/doc/DETAIL.md @@ -0,0 +1,325 @@ +## TIMUIKitCore +`TIMUIKitCore`提供两个静态方法`getInstance` 和 `getSDKInstance`。 +- `getInstance`: 返回 `CoreServicesImpl` 实例。 +- `getSDKInstance`: 返回SDK实例。 + +`CoreServicesImpl` 为`TIMUIKit` 核心类,包含初始化、登录、登出、获取用户信息等方法。 +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +final V2TIMManager _sdkInstance = TIMUIKitCore.getSDKInstance(); + +/// init +_coreInstance.init( + sdkAppID: 0, // 控制台申请的sdkAppID + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, + listener: V2TimSDKListener()); +/// unInit +_coreInstance.unInit(); + +/// login +_coreInstance.login( + userID: 0, // 用户ID + userSig: "" // 参考官方文档userSig +) + +/// logout +_coreInstance.logout(); + +/// getUsersInfo +_coreInstance.getUsersInfo(userIDList: ["123", "456"]); + +/// setOfflinePushConfig +_coreInstance.setOfflinePushConfig( + businessID: businessID, // IM 控制台证书 ID,接入 TPNS 不需要填写 + token: token, // 注册应用到厂商平台或者 TPNS 时获取的 token + isTPNSToken: false // 是否接入配置 TPNS,token 是否是从TPNS 获取 +) + +/// setSelfInfo +_coreInstance.setSelfInfo(userFullInfo: userFullInfo) // 设置用户信息 + +/// setTheme +_coreInstance.setTheme(TUITheme theme: theme) // 设置主题色 +/* + TUITheme( + // 应用主色 + final Color? primaryColor; + // 应用次色 + final Color? secondaryColor; + // 提示颜色,用于次级操作或提示 + final Color? infoColor; + // 浅背景颜色,比主背景颜色浅,用于填充缝隙或阴影 + final Color? weakBackgroundColor; + // 浅分割线颜色,用于分割线或边框 + final Color? weakDividerColor; + // 浅字色 + final Color? weakTextColor; + // 深字色 + final Color? darkTextColor; + // 浅主色,用于AppBar或Panels + final Color? lightPrimaryColor; + // 字色 + final Color? textColor; + // 警示色,用于危险操作 + final Color? cautionColor; + // 群主标识色 + final Color? ownerColor; + // 群管理员标识色 + final Color? adminColor;) +*/ +``` + +### 静态方法 +- **TIMUIKitCore.getInstance()**: +返回`CoreServicesImpl` 实例 +- **TIMUIKitCore.getSDKInstance()**: +返回为 `V2TIMManager` 为`SDK 实例` 具体使用方式请参考[`Flutter IM SDK 文档`](https://pub.dev/documentation/tencent_im_sdk_plugin/latest/manager_v2_tim_manager/V2TIMManager/initSDK.html) + +--- + +## TIMUIKitConversation +`TIMUIKitConversation` 为会话组件,拉取用户会话列表,默认提供一套UI,用户也可自定义会话条目。同时提供对应的`TIMUIKitConversationController`。 + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final TIMUIKitConversationController _controller = + TIMUIKitConversationController(); +void _handleOnConvItemTaped(V2TimConversation? selectedConv) { + /// 处理逻辑,在此可跳转至聊天界面 +} + +List _itemSlidableBuilder( + V2TimConversation conversationItem) { + return [ + ConversationItemSlidablePanel( + onPressed: (context) { + _clearHistory(conversationItem); + }, + backgroundColor: hexToColor("006EFF"), + foregroundColor: Colors.white, + label: '清除聊天', + autoClose: true, + ), + ConversationItemSlidablePanel( + onPressed: (context) { + _pinConversation(conversationItem); + }, + backgroundColor: hexToColor("FF9C19"), + foregroundColor: Colors.white, + label: conversationItem.isPinned! ? '取消置顶' : '置顶', + ) + ]; + } + +TIMUIKitConversation( + onTapItem: _handleOnConvItemTaped, /// 会话Item tap回调 可用于跳转至聊天界面 + itemSlidableBuilder: _itemSlidableBuilder, /// 会话Item 向左滑动 的操作项, 可自定义会话置顶等 + controller: _controller, /// 会话组件控制器, 可通过其获取会话的数据,设置会话数据,会话置顶等操作 + itembuilder: (conversationItem) {} /// 用于自定义会话Item 的UI。 可结合TIMUIKitConversationController 实现业务逻辑 + conversationCollector: (conversation) {} /// 会话收集器,可自定义会话是否显示 +) +``` + +### TIMUIKitConversationController + +#### 方法: + +- **loadData(int count)**: +加载会话列表, count 为单次加载数量 +- **reloadData(int count)**: +重新加载会话列表, count 为单次加载数量 +- **pinConversation({required String conversationID, required bool isPinned})**: +会话置顶 +- **clearHistoryMessage({required V2TimConversation conversation})**: +清除指定会话消息 +- **deleteConversation({required String conversationID})**: +删除指定会话 +- **setConversationListener({V2TimConversationListener? listener})**: +添加会话监听器 +- **dipose()**: +销毁 + +--- + +## TIMUIKitChat +`TIMUIKitChat` 为聊天组件,提供消息列表的展示及消息发送的能力,同时支持自定义各种消息类型的展示。同时可结合TIMUIKitChatController 实现消息的本地存储及消息预渲染。 +目前支持的消息解析: +- 文本消息 +- 图片消息 +- 视频消息 +- 语音消息 +- 群消息 +- 合并消息 +- 文件消息 + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +TIMUIKitChat( + conversationID: "", /// 会话ID + conversationType: 0, /// 会话类型 + conversationShowName: "", /// 会话显示名称 + onTapAvatar: _onTapAvatar, /// 头像tap 回调,可用于跳转至用户详情界面。 + showNickName: false, /// 是否显示昵称 + messageItemBuilder: (message) { + /// 自定义消息构造器、返回null 会使用默认构造器。 + }, + exteraTipsActionItemBuilder: (message, close) { + /// 消息长按Tips自定义配置项,可根据业务额外配置 + }, + textFieldHintText: "", /// 输入框hintText + appBarConfig: AppBar(), /// 用于自定chat appBar + draftText: "", /// 会话草稿,用于草稿回显, + initFindingTimestamp: 0, /// 用于消息跳转,使用场景为,搜索历史消息后,可跳转至指定消息位置。 + morePanelConfig: MorePanelConfig(), /// "+" 号面板配置,可用于自定义面板Action. +) +``` + +### TIMUIKitChatController + +#### 方法 +- **setMessageListener({V2TimAdvancedMsgListener? listener})**: 设置高级消息监听器 +- **removeMessageListener({V2TimAdvancedMsgListener? listener})**: 移除高级消息监听器 +- **clearHistory()**: 清除历史消息 +- **dispose()**:销毁 + + +--- + +## TIMUIKitProfile + +`TIMUIKitProfile` 为用户详情展示。同时支持自定义添加操作项. + +```dart +TIMUIKitProfile( + userID: "", + controller: TIMUIKitProfileController(), // Profile Controller + operationListBuilder: (context, userInfo, conversation) { + ///自定义操作项,例如消息免打扰、消息置顶等。 如若不传,会提供默认的操作项 + }, + bottomOperationBuilder: (context, friendInfo, conversation) { + /// 底部操作项,如删除好友等。 + }, + handleProfileDetailCardTap: (BuildContext context, V2TimUserFullInfo? userFullInfo) { + /// 个人详情tile tap 回调 + }, + canJumpToPersonalProfile: false, // 是否可以跳转至个人详情界面 +) +``` + +### TIMUIKitProfileController +- **pinedConversation(bool isPined, String convID)**: +会话置顶, `isPined` 为是否置顶,`convID` 为需要置顶的会话ID. +- **addUserToBlackList(bool shouldAdd, String userID)**: +添加用户至黑名单, `shouldAdd`为是否需要添加至黑名单, `userID`为需要被添加到黑名单的用户. +- **changeFriendVerificationMethod(int allowType)**: +更改好友验证方式, `0`为"同意任何用户添加好友"、`1`为"需要验证"、`2`为"拒绝任何人加好友". +- **updateRemarks(String userID, String remark)**: +更新好友备注, `userID`为被更新的用户ID, `remark`为备注. +- **loadData**: +加载数据 +- **dispose()**: +销毁 +- **addFriend(String userID)**: +添加好友,`userID`为被添加好友的用户ID. + + +--- + +## TIMUIKitGroupProfile +`TIMUIKitGroupProfile` 为群管理页面。同时支持自定义添加操作项. +```dart +TIMUIKitGroupProfile( + groupID: "", //群ID 必填 + operationListBuilder:(){}, // 操作项自定义构造器 + bottomOperationListBuilder: () {}, // 底部操作项自定义构造器 +) +``` +`operationListBuilder` 及 `bottomOperationListBuilder` 主要给予用户可配置操作条目的能力,同时可结合子组件配合使用,可以自己选择搭配。 + +### 静态方法 +- **TIMUIKitGroupProfile.memberTile()**: +群成员卡片、用于显示群成员概览、群成员列表、删除群成员等操作 +- **TIMUIKitGroupProfile.groupNotification()**: +群公告显示及群公告更改 +- **TIMUIKitGroupProfile.groupManage()**: +群管理、可设置管理员、禁言等 +- **TIMUIKitGroupProfile.groupType()**: +显示群类型 +- **TIMUIKitGroupProfile.groupAddOpt()**: +加群方式及修改 +- **TIMUIKitGroupProfile.nameCard()**: +群昵称及修改 + +--- + +## TIMUIKitBlackList +`TIMUIKitBlackList` 为黑名单列表。 + +```dart +TIMUIKitBlackList( + onTapItem: (_) {}, /// tap item 回调 + emptyBuilder: () {} /// 当列表为空时显示 + itemBuilder: () {} /// 自定义 item +) +``` + +--- + +## TIMUIKitGroup +`TIMUIKitGroup` 为群列表。 + +```dart +TIMUIKitGroup( + onTapItem: (_) {}, /// tap item 回调 + emptyBuilder: () {} /// 当列表为空时显示 + itemBuilder: () {} /// 自定义 item +) +``` + +--- + +## TIMUIKitContact +`TIMUIKitContact` 为联系人组件,提供联系人列表。 + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +TIMUIKitContact( + topList: [ + TopListItem(name: "新的联系人", id: "newContact"), + TopListItem(name: "我的群聊", id: "groupList"), + TopListItem(name: "黑名单", id: "blackList") + ], /// 顶部操作列表 + topListItemBuilder: _topListBuilder, /// 顶部操作列表构造器 + onTapItem: (item) { }, /// 点击联系人 + emptyBuilder: (context) => const Center( + child: Text("无联系人"), + ), /// 联系人列表为空时显示 + ); +``` + +--- + +## TIMUIKitNewContact +`TIMUIKitNewContact` 为新的联系人界面 +```dart +TIMUIKitNewContact( + onAccept: (applicationInfo) { + /// 接受好友回调 + }, + onRefuse: (applicationInfo) { + /// 拒绝好友回调 + }, + emptyBuilder: () { + /// 未收到好友申请时回调 + }, + itemBuilder: () { + /// 自定义好友申请项构造器 + } +) +``` + diff --git a/doc/FAST_INTEGRATED.md b/doc/FAST_INTEGRATED.md new file mode 100644 index 0000000..7001e91 --- /dev/null +++ b/doc/FAST_INTEGRATED.md @@ -0,0 +1,236 @@ +# 快速集成方案 +## 什么是Flutter TIMUIKit? +Flutter TIMUIKit 是基于Flutter IM SDK 实现的一套UI组件,其中包含会话、聊天、关系链、群组等功能,基于UI组件您可以像搭积木一样快速搭建起自己的业务逻辑。 + +目前包含的组件如下: + +- [TIMUIKitCore](DETAIL.md#timuikitcore) 核心 +- [TIMUIKitConversation](DETAIL.md#timuikitconversation) 会话 +- [TIMUIKitChat](DETAIL.md#timuikitchat) 聊天 +- [TIMUIKitContact](DETAIL.md#timuikitcontact) 联系人 +- [TIMUIKitProfile](DETAIL.md#timuikitprofile) 好友管理 +- [TIMUIKitGroupProfile](DETAIL.md#timuikitgroupprofile) 群管理 +- [TIMUIKitGroup](DETAIL.md#timuikitgroup) 我的群聊 +- [TIMUIKitBlackList](DETAIL.md#timuikitblacklist) 黑名单 +- [TIMUIKitNewContact](DETAIL.md#timuikitnewcontact) 新的联系人 + +![](https://imgcache.qq.com/operation/dianshi/other/uikit.e8f3557a9e34f99120644b7a4a5645ec30c2cbd2.jpg) + +## 支持平台 +- Android +- ios + +## 如何集成? +如下会介绍如何使用`TIMUIKit`快速构建一个简单的即时通信应用. + +### 步骤1: 创建Flutter应用 +参考Flutter[文档](https://flutter.cn/docs/get-started/test-drive?tab=terminal)快速创建一个flutter应用。 + +### 步骤2: 安装依赖 +在`pubspec.yaml`文件中的`dependencies`下添加`tencent_cloud_chat_uikit`。或者执行如下命令: +``` +// step 1: +flutter pub add tencent_cloud_chat_uikit + +// step 2: +flutter pub get +``` + +### 步骤3: 初始化TIMUIKit + +在`initState`中初始化`TIMUIKit`,项目启动只需要初始化一次即可。 +```dart +/// main.dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'TIMUIKit Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'TIMUIKit Demo'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key? key, required this.title}) : super(key: key); + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + + @override + void initState() { + _coreInstance.init( + sdkAppID: 0, // 控制台申请的sdkAppID + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, + listener: V2TimSDKListener()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Container(), + ); + } +} +``` +### 步骤4: 获取签名和登录 +>- 正确的 `UserSig` 签发方式是将 `UserSig` 的计算代码集成到您的服务端,并提供面向 App 的接口,在需要 `UserSig` 时由您的 App 向业务服务器发起请求获取动态 `UserSig`。更多详情请参见 [服务端生成 UserSig](https://cloud.tencent.com/document/product/647/17275#Server)。 + +添加两个`TextField`用于输入`userID` 和 `userSig`。点击登录后掉用登录接口。 +```dart +/// main.dart +/// 省略 +class _MyHomePageState extends State { + /// 获取 TIMUIKitCore Instance + final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + String userID = ""; + String userSig = ""; + + /// 省略 + + void _login() { + // 登录 + _coreInstance.login(userID: userID, userSig: userSig); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + onChanged: ((value) { + setState(() { + userID = value; + }); + }), + decoration: InputDecoration(hintText: "userID"), + ), + TextField( + onChanged: ((value) { + setState(() { + userSig = value; + }); + }), + decoration: InputDecoration(hintText: "userSig"), + ), + ElevatedButton( + onPressed: (() { + _login(); + }), + child: const Text("登录")) + ], + ), + ), + ); + } +} + +``` + + + +### 步骤4: 集成所需组件 +- 创建`message.dart`文件集成`TIMUIKitConversation` 和 `TIMUIKitChat`包含不仅限于此。可根据您的需求集成更多的组件。 +- 修改`main.dart`中代码,登录成功后跳转至该页面。 +```dart +/// message.dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class Conversation extends StatelessWidget { + const Conversation({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + "消息", + style: TextStyle(color: Colors.black), + ), + ), + body: TIMUIKitConversation( + onTapItem: (selectedConv) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Chat( + selectedConversation: selectedConv, + ), + )); + }, + ), + ); + } +} + +class Chat extends StatelessWidget { + final V2TimConversation selectedConversation; + const Chat({Key? key, required this.selectedConversation}) : super(key: key); + String? _getConvID() { + return selectedConversation.type == 1 + ? selectedConversation.userID + : selectedConversation.groupID; + } + + @override + Widget build(BuildContext context) { + return TIMUIKitChat( + conversationID: _getConvID() ?? '', // groupID 或者 userID + conversationType: selectedConversation.type ?? 0, // 会话类型 + conversationShowName: selectedConversation.showName ?? "", // 会话展示名称 + onTapAvatar: (_) {}, // 点击消息发送者头像回调事件、可与TIMUIKitProfile关联使用 + appBarActions: [ + IconButton( + onPressed: () {}, icon: const Icon(Icons.more_horiz_outlined)) + ], + ); + } +} + + +/// main.dart + +/// 部分代码省略 +void _login() async { + final res = await _coreInstance.login(userID: userID, userSig: userSig); + if (res.code == 0) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) => const Conversation()), + (route) => false, + ); + } + } +``` +### 常见问题 +#### 1: 引入了 `TIMUIKit` 还需要引入 `IM SDK` 吗? +不需要再次引入`IM SDK`了。如果需要使用`IM SDK` 相关的接口,可通过 `TIMUIKitCore.getSDKInstance()`获取。为了保证`IM SDK` 的版本一致性,我们推荐您使用该方式使用SDK。 +#### 2: 发送语音、图片、文件等消息闪退? +请查看是否打开了`相机、麦克风、相册`等权限。 \ No newline at end of file diff --git a/doc/I18N.md b/doc/I18N.md new file mode 100644 index 0000000..782353c --- /dev/null +++ b/doc/I18N.md @@ -0,0 +1,172 @@ + +腾讯云IM Flutter TUIKit默认自带 英文/简体中文/繁体中文/日语/韩语 语言包,作为界面展示语言。 + +根据此文档指引,您可以使用默认语言包,也可自定义语言翻译表述,并增添额外的非自带语言的支持。 + +![](https://qcloudimg.tencent-cloud.cn/raw/2df62f8a62453c063c396cb656dd07bc.png) + +## 使用自带语言 + +如果您的App,需要的语言仅包括英语/简体中文/繁体中文/日语/韩语,请参考本部分。 + +### 跟随系统语言 + +直接使用TUIKit即可,无需额外步骤。插件内部会跟随系统语言自适应。 + +### 预指定显示的语言 + +如果您需要在初始化是,手动设置TUIKit界面语言,请在`TIMUIKitCore.getInstance()`中调用`init`方法时,传入需要的语言。 + +```dart +import 'package:tim_ui_kit/tim_ui_kit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + +final isInitSuccess = await _coreInstance.init( + language: LanguageEnum.en, // 请在此处定义语言,枚举值见下方 + // ...其他配置 +); +``` + +语言可选项,枚举值为: + +```dart +enum LanguageEnum { + zhHant, //繁体中文 + zhHans, //简体中文 + en, // 英文 + ko, // 韩语 + ja // 日语 +} +``` + +### 实时动态修改 + +调用 `I18nUtils(null, language);` 即可。此处的 `language` 为 [ISO 639-1 语言代码](#code)。示例如下: + +```dart +I18nUtils(null, "en"); +``` + +>? 语言代码清单见[附录](#code)。 + +## 使用更多语言/自定义翻译表述 + +如果您需要支持,除 英文/简体中文/繁体中文/日语/韩语 外的更多语言,或更改我们部分词条的翻译,请参考本部分。 + +>? 本方案仅适用于,目标语言为的阅读方向为从左至右的语言。对于阅读方向从右至左的小语种,如阿拉伯语,请自行在[GitHub fork](https://github.com/TencentCloud/chat-uikit-flutter)一份我们的源码,完成自定义左右镜像开发适配。 + +### 新增语言词条包 + +本章节的核心为本部分, 即,将您自定义的国际化多语言词条库文件,注入腾讯云IM项目内。 + +#### 获取语言模板 + +在您的项目中,允许如下代码。 + +```shell +flutter pub run tencent_im_base +``` + +根据提示,选择 `A` 选项。 + +![](https://qcloudimg.tencent-cloud.cn/raw/01215e7861ed2736c0155c456ad2d0d6.png) + +此时,我们自带的所有语言包,以JSON文件模板的形式,存储于您项目根目录下,`languages/` 路径内。 + +![](https://qcloudimg.tencent-cloud.cn/raw/2618d546ece854d93cfe21d1ad342ade.png) + +请复制一份您熟悉的语言JSON模板文件,例如,简体中文版,`strings_zh-Hans.i18n.json`。 + +复制一份新的,并命名为 `strings_${语言编码}.i18n.json`。其中,`${语言编码}` 需要替换为 [ISO 639-1 语言代码](#code)。例如,丹麦语,`strings_da.i18n.json`。 + +如果您需要兼容支持多个新语言,复制多份,并准确指定每一份的语言编码即可。 + +#### 个性化自定义翻译 + +此时,您可以修改上一步 **复制新生成的目标语言模板**。 + +打开您复制生成的新文档,**保留不动JSON的md5 key值**,将所有的value值替换成对应目标翻译语言。 + +![](https://qcloudimg.tencent-cloud.cn/raw/540536815ec579ca4343a7013a768178.png) + +>? 如果您需要修改默认语言模板的翻译文案,也可直接打开自动生成的语言模板,进行修改。**除简体中文版本外**,其他翻译文案,均可修改。 + +翻译完成后,`languages/` 内,包含原始提供的自带语言模板,及您复制生成的其他语言词条集。 + +![](https://qcloudimg.tencent-cloud.cn/raw/0b409d05e26b81b60a4babed07936cda.png) + +#### 回装您的语言包 + +在您项目的根目录下,执行 `flutter pub run tencent_im_base` 命令,并选择 `B` 选项。 + +代码运行完成后,即可使您的语言包,在当前电脑本地生效。 + +![](https://qcloudimg.tencent-cloud.cn/raw/7823200ee5f323bc254aad61be122907.png) + +>? 如果您是团队协同开发,或使用了远程流水线编译。需要在您同事电脑中或流水线编译命令脚本中,使用同样的方式,执行本章节所述方法。 + +### 跟随系统语言 + +直接使用TUIKit即可,无需额外步骤。 + +只要您新增的语言词条包命名[符合标准](#code),插件内部会跟随系统语言自适应。 + +### 预指定显示的语言 + +如果您需要在初始化是,手动设置TUIKit界面语言,请在`TIMUIKitCore.getInstance()`中调用`init`方法时,传入需要的语言。 + +```dart +import 'package:tim_ui_kit/tim_ui_kit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + +final isInitSuccess = await _coreInstance.init( + extraLanguage: "ja", // 请在此处定义语言,ISO 639-1 语言代码 见下方 + // ...其他配置 +); +``` + +### 实时动态修改 + +调用 `I18nUtils(null, language);` 即可。此处的 `language` 为 [ISO 639-1 语言代码](#code)。示例如下: + +```dart +I18nUtils(null, "ja"); +``` + +>? 语言代码清单见[附录](#code)。 + +[](id:code) + +## 附录:语言代码表 + +| 语言 | 代码 | 语言 | 代码 | +|--------|--------|--------|--------| +| 阿拉伯语 | ar | 保加利亚语 | bg | +| 克罗地亚语 | hr | 捷克语 | cs | +| 丹麦语 | da | 德语 | de | +| 希腊语 | el | 英语 | en | +| 爱沙尼亚语 | et | 西班牙语 | es | +| 芬兰语 | fi | 法语 | fr | +| 爱尔兰语 | ga | 印地语 | hi | +| 匈牙利语 | hu | 希伯来语 | he | +| 意大利语 | it | 日语 | ja | +| 朝鲜语/韩语 | ko | 拉脱维亚语 | lv | +| 立陶宛语 | lt | 荷兰语 | nl | +| 挪威语 | no | 波兰语 | pl | +| 葡萄牙语 | pt | 瑞典语 | sv | +| 罗马尼亚语 | ro | 俄语 | ru | +| 塞尔维亚语 | sr | 斯洛伐克语 | sk | +| 斯洛文尼亚语 | sl | 泰语 | th | +| 土耳其语 | tr | 乌克兰语 | uk-UA | +| 中文(简体) | zh-Hans | 中文(繁体) | zh-Hant | + +完整版[请见此处](https://quickref.me/iso-639-1)。 + +## 联系我们[](id:contact) + +如果您在接入使用过程中有任何疑问,请通过如下方式联系我们。 + +- [Telegram Group](https://t.me/+1doS9AUBmndhNGNl) +- [WhatsApp Group](https://chat.whatsapp.com/Gfbxk7rQBqc8Rz4pzzP27A) diff --git a/doc/I18N_en.md b/doc/I18N_en.md new file mode 100644 index 0000000..ee4d0aa --- /dev/null +++ b/doc/I18N_en.md @@ -0,0 +1,169 @@ + +English, Simplified Chinese, Traditional Chinese, Japanese and Korean have been embedded in Tencent Cloud Chat TUIKit as the default interface languages. + +Adding other interface languages or modifying the current language items are available for you, according to the instructions of this tutorial. + +![](https://qcloudimg.tencent-cloud.cn/raw/2df62f8a62453c063c396cb656dd07bc.png) + +## Using the default languages + +If only Chinese(both traditional and simplified), English, Japanese and Korean are needed for your application, please refer to this section. + +### Choosing device language + +No further steps are needed, as meeting device language can be automatically. + +### Pre-set the language manually + +If you tend to specify the language manually, please provide the target language Enum to `init()` in `TIMUIKitCore.getInstance()`. + +```dart +import 'package:tim_ui_kit/tim_ui_kit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + +final isInitSuccess = await _coreInstance.init( + language: LanguageEnum.en, // Enums as below +// ...Other configurations +); +``` + +Enum options for language: + + +```dart +enum LanguageEnum { + zhHant, // Chinese, traditional + zhHans, // Chinese, simplified + en, // English + ko, // korean + ja // Japanese +} +``` + +### Modify language dynamically + +Please just invoking `I18nUtils(null, language);`, while the `language` here should be set as the [ISO 639 Language codes](#code). + +Example code: + +```dart +I18nUtils(null, "en"); +``` + +## Need more languages / customize translation items + +Adding languages, apart from English, Simplified Chinese, Traditional Chinese, Japanese and Korean, or modifying some translation items words, can be referred to this section. + +>? This solution only works for languages with a left-to-right reading direction. For small languages that read from right to left, such as Arabic, please fork our source code from [our GitHub repository](https://github.com/TencentCloud/chat-uikit-flutter) to complete the custom left and right mirroring Development adaptation, and import to your project manually. + +### Adding language translation files + +The key of this section is this part, that is, inject your custom internationalized language file into the Tencent Cloud Chat. + +#### Get the language template + +Run the following command, and choose `A` as instruction. + +```shell +flutter pub run tencent_im_base +``` + +![](https://qcloudimg.tencent-cloud.cn/raw/01215e7861ed2736c0155c456ad2d0d6.png) + +Now, all the pre-set default language files, as JSON, have been generated to your project, `languages/` directory. + +![](https://qcloudimg.tencent-cloud.cn/raw/2618d546ece854d93cfe21d1ad342ade.png) + +Duplicate for language files, based on the template you are most familiar with. + +The newly duplicated language files should be named as `strings_${language code}.i18n.json`. While, `${language code}` should be replaced by [ISO 639 Language Codes](#code). Such as, the file containing Danish language items should be named as `strings_da.i18n.json`. + +Duplicate multiple language files, if you need to support multiple other languages. + +#### Customize translations + +Now, you can modify the language files generated in the previous step. + +Open each language file, including the files you just duplicated, except `strings_zh-Hans.i18n.json`, translate or modify each `value` to target language, while keeping the md5 key unchanged. + +![](https://qcloudimg.tencent-cloud.cn/raw/540536815ec579ca4343a7013a768178.png) + +After translation and modification, all the supported languages files, including those you duplicated and default, should be in the `languages/` directory. + +![](https://qcloudimg.tencent-cloud.cn/raw/0b409d05e26b81b60a4babed07936cda.png) + +#### Activate those language files + +Run the following command, and choose `B` as instruction. + +```dart +flutter pub run tencent_im_base +``` + +After the script has finished, those customization languages are activated on your local Flutter environment. + +![](https://qcloudimg.tencent-cloud.cn/raw/7823200ee5f323bc254aad61be122907.png) + +>? If you are developing with a team collaboratively, or using DevOps pipeline compilation. You also need to execute this solution on your colleague's computer or in the DevOps pipeline compilation command script. + +### Choosing device language + +No further steps are needed, as meeting device language can be automatically. + +### Pre-set the language manually + +If you tend to specify the language manually, please provide the [ISO 639 Language Codes](#code) of the language to `init()` in `TIMUIKitCore.getInstance()`. + +```dart +import 'package:tim_ui_kit/tim_ui_kit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + +final isInitSuccess = await _coreInstance.init( + extraLanguage: "ja", // ISO 639 Language Codes +// ...Other configurations +); +``` + +### Modify language dynamically + +Please just invoking `I18nUtils(null, language);`, while the `language` here should be set as the [ISO 639 Language codes](#code). + +Example code: + +```dart +I18nUtils(null, "en"); +``` + +[](id:code) + +## Appendix: Language codes + +| Language | Code | Language | Code | +|--------|--------|--------|--------| +| Arabic | ar | Bulgarian | bg | +| Croatian | hr | Czech | cs | +| Danish | da | German | de | +| Greek | el | English | en | +| Estonian | et | Spanish | es | +| Finnish | fi | French | fr | +| Irish | ga | Hindi | hi | +| Hungarian | hu | Hebrew | he | +| Italian | it | Japanese | ja | +| Korean | ko | Latvian | lv | +| Lithuanian | lt | Dutch | nl | +| Norwegian | no | Polish | pl | +| Portuguese | pt | Swedish | sv | +| Romanian | ro | Russian | ru | +| Serbian | sr | Slovak | sk | +| Slovenian | sl | Thai | th | +| Turkish | tr | Ukrainian | uk | +| Chinese (Simplified)) | zh-Hans | Chinese (Traditional) | zh-Hant | + +## Contact us[](id:contact) + +If there's anything unclear or you have more ideas, feel free to contact us! + +- [Telegram Group](https://t.me/+1doS9AUBmndhNGNl) +- [WhatsApp Group](https://chat.whatsapp.com/Gfbxk7rQBqc8Rz4pzzP27A) diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..f99fa51 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,8 @@ +## TIMUIKit +TUIKit 是基于 IM SDK 实现的一套 UI 组件,其包含会话、聊天、搜索、关系链、群组、音视频通话等功能,基于 UI 组件您可以像搭积木一样快速搭建起自己的业务逻辑。 + +#### 架构 + +![](https://imgcache.qq.com/operation/dianshi/other/191645543019_.pic.06d8f22e726287c07cf38d362ec40d4deb4799c7.jpg) + +- [快速集成文档](https://git.woa.com/29294-22989-29805-29810/im-flutter-uikit/blob/feature/add-doc/package_src/tencent_cloud_chat_uikit/doc/FAST_INTEGRATED.md) diff --git a/doc/get_start.md b/doc/get_start.md new file mode 100644 index 0000000..753d8cd --- /dev/null +++ b/doc/get_start.md @@ -0,0 +1,606 @@ +[toc] +通过阅读本文,您可以了解集成Flutter SDK的方法。 + +## 环境要求 + +| | 版本 | +|---------|---------| +| Flutter | IM SDK最低要求Flutter 2.2.0版本,TUIKit集成组件库最低要求Flutter 2.8.1版本。| +|Android|Android Studio 3.5及以上版本,App 要求 Android 4.1及以上版本设备。| +|iOS|Xcode 11.0及以上版本,请确保您的项目已设置有效的开发者签名。| + +## 前提条件 + +1. 您已 [注册腾讯云](https://cloud.tencent.com/document/product/378/17985) 帐号,并完成 [实名认证](https://cloud.tencent.com/document/product/378/3629)。 +2. 参照 [创建并升级应用](https://cloud.tencent.com/document/product/269/32577) 创建应用,并记录好`SDKAppID`。 + +[](id:part1) + +## Part1:创建测试用户 + +在[IM控制台](https://console.cloud.tencent.com/im)选择您的应用,在左侧导航栏依次点击 **辅助工具**->**UserSig 生成&校验** ,创建两个 UserID 及其对应的 UserSig,复制`UserID`、`签名(Key)`、`UserSig`这三个,后续登录时会用到。 + +>? 该账户仅限开发测试使用。应用上线前,正确的 `UserSig` 签发方式是由服务器端生成,并提供面向 App 的接口,在需要 `UserSig` 时由App向业务服务器发起请求获取动态 `UserSig`。更多详情请参见 [服务端生成 UserSig](https://cloud.tencent.com/document/product/269/32688#GeneratingdynamicUserSig)。 + +![](https://main.qcloudimg.com/raw/8315da2551bf35ec85ce10fd31fe2f52.png) + +[](id:part2) + +## Part2:选择合适的方案集成Flutter SDK + + +IM提供了三种方式来集成,您可以选择最合适的方案来集成: +| | 适用场景 | +|---------|---------| +| [使用DEMO](#part3) | IM demo是一个完整的聊天app,代码已开源,如果您需要实现聊天类似场景,可以使用demo进行二次开发。 [点此体验demo](https://cloud.tencent.com/document/product/269/36852) | +| [含UI集成](#part4) | IM的UI组件库`TUIKit`提供了通用的 UI 组件,例如会话列表、聊天界面和联系人列表等,开发者可根据实际业务需求通过该组件库快速地搭建自定义 IM 应用。**推荐优先使用该方案** | +| [自实现UI集成](#part5) | 如果TUIKit不能满足您应用的界面需求,或者您需要比较多的定制,可以使用该方案。 | + + +为帮助您更好的理解IM SDK的各API,[我们还提供了API Example](https://github.com/TencentCloud/TIMSDK/tree/master/Flutter/IMSDK/im-flutter-plugin/tencent_im_sdk_plugin/example),演示各API的调用及监听的触发。 + + +[](id:part3) + +## Part3:使用DEMO + +### 跑通DEMO + +1. 下载demo源码、安装依赖 + +```shell +#clone 代码 +git clone https://github.com/TencentCloud/TIMSDK.git + +#进入flutter的demo目录 +cd TIMSDK/Flutter/Demo/im-flutter-uikit + +#安装依赖 +flutter pub get +``` + +2. 运行demo项目: + +```shell +#启动demo项目,请替换SDK_APPID、KEY两个参数 +flutter run --dart-define=SDK_APPID={YOUR_SDKAPPID} --dart-define=ISPRODUCT_ENV=false --dart-define=KEY={YOUR_KEY} +``` + +>? +> +>- `--dart-define=SDK_APPID={YOUR_SDKAPPID}` 其中`{YOUR_SDKAPPID}`需替换成您自己应用的 SDKAppID。 +>- `--dart-define=ISPRODUCT_ENV=false` 对开发生产环境做判断,如您是开发环境请用 false。 +>- `--dart-define=KEY={YOUR_KEY}` 其中`{YOUR_KEY}`需替换成 [Part1:创建测试用户](#part1) 中的`密钥(Key)`信息。 +> + +#### 也可以使用 IDE 运行:(可选步骤) + + +::: Android 平台[](id:android) +1. 在 Android Studio 打开 discuss/andorid 目录。 +![](https://qcloudimg.tencent-cloud.cn/raw/6516f9b17c58915c4ebc93c5c8829831.png) +2. 启动一个 Android 的模拟器,单击 **Build And Run**,Demo 可以运行起来。您可以随机输入一个 UserID(数字字母组合)。 +>?UI 可能会有部分调整更新,请以最新版为准。 +::: +::: iOS 平台[](id:ios) +1. 打开 Xcode,打开文件 discuss/ios/Runner.xcodeproj: +![](https://qcloudimg.tencent-cloud.cn/raw/6d74814ba9bce54c7439e8b3cea53e73.png) +2. 连接 iPhone 真机,单击 **Build And Run**,iOS 工程等待编译完成,会有新窗口弹出 Xcode 工程。 +3. 打开 iOS 工程,设置主 Target 的 Signing & Capabilities(需要苹果开发者帐号),让项目可以在 iPhone 真机上运行。 +4. 启动项目,在真机上进行 Demo 的调试。 +![](https://qcloudimg.tencent-cloud.cn/raw/3fe6bbac88bb21ad7a7822bb297793b3.png) +::: + + +#### DEMO代码结构概览 + +>? 我们DEMO的UI及业务逻辑部分,使用Flutter TUIKit。DEMO层本身仅用于构建APP,处理导航跳转及调用实例化TUIKit中各个组件。 + + +| 文件夹 | 介绍 | +|---------|---------| +| lib | 程序核心目录 | +| lib/i18n | 国际化相关代码。这里的国际化,不包含TUIKit本身的国际化能力和国际化词条,您可按需引入 | +| lib/src | 项目主体目录 | +| lib/src/pages | 本DEMO几个重点导航页。项目初始化完成后,由 `app.dart` 负责展示加载动画,并判断登陆态,将用户引导至 `login.dart` 或 `home_page.dart`。用户登录后,会将登陆信息通过 `shared_preference` 插件,存储至本地。以后每次启动应用,若在本地发现原来的登录信息,则自动使用该信息进行登录,若无或登录失败,则引导至登录页。自动登录过程中,用户还在 `app.dart` ,可看到加载动画。`home_page.dart`含一个底部Tab,支撑本demo的四个主功能页的切换。 | +| lib/utils | 一些工具函数类 | + + +基本上,`lib/src` 内每个dart文件引入了一个TUIKit组件,在文件内,实例化组件后,即可渲染页面。 + +主要文件如下: + + +| lib/src 主要文件 | 文件介绍 | +|---------|---------| +| add_friend.dart | 申请添加好友页面,使用 `TIMUIKitAddFriend` 组件| +| add_group.dart | 申请入群页面,使用 `TIMUIKitAddGroup` 组件| +| blacklist.dart| 黑名单列表页面,使用 `TIMUIKitBlackList` 组件 | +| chat.dart | 主聊天页面,使用全套TUIKit聊天能力,使用 `TIMUIKitChat` 组件 | +| chatv2.dart | 主聊天页面,使用原子化能力,使用 `TIMUIKitChat` 组件 | +| contact.dart | 联系人页面 ,使用 `TIMUIKitContact` 组件| +| conversation.dart | 会话列表界面,使用 `TIMUIKitConversation` 组件 | +| create_group.dart | 发起群聊页面,纯Demo实现,未使用组件 | +| group_application_list.dart | 入群申请列表页面,使用 `TIMUIKitGroupApplicationList` 组件 | +| group_list.dart | 群列表页面,使用 `TIMUIKitGroup` 组件 | +| group_profile.dart | 群资料及群管理页面,使用 `TIMUIKitGroupProfile` 组件 | +| newContact.dart | 联系人好友申请页面,使用 `TIMUIKitNewContact` 组件 | +| routes.dart | Demo的路由,导航至登录页 `login.dart` 或主页面 `home_page.dart`。 | +| search.dart | 全局搜索及会话内搜索页面,使用 `TIMUIKitSearch`(全局搜索) 及 `TIMUIKitSearchMsgDetail`(会话内搜索) 组件 | +| user_profile.dart | 用户信息及关系链维护页面,使用 `TIMUIKitProfile` 组件| + +大部分TUIKit组件需要传入导航跳转方法,因此需要DEMO层处理 `Navigator` 。 + +以上介绍了我们的DEMO,您可以直接直接修改它二次开发,或参照它实现您的业务需求。 + +[](id:part4) + +## Part4:含UI集成,使用TUIKit组件库,半天完成IM能力植入 + +TUIKit 是基于腾讯云 IM SDK 的一款 UI 组件库,它提供了一些通用的 UI 组件,例如会话列表、聊天界面和联系人列表等,开发者可根据实际业务需求通过该组件库快速地搭建自定义 IM 应用。[点此查看TUIKit详细介绍](https://cloud.tencent.com/document/product/269/70746) + +### 前提条件 + +您已经完成创建Flutter项目,或有可以基于的Flutter项目。 + +### 接入步骤 + +#### 安装IM TUIkit + +我们的TUIkit已经内含IM SDK,因此仅需安装`tencent_cloud_chat_uikit`,不需要再安装基础im sdk。 + +```shell +#在命令行执行: +flutter pub add tencent_cloud_chat_uikit +``` + +#### 初始化 + +在您应用启动时,初始化TUIKit。 + +请务必保证先执行 `TIMUIKitCore.getInstance()` ,再调用初始化函数 `init()` ,并将您的[sdkAppID]传入。 + +```dart +/// main.dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); + @override + void initState() { + _coreInstance.init( + sdkAppID: 0, // Replace 0 with the SDKAppID of your IM application when integrating + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, + listener: V2TimSDKListener()); + super.initState(); + } +} +``` + +#### 登录测试账户 + +此时,您可以使用最开始的时候,在控制台生成的测试账户,完成登录验证。 + +调用`_coreInstance.login`方法,登录一个测试账户。 + +```dart +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +_coreInstance.login(userID: userID, userSig: userSig); +``` + +>? 该账户仅限开发测试使用。应用上线前,正确的 `UserSig` 签发方式是由服务器端生成,并提供面向 App 的接口,在需要 `UserSig` 时由App向业务服务器发起请求获取动态 `UserSig`。更多详情请参见 [服务端生成 UserSig](https://cloud.tencent.com/document/product/269/32688#GeneratingdynamicUserSig)。 + +#### 实现:会话列表页面 + +您可以以会话列表作为您的IM功能首页,其涵盖了与所有有聊天记录的用户及群聊的会话。 + +![wecom-temp-320803-502538740c22124e9f3e0efc1d5a10ee](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/wecom-temp-320803-502538740c22124e9f3e0efc1d5a10ee.jpg) + +请创建一个 `Conversation` 类,`body` 中使用 `TIMUIKitConversation` 组件,渲染会话列表。 + +您仅需传入一个 `onTapItem` 事件的处理函数,用于跳转至具体会话聊天页的导航。关于 `Chat` 类,会在下一步讲解。 + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class Conversation extends StatelessWidget { +const Conversation({Key? key}) : super(key: key); +@override +Widget build(BuildContext context) { +return Scaffold( + appBar: AppBar( + title: const Text( + "Message", + style: TextStyle(color: Colors.black), + ), + ), + body: TIMUIKitConversation( + onTapItem: (selectedConv) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Chat( + selectedConversation: selectedConv, + ), + )); + }, + ), +); +} +} +``` + +#### 实现:会话聊天页面 + +该页面由顶部主体聊天历史记录及底部发送消息模块组成。 + +![20220701202206](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/20220701202206.png) + +请创建一个 `Chat` 类,`body` 中使用 `TIMUIKitChat` 组件,渲染聊天页面。 + +您最好传入一个 `onTapAvatar` 事件的处理函数,用于跳转至联系人的详细信息页。关于 `UserProfile` 类,会在下一步讲解。 + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class Chat extends StatelessWidget { +final V2TimConversation selectedConversation; +const Chat({Key? key, required this.selectedConversation}) : super(key: key); +String? _getConvID() { +return selectedConversation.type == 1 + ? selectedConversation.userID + : selectedConversation.groupID; +} +@override +Widget build(BuildContext context) { +return TIMUIKitChat( + conversationID: _getConvID() ?? '', // groupID or UserID + conversationType: selectedConversation.type ?? 1, // Conversation type + conversationShowName: selectedConversation.showName ?? "", // Conversation display name + onTapAvatar: (_) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => UserProfile(userID: userID), + )); + }, // Callback for the clicking of the message sender profile photo. This callback can be used with `TIMUIKitProfile`. +); +} +``` + +#### 实现:用户详情页面 + +该页面默认,可在只传入一个 `userID` 的情况下,自动根据是否是好友,生成用户详情页。 + +请创建一个 `UserProfile` 类,`body` 中使用 `TIMUIKitProfile` 组件,渲染用户详情及关系链页面。 + +>? 如果您希望自定义该页面,请优先考虑使用 `profileWidgetBuilder` 传入需自定义的profile组件并配合 `profileWidgetsOrder` 确定纵向排列顺序;如果无法满足,才可使用 `builder` 。 + +![wecom-temp-215357-bdcdaa6f33a21573e0a2785a8cff72c0](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/wecom-temp-215357-bdcdaa6f33a21573e0a2785a8cff72c0.jpg) + +```dart +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class UserProfile extends StatelessWidget { + final String userID; + const UserProfile({required this.userID, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + "Message", + style: TextStyle(color: Colors.black), + ), + ), + body: TIMUIKitProfile( + userID: widget.userID, + ), + ); + } +} +``` + +此时,您的应用已经可以完成消息收发,管理好友关系,展示用户详情及展示会话列表。 + +#### 更多能力 + +您还可以继续使用以下TUIKit插件快速实现完整IM功能。 + +[TIMUIKitContact](https://intl.cloud.tencent.com/document/product/1047/46297#timuikitcontact): 联系人列表页面。 + +[TIMUIKitGroupProfile](https://intl.cloud.tencent.com/document/product/1047/46297#timuikitgroupprofile): 群资料页面,使用方式与 `TIMUIKitProfile` 基本一致。 + +[TIMUIKitGroup](https://intl.cloud.tencent.com/document/product/1047/46297#timuikitgroup): 群列表界面。 + +[TIMUIKitBlackList](https://intl.cloud.tencent.com/document/product/1047/46297#timuikitblacklist): 黑名单列表界面。 + +[TIMUIKitNewContact](https://intl.cloud.tencent.com/document/product/1047/46297#timuikitnewcontact): 联系人(好友)申请列表。如需在外部显示小红点,可使用 `TIMUIKitUnreadCount` 小红点组件,其会自动挂载监听。 + +[TIMUIKitSearch](https://pub.dev/documentation/tencent_cloud_chat_uikit/latest/ui_views_TIMUIKitSearch_tim_uikit_search/TIMUIKitSearch-class.html): 搜索组件,支持全局搜索联系人/群组/聊天记录,也支持在特定会话中搜索聊天记录。两种模式取决于是否传入 `conversation`。 + +详细UI插件指南[可参考本文档](https://cloud.tencent.com/document/product/269/70747#timuikitcontact)或[插件README](https://pub.dev/packages/tencent_cloud_chat_uikit)。 + +[](id:part5) + +## Part5:自实现UI集成 + +### 前提条件 + +您已经完成创建Flutter项目,或有可以基于的Flutter项目。 + +### 接入步骤 + +#### 安装IM SDK + +[本节详细文档](https://cloud.tencent.com/document/product/269/75286) + +使用如下命令,安装Flutter IM SDK最新版本。 + +在命令行执行: + +```shell +flutter pub add tencent_im_sdk_plugin +``` + +#### 完成SDK初始化 + +[本节详细文档](https://cloud.tencent.com/document/product/269/75293) + +调用`initSDK`,完成SDK初始化。 + +将您的[sdkAppID]传入。 + +```Dart +import 'package:tencent_im_sdk_plugin/enum/V2TimSDKListener.dart'; +import 'package:tencent_im_sdk_plugin/enum/log_level_enum.dart'; +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + TencentImSDKPlugin.v2TIMManager.initSDK( + sdkAppID: 0, // Replace 0 with the SDKAppID of your IM application when integrating + loglevel: LogLevelEnum.V2TIM_LOG_DEBUG, // Log + listener: V2TimSDKListener(), + ); +``` + +在本步骤,你可以针对IM SDK挂载一些监听,主要包括网络状态及用户信息变更等,[详情可参考该文档](https://pub.dev/documentation/tencent_im_sdk_plugin_platform_interface/latest/enum_V2TimSDKListener/V2TimSDKListener-class.html)。 + +#### 登录测试账户 + +[本节详细文档](https://cloud.tencent.com/document/product/269/75296) + +此时,您可以使用最开始的时候,在控制台生成的测试账户,完成登录验证。 + +调用`TencentImSDKPlugin.v2TIMManager.login`方法,登录一个测试账户。 + +当返回值`res.code`为0时,登录成功。 + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + V2TimCallback res = await TencentImSDKPlugin.v2TIMManager.login( + userID: userID, + userSig: userSig, + ); +``` + +>? 该账户仅限开发测试使用。应用上线前,正确的 `UserSig` 签发方式是将 `UserSig` 的计算代码集成到您的服务端,并提供面向 App 的接口,在需要 `UserSig` 时由您的 App 向业务服务器发起请求获取动态 `UserSig`。更多详情请参见 [服务端生成 UserSig](https://cloud.tencent.com/document/product/269/32688#GeneratingdynamicUserSig)。 + +#### 发送消息 + +[本节详细文档](https://cloud.tencent.com/document/product/269/75317) + +此处以发送文本消息举例,其流程为: + +1. 调用 `createTextMessage(String)`创建一个文本消息。 +2. 根据其返回值,拿到消息ID。 +3. 调用 `sendMessage()` 发送该ID的消息。`receiver`可填入您此前创建的另一个测试账户ID。发送单聊消息无需填入`groupID`。 + +代码示例: + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + +V2TimValueCallback createMessage = + await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createTextMessage(text: "The text to create"); + +String id = createMessage.data!.id!; // The message creation ID + +V2TimValueCallback res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .sendMessage( + id: id, // Pass in the message creation ID to + receiver: "The userID of the destination user", + groupID: "The groupID of the destination group", + ); +``` + +> 如果发送失败,可能是由于您的sdkAppID不支持陌生人发送消息,您可至控制台开启,用于测试。 +> +> [请点击此链接](https://console.cloud.tencent.com/im/login-message),关闭[好友关系链检查]。 + +#### 获取会话列表 + +[本节详细文档](https://cloud.tencent.com/document/product/269/75368) + +在上一个步骤中,完成发送测试消息,现在可登录另一个测试账户,拉取会话列表。 + +![wecom-temp-320803-502538740c22124e9f3e0efc1d5a10ee](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/wecom-temp-320803-502538740c22124e9f3e0efc1d5a10ee.jpg) + +获取会话列表的方式有两种: + +1. 监听长连接回调,实时更新会话列表。 +2. 请求API,根据分页一次性获取会话列表。 + +常见应用场景为: + +在启动应用程序后立即获取会话列表,然后监听长连接以实时更新会话列表的变化。 + +##### 一次性请求会话列表 + +为了获取会话列表,需要维护`nextSeq`,记录当前位置。 + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + +String nextSeq = "0"; + +getConversationList() async { + V2TimValueCallback res = await TencentImSDKPlugin + .v2TIMManager + .getConversationManager() + .getConversationList(nextSeq: nextSeq, count: 10); + + nextSeq = res.data?.nextSeq ?? "0"; +} +``` + +此时,你可以看到您在上一步中,使用另一个测试账号,发来消息的会话。 + +##### 监听长链接实时获取会话列表 + +您在此步骤中,需要先在SDK上挂载监听,然后处理回调事件,更新UI。 + +1. 挂载监听。 + +```dart +await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .setConversationListener( + listener: new V2TimConversationListener( + onConversationChanged: (List list){ + _onConversationListChanged(list); + }, + onNewConversation:(List list){ + _onConversationListChanged(list); + }, +``` + +2. 处理回调事件,将最新的会话列表展示在界面上。 + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + +List _conversationList = []; + +_onConversationListChanged(List list) { + for (int element = 0; element < list.length; element++) { + int index = _conversationList.indexWhere( + (item) => item!.conversationID == list[element].conversationID); + if (index > -1) { + _conversationList.setAll(index, [list[element]]); + } else { + _conversationList.add(list[element]); + } + } +``` + +#### 接收消息 + +[本节详细文档](https://cloud.tencent.com/document/product/269/75320) + +通过腾讯云IM Ffltter SDK接收消息有两种方式: + +1. 监听长连接回调,实时获取消息变化,更新渲染历史消息列表。 +2. 请求API,根据分页一次性获取历史消息。 + +常见应用场景为: + +1. 界面进入新的会话后,首先一次性请求一定数量的历史消息,用于展示历史消息列表。 +2. 监听长链接,实时接收新的消息,将其添加进历史消息列表中。 + +##### 一次性请求历史消息列表 + +每页拉取的消息数量不能太大,否则会影响拉取速度。建议此处设置为20左右。 + +您应该动态记录当前页数,用于下一轮请求。 + +示例代码如下: + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + + V2TimValueCallback> res = await TencentImSDKPlugin + .v2TIMManager + .getMessageManager() + .getGroupHistoryMessageList( + groupID: "groupID", + count: 20, + lastMsgID: "", + ); + + List msgList = res.data ?? []; + + // here you can use msgList to render your message list + } +``` + +##### 监听长链接实时获取新消息 + +历史消息列表初始化后,新消息来自长链接 `V2TimAdvancedMsgListener.onRecvNewMessage`。 + +`onRecvNewMessage`回调被触发后,您可以按需将新消息添加进历史消息列表中。 + +绑定监听器示例代码如下: + +```dart +import 'package:tencent_im_sdk_plugin/tencent_im_sdk_plugin.dart'; + +final adVancesMsgListener = V2TimAdvancedMsgListener( +onRecvNewMessage: (V2TimMessage newMsg) { + _onReceiveNewMsg(newMsg); +}, +/// ... other listeners related to message +); + +TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .addAdvancedMsgListener(listener: adVancesMsgListener); +``` + +此时,您已基本完成IM模块开发,可以发送接收消息,也可以进入不同的会话。 + +您可以继续完成[群组](https://cloud.tencent.com/document/product/269/75697),[用户资料](https://cloud.tencent.com/document/product/269/75418),[关系链](https://cloud.tencent.com/document/product/269/75421),[离线推送](https://cloud.tencent.com/document/product/269/75430),[本地搜索](https://cloud.tencent.com/document/product/269/75438)等相关功能开发。 + +详情可查看[自实现UI集成SDK文档](https://cloud.tencent.com/document/product/269/75260)。 + +## 常见问题 + +### 支持哪些平台? + +目前支持 iOS 、Android 和 Web 三个平台,另外 Windows 和 Mac 版正在开发中,敬请期待。 + +### Android 单击 Build And Run 报错找不到可用设备? + +确保设备没被其他资源占用,或单击 **Build** 生成 APK 包,再拖动进模拟器里运行。 + +### iOS 第一次运行报错? + +配置运行后,如果报错,可以单击 **Product** > **Clean**,清除产物后重新 Build,或者关闭 Xcode,重新打开后再次 Build。 + +### Flutter 环境问题 + +如您需得知 Flutter 的环境是否存在问题,请运行 Flutter doctor 检测 Flutter 环境是否装好。 + +### 使用Flutter自动生成的项目,引入TUIKit后报错 + +![20220706132722](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/20220706132722.png) + +需要在\android\app\src\main\AndroidManifest.xml中进行修改。 + +打开 `\android\app\src\main\AndroidManifest.xml`,根据下图,补全。 + +![20220706133714](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/20220706133714.png) + +打开 `\android\app\build.gradle`,根据下图,补全 `defaultConfig`。 + +![20220706133740](https://tuikit-1251787278.cos.ap-guangzhou.myqcloud.com/20220706133740.png) + +## 联系我们 + +如果您在接入使用过程中有任何疑问,请加入QQ群:788910197 咨询。 diff --git a/doc/theme.md b/doc/theme.md new file mode 100644 index 0000000..086547c --- /dev/null +++ b/doc/theme.md @@ -0,0 +1,101 @@ +# 主题方案 +## 1 介绍 +TUIKit 自定义了 **TUITheme** 类,用于规范TUIKit内的色彩使用。 + +请开发时务必注意,目前除 Colors.white 和 Colors.black 等底色/前景色外一律使用theme里提供的颜色。没有对应颜色可提出加到 TUITheme 里。 + +颜色概览: + + // 应用主色 + // Primary Color For The App + final Color? primaryColor; + + // 应用次色 + // Secondary Color For The App + final Color? secondaryColor; + + // 提示颜色,用于次级操作或提示 + // Info Color, Used For Secondary Action Or Info + final Color? infoColor; + + // 浅背景颜色,比主背景颜色浅,用于填充缝隙或阴影 + // Weak Background Color, Lighter Than Main Background, Used For Marginal Space Or Shadowy Space + final Color? weakBackgroundColor; + + // 浅分割线颜色,用于分割线或边框 + // Weak Divider Color, Used For Divider Or Border + final Color? weakDividerColor; + + // 浅字色 + // Weak Text Color + final Color? weakTextColor; + + // 深字色 + // Dark Text Color + final Color? darkTextColor; + + // 浅主色,用于AppBar或Panels + // Light Primary Color, Used For AppBar Or Several Panels + final Color? lightPrimaryColor; + + // 字色 + // TextColor + final Color? textColor; + + // 警示色,用于危险操作 + // Caution Color, Used For Warning Actions + final Color? cautionColor; + + // 群主标识色 + // Group Owner Identification Color + final Color? ownerColor; + + // 群管理员标识色 + // Group Admin Identification Color + final Color? adminColor; + + //除各种固定颜色外提供2种MaterialColor + `primaryMaterialColor` && `lightPrimaryMaterialColor`。 + 提供由 primaryColor 和 lightPrimaryColor 生成的十级色阶(50 ~ 900),eg: `primaryMaterialColor.shade50` `primaryMaterialColor.shade900` + +## 2 使用方式 +### 2.1 开发中 +#### 2.1.1 Demo + +通过 provider 里的 **DefaultThemeData.theme** 来获取theme。 + +通过 provider 里的 **DefaultThemeData.currentThemeType** 来获取/设置当前ThemeType。 + +设置 **DefaultThemeData.currentThemeType** 会将currentThemeType 写入localStorage 并同步 TUIKit 的 Theme。 + +当前支持四种ThemeType: +`enum ThemeType { solemn, brisk, bright, fantasy }` + +#### 2.1.2 UIKit + +通过全局唯一的 `serviceLocator()` 获取当前 theme。 + +#### 2.1.3 AppBar + +目前 **AppBar** 的统一处理如下: + +字色目前统一为 Colors.white + +`flexibleSpace: Container( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [ + theme.lightPrimaryColor ?? CommonColor.lightPrimaryColor, + theme.primaryColor ?? CommonColor.primaryColor + ]), + ), + ),` + +`IconThemeData( + color: Colors.white, + ),` + + +## 3 TODO +3.1 目前只支持暗色底白字,需要提供亮/暗两种主题间的切换。 + +3.2 自动识别亮/暗主题并更改字色。 \ No newline at end of file diff --git a/doc/混合开发.md b/doc/混合开发.md new file mode 100644 index 0000000..599e798 --- /dev/null +++ b/doc/混合开发.md @@ -0,0 +1,938 @@ +[](id:toc) +通过阅读本文,你可以了解在您现有的 Android / iOS 原生开发项目中,集成腾讯云IM Flutter 的方法。 + +有的时候,使用Flutter重写您现有的应用程序是不现实的。如果您想在现有APP中,使用腾讯云IM的能力,推荐采用混合开发方案,即将Flutter模块,嵌入您的原生开发APP项目中。 + +**可在很大程度上,降低您的工作量,快速在双端原生APP中,植入IM通信能力。** + +![](https://qcloudimg.tencent-cloud.cn/raw/54adc2b0587f9f30d56e96eb6461b969.png) + +## 环境要求 + +| 环境 | 版本 | +|---------|---------| +| Flutter | SDK 最低要求 Flutter 2.2.0版本,TUIKit 集成组件库最低要求 Flutter 2.10.0 版本。| +|Android|Android Studio 3.5及以上版本,App 要求 Android 4.1及以上版本设备。| +|iOS|Xcode 11.0及以上版本,请确保您的项目已设置有效的开发者签名。| +|腾讯云IM SDK|[tencent_im_sdk_plugin](https://pub.dev/packages/tencent_im_sdk_plugin) 5.0 及以上版本, [tencent_cloud_chat_uikit](https://pub.dev/packages/tencent_cloud_chat_uikit) 0.2 及以上版本。| + +## 快速了解 + +
+ +>? +> +> 对于以上的Demo项目,源代码可在我们的[GitHub仓库](https://github.com/TencentCloud/tencentchat-add-flutter-to-app)中找到,欢迎查阅。 + +## 前置知识点 + +开始之前,您需要了解腾讯云IM Flutter SDK及TUIKit的用法;及Flutter-原生混合开发原理。 + +### 腾讯云IM + +#### 总体入门 + +在开始前,您首先需要了解腾讯云IM Flutter的SDK构成及使用方式。 + +主要包括两个SDK:[无UI版本](https://cloud.tencent.com/document/product/269/68823#.E7.AC.AC.E4.BA.94.E9.83.A8.E5.88.86.EF.BC.9A.E8.87.AA.E5.AE.9E.E7.8E.B0-ui-.E9.9B.86.E6.88.90)及[含UI组件库](https://cloud.tencent.com/document/product/269/70747)。本文将以 [含UI组件库(TUIKit)](https://cloud.tencent.com/document/product/269/70747) 为例,介绍混合开发方案。 + +**关于腾讯云IM Flutter详细用法,可从我们的 [快速入门文档](https://cloud.tencent.com/document/product/269/68823) 看起。** + +[](id:modules) + +#### 两个模块 + +腾讯云IM主要有两个部分,包括 Chat聊天模块 和 Call通话模块。 + +Chat聊天模块主要包括消息收发、会话管理、用户关系管理等。 + +Call通话模块主要包括音视频通话,包括一对一通话和群组多人通话。 + +### Flutter 混合开发 + +核心原理是,将 module 形式的Flutter项目,打包成Native端的可执行程序,嵌入Native项目中。因Flutter module可以通用,因此仅需编写一次Flutter module,即可嵌入 Android/iOS APP 中。 + +当您现有应用需要展示腾讯云IM相关页面时,可加载对应用于承载Flutter的Activity(Android)或ViewController(iOS)。 + +当需要两端通信时,如传递当前用户信息,传递音视频通话数据,触发离线推送数据,可采用[Method Channel](https://docs.flutter.dev/development/platform-integration/platform-channels#channels-and-platform-threading)方式进行。触发另一端的方法使用 `invokeMethod`,监听另一端发来的方法调用使用[预挂载的Method Channel监听器](https://docs.flutter.dev/development/platform-integration/platform-channels#executing-channel-handlers-on-background-threads)。 + +[](id:android) + +#### 将 Flutter 模块添加至 Android 项目中 + +[详细学习](https://docs.flutter.dev/development/add-to-app/android/project-setup) + +将Flutter module添加为Gradle中现有应用程序的依赖项。有两种方式可以实现这一点。 + +##### Android方式一:依赖 Android Archive (AAR) + +AAR机制创建通用的Android AAR作为打包Flutter module的中介。如果您经常构建,它会增加一个构建步骤。 + +该选项将Flutter库打包为由AAR和POMS构件组成的通用本地Maven存储库。此选项允许您的团队在不安装Flutter SDK的情况下构建主机应用程序。然后,您可以从本地或远程存储库中分发构件。 + +因此,建议在线上生产环境,使用本方案。 + +**具体步骤:** + +在您的Flutter module中,运行: + +```shell +flutter build aar +``` + +然后,按照屏幕上的说明进行集成。 + +![](https://qcloudimg.tencent-cloud.cn/raw/32e9376de02da10e97a8c54b9ab2b51c.png) + +您的应用程序现在将Flutter模块作为依赖项包括在内。 + +##### Android方式二:依赖Flutter module源代码 + +源代码子项目机制是一个方便的一键构建过程,但需要Flutter SDK。这是Android Studio IDE插件使用的机制。 + +此方式可为您的Android项目和Flutter项目实现一步构建。当您同时处理两个部分并快速迭代时,此选项很方便,但您的团队必须安装Flutter SDK才能构建应用程序。 + +因此,建议在开发测试环境,使用本方案。 + +**具体步骤:** + +将Flutter module作为一个子项目,添加至宿主APP的 `settings.gradle` 中: + +```gradle +// Include the host app project. +include ':app' // assumed existing content +setBinding(new Binding([gradle: this])) // new +evaluate(new File( // new + settingsDir.parentFile, // new + 'tencent_chat_module/.android/include_flutter.groovy' // new +)) // new +``` + +在您应用中的 `app/build.gradle => dependencies` 中引入对Flutter module的 `implementation`: + +```gradle +dependencies { + implementation project(':flutter') +} +``` + +您的应用程序现在将Flutter模块作为依赖项包括在内。 + +[](id:ios) + +#### 将 Flutter 模块添加至 iOS 项目中 + +[详细学习](https://docs.flutter.dev/development/add-to-app/ios/project-setup#embed-the-flutter-module-in-your-existing-application) + +有两种方法可以在现有应用程序中嵌入Flutter。 + +##### iOS方式一:嵌入 CocoaPods 和 Flutter SDK 集成 + +使用CocoaPods依赖项管理器并安装Flutter SDK。这种方法要求每个从事项目工作的开发人员都有一个本地安装的Flutter SDK版本。 + +只需在Xcode中构建您的应用程序,即可自动运行脚本来嵌入您的DART和插件代码。这允许快速迭代最新版本的颤振模块,而无需在Xcode之外运行其他命令。 + +因此,建议在开发测试环境,使用本方案。 + +**具体步骤:** + +将以下代码添加到Podfile中: + +``` +// 上一步构建的Flutter Module的路径 +flutter_chat_application_path = '../tencent_chat_module' + +load File.join(flutter_chat_application_path, '.ios', 'Flutter', 'podhelper.rb') +``` + +对于每个需要嵌入Flutter的[Podfile target](https://guides.cocoapods.org/syntax/podfile.html#target),调用 `install_all_flutter_pods(flutter_chat_application_path)`. + +``` +target 'MyApp' do + install_all_flutter_pods(flutter_chat_application_path) +end +``` + +在Podfile的 `post_install` 块中,调用 `flutter_post_install(installer)`,并完成 [腾讯云IM TUIKit](https://cloud.tencent.com/document/product/269/70747) 所需的权限声明,包括麦克风权限/相机权限/相册权限。 + +``` +post_install do |installer| + flutter_post_install(installer) if defined?(flutter_post_install) + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + 'PERMISSION_CAMERA=1', + 'PERMISSION_PHOTOS=1', + ] + end + end +end +``` + +执行 `pod install`。 +> ? +> +> - 在 `tencent_chat_module/pubspec.yaml` 中更改Flutter插件依赖时,请在Flutter Module目录中运行 `flutter pub get` 以刷新 `podhelper.rb` 脚本读取的插件列表。然后,从您iOS应用程序的根目录,再次执行 `pod install`。 +> - 对于 Apple Silicon 芯片 arm64 架构的 Mac电脑,可能需要执行 `arch -x86_64 pod install --repo-update`。 + +`podhelper.rb` 脚本将您的插件 / `Flutter.framework` / `App.framework` 植入您的项目中。 + +##### iOS方式二:在Xcode中嵌入frameworks + +为Flutter引擎、已编译的DART代码和所有Flutter插件创建框架。手动嵌入框架,并在Xcode中更新现有应用程序的构建设置。 + +通过手动编辑现有的Xcode项目,您可以生成必要的framework并将它们嵌入到应用程序中。如果您的团队成员无法在本地安装Flutter SDK和CocoaPods,或者如果您不想在现有应用程序中使用CocoaPods作为依赖项管理器,则可以这样做。每次你在你的颤动模块中修改代码时,你都必须运行 `flutter build ios-framework`. + +因此,建议在线上环境,使用本方案。 + +**具体步骤:** + +在您的Flutter module中,运行如下代码。 + +下面的示例,假设您想要将framework生成到 `some/path/MyApp/Flutter/`. + +```shell +flutter build ios-framework --output=some/path/MyApp/Flutter/ +``` + +在 Xcode 中将生成的 frameworks 集成到你的既有应用中。例如,你可以在 `some/path/MyApp/Flutter/Release/` 目录拖拽 frameworks 到你的应用 target 编译设置的 General > Frameworks, Libraries, and Embedded Content 下,然后在 Embed 下拉列表中选择 “Embed & Sign”。 + + +## 混合开发选型 + +我们推荐您使用Flutter Module方式进行混合开发集成。 + +在Native原生项目中,构建Flutter引擎,来承载Flutter中的Chat及Call模块。有关两个模块的介绍,[请看此处](#modules)。 + +对于Flutter引擎的创建管理,目前两种方式:单Flutter引擎及多Flutter引擎。 + +| 引擎模式 | 介绍 | 优点 | 缺点 | Demo源码下载 | +|---------|---------|---------|---------|---------| +| [Flutter单引擎](#single) | Chat模块和Call模块在同一个Flutter引擎中承载。 | 方便,所有Flutter代码统一维护。 | 由于Call插件,在有电话呼入时,需要自动展示来电页面。如果在同一个引擎中,需要强制跳转至Flutter所在页面,体验较差。 | [点击下载](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Single%20Flutter%20Engines) | +| [Flutter多引擎](#multiple) | Chat模块和Call模块分别承载于不同的Flutter引擎中,使用Flutter引擎组来统一管理这两个引擎。 | Call插件独立存在于一个Flutter引擎中,独立页面控制,来电时,直接将该页面弹窗即可,不影响用户当前所在页面,体验较好。 | 通话模块无法最小化成浮窗形式。 | [点击下载](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Multiple%20Flutter%20Engines) | + +此外,我们还提供,将腾讯云 IM Native SDK 与 Flutter SDK 结合使用的方案,[适用场景和步骤介绍可查看这里](#native)。[Demo源码下载](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Initialize%20from%20Native)。 + +[](id:multiple) + +## 方案一:Flutter 多引擎方案【推荐】 + +本方案中,Chat 和 Call 模块分别独立于不同的Flutter引擎。 + +使用多个Flutter引擎的优点是,每个实例都是独立的,并维护其自己的内部导航堆栈、UI和应用程序状态。这简化了整个应用程序代码的状态保持责任,并提高了模块化能力。 + +![](https://qcloudimg.tencent-cloud.cn/raw/912d986a5ff57606422455a273a033f3.png) + +在Android和iOS上添加多个Flutter引擎,主要基于一个FlutterEngineGroup类(Android API、iOS API)来构造并管理多个FlutterEngine(Flutter引擎)。 + +在我们的项目中,我们基于一个统一的FlutterEngineGroup,来管理两个FlutterEngine(Flutter引擎),分别用于承载 Chat 和 Calling 模块。 + +[![](https://qcloudimg.tencent-cloud.cn/raw/b622951f776a505e83f843de1f62fffc.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Multiple%20Flutter%20Engines) + +### Flutter Module 开发 + +要将Flutter嵌入到现有应用程序中,请首先创建一个Flutter模块。 + +在您项目的根目录外层,运行 + +``` +cd some/path/ +flutter create --template module tencent_chat_module +``` + +这会在 some/path/tencent_chat_module/ 创建一个 Flutter 模块项目。 在该目录中,您可以运行与在任何其他 Flutter 项目中相同的 Flutter 命令,例如 `flutter run --debug` 或 `flutter build ios`。 您还可以使用 Flutter 和 Dart 插件在 Android Studio, IntelliJ 或 VS Code 中运行该模块。 该项目在嵌入到现有应用程序之前包含模块的单视图示例版本,这对于测试代码的仅 Flutter 部分很有用。 + +`tencent_chat_module` 模块目录结构类似于普通的 Flutter 应用程序: + +``` +tencent_chat_module/ +├── .ios/ +│ ├── Runner.xcworkspace +│ └── Flutter/podhelper.rb +├── lib/ +│ └── main.dart +├── test/ +└── pubspec.yaml +``` + +现在,我们可以在 `lib/` 中,编写代码了。 + +#### 梳理Flutter lib 目录 + +>? +> +> 以下代码结构,仅供参考,您可根据需要灵活组织,以引入腾讯云IM Flutter。 + +在 `lib/` 我们创建三个目录,`call`, `chat`, `common`。分别用于放置通话引擎,IM引擎,及通用model类。 + +``` +tencent_chat_module/ +├── lib/ +│ └── call/ +│ └── chat/ +│ └── common/ +``` + +#### 通用model类模块 + +新建 `common/common_model.dart` 文件,如下所示,新建两个class,用于定义Flutter与原生应用通信规范。 + +```dart +class ChatInfo { + String? sdkappid; + String? userSig; + String? userID; + + ChatInfo.fromJSON(Map json) { + sdkappid = json["sdkappid"].toString(); + userSig = json["userSig"].toString(); + userID = json["userID"].toString(); + } + + Map toMap(){ + final Map map = {}; + if(sdkappid != null){ + map["sdkappid"] = sdkappid!; + } + if(userSig != null){ + map["userSig"] = userSig!; + } + if(userID != null){ + map["userID"] = userID!; + } + return map; + } +} + +class CallInfo{ + String? userID; + String? groupID; + + CallInfo(); + + CallInfo.fromJSON(Map json) { + groupID = json["groupID"].toString(); + userID = json["userID"].toString(); + } + + Map toMap(){ + final Map map = {}; + if(userID != null){ + map["userID"] = userID!; + } + if(groupID != null){ + map["groupID"] = groupID!; + } + return map; + } +} +``` + +#### Chat 模块 + +**首先编写IM引擎。本模块所有代码及文件,均在 `lib/chat` 目录下。** + +1. 新建全局状态管理Model,名为 `model.dart`。 + 该Model用于挂载初始化并管理腾讯云IM Flutter模块,离线推送能力,全局状态管理,维护与Native间通信。 + 是整个Chat模块的核心。 + 详细代码可查看Demo源码。重点关注三个部分: + - Future _handleMessage(MethodCall call): 动态监听 Native 透传来的事件,包括登录信息及点击推送事件。 + - Future handleClickNotification(Map msg): 点击通知处理事件,来自Native透传,从 Map 中取出数据,跳转至对应的子模块,如某个具体会话。 + - Future initChat(): 初始化腾讯云IM/登录腾讯云IM/并完成离线推送的初始化及Token上报。该方法使用线程锁机制,保证同时只能执行一个,并在初始化成功后,不重复执行。 + +> ? +> +> 请根据 [离线推送接入指引](https://cloud.tencent.com/document/product/269/74605),完成厂商离线推送功能接入,才可正常上报推送Token,使用推送功能。 + +2. 新建 `chat_main.dart` 文件,用于Chat模块主入口。 + - 该页面也是Flutter Chat模块的首页。 + - 在Demo中,该页面在未登录前为加载状态,登录后展示会话列表。 + - 此外,还需要在这里,完成 `didChangeAppLifecycleState` 监听与前后台切换事件上报,详情请查看[离线推送插件文档步骤5](https://cloud.tencent.com/document/product/269/74605#.E6.AD.A5.E9.AA.A45.EF.BC.9A.E5.89.8D.E5.90.8E.E5.8F.B0.E5.88.87.E6.8D.A2.E7.9B.91.E5.90.AC.3Ca-id.3D.22step_5.22.3E.3C.2Fa.3E)。 + - 详细代码可查看Demo源码。 + +3. 新建 `push.dart` 文件,用于单例管理 [离线推送插件](https://cloud.tencent.com/document/product/269/74605) 能力。用于获取并上报Token/获取推送权限等操作。详细代码可查看Demo源码。 + +4. 新建 `conversation.dart` 文件,用于承载TUIKit的会话模块组件 `TIMUIKitConversation`。详细代码可查看Demo源码。 + +5. 新建 `chat.dart` 文件,用于承载TUIKit的历史消息列表和发送消息模块组件 `TIMUIKitChat`。 + 该页面还有跳转至 Profile 及 Group Profile 页面的能力。 + 详细代码可查看Demo源码。 + +6. 新建 `user_profile.dart` 文件,用于承载TUIKit的用户信息及关系链管理模块组件 `TIMUIKitProfile`。详细代码可查看Demo源码。 + +7. 新建 `group_profile.dart` 文件,用于承载TUIKit的群信息及群管理模块组件 `TIMUIKitGroupProfile`。详细代码可查看Demo源码。 + +此时,Chat模块已开发完成。最终结构如下: + +``` +tencent_chat_module/ +├── lib/ +│ └── call/ +│ └── chat.dart +│ └── model.dart +│ └── chat_main.dart +│ └── push.dart +│ └── conversation.dart +│ └── user_profile.dart +│ └── group_profile.dart +│ └── chat/ +│ └── common/ +``` + +#### Call 模块 + +该模块用于承载音视频通话能力,该能力由 [音视频通话插件](https://cloud.tencent.com/document/product/269/72485) 提供。 + +该模块的核心是,监听收到新的通话邀请时,通过调用Native方法,自动弹出通话页面;并接受 Chat 模块经由Native转发来的通话请求,主动发起通话。 + +**首先编写IM引擎。本模块所有代码及文件,均在 `lib/call` 目录下。** + +1. 新建全局状态管理Model,名为 `model.dart`。 + 该Model用于挂载初始化并管理 [音视频通话插件](https://cloud.tencent.com/document/product/269/72485),全局状态管理,维护与Native间通信。 + 是整个Call模块的核心。 + 详细代码可查看Demo源码。重点关注两个部分: + - _onRtcListener = TUICallingListener(...): 定义了通话事件的监听器,通过 Method Channel 通知Native层,动态控制 Call 模块所属的 ViewController(iOS)/Activity(Android) 的前端展示与否。 + - Future _handleMessage(MethodCall call): 动态监听 Native 透传来的主动发起通话请求,来自 Call 模块的调用,主动发起通话。 + +2. 新建 `call_main.dart` 文件,用于Call模块主入口。 + 该组件用于注入[音视频通话插件所需绑定的navigatorKey](https://cloud.tencent.com/document/product/269/72485#.E6.AD.A5.E9.AA.A41.EF.BC.9A.E5.BC.95.E5.85.A5-navigatorkey)。 + 详细代码可查看Demo源码。 + + +#### 配置各个Flutter引擎的入口 + +开发完上述三个模块后,现在可完成最终对外暴露的main方法,作为Flutter引擎的入口。 + +1. 默认入口 + +打开 `lib/main.dart` 文件,将 `main()` 方法改成一个空 MaterialApp 即可。 + +该方法作为 Flutter Module 的默认入口,在Flutter多引擎,使用FlutterEngineGroup管理的背景下,如果没有子Flutter Engine不设置任何entry point,这个方法就不会被用到。 + +例如,在我们的场景中,这个默认 `main()` 方法就没有被用上。 + +```dart +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + runApp(MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: Container(), + )); +} +``` + +2. 配置 Chat 模块的入口 + +使用 `@pragma('vm:entry-point')` 注解,将该方法标记为一个 `entry-point` 入口。方法名 `chatMain` 即该入口的名称,在Native中,也使用该名称,创建对应Flutter引擎。 + +使用全局 `ChangeNotifierProvider` 状态管理,维护 `ChatInfoModel` 数据及业务逻辑。 + +```dart +@pragma('vm:entry-point') +void chatMain() { + // This call ensures the Flutter binding has been set up before creating the + // MethodChannel-based model. + WidgetsFlutterBinding.ensureInitialized(); + + final model = ChatInfoModel(); + + runApp( + ChangeNotifierProvider.value( + value: model, + child: const ChatAPP(), + ), + ); +} +``` + +3. 配置 Call 模块的入口 + +同理,该入口命名为 `callMain`。 + +使用全局 `ChangeNotifierProvider` 状态管理,维护 `CallInfoModel` 数据及业务逻辑。 + +```dart +@pragma('vm:entry-point') +void callMain() { + // This call ensures the Flutter binding has been set up before creating the + // MethodChannel-based model. + WidgetsFlutterBinding.ensureInitialized(); + + final model = CallInfoModel(); + + runApp( + ChangeNotifierProvider.value( + value: model, + child: const CallAPP(), + ), + ); +} +``` + +至此,Flutter Module部分,Dart代码编写完成。 + +接下来,开始编写 Native 代码。 + +### iOS Native 开发 + +本文以 Swift 语言为例。 + +>? +> +> 以下代码结构,仅供参考,您可根据需要灵活组织。 + +进入您的iOS项目目录。 + +如果您现有的应用程序,假设叫做 `MyApp`, 还没有Podfile,请按照[CocoaPods入门指南](https://guides.cocoapods.org/using/using-cocoapods.html)将 `Podfile` 添加到项目中。 + +#### 引入 Flutter Module + +请参考[此部分](#ios),将Flutter module引入您的原生应用程序中。建议采用方式一。 + +#### 在 iOS 项目中,管理Flutter引擎 + +![](https://qcloudimg.tencent-cloud.cn/raw/e906c61c593195ca2310d08c1ac4a1f4.png) + +**创建一个 `FlutterEngineGroup` (Flutter 引擎组),统一管理多个引擎实例。** + +在 `AppDelegate.swift` 文件中,添加如下代码: + +```swift +@UIApplicationMain +class AppDelegate: FlutterAppDelegate { + lazy var flutterEngines = FlutterEngineGroup(name: "chat.flutter.tencent", project: nil) + ... +} +``` + +**创建一个用于管理Flutter引擎的单例对象。** + +这个 Swift 单例对象,用于集中管理 Flutter 实例,并方便在项目中各处,直接调用。 + +Demo代码的逻辑是,使用新的路由,承载Chat的ViewController;Call的ViewController,通过present和dismiss动态弹窗维护。 + +新建 `FlutterUtils.swift` 文件,编写代码。本部分详细代码,可查看Demo源码。 + +重点关注: +- private override init(): 初始化各 Flutter 引擎实例,注册Method Channel,监听事件。 +- func reportChatInfo(): 将用户登录信息和SDKAPPID透传至Flutter Module,使Flutter层得以初始化并登录腾讯云IM。 +- func launchCallFunc(): 用于拉起Call的Flutter页面,可被Call模块收到通话邀请触发,也可被Chat模块主动发起通话触发。 +- func triggerNotification(msg: String): 将 iOS Native 层收到的离线推送消息点击事件,及其包含的ext信息,以 JSON String形式,透传至 Flutter 层绑定的监听处理事件。用于处理离线推送点击跳转,例如至对应会话。 + +**监听及转发离线推送点击事件** + +离线推送的初始化/Token上报/点击事件对应的会话跳转处理,已在Flutter Chat模块中进行,因此,Native区域,仅需透传点击通知事件的ext即可。 + +之所以这么做,是因为点击通知事件已在Native被拦截消费,Flutter层无法直接拿到,必须经由Native转发。 + +在 `AppDelegate.swift` 文件中,新增如下代码。具体代码,可以参考Demo源码。 + +![](https://qcloudimg.tencent-cloud.cn/raw/9c816ae4745e5a8d2b9b1d64167e1fc5.png) + +此时,iOS Native层编写完成。 + +### Android Native 开发 + +本文以 Kotlin 语言为例。 + +>? +> +> 以下代码结构,仅供参考,您可根据需要灵活组织。 + +#### 引入 Flutter Module + +请参考[此部分](#android),将Flutter module引入您的原生应用程序中。建议采用方式二。 + +#### 在 Android 项目中,管理Flutter引擎 + +**创建一个用于管理Flutter引擎的单例对象。** + +这个 Kotlin 单例对象,用于集中管理 Flutter 实例,并方便在项目中各处,直接调用。 + +新建 `FlutterUtils.kt` 文件,并定义 `FlutterUtils` 静态类。 + +```kotlin +@SuppressLint("StaticFieldLeak") +object FlutterUtils {} +``` + +**创建 `FlutterEngineGroup` (Flutter 引擎组),统一管理多个引擎实例。** + +在 `FlutterUtils.kt` 文件中,定义一个 `FlutterEngineGroup`,及配套各个Flutter Engine实例和Method Channel,并在初始化时,将其初始化。 + +```kotlin +lateinit var context : Context +lateinit var flutterEngines: FlutterEngineGroup +private lateinit var chatFlutterEngine:FlutterEngine +private lateinit var callFlutterEngine:FlutterEngine + +lateinit var chatMethodChannel: MethodChannel +lateinit var callMethodChannel: MethodChannel + +// 初始化 +flutterEngines = FlutterEngineGroup(context) +... +``` + +**继续完成该用于管理Flutter引擎的单例对象。** + +Demo代码的逻辑是,使用新的路由,承载Chat和Call的Activity。 + +Chat的Activity,由用户主动进入及退出;Call的Activity,由监听器或主动外呼,自动导航进及返回出。 + +重点关注: +- fun init(): 初始化各 Flutter 引擎实例,注册Method Channel,监听事件。 +- fun reportChatInfo(): 将用户登录信息和SDKAPPID透传至Flutter Module,使Flutter层得以初始化并登录腾讯云IM。 +- fun launchCallFunc(): 用于拉起Call的Flutter页面,可被Call模块收到通话邀请触发,也可被Chat模块主动发起通话触发。 +- fun triggerNotification(msg: String): 将 iOS Native 层收到的离线推送消息点击事件,及其包含的ext信息,以 JSON String形式,透传至 Flutter 层绑定的监听处理事件。用于处理离线推送点击跳转,例如至对应会话。 + +本单例 object 的详细代码,可以参考Demo源码。 + +**在 总入口 `MyApplication` 中,初始化上述对象** + +在 `MyApplication.kt` 文件中,将全局context传入单例对象,并执行初始化。 + +```kotlin +class MyApplication : MultiDexApplication() { + + override fun onCreate() { + super.onCreate() + FlutterUtils.context = this // new + FlutterUtils.init() // new + } +} +``` + +**监听及转发离线推送点击事件** + +离线推送的初始化/Token上报/点击事件对应的会话跳转处理,已在Flutter Chat模块中进行,因此,Native区域,仅需透传点击通知事件的ext即可。 + +之所以这么做,是因为点击通知事件已在Native被拦截消费,Flutter层无法直接拿到,必须经由Native转发。 + +> 由于不同厂商的离线推送接入步骤不一致,本文以OPPO为例,全部厂商接入方案,可查看[本文档](https://cloud.tencent.com/document/product/269/75428). + +在腾讯云IM控制台中,新增OPPO的推送证书,`点击后续动作` 选择 `打开应用内指定页面`,`应用内页面` 以 `Activity` 方式,配置一个用于处理离线推送信息的页面,建议为应用首页。如,我们的Demo配置为:`com.tencent.chat.android.MainActivity`. + +![](https://qcloudimg.tencent-cloud.cn/raw/c0f8737ce6fa484479ffc9a1bec6c9c0.png) + +在上方控制台配置的用于离线推送的Activity文件中,新增如下代码。 + +该代码的作用是,当厂商拉起相应Activity时,从Bundle中取出HashMap形式ext信息,触发单例对象中的方法,将这个信息,手动转发至Flutter中。具体代码,可以参考Demo源码。 + +![](https://qcloudimg.tencent-cloud.cn/raw/2ec45c1a8b3bd952bcb86a8095f91515.png) + +此时,Android Native层编写完成。 + + +[](id:single) + +## 方案二:Flutter 单引擎方案 + +本方案,将Chat模块和Call模块,写在同一个Flutter引擎实例中。 + +![](https://qcloudimg.tencent-cloud.cn/raw/c6c52a028f5b86c88babe3074805b295.png) + +这两个模块只能同时出现同时隐藏,仅需维护一个Flutter引擎即可。 + +[![](https://qcloudimg.tencent-cloud.cn/raw/b622951f776a505e83f843de1f62fffc.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Single%20Flutter%20Engines) + +### Flutter Module 开发 + +要将Flutter嵌入到现有应用程序中,请首先创建一个Flutter模块。 + +在您项目的根目录外层,运行 + +``` +cd some/path/ +flutter create --template module tencent_chat_module +``` + +这会在 some/path/tencent_chat_module/ 创建一个 Flutter 模块项目。 在该目录中,您可以运行与在任何其他 Flutter 项目中相同的 Flutter 命令,例如 `flutter run --debug` 或 `flutter build ios`。 您还可以使用 Flutter 和 Dart 插件在 Android Studio, IntelliJ 或 VS Code 中运行该模块。 该项目在嵌入到现有应用程序之前包含模块的单视图示例版本,这对于测试代码的仅 Flutter 部分很有用。 + +`tencent_chat_module` 模块目录结构类似于普通的 Flutter 应用程序: + +``` +tencent_chat_module/ +├── .ios/ +│ ├── Runner.xcworkspace +│ └── Flutter/podhelper.rb +├── lib/ +│ └── main.dart +├── test/ +└── pubspec.yaml +``` + +现在,我们可以在 `lib/` 中,编写代码了。 + +#### main.dart + +修改 `main.dart` 文件,引入[TUIKit](https://cloud.tencent.com/document/product/269/70747), [离线推送插件](https://cloud.tencent.com/document/product/269/74605)及[音视频通话插件](https://cloud.tencent.com/document/product/269/72485)。 + +全局状态,我们的IM SDK及Method Channel与Native通信状态,管理于 `ChatInfoModel` 中。 + +接收到Native传来的用户信息及SDKAPPID后,调用 `_coreInstance.init()` 及 `_coreInstance.login() ` 初始化并登录腾讯云IM。并初始化音视频推送插件及离线推送插件,完成推送Token上报。 + +> ? +> +> 请根据 [离线推送接入指引](https://cloud.tencent.com/document/product/269/74605),完成厂商离线推送功能接入,才可正常上报推送Token,使用推送功能。 + +对于音视频通话插件,需要关注: +- 监听收到新的通话邀请时,通过调用Native方法,让Native检测用户当前是否在本Flutter模块页面,如果不在,需要强制将前端页面调整至本模块,以展示来电页面。 + +对于离线推送插件,需要关注: +- 点击通知处理事件,来自Native透传,从 Map 中取出数据,跳转至对应的子模块,如某个具体会话。 + +完成首页的制作,在未登录时展示加载动画;登录成功后,展示会话列表页面。 + +此外,还需要在这里,完成 `didChangeAppLifecycleState` 监听与前后台切换事件上报,详情请查看[离线推送插件文档步骤5](https://cloud.tencent.com/document/product/269/74605#.E6.AD.A5.E9.AA.A45.EF.BC.9A.E5.89.8D.E5.90.8E.E5.8F.B0.E5.88.87.E6.8D.A2.E7.9B.91.E5.90.AC.3Ca-id.3D.22step_5.22.3E.3C.2Fa.3E)。 + +详细代码可查看Demo源码。 + +#### 其他TUIKit模块引入 + +1. 新建 `push.dart` 文件,用于单例管理 [离线推送插件](https://cloud.tencent.com/document/product/269/74605) 能力。用于获取并上报Token/获取推送权限等操作。详细代码可查看Demo源码。 + +2. 新建 `conversation.dart` 文件,用于承载TUIKit的会话模块组件 `TIMUIKitConversation`。详细代码可查看Demo源码。 + +3. 新建 `chat.dart` 文件,用于承载TUIKit的历史消息列表和发送消息模块组件 `TIMUIKitChat`。 + 该页面还有跳转至 Profile 及 Group Profile 页面的能力。 + 详细代码可查看Demo源码。 + +4. 新建 `user_profile.dart` 文件,用于承载TUIKit的用户信息及关系链管理模块组件 `TIMUIKitProfile`。详细代码可查看Demo源码。 + +5. 新建 `group_profile.dart` 文件,用于承载TUIKit的群信息及群管理模块组件 `TIMUIKitGroupProfile`。详细代码可查看Demo源码。 + +至此,统一的Flutter Module开发完成。 + +### iOS Native 开发 + +本文以 Swift 语言为例。 + +>? +> +> 以下代码结构,仅供参考,您可根据需要灵活组织。 + +进入您的iOS项目目录。 + +如果您现有的应用程序,假设叫做 `MyApp`, 还没有Podfile,请按照[CocoaPods入门指南](https://guides.cocoapods.org/using/using-cocoapods.html)将 `Podfile` 添加到项目中。 + +#### 引入 Flutter Module + +请参考[此部分](#ios),将Flutter module引入您的原生应用程序中。建议采用方式一。 + +#### 在 iOS 项目中,管理Flutter引擎 + +**创建一个FlutterEngine。** + +创建FlutterEngine的适当位置特定于您的主应用程序入口。作为一个例子,我们演示了如何在 `AppDelegate` 中的app启动时创建一个FlutterEngine,并公开为一个属性。 + +```swift +import UIKit +import Flutter +import FlutterPluginRegistrant + +@UIApplicationMain +class AppDelegate: FlutterAppDelegate { // More on the FlutterAppDelegate. + lazy var flutterEngine = FlutterEngine(name: "tencent cloud chat") + + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Runs the default Dart entrypoint with a default Flutter route. + flutterEngine.run(); + GeneratedPluginRegistrant.register(with: self.flutterEngine); + return super.application(application, didFinishLaunchingWithOptions: launchOptions); + } +} +``` + +**创建一个用于管理Flutter引擎的单例对象。** + +这个 Swift 单例对象,用于集中管理 Flutter Method Channel,并提供一系列与 Flutter Module 通信的方法,方便在项目中各处,直接调用。 + +这些方法包括: +- private override init(): 初始化 Method Channel,并为其绑定事件监听方法。 +- func reportChatInfo(): 将用户登录信息和SDKAPPID透传至Flutter Module,使Flutter层得以初始化并登录腾讯云IM。 +- func launchChatFunc(): 拉起或导航至 Flutter Module 所在 ViewController。 +- func triggerNotification(msg: String): 将 iOS Native 层收到的离线推送消息点击事件,及其包含的ext信息,以 JSON String形式,透传至 Flutter 层绑定的监听处理事件。用于处理离线推送点击跳转,例如至对应会话。 + +详细代码可查看Demo源码。 + +**监听及转发离线推送点击事件** + +离线推送的初始化/Token上报/点击事件对应的会话跳转处理,已在Flutter Chat模块中进行,因此,Native区域,仅需透传点击通知事件的ext即可。 + +之所以这么做,是因为点击通知事件已在Native被拦截消费,Flutter层无法直接拿到,必须经由Native转发。 + +在 `AppDelegate.swift` 文件中,新增如下代码。具体代码,可以参考Demo源码。 + +![](https://qcloudimg.tencent-cloud.cn/raw/0ea48d21696a1e696ab98091983168f9.png) + +此时,iOS Native层编写完成。 + +### Android Native 开发 + +本文以 Kotlin 语言为例。 + +>? +> +> 以下代码结构,仅供参考,您可根据需要灵活组织。 + +#### 引入 Flutter Module + +请参考[此部分](#android),将Flutter module引入您的原生应用程序中。建议采用方式二。 + +#### 在 Android 项目中,管理Flutter引擎 + +**创建一个用于管理Flutter引擎的单例对象。** + +这个 Kotlin 单例对象,用于集中管理 Flutter Method Channel,并提供一系列与 Flutter Module 通信的方法,方便在项目中各处,直接调用。 + +新建 `FlutterUtils.kt` 文件,并定义 `FlutterUtils` 静态类。 + +```kotlin +@SuppressLint("StaticFieldLeak") +object FlutterUtils {} +``` + +**创建一个 `FlutterEngine` (Flutter 引擎)。** + +在 `FlutterUtils.kt` 文件中,定义一个 `FlutterEngine`,并在初始化时,将其初始化。 + +```kotlin +lateinit var context : Context +private lateinit var flutterEngine:FlutterEngine + +// 初始化 +flutterEngine = FlutterEngine(context) +``` + +**继续完成该用于管理Flutter引擎的单例对象。** + +Demo代码的逻辑是,使用新的路由,承载Chat和Call的Activity。 + +Chat的Activity,由用户主动进入及退出;Call的Activity,由监听器或主动外呼,自动导航进及返回出。 + +重点关注: +- fun init(): 初始化 Method Channel,并为其绑定事件监听方法。 +- fun reportChatInfo(): 将用户登录信息和SDKAPPID透传至Flutter Module,使Flutter层得以初始化并登录腾讯云IM。 +- fun launchChatFunc(): 拉起或导航至 Flutter Module 所在 ViewController。 +- fun triggerNotification(msg: String): 将 iOS Native 层收到的离线推送消息点击事件,及其包含的ext信息,以 JSON String形式,透传至 Flutter 层绑定的监听处理事件。用于处理离线推送点击跳转,例如至对应会话。 + +本单例 object 的详细代码,可以参考Demo源码。 + +**在 总入口 `MyApplication` 中,初始化上述对象** + +在 `MyApplication.kt` 文件中,将全局context传入单例对象,并执行初始化。 + +```kotlin +class MyApplication : MultiDexApplication() { + + override fun onCreate() { + super.onCreate() + FlutterUtils.context = this // new + FlutterUtils.init() // new + } +} +``` + +**监听及转发离线推送点击事件** + +离线推送的初始化/Token上报/点击事件对应的会话跳转处理,已在Flutter Chat模块中进行,因此,Native区域,仅需透传点击通知事件的ext即可。 + +之所以这么做,是因为点击通知事件已在Native被拦截消费,Flutter层无法直接拿到,必须经由Native转发。 + +> 由于不同厂商的离线推送接入步骤不一致,本文以OPPO为例,全部厂商接入方案,可查看[本文档](https://cloud.tencent.com/document/product/269/75428). + +在腾讯云IM控制台中,新增OPPO的推送证书,`点击后续动作` 选择 `打开应用内指定页面`,`应用内页面` 以 `Activity` 方式,配置一个用于处理离线推送信息的页面,建议为应用首页。如,我们的Demo配置为:`com.tencent.chat.android.MainActivity`. + +![](https://qcloudimg.tencent-cloud.cn/raw/c0f8737ce6fa484479ffc9a1bec6c9c0.png) + +在上方控制台配置的用于离线推送的Activity文件中,新增如下代码。 + +该代码的作用是,当厂商拉起相应Activity时,从Bundle中取出HashMap形式ext信息,触发单例对象中的方法,将这个信息,手动转发至Flutter中。具体代码,可以参考Demo源码。 + +![](https://qcloudimg.tencent-cloud.cn/raw/2ec45c1a8b3bd952bcb86a8095f91515.png) + +此时,Android Native层编写完成。 + +[](id:native) + +## 附加方案:在 Native 层,初始化并登录腾讯云IM + +有的时候,对于Chat和Call模块能力,您希望对于高频的简单应用场景,能深入嵌入您现有的业务逻辑中。 + +例如对于游戏场景,在对局内,希望能直接发起会话。 + +而您的完整功能Chat模块,使用Flutter实现,仅是您APP中一个重要性较低的子模块,因此不希望一上来就启动一个完整的Flutter Module。 + +这个时候,您可以在Native层调用腾讯云IM Native SDK的初始化及登录方法,此后,便可在您需要的高频简单场景,直接使用腾讯云IM Native SDK,构建 In-App Chat 能力。 + +>? +> 当然,在此种情况下,您也可以选择提前先在 Flutter 初始化并登录腾讯云IM,此时,您将不再需要在 Native 层再次初始化并登录。两端仅需初始化并登录一次,即可在双端都能使用。 + +由于Flutter SDK已自带Native SDK,您不需要在Native层,再次引入,即可直接使用。 + +### Native初始化并登录 + +以 iOS Swift 代码为例,演示如何在 Native 层,初始化并登录。 + +```swift +import ImSDK_Plus + + +func initTencentChat(){ + if(isLoginSuccess == true){ + return + } + let data = V2TIMManager.sharedInstance().initSDK( 您的SDKAPPID , config: nil); + if (data == true){ + V2TIMManager.sharedInstance().login( + chatInfo.userID, + userSig: chatInfo.userSig, + succ: { + self.isLoginSuccess = true + self.reportChatInfo() + }, + fail: onLoginFailed() + ) + } +} +``` + +此后,在 Native 层面,便可直接使用Native SDK,搭建您的业务功能模块。详情可查阅 [iOS 快速入门]() 或 [Android 快速入门](https://cloud.tencent.com/document/product/269/36838)。 + +### 初始化 Flutter TUIKit + +如果您已在 Native 层完成初始化并登录,您不需要再次在 Flutter 层再次执行,但需要调用 TUIKit的 `_coreInstance.setDataFromNative()`,将当前用户信息传入。 + +```dart +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +_coreInstance.setDataFromNative(userId: chatInfo?.userID ?? ""); +``` +**更详细代码,请查阅我们的Demo 源码。** + +[![](https://qcloudimg.tencent-cloud.cn/raw/b622951f776a505e83f843de1f62fffc.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Initialize%20from%20Native) + +----- + +至此,腾讯云IM Flutter - Native 混合开发方式已全部介绍完成。 + +您可以基于本文档给出的方案,快速在您现有的原生开发 Android/iOS APP 中,使用 Flutter SDK,使用同一套Flutter代码,快速植入 Chat 和 Call 模块能力。 + +如果您还有任何疑问,欢迎随时联系我们。 + +![](https://qcloudimg.tencent-cloud.cn/raw/eacb194c77a76b5361b2ae983ae63260.png) + + +## Reference + +1. [Integrate a Flutter module into your Android project](https://docs.flutter.dev/development/add-to-app/android/project-setup). +2. [Integrate a Flutter module into your iOS project](https://docs.flutter.dev/development/add-to-app/ios/project-setup). +3. [Adding a Flutter screen to an iOS app](https://docs.flutter.dev/development/add-to-app/ios/add-flutter-screen?tab=no-engine-vc-swift-tab). +4. [Multiple Flutter screens or views](https://docs.flutter.dev/development/add-to-app/multiple-flutters). diff --git a/doc/混合开发_en.md b/doc/混合开发_en.md new file mode 100644 index 0000000..c3c7885 --- /dev/null +++ b/doc/混合开发_en.md @@ -0,0 +1,974 @@ + +If you already have an Android/iOS APP being public online, In-APP chat and call modules may need to be added as your business develops. + +For instance, adding chat modules to video apps to facilitate interaction between viewers; adding chat modules to eshop apps to facilitate communication between customers and merchants; or adding chat modules to music and entertainment apps so that people with the same interests Taste groups, find organizations and communicate. + +Adding the chat module requires the Android/iOS team to develop and access it separately, for your existing application. It not only consumes a lot, but also may cause inconsistent experience and out-of-sync message sending and receiving. + +So, it is recommended to integrate Tencent Cloud Chat with Flutter, coding once and deploying to all platforms, which is really convenient. + +But, it’s sometimes not practical to rewrite your entire application in Flutter all at once. For those situations, Flutter can be integrated into your existing application piecemeal, as a library or module. That module can then be imported into your Android or iOS (currently supported platforms) app to render a part of your app’s UI in Flutter. + +With this solution, you are able to integrate Tencent Cloud Chat Flutter SDKs to your existing Android/iOS application. + +**It could reduce your workload, to adding chat and call modules to your existing, to a large extent.** + +![](https://qcloudimg.tencent-cloud.cn/raw/0a54bc281851a147b0f034a74c6001e5.png) + +## Environment requirements + +| | Version | +|---------|---------| +| Flutter | Flutter 2.2.0 or later for the IM SDK; Flutter 2.10.0 or later for the TUIKit integration component library.| +|Android|Android Studio 3.5 or later; devices with Android 4.1 or later for apps. | +|iOS| Xcode 11.0 or later. Ensure that your project has a valid developer signature. | +|Tencent Cloud Chat Flutter SDK|[tencent_im_sdk_plugin](https://pub.dev/packages/tencent_im_sdk_plugin) 5.0 or later, [tencent_cloud_chat_uikit](https://pub.dev/packages/tencent_cloud_chat_uikit) 0.2 or later. | + +## What you need to know first + +Before starting, you are recommended to know about Tencent Cloud Chat and adding the Flutter module to existing apps. + +### Tencent Cloud Chat + +#### Overall + +Before starting, you are supposed to be familiar with our SDKs, and the basic usage. + +Two main SDKs are included, [non-UI SDK](https://www.tencentcloud.com/document/product/1047/45907#part-5.-self-implementing-integration), and [TUIKit](https://www.tencentcloud.com/document/product/1047/50059). + +In this tutorial, we will mainly develop with TUIKit, with UI library and basic business logic. + +**You could get to know about our SDKs, with [Get Started](https://www.tencentcloud.com/document/product/1047/45907).** + +[](id:modules) + +#### Two modules + +Two main modules are included, Chat and Call. + +Chat module includes, sending and receiving messages, relationship management, etc. + +Call module includes voice call and video call, for both one-to-one call and group call. + +### Adding Flutter to Native APP + +Be simplified, the key of this solution is, embedding the Flutter module to your native application in a subproject. As the cross-platform feature of Flutter, one single Flutter module can be added to both Android and iOS projects. + +To launch a Flutter screen from an existing iOS/Android, you start a [FlutterEngine](https://api.flutter.dev/objcdoc/Classes/FlutterEngine.html) and a FlutterViewController/FlutterActiviy. + +The `FlutterEngine` serves as a host to the Dart VM and your Flutter runtime, and the `FlutterViewController`/`FlutterActivity` attaches to a FlutterEngine to pass input events into Flutter and to display frames rendered by the `FlutterEngine`. + +The `FlutterEngine` may have the same lifespan as your `FlutterViewController`/`FlutterActivity` or outlive your `FlutterViewController`/`FlutterActivity`. + +[Method Channel](https://docs.flutter.dev/development/platform-integration/platform-channels#channels-and-platform-threading) can be used to communicate between Native APP and Flutter module if necessary, like transmitting the current user info, the EXT of offline push and call data. Invoking a method on the method channel, and listening for the invoking with a `MethodCallHandler` being preset. + +[](id:android) + +#### Adding to an Android app + +[Details documentation](https://docs.flutter.dev/development/add-to-app/android/project-setup) + +Adding the Flutter module as a dependency of your existing app in Gradle. There are two ways to achieve this. The AAR mechanism creates generic Android AARs as intermediaries that package your Flutter module. This is good when your downstream app builders don’t want to have the Flutter SDK installed. But, it adds one more build step if you build frequently. + +The source code subproject mechanism is a convenient one-click build process, but requires the Flutter SDK. This is the mechanism used by the Android Studio IDE plugin. + +##### Option A - Depend on the Android Archive (AAR) + +This option packages your Flutter library as a generic local Maven repository composed of AARs and POMs artifacts. + +This option allows your team to build the host app without installing the Flutter SDK. You can then distribute the artifacts from a local or remote repository. + +It's recommended to use this option for the released version. + +**Steps:** + +Run the following command on your Flutter module. + +```shell +flutter build aar +``` + +Then, follow the on-screen instructions to integrate. + +![](https://qcloudimg.tencent-cloud.cn/raw/32e9376de02da10e97a8c54b9ab2b51c.png) + +Your app now includes the Flutter module as a dependency. + +##### Option B - Depend on the module’s source code + +This option enables a one-step build for both your Android project and Flutter project. + +This option is convenient when you work on both parts simultaneously and rapidly iterate, but your team must install the Flutter SDK to build the host app. + +It's recommended to use this option when development and debugging. + +**Steps:** + +Include the Flutter module as a subproject in the host app’s `settings.gradle`: + +```gradle +// Include the host app project. +include ':app' // assumed existing content +setBinding(new Binding([gradle: this])) // new +evaluate(new File( // new +settingsDir.parentFile, // new +'tencent_chat_module/.android/include_flutter.groovy' // new +)) // new +``` + +Introduce an `implementation` dependency on the Flutter module from your app: + +```gradle +dependencies { +implementation project(':flutter') +} +``` + +Your app now includes the Flutter module as a dependency. + +[](id:ios) + +#### Adding to an iOS app + +[Details documentation](https://docs.flutter.dev/development/add-to-app/ios/project-setup#embed-the-flutter-module-in-your-existing-application) + +There are two ways to embed Flutter in your existing application. + +- Use the CocoaPods dependency manager and install Flutter SDK. (Recommended) +- Create frameworks for the Flutter engine, your compiled Dart code, and all Flutter plugins. Manually embed the frameworks, and update your existing application’s build settings in Xcode. + +>? +> Your app does not run on a simulator in Release mode because Flutter does not yet support outputting x86/x86_64 ahead-of-time (AOT) binaries for your Dart code. You can run in Debug mode on a simulator or a real device, and Release on a real device. +> +> To run your app on a simulator follow the instructions in the bottom of section [embed the frameworks](https://docs.flutter.dev/development/add-to-app/ios/project-setup#embed-the-frameworks). + +##### Option A - Embed with CocoaPods and the Flutter SDK + +This method requires every developer working on your project to have a locally installed version of the Flutter SDK. Simply build your application in Xcode to automatically run the script to embed your Dart and plugin code. + +This allows rapid iteration with the most up-to-date version of your Flutter module without running additional commands outside of Xcode. + +It's recommended to use this option when development and debugging. + +**Steps:** + +Add the following lines to your `Podfile`: + +``` +// The path of your Flutter module +flutter_chat_application_path = '../tencent_chat_module' + +load File.join(flutter_chat_application_path, '.ios', 'Flutter', 'podhelper.rb') +``` + +For each [Podfile target](https://guides.cocoapods.org/syntax/podfile.html#target) that needs to embed Flutter, call `install_all_flutter_pods(flutter_application_path)`. + +``` +target 'MyApp' do +install_all_flutter_pods(flutter_chat_application_path) +end +``` + +In the `Podfile`’s `post_install` block, call `flutter_post_install(installer)`, and with the statement of necessary permissions. + +``` +post_install do |installer| +flutter_post_install(installer) if defined?(flutter_post_install) +installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_MICROPHONE=1', + 'PERMISSION_CAMERA=1', + 'PERMISSION_PHOTOS=1', + ] + end + end +end +``` + +Run `pod install`. + +> ? +> +> - When you change the Flutter plugin dependencies in `tencent_chat_module/pubspec.yaml`, run `flutter pub get` in your Flutter module directory to refresh the list of plugins read by the `podhelper.rb` script. Then, run pod install again from the root directory of your application. +> - You may need to run `arch -x86_64 pod install --repo-update` on the Mac with Apple Silicon, like M1 or M2. + +The `podhelper.rb` script embeds your plugins, `Flutter.framework`, and `App.framework` into your project. + +##### Option B - Embed frameworks in Xcode + +Alternatively, you can generate the necessary frameworks and embed them in your application by manually editing your existing Xcode project. + +You may do this if members of your team can’t locally install Flutter SDK and CocoaPods, or if you don’t want to use CocoaPods as a dependency manager in your existing applications. + +You must run `flutter build ios-framework` every time you make code changes in your Flutter module. + +It's recommended to use this option for the released version. + +**Steps:** + +Run the following command on your Flutter module. + +The following example assumes that you want to generate the frameworks to `some/path/MyApp/Flutter/`. + +```shell +flutter build ios-framework --output=some/path/MyApp/Flutter/ +``` + +Embed and link the generated frameworks into your existing application in Xcode. + +## The mode of adding + +It's recommended to add the Flutter module to your existing application. + +Two modules needed to be added by `FlutterEngine`, Chat and Call. For details, [see here](#modules). + +Two modes of `FlutterEngine` are provided, single `FlutterEngine` or two `FlutterEngine`s with a `FlutterEngineGroup`. + +| Mode | Introduction | Props | Cons | Demo | +|---------|---------|---------|---------|---------| +| [Single FlutterEngine](#single) | Both Chat and Call integrate into one `FlutterEngine` | Convenient | It's required to navigate to the Flutter page, when new call income, to show the call status, which may interrupt the current status, with a relatively bad experience. | [GitHub](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Single%20Flutter%20Engines) | +| [Multiple FlutterEngines](#multiple) | Call and Chat modules located in two separate FlutterEngine | Only the presence of the calling page separately when a new call is incoming is necessary, and dismiss it automatically when call ends, without the navigating of current status, for a better experience. | Minimize for the calling page is not allowed. | [GitHub](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Multiple%20Flutter%20Engines) | + +Additionally, the solution of integrating both Native Chat SDK and Flutter Chat SDK is also provided. For details, [see here](#native), and demo can be found from [GitHub](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Initialize%20from%20Native). + +[](id:multiple) + +## Solution A: Multiple FlutterEngines (Recommended) + +The advantage of using multiple Flutter instances is that each instance is independent and maintains its own internal navigation stack, UI, and application states. This simplifies the overall application code’s responsibility for state keeping and improves modularity. More details on the scenarios motivating the usage of multiple Flutters can be found at docs.flutter.dev/go/multiple-flutters. + +![](https://qcloudimg.tencent-cloud.cn/raw/87cc37d846388fb3c66aab6743cfede2.png) + +The primary API for adding multiple Flutter instances on both Android and iOS is based on a new `FlutterEngineGroup` class to construct `FlutterEngine`s, rather than the `FlutterEngine` constructors used in the [Solution B: Single FlutterEngine](#single). + +Whereas the `FlutterEngine` API was direct and easier to consume, the `FlutterEngine` spawned from the same `FlutterEngineGroup` have the performance advantage of sharing many of the common, reusable resources such as the GPU context, font metrics, and isolate group snapshot, leading to a faster initial rendering latency and lower memory footprint. + +In our project, one single `FlutterEngineGroup` is used to manage the two `FlutterEngine`s, including Chat and Call modules. + +[![](https://qcloudimg.tencent-cloud.cn/raw/9ab7dc1c98627885eea01ddfd1803bb3.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Multiple%20Flutter%20Engines) + +### The development of Flutter module + +To embed Flutter into your existing application, first create a Flutter module. + +From the command line, run: + +``` +cd some/path/ +flutter create --template module tencent_chat_module +``` + +A Flutter module project is created at `some/path/tencent_chat_module/`. From that directory, you can run the same flutter commands you would in any other Flutter project, like `flutter run --debug` or `flutter build ios`. You can also run the module in Android Studio/IntelliJ or VS Code with the Flutter and Dart plugins. This project contains a single-view example version of your module before it’s embedded in your existing application, which is useful for incrementally testing the Flutter-only parts of your code. + +The `tencent_chat_module` module directory structure is similar to a normal Flutter application: + +``` +tencent_chat_module/ +├── .ios/ +│ ├── Runner.xcworkspace +│ └── Flutter/podhelper.rb +├── lib/ +│ └── main.dart +├── test/ +└── pubspec.yaml +``` + +Now, we can code within `lib/`. + +#### The structure of `lib/` + +>? +> +> The following structure and code is for demonstration purposes only, you could modify it to meet your actual needs dynamically. + +Now, let's create three directory within `lib/`, including `call`, `chat` and `common`. Which is used for Call module, Chat module and some common classes separately. + +``` +tencent_chat_module/ +├── lib/ +│ └── call/ +│ └── chat/ +│ └── common/ +``` + +#### Common model classes + +Add the two following classes to a new file, `common/common_model.dart`. Used for definition the communication proxy between native and Flutter. + +```dart +class ChatInfo { +String? sdkappid; +String? userSig; +String? userID; + +ChatInfo.fromJSON(Map json) { + sdkappid = json["sdkappid"].toString(); + userSig = json["userSig"].toString(); + userID = json["userID"].toString(); +} + +Map toMap(){ + final Map map = {}; + if(sdkappid != null){ + map["sdkappid"] = sdkappid!; + } + if(userSig != null){ + map["userSig"] = userSig!; + } + if(userID != null){ + map["userID"] = userID!; + } + return map; +} +} + +class CallInfo{ +String? userID; +String? groupID; + +CallInfo(); + +CallInfo.fromJSON(Map json) { + groupID = json["groupID"].toString(); + userID = json["userID"].toString(); +} + +Map toMap(){ + final Map map = {}; + if(userID != null){ + map["userID"] = userID!; + } + if(groupID != null){ + map["groupID"] = groupID!; + } + return map; +} +} +``` + +#### Chat Module + +**The following files and codes are located in the `lib/chat` directory.** + +1. Create a file, `model.dart`, used as a state container. + This model is used to initialize and maintain the instance of Tencent Cloud Chat, offline line push module, global state, and the communication with native apps. + Is the core of the Chat module. + Detailed implementation can refer to the source code of the demo, while it's recommended to focus on these three functions: + - Future _handleMessage(MethodCall call): Listening for the message call from native app. + - Future handleClickNotification(Map msg): The function invoked by the callback after clicking the notification. + - Future initChat(): Initialize and log in Tencent Cloud Chat SDK and offline push plugin, upload token. This method uses the sync lock mechanism to ensure that only one can be executed at the same time, and after the initialization is successful, it will not be executed repeatedly. + +> ? +> +> Please configure the offline push before uploading the token and use this capability, referring to this [documentation](https://www.tencentcloud.com/document/product/1047/50032). + +2. Create a file, `chat_main.dart`, used as the main entrance of the chat module. + - Also, used as the home page of the chat module. + - It shows the loading status before logged in, followed by the conversation list. + - Besides, the current status of the application needs to be reported to the Tencent Cloud Chat backend upon each foreground/background switch from here. Referring to this [documentation](https://www.tencentcloud.com/document/product/1047/50032#step-5.-listen-for-the-foreground.2Fbackground-switch.3Ca-id.3D.22step_5.22.3E.3C.2Fa.3E). + - Detailed implementation can refer to the source code of the demo. + +3. Create a file, `push.dart`, used for maintaining the [offline push plugin](https://www.tencentcloud.com/document/product/1047/50032). Detailed implementation can refer to the source code of the demo. + +4. Create a file, `conversation.dart`, used for implementing conversation list widget `TIMUIKitConversation`. Detailed implementation can refer to the source code of the demo. + +5. Create a file, `user_profile.dart`, used to implement the user profile widget `TIMUIKitProfile`. Detailed implementation can refer to the source code of the demo. + +6. Create a file, `group_profile.dart`, used to implement group profile widget `TIMUIKitGroupProfile`. Detailed implementation can refer to the source code of the demo. + +7. Create a file, `chat.dart`, used for implementing the history message list and sending messages widget `TIMUIKitChat`. This page can also navigate to `user_profile.dart` and `conversation.dart`.Detailed implementation can refer to the source code of the demo. + +Now, Chat module has been developed, with the following structure: + +``` +tencent_chat_module/ +├── lib/ +│ └── call/ +│ └── chat.dart +│ └── model.dart +│ └── chat_main.dart +│ └── push.dart +│ └── conversation.dart +│ └── user_profile.dart +│ └── group_profile.dart +│ └── chat/ +│ └── common/ +``` + +#### Call Module + +This module is used for voice call and video call, provided by our [calling plugin](https://pub.dev/packages/tim_ui_kit_calling_plugin). + +The key feature of this module is, when receiving the income calling, invoke the method to native requesting show this page; or, initiate a call to others, when receiving the request from Chat module, via native. + +**The following files and codes are located in the `lib/calls` directory.** + +1. Create a file, `model.dart`, used as a state container. + This model is used for initializing and maintaining the instance of [Calling plug-in](https://pub.dev/packages/tim_ui_kit_calling_plugin), global state, and the communication with native apps. + Is the core of the Call module. + Detailed implementation can refer to the source code of the demo, while it's recommended to focus on these three functions: + - _onRtcListener = TUICallingListener(...): The listener of the calling events, notify native to show this page, when receiving a new call. + - Future _handleMessage(MethodCall call): The listener of the method channel call events, mainly used for initiating a call to others, when receiving the request from the Chat module, via native. + +2. Create a file, `call_main.dart`, used as the main entry point of the Call module. +The `navigatorKey` used for the launch calling page is supposed to be added here. + Detailed implementation can refer to the source code of the demo. + +#### Configure the entry point for each modules + +After developing the three parts above, now we can configure the entry point for each module, used as the entrance for FlutterEngine. + +1. Default entry + +Open `lib/main.dart`, modify the default main functions to return an empty `MaterialApp`. + +This function is the entry point, while not being used in this solution. + +As, the name of the entry point is necessary while the creation of a `FlutterEngine`, and we won't create a `FlutterEngine` without a name. + +```dart +void main() { +WidgetsFlutterBinding.ensureInitialized(); + +runApp(MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: Container(), +)); +} +``` + +2. The entry for Chat module + +Use `@pragma('vm:entry-point')` annotation to mark a method as an entry point. The method name `chatMain` is the name of the entry. + +In Native, this name is also used to create the corresponding `FlutterEngine`. + +Use global `ChangeNotifierProvider` status management to maintain `ChatInfoModel` data and business logic. + +```dart +@pragma('vm:entry-point') +void chatMain() { +// This call ensures the Flutter binding has been set up before creating the +// MethodChannel-based model. +WidgetsFlutterBinding.ensureInitialized(); + +final model = ChatInfoModel(); + +runApp( + ChangeNotifierProvider.value( + value: model, + child: const ChatAPP(), + ), +); +} +``` + +3. The entry for Call module + +This entry point is named as `callMain`. + +Use global `ChangeNotifierProvider` status management to maintain `CallInfoModel` data and business logic. + +```dart +@pragma('vm:entry-point') +void callMain() { +// This call ensures the Flutter binding has been set up before creating the +// MethodChannel-based model. +WidgetsFlutterBinding.ensureInitialized(); + +final model = CallInfoModel(); + +runApp( + ChangeNotifierProvider.value( + value: model, + child: const CallAPP(), + ), +); +} +``` + +So far, the Dart code for the Flutter Module has been written. + +Now, let's take a look at the native integration for your existing app. + +### iOS Native development + +Here, we take `Swift` as an example, while `Objective-C` is also available. + +>? +> +> The following structure and code is for demonstration purposes only, you could modify it to meet your actual needs dynamically. + +Open your iOS project within XCode. + +If your existing application (MyApp) doesn’t already have a `Podfile`, follow the [CocoaPods getting started guide](https://guides.cocoapods.org/using/using-cocoapods.html) to add a Podfile to your project. + +#### Import Flutter Module + +Please refer to [this part](#ios), adding the Flutter module to your existing iOS app. + +#### FlutterEngineGroup + +![](https://qcloudimg.tencent-cloud.cn/raw/039ec36a5696f2188f9fa8ab11071210.png) + +**Create a `FlutterEngineGroup` to maintain and manage the `FlutterEngine`s.** + +The proper place to create a `FlutterEngineGroup` is specific to your host app. As an example, we demonstrate creating a `FlutterEngineGroup`, exposed as a property, on app startup in the app delegate. + +Add the following to `AppDelegate.swift`. + +```swift +@UIApplicationMain +class AppDelegate: FlutterAppDelegate { +lazy var flutterEngines = FlutterEngineGroup(name: "chat.flutter.tencent", project: nil) +... +} +``` + +**Create a singleton static object to hold `FlutterEngine`s.** + +This singleton is used for managing those `FlutterEngine`s in one place, and provides methods to the whole project to invoke methods related to the Flutter module. + +The basic implementation logic of the demo is that, using a new navigator for the ViewController of Chat, while `present` and `dismiss` the ViewController of Call automatically. + +Create a new file, `FlutterUtils.swift`, and coding, refer to our demo source code. + +Mainly focus on: + +- private override init(): Initialize each Flutter instance, register method channel events. +- func reportChatInfo(): Report the current user info to the Flutter module, for initialization and login Tencent Cloud Chat SDK. +- func launchCallFunc(): Present the ViewController for Call module, invoked when new call income or user active it manually from Chat module. +- func triggerNotification(msg: String): Transit the data of notification, after the user clicks it, and Chat module may navigate to the corresponding chat page. + +**Listening and transit the notification click event** + +Only transit of the data of notification after clicking is necessary as, the initialization of Push plug-in, uploading token and the navigating for notification clicking events have been done in Flutter Chat module. + +The reason why we need to do this is because the clicking event has been consumed by Native, so it is impossible for the Flutter Push plug-in to receive this event. + +Add the following codes to `AppDelegate.swift`. + +![](https://qcloudimg.tencent-cloud.cn/raw/9c816ae4745e5a8d2b9b1d64167e1fc5.png) + +Now, we finished the implementation for iOS. + +### Android Native Development + +Here, we take `Kotlin` as an example, while `Java` is also available. + +>? +> +> The following structure and code is for demonstration purposes only, you could modify it to meet your actual needs dynamically. + +Open your Android project within Android Studio. + +#### Import Flutter Module + +Please refer to [this part](#android), adding the Flutter module to your existing Android app. + +#### FlutterEngineGroup + +**Create a `FlutterEngineGroup` to maintain and manage the `FlutterEngine`s.** + +The proper place to create a `FlutterEngineGroup` is specific to your host app. As an example, we demonstrate creating a `FlutterEngineGroup`, exposed as a property, on app startup in the app delegate. + +Create a new file, `FlutterUtils.kt`, and define a singleton static object `FlutterUtils`. + +```kotlin +@SuppressLint("StaticFieldLeak") +object FlutterUtils {} +``` + +**Create a `FlutterEngineGroup` to maintain and manage the `FlutterEngine`s.** + +Define a `FlutterEngineGroup`, `FlutterEngine`s and corresponding `MethodChannel`s in `FlutterUtils.kt`. + +```kotlin +lateinit var context : Context +lateinit var flutterEngines: FlutterEngineGroup +private lateinit var chatFlutterEngine:FlutterEngine +private lateinit var callFlutterEngine:FlutterEngine + +lateinit var chatMethodChannel: MethodChannel +lateinit var callMethodChannel: MethodChannel + +// Initialize them +flutterEngines = FlutterEngineGroup(context) +... +``` + +**Further developed for this singleton static object** + +The basic implementation logic of the demo is that, using a new navigator for the `Activity` for both Chat and Chat. + +The `Activity` for Chat is entered and exited by the user, while the `Activity` for Call has been entered and exited automatically, triggered by the listener or making a call manually. + +Mainly focus on: +- fun init(): Initialize each Flutter instance, register method channel events. +- fun reportChatInfo(): Report the current user info to the Flutter module, for initialization and login Tencent Cloud Chat SDK. +- fun launchCallFunc(): Present the `Activity` for Call module, invoked when new call income or user active it manually from Chat module. +- fun triggerNotification(msg: String): Transit the data of notification, after the user clicks it, and Chat module may navigate to the corresponding chat page. + +You can refer to the demo source code for this object. + +**Initialize the singleton static object above from the main entry `MyApplication`.** + +Transit the global context to the singleton static object, and initialize it from `MyApplication.kt`. + +```kotlin +class MyApplication : MultiDexApplication() { + + override fun onCreate() { + super.onCreate() + FlutterUtils.context = this // new + FlutterUtils.init() // new + } +} +``` + +**Listening and transit the notification click event** + +Only transit of the data of notification after clicking is necessary as, the initialization of Push plug-in, uploading token and the navigating for notification clicking events have been done in Flutter Chat module. + +The reason why we need to do this is the clicking event has been consumed by Android Kotlin, so it is impossible for the Flutter Push plug-in to receive this event. + +> Due to the diversity and inconsistency among different manufacturers, we only take OPPO as an example. For the whole manufacturer's support, please refer to this [documentation](https://www.tencentcloud.com/document/product/1047/50032). + +Add a new push certificate to the Tencent Cloud Chat console, Select **Open specified in-app page > activity** for the opening method and enter an activity to receive the notification clicking event with EXT data, it's suggested to set it as the home page or the main entrance. Like, we set `MainActivity` for our demo, `com.tencent.chat.android.MainActivity`. + +![](https://qcloudimg.tencent-cloud.cn/raw/fd384ea1140199113d01a6650c0c8f3d.png) + +Adding the following codes to the `Activity`, set for the console above. + +The EXT data of the notification can be found from `Bundle` when the `Activity` has been launched by the device, when the user clicks the notification. + +You can receive the EXT from `Activity`, and transit them to Flutter. + +You can refer to the demo source code for this capability. + +![](https://qcloudimg.tencent-cloud.cn/raw/2ec45c1a8b3bd952bcb86a8095f91515.png) + +Now, we finished the implementation for Android. + +[](id:single) + +## Solution B: Single FlutterEngine + +In this solution, the Chat module and Call module embed in one single Flutter instance. + +![](https://qcloudimg.tencent-cloud.cn/raw/115b917df15da5d84ea6794774a3b080.png) + +As a result, those modules can only be shown or hidden at the same time. + +[![](https://qcloudimg.tencent-cloud.cn/raw/9ab7dc1c98627885eea01ddfd1803bb3.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Single%20Flutter%20Engines) + +### Flutter Module development + +To embed Flutter into your existing application, first create a Flutter module. + +From the command line, run: + +``` +cd some/path/ +flutter create --template module tencent_chat_module +``` + +A Flutter module project is created at `some/path/tencent_chat_module/`. From that directory, you can run the same flutter commands you would in any other Flutter project, like `flutter run --debug` or `flutter build ios`. You can also run the module in Android Studio/IntelliJ or VS Code with the Flutter and Dart plugins. This project contains a single-view example version of your module before it’s embedded in your existing application, which is useful for incrementally testing the Flutter-only parts of your code. + +The `tencent_chat_module` module directory structure is similar to a normal Flutter application: + +``` +tencent_chat_module/ +├── .ios/ +│ ├── Runner.xcworkspace +│ └── Flutter/podhelper.rb +├── lib/ +│ └── main.dart +├── test/ +└── pubspec.yaml +``` + +Now, we can code within `lib/`. + +#### main.dart + +Modify `main.dart`, integrating [TUIKit](https://www.tencentcloud.com/document/product/1047/50054), [Offline Push plug-in](https://www.tencentcloud.com/document/product/1047/50032) and [Call Plug-in](https://pub.dev/packages/tim_ui_kit_calling_plugin). + +The global state, method channel and our Tencent Cloud Chat SDKs, maintained by `ChatInfoModel`. + +After receiving the login user info from Native, invoke `_coreInstance.init()` and `_coreInstance.login()` to initialize and login the SDK. Also, Call plug-in and Push plug-in need to be initialized. + +> ? +> +> Please configure the offline push before uploading the token and use this capability, referring to this [documentation](https://www.tencentcloud.com/document/product/1047/50032). + +Tips for Call plug-in: +- Listening for the call invitation, when a new call comes, notify Native to launch the Flutter page if not currently located in. + +Tips for Push plug-in: +- The callback event of notification clicking, transmitted from Native, and used for navigating to corresponding chat from EXT data. + +Also, this is used as the home page of the chat module. It shows the loading status before logged in, followed by the conversation list. + +Besides, the current status of the application needs to be reported to the Tencent Cloud Chat backend upon each foreground/background switch from here. Referring to this [documentation](https://www.tencentcloud.com/document/product/1047/50032#step-5.-listen-for-the-foreground.2Fbackground-switch.3Ca-id.3D.22step_5.22.3E.3C.2Fa.3E). + +Detailed implementation can refer to the source code of the demo. + +#### Other widgets from TUIKit + +1. Create a file, `push.dart`, used for maintaining the [offline push plugin](https://www.tencentcloud.com/document/product/1047/50032). Detailed implementation can refer to the source code of the demo. + +2. Create a file, `conversation.dart`, used to implement group profile widget `TIMUIKitGroupProfile`. Detailed implementation can refer to the source code of the demo. + +3. Create a file, `user_profile.dart`, used to implement the user profile widget `TIMUIKitProfile`. Detailed implementation can refer to the source code of the demo. + +4. Create a file, `group_profile.dart`, used to implement group profile widget `TIMUIKitGroupProfile`. Detailed implementation can refer to the source code of the demo. + + +5. Create a file, `chat.dart`, used for implementing the history message list and sending messages widget `TIMUIKitChat`. This page can also navigate to `user_profile.dart` and `conversation.dart`.Detailed implementation can refer to the source code of the demo. + +Now, the Flutter module has been developed. + +### iOS Native development + +Here, we take `Swift` as an example, while `Objective-C` is also available. + +>? +> +> The following structure and code is for demonstration purposes only, you could modify it to meet your actual needs dynamically. + +Open your iOS project within XCode. + +If your existing application (MyApp) doesn’t already have a `Podfile`, follow the [CocoaPods getting started guide](https://guides.cocoapods.org/using/using-cocoapods.html) to add a Podfile to your project. + +#### Import Flutter Module + +Please refer to [this part](#ios), adding the Flutter module to your existing iOS app. + +#### FlutterEngine + +**Create a FlutterEngine.** + +The proper place to create a `FlutterEngine` is specific to your host app. As an example, we demonstrate creating a `FlutterEngine`, exposed as a property, on app startup in the app delegate. + +```swift +import UIKit +import Flutter +import FlutterPluginRegistrant + +@UIApplicationMain +class AppDelegate: FlutterAppDelegate { // More on the FlutterAppDelegate. + lazy var flutterEngine = FlutterEngine(name: "tencent cloud chat") + + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Runs the default Dart entrypoint with a default Flutter route. + flutterEngine.run(); + GeneratedPluginRegistrant.register(with: self.flutterEngine); + return super.application(application, didFinishLaunchingWithOptions: launchOptions); + } +} +``` + +**Create a singleton static object to manage the FlutterEngine.** + +This singleton is used for managing `FlutterEngine` in one place, and provides methods to the whole project to invoke methods related to the Flutter module. + +The basic implementation logic of the demo is that, using a new navigator for the ViewController of Flutter module, and show or hidden can be handled automatically according to call. + +Create a new file, `FlutterUtils.swift`, and coding, refer to our demo source code. + +Mainly focus on: + +- private override init(): Initialize each Flutter instance, register method channel events. +- func reportChatInfo(): Report the current user info to the Flutter module, for initialization and login Tencent Cloud Chat SDK. +- func launchChatFunc(): Present the ViewController for Flutter module. +- func triggerNotification(msg: String): Transit the data of notification, after the user clicks it, and Chat module may navigate to the corresponding chat page. + +**Listening and transit the notification click event** + +Only transit of the data of notification after clicking is necessary as, the initialization of Push plug-in, uploading token and the navigating for notification clicking events have been done in Flutter Chat module. + +The reason why we need to do this is the clicking event has been consumed by iOS Swift, so it is impossible for the Flutter Push plug-in to receive this event. + +Add the following codes to `AppDelegate.swift`. + +![](https://qcloudimg.tencent-cloud.cn/raw/9c816ae4745e5a8d2b9b1d64167e1fc5.png) + +Now, we finished the implementation for iOS. + +### Android Native Development + +Here, we take `Kotlin` as an example, while `Java` is also available. + +>? +> +> The following structure and code is for demonstration purposes only, you could modify it to meet your actual needs dynamically. + +Open your Android project within Android Studio. + +#### Import Flutter Module + +Please refer to [this part](#android), adding the Flutter module to your existing Android app. + +#### FlutterEngine + +**Create a singleton static object to manage the FlutterEngine.** + +This singleton is used for managing `FlutterEngine` in one place, and provides methods to the whole project to invoke methods related to the Flutter module. + +Create a new file, `FlutterUtils.kt`, and define a singleton static object `FlutterUtils`. + +```kotlin +@SuppressLint("StaticFieldLeak") +object FlutterUtils {} +``` + +**Create a `FlutterEngine`.** + +Define a `FlutterEngine` and corresponding `MethodChannel` in `FlutterUtils.kt`. + +```kotlin +lateinit var context : Context +private lateinit var flutterEngine:FlutterEngine + +// 初始化 +flutterEngine = FlutterEngine(context) +``` + +**Further developed for this singleton static object** + +The basic implementation logic of the demo is that, using a new navigator for the `Activity` of Flutter module, and show or hidden can be handled automatically according to call. + +Mainly focus on: + +- fun init(): Initialize each Flutter instance, register method channel events. +- fun reportChatInfo(): Report the current user info to the Flutter module, for initialization and login Tencent Cloud Chat SDK. +- fun launchChatFunc(): Present the `Activity` for Flutter module. +- fun triggerNotification(msg: String): Transit the data of notification, after the user clicks it, and Chat module may navigate to the corresponding chat page. + +Detailed implementation can refer to the source code of the demo. + +**Initialize the singleton static object above from the main entry `MyApplication`.** + +Transit the global context to the singleton static object, and initialize it from `MyApplication.kt`. + +```kotlin +class MyApplication : MultiDexApplication() { + + override fun onCreate() { + super.onCreate() + FlutterUtils.context = this // new + FlutterUtils.init() // new + } +} +``` + +**Listening and transit the notification click event** + +Only transit of the data of notification after clicking is necessary as, the initialization of Push plug-in, uploading token and the navigating for notification clicking events have been done in Flutter Chat module. + +The reason why we need to do this is the clicking event has been consumed by Android Kotlin, so it is impossible for the Flutter Push plug-in to receive this event. + +> Due to the diversity and inconsistency among different manufacturers, we only take OPPO as an example. For the whole manufacturer's support, please refer to this [documentation](https://www.tencentcloud.com/document/product/1047/50032). + +Add a new push certificate to the Tencent Cloud Chat console, Select **Open specified in-app page > activity** for the opening method and enter an activity to receive the notification clicking event with EXT data, it's suggested to set it as the home page or the main entrance. Like, we set `MainActivity` for our demo, `com.tencent.chat.android.MainActivity`. + +![](https://qcloudimg.tencent-cloud.cn/raw/fd384ea1140199113d01a6650c0c8f3d.png) + +Adding the following codes to the `Activity`, set for the console above. + +The EXT data of the notification can be found from `Bundle` when the `Activity` has been launched by the device, when the user clicks the notification. + +You can receive the EXT from `Activity`, and transit them to Flutter. + +You can refer to the demo source code for this capability. + +![](https://qcloudimg.tencent-cloud.cn/raw/2ec45c1a8b3bd952bcb86a8095f91515.png) + +Now, we finished the implementation for Android. + +[](id:native) + +## Additional solution: Initialize Tencent Cloud Chat from Native + +Sometimes, you may prefer to integrate a chat module to your existing UI without a complex chat page. + +Like, assuming that you have a game, and hope players can chat with each other during the match, without navigating to the full screen chat page. + +Means, you may not wish to launch a complex Flutter engine, before the user switches to the chat page, but hope they can still chat in a small module directly. + +In this case, you are supposed to initialize and login Tencent Cloud Chat with Native SDK. + +>? +> However, you can also choose to initialize and login within Flutter up to your needs. This process should only be executed once, no matter where you execute it. + +It's unnecessary to import Native SDK manually, as our Flutter SDK can help you integrate it. + +### Initialize and login + +Take the iOS Swift code as an example to demonstrate how to initialize and log in at Native. + +```swift +import ImSDK_Plus + + +func initTencentChat(){ + if(isLoginSuccess == true){ + return + } + let data = V2TIMManager.sharedInstance().initSDK( Yours SDKAPPID , config: nil); + if (data == true){ + V2TIMManager.sharedInstance().login( + chatInfo.userID, + userSig: chatInfo.userSig, + succ: { + self.isLoginSuccess = true + self.reportChatInfo() + }, + fail: onLoginFailed() + ) + } +} +``` + +After that, you could use the API provided by Native SDK to implement your chat modules to your existing UI page manually. + +For more information about the Native SDK, please refer to [this documentation](https://www.tencentcloud.com/document/product/1047/47968). + +### Initialize Flutter TUIKit + +The current user info should be provided to Flutter TUIKit, after initialized and logged in from Native, by invoking `_coreInstance.setDataFromNative()`. + +```dart +final CoreServicesImpl _coreInstance = TIMUIKitCore.getInstance(); +_coreInstance.setDataFromNative(userId: chatInfo?.userID ?? ""); +``` + +You can refer to the demo source code for this module. + +[![](https://qcloudimg.tencent-cloud.cn/raw/9ab7dc1c98627885eea01ddfd1803bb3.png)](https://github.com/TencentCloud/tencentchat-add-flutter-to-app/tree/main/Initialize%20from%20Native) + +----- + +That's all you need to add Tencent Cloud Chat to your existing application. + +You can easily add In-App Chat and Voice/Video Call to your application with Flutter. + +If there's anything unclear or you have more ideas, feel free to contact us! + +- Telegram Group: https://t.me/+1doS9AUBmndhNGNl +- WhatsApp Group: https://chat.whatsapp.com/Gfbxk7rQBqc8Rz4pzzP27A +- QQ Group: 788910197, chat in Chinese + +## Reference + +1. [Integrate a Flutter module into your Android project](https://docs.flutter.dev/development/add-to-app/android/project-setup). +2. [Integrate a Flutter module into your iOS project](https://docs.flutter.dev/development/add-to-app/ios/project-setup). +3. [Adding a Flutter screen to an iOS app](https://docs.flutter.dev/development/add-to-app/ios/add-flutter-screen?tab=no-engine-vc-swift-tab). +4. [Multiple Flutter screens or views](https://docs.flutter.dev/development/add-to-app/multiple-flutters). diff --git a/doc/表情.md b/doc/表情.md new file mode 100644 index 0000000..57fed56 --- /dev/null +++ b/doc/表情.md @@ -0,0 +1,453 @@ + +以下为您介绍,如何为腾讯云IM Flutter TUIKit引入表情能力。 + +我们的 `TIMUIKitChat` 组件中,支持发送及接收三种类型的表情: + +| 表情类型 | 发送形式 | 是否文字混排 | 发送内容 | 解析方式 | 引入方式 | TUIKit默认自带 | +|---------|---------|---------|---------|---------|---------|---------| +| [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) Emoji表情 | 文本消息 | 是 | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)编码 | 设备自动将[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)编码解析成小表情。不同的设备,对[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)解析后的图形,略有不同 | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) List | 文档中提供一套[默认Unicode列表示例](#unicode) | +| 小图片表情 | 文本消息 | 是 | 表情图片名称 | 根据名称,自动匹配本地Asset图片资源 | 图片资源预存于Asset,并定义 `List` | 一套 QQ 同款小表情图库,可直接使用 | +| 大图片表情 | 表情消息 | 否 | `baseURL` 拼接图片文件名,表情图片Asset路径 | 通过路径,解析Asset资源 | 图片资源预存于Asset,并定义 `List` | - | + +![](https://qcloudimg.tencent-cloud.cn/raw/bf06763d76d0c6e0c1871a1da401f6ab.png) + +现在,我们就来动手接入TUIKit的表情能力。 + +>? [TUIKit](https://pub.dev/packages/tencent_cloud_chat_uikit)在升级至1.1.0版本后,对表情能力用法有较大改动。请您在升级最新版后,根据本文档指引,重新适配接入表情能力。谢谢~ + +## STEP1: 自定义表情图片资源 + +>? **本步骤选做:** +> 如果您需要用到非默认提供的QQ小表情外的其他图片表情,如自定义图片小表情及图片大表情,才需完成本步骤。 +> QQ小表情包,TUIKit已自带提供,无需在本步骤引入,请关注后续步骤。 + +### 将文件导入项目 + +请将您的表情资源文件,导入项目的 `assets/custom_face_resource/` 目录内。无论是图片大表情,亦或是图片小表情,都需按此步骤引入。 + +该目录内,请使用不同的文件夹,区分表情面板中的不同Tab。每个Tab内表情,仅支持一种类型,图片大表情或图片小表情。 + +文件夹的名称,请使用该Tab的 `name` 命名。该命名不会对客户展现,请根据开发需要,自定义即可。 + +请保证,所有表情资源文件,不要重名。 + +此路径的表情图片放置引入,可参考我们的[Demo](https://github.com/TencentCloud/chat-demo-flutter/tree/main/assets/custom_face_resource)。 + +![](https://qcloudimg.tencent-cloud.cn/raw/9cf910304e3161bd17336054de3ba7d9.png) + +### 声明表情文件 + +打开 `pubspec.yaml` 文件,在 `flutter` => `assets` 中,声明刚刚引入的表情资源文件。 + +```yaml +flutter: + assets: + - assets/custom_face_resource/ +``` + +### 在代码中配置图片资源List + +>? 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/utils/constant.dart),直接看 `emojiList` 即可。 + +在您代码定义静态参数或配置的地方,定义一个 `static List`,用于将本地图片资源,转换成TUIKit可以接受的格式,后续以 `List` 方式传入。 + +此 `List` 中,每个item都是 `CustomEmojiFaceData`,每个 `CustomEmojiFaceData` 构成一个表情面板中的Tab。具体参数说明如下: + +```dart +CustomEmojiFaceData( + { + String name, // 文件夹目录名称 + String icon, // Tab中的icon资源文件名 + List list, // 每个图片的文件名,以List + bool isEmoji // 是否为图片小表情,默认为false,即图片大表情 + } +); +``` + +示例代码如下: + +```dart +static final List emojiList = [ + // 使用图片小表情,支持图文混排,以文本消息形式发送 + CustomEmojiFaceData( + name: '4349', + icon: "aircraft.png", + isEmoji: true, + list: [ + "aircraft.png", + "alarmClock.png", + "anger.png", + // ... + ]), + + // 使用图片大表情,不支持图文混排,以表情消息形式发送 + CustomEmojiFaceData( + name: '4350', + icon: "menu@2x.png", + list: [ + "yz00@2x.png", + // ... + ]), +] +``` + +## STEP2: 自定义Emoji Unicode 字符串List + +>? **本步骤选做:** +> 如果您需要用到[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) Emoji表情,才需完成本步骤。 + +在您代码定义静态参数或配置的地方,定义一个 `List>` Unicode 列表,供传入。 + +该列表,我们提供一套示例,在[附录](#unicode)中。 + +您看直接复制引入这套示例列表,或基于此修改。 + +## STEP3: 表情资源预读入内存 + +>? +> 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/pages/app.dart),直接看 `setCustomSticker` 方法即可。 +> QQ小表情包,TUIKit已自带提供,无需在本步骤引入,请关注后续步骤。 + +在您的项目启动后,首个 `TIMUIKitChat` 组件渲染前,将上一步定义的图片表情资源List,转换成TUIKit表情的实例,放入全局Provider中,存储于内存里。 + +**本步骤方法仅需执行一次,一次性全部读入内存中。** 因展示渲染表情资源为高频操作,如每次展示前才动态读入内存,对资源与性能占用比较大。 + +单个表情内存实例,使用 `CustomSticker` 类生成。如果传入了 `unicode` 则为 Unicode Emoji表情,否则为图片类型表情。 + +```dart +class CustomSticker { + int? unicode; // Unicode int值。如果传入了 `unicode` 则为 Unicode Emoji表情,否则为图片类型表情 + String name; // 表情名称 + int index; // 表情序号 + bool isEmoji; // 是否为图片小表情,默认为图片大表情 +} +``` + +每个Tab的内存实例,使用 `CustomStickerPackage` 类生成。 + +```dart +class CustomStickerPackage { // 一个系列的表情包定义为一个package,占据一个表情面板Tab + String name; // 表情包package name,该Tab文件夹名称。 + String? baseUrl; // 表情包package baseUrl,建议配置成:"assets/custom_face_resource/${表情包文件夹名称 即 表情包package name}" + List stickerList; // 表情资源列表 + CustomSticker menuItem; // 表情面包Tab按钮icon + bool isEmoji; // 是否为图片小表情,默认为图片大表情 +} +``` + +综上所述,需要写的代码,我们给出示例版本。 + +表情项一演示如何使用Emoji Unicode表情包,表情项二演示如何使用图片类型(包含大或小)表情包。您可以根据需要,使用全部或部分代码。 + +```dart +setCustomSticker() async { + // 定义一个大List来承载各个表情包 package Tab + List customStickerPackageList = []; + + // 表情项一:使用Emoji Unicode表情列表。可以嵌入文字内容中。 + // `emojiData` 来自于STEP2。 + final defEmojiList = emojiData.asMap().keys.map((emojiIndex) { + final emoji = Emoji.fromJson(emojiData[emojiIndex]); + return CustomSticker( + index: emojiIndex, name: emoji.name, unicode: emoji.unicode); + }).toList(); + customStickerPackageList.add(CustomStickerPackage( + name: "defaultEmoji", + stickerList: defEmojiList, + menuItem: defEmojiList[0])); + + // 表情项二:使用您提供的图片表情包。 + // 务必保证 `customEmojiPackage.name` 为该Tab文件夹名称。 + // `Const.emojiList` 来自于STEP1。 + customStickerPackageList.addAll(Const.emojiList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList()); + + Provider.of(context, listen: false) + .customStickerPackageList = customStickerPackageList; +} +``` + +## STEP4: 为 TIMUIKitChat 组件添加表情解析能力 + +>? +> 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/chat.dart),重点浏览 `renderCustomStickerPanel`, `customStickerPanel` 及 `customEmojiList` 即可。 + +将以下代码,直接拷贝进入您用于承载 `TIMUIKitChat` 组件的类中。 + +```dart +Widget renderCustomStickerPanel({ + sendTextMessage, + sendFaceMessage, + deleteText, + addCustomEmojiText, + addText, + List defaultCustomEmojiStickerList = const [], +}) { + final theme = Provider.of(context).theme; + final customStickerPackageList = + Provider.of(context).customStickerPackageList; + final defaultEmojiList = + defaultCustomEmojiStickerList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + isEmoji: customEmojiPackage.isEmoji, + isDefaultEmoji: true, + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList(); + return StickerPanel( + sendTextMsg: sendTextMessage, + sendFaceMsg: (index, data) => + sendFaceMessage(index + 1, (data.split("/")[3]).split("@")[0]), + deleteText: deleteText, + addText: addText, + addCustomEmojiText: addCustomEmojiText, + customStickerPackageList: [ + ...defaultEmojiList, + ...customStickerPackageList + ], + backgroundColor: theme.weakBackgroundColor, + lightPrimaryColor: theme.lightPrimaryColor); +} +``` + +### STEP4.1: 渲染图片小表情 + +>? **本步骤选做:** +> - 如果您的项目需要用到图片小表情,包括自定义图片小表情,或直接使用默认自带 QQ 同款图片小表情,才需完成本步骤。 +> - 图片小表情展现形式和Unicode Emoji类似,建议Unicode Emoji和图片小表情选用一个即可。即,如果您选用了Unicode Emoji,可直接跳过本步骤。 + +- STEP4.1(a) 为使用自定义图片小表情; +- STEP4.1(b) 为使用默认自带 QQ 同款图片小表情。 + +以上方案,建议直接选用一个方案即可。 + +如果需要同时使用,请保证您的自定义图片小表情名称,不要和我们默认提供的 QQ 同款图片小表情重复。 + +#### STEP4.1(a): 添加渲染解析自定义图片小表情的支持 + +在您用于承载 `TIMUIKitChat` 组件的 `build` 方法中,定义一个 `List customEmojiList` 变量,用于存放图片小表情列表。 + +```dart +List customEmojiList = + Const.emojiList.where((element) => element.isEmoji == true).toList(); +``` + +并将此列表,传入 `TIMUIKitChat` 组件的 `customEmojiStickerList` 参数内。 + +```dart +return TIMUIKitChat( + customEmojiStickerList: customEmojiList, + // ...... +); +``` + +>? 如果您用于承载 `TIMUIKitChat` 组件的类为 `StatefulWidget`,您可将 `customEmojiList` 变量,放如State中,仅在首次build时,才去执行 `where` 命令,优化性能。 + +#### STEP4.1(b): 启用 QQ 小表情包 + +将 `TIMUIKitChat` 的 `TIMUIKitChatConfig` 的 `isUseDefaultEmoji` 参数,设置为 `true` 即可。此时,会向表情包面板最左侧,自动生成一个承载 QQ 小表情包的 Tab。 + +```dart +return TIMUIKitChat( + config: TIMUIKitChatConfig( + isUseDefaultEmoji: true, + // ...... + ), + // ...... +); +``` + +![](https://qcloudimg.tencent-cloud.cn/raw/f096cf79710207a0db6f33936a55bbb0.png) + +### STEP4.2: 将表情包能力,注入 TIMUIKitChat + +将本步骤最开始让您复制的代码方法,传入 `TIMUIKitChat` 组件的 `customStickerPanel` 参数内。 + +```dart +return TIMUIKitChat( + customStickerPanel: renderCustomStickerPanel, + // ...... +); +``` + +此时,TUIKit表情能力接入完成。您可正常收发测试。如在接入过程中,有任何问题,欢迎随时[联系我们](#contact)。 + +[](id:unicode) + +## 附录: Emoji Unicode 列表示例 + +本列表仅用于示例演示,您可根据需要,增加或修改。 + +```dart +List> emojiData = [ + {"name": "GRINNING FACE WITH SMILING EYES", "unicode": 128513}, + {"name": "FACE WITH TEARS OF JOY", "unicode": 128514}, + {"name": "SMILING FACE WITH OPEN MOUTH", "unicode": 128515}, + {"name": "SMILING FACE WITH OPEN MOUTH AND SMILING EYES", "unicode": 128516}, + {"name": "SMILING FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128517}, + { + "name": "SMILING FACE WITH OPEN MOUTH AND TIGHTLY-CLOSED EYES", + "unicode": 128518 + }, + {"name": "WINKING FACE", "unicode": 128521}, + {"name": "SMILING FACE WITH SMILING EYES", "unicode": 128522}, + {"name": "FACE SAVOURING DELICIOUS FOOD", "unicode": 128523}, + {"name": "RELIEVED FACE", "unicode": 128524}, + {"name": "SMILING FACE WITH HEART-SHAPED EYES", "unicode": 128525}, + {"name": "SMIRKING FACE", "unicode": 128527}, + {"name": "UNAMUSED FACE", "unicode": 128530}, + {"name": "FACE WITH COLD SWEAT", "unicode": 128531}, + {"name": "PENSIVE FACE", "unicode": 128532}, + {"name": "CONFOUNDED FACE", "unicode": 128534}, + {"name": "FACE THROWING A KISS", "unicode": 128536}, + {"name": "KISSING FACE WITH CLOSED EYES", "unicode": 128538}, + {"name": "FACE WITH STUCK-OUT TONGUE AND WINKING EYE", "unicode": 128540}, + { + "name": "FACE WITH STUCK-OUT TONGUE AND TIGHTLY-CLOSED EYES", + "unicode": 128541 + }, + {"name": "DISAPPOINTED FACE", "unicode": 128542}, + {"name": "ANGRY FACE", "unicode": 128544}, + {"name": "POUTING FACE", "unicode": 128545}, + {"name": "CRYING FACE", "unicode": 128546}, + {"name": "PERSEVERING FACE", "unicode": 128547}, + {"name": "FACE WITH LOOK OF TRIUMPH", "unicode": 128548}, + {"name": "DISAPPOINTED BUT RELIEVED FACE", "unicode": 128549}, + {"name": "FEARFUL FACE", "unicode": 128552}, + {"name": "WEARY FACE", "unicode": 128553}, + {"name": "SLEEPY FACE", "unicode": 128554}, + {"name": "TIRED FACE", "unicode": 128555}, + {"name": "LOUDLY CRYING FACE", "unicode": 128557}, + {"name": "FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128560}, + {"name": "FACE SCREAMING IN FEAR", "unicode": 128561}, + {"name": "ASTONISHED FACE", "unicode": 128562}, + {"name": "FLUSHED FACE", "unicode": 128563}, + {"name": "DIZZY FACE", "unicode": 128565}, + {"name": "FACE WITH MEDICAL MASK", "unicode": 128567}, + {"name": "GRINNING CAT FACE WITH SMILING EYES", "unicode": 128568}, + {"name": "CAT FACE WITH TEARS OF JOY", "unicode": 128569}, + {"name": "SMILING CAT FACE WITH OPEN MOUTH", "unicode": 128570}, + {"name": "SMILING CAT FACE WITH HEART-SHAPED EYES", "unicode": 128571}, + {"name": "CAT FACE WITH WRY SMILE", "unicode": 128572}, + {"name": "KISSING CAT FACE WITH CLOSED EYES", "unicode": 128573}, + {"name": "POUTING CAT FACE", "unicode": 128574}, + {"name": "CRYING CAT FACE", "unicode": 128575}, + {"name": "WEARY CAT FACE", "unicode": 128576}, + {"name": "FACE WITH NO GOOD GESTURE", "unicode": 128581}, + {"name": "FACE WITH OK GESTURE", "unicode": 128582}, + {"name": "PERSON BOWING DEEPLY", "unicode": 128583}, + {"name": "SEE-NO-EVIL MONKEY", "unicode": 128584}, + {"name": "HEAR-NO-EVIL MONKEY", "unicode": 128585}, + {"name": "SPEAK-NO-EVIL MONKEY", "unicode": 128586}, + {"name": "HAPPY PERSON RAISING ONE HAND", "unicode": 128587}, + {"name": "PERSON RAISING BOTH HANDS IN CELEBRATION", "unicode": 128588}, + {"name": "PERSON FROWNING", "unicode": 128589}, + {"name": "PERSON WITH POUTING FACE", "unicode": 128590}, + {"name": "PERSON WITH FOLDED HANDS", "unicode": 128591}, + {"name": "BLACK SCISSORS", "unicode": 9986}, + {"name": "WHITE HEAVY CHECK MARK", "unicode": 9989}, + {"name": "AIRPLANE", "unicode": 9992}, + {"name": "ENVELOPE", "unicode": 9993}, + {"name": "RAISED FIST", "unicode": 9994}, + {"name": "RAISED HAND", "unicode": 9995}, + {"name": "VICTORY HAND", "unicode": 9996}, + {"name": "PENCIL", "unicode": 9999}, + {"name": "BLACK NIB", "unicode": 10002}, + {"name": "HEAVY CHECK MARK", "unicode": 10004}, + {"name": "HEAVY MULTIPLICATION X", "unicode": 10006}, + {"name": "SPARKLES", "unicode": 10024}, + {"name": "EIGHT SPOKED ASTERISK", "unicode": 10035}, + {"name": "EIGHT POINTED BLACK STAR", "unicode": 10036}, + {"name": "SNOWFLAKE", "unicode": 10052}, + {"name": "SPARKLE", "unicode": 10055}, + {"name": "CROSS MARK", "unicode": 10060}, + {"name": "NEGATIVE SQUARED CROSS MARK", "unicode": 10062}, + {"name": "BLACK QUESTION MARK ORNAMENT", "unicode": 10067}, + {"name": "WHITE QUESTION MARK ORNAMENT", "unicode": 10068}, + {"name": "WHITE EXCLAMATION MARK ORNAMENT", "unicode": 10069}, + {"name": "HEAVY EXCLAMATION MARK SYMBOL", "unicode": 10071}, + {"name": "HEAVY BLACK HEART", "unicode": 10084}, + {"name": "HEAVY PLUS SIGN", "unicode": 10133}, + {"name": "HEAVY MINUS SIGN", "unicode": 10134}, + {"name": "HEAVY DIVISION SIGN", "unicode": 10135}, + {"name": "BLACK RIGHTWARDS ARROW", "unicode": 10145}, + {"name": "CURLY LOOP", "unicode": 10160}, + {"name": "ROCKET", "unicode": 128640}, + {"name": "RAILWAY CAR", "unicode": 128643}, + {"name": "HIGH-SPEED TRAIN", "unicode": 128644}, + {"name": "HIGH-SPEED TRAIN WITH BULLET NOSE", "unicode": 128645}, + {"name": "METRO", "unicode": 128647}, + {"name": "STATION", "unicode": 128649}, + {"name": "BUS", "unicode": 128652}, + {"name": "BUS STOP", "unicode": 128655}, + {"name": "AMBULANCE", "unicode": 128657}, + {"name": "FIRE ENGINE", "unicode": 128658}, + {"name": "POLICE CAR", "unicode": 128659}, + {"name": "TAXI", "unicode": 128661}, + {"name": "AUTOMOBILE", "unicode": 128663}, + {"name": "RECREATIONAL VEHICLE", "unicode": 128665}, + {"name": "DELIVERY TRUCK", "unicode": 128666}, + {"name": "SHIP", "unicode": 128674}, + {"name": "SPEEDBOAT", "unicode": 128676}, + {"name": "HORIZONTAL TRAFFIC LIGHT", "unicode": 128677}, + {"name": "CONSTRUCTION SIGN", "unicode": 128679}, + {"name": "POLICE CARS REVOLVING LIGHT", "unicode": 128680}, + {"name": "TRIANGULAR FLAG ON POST", "unicode": 128681}, + {"name": "DOOR", "unicode": 128682}, + {"name": "NO ENTRY SIGN", "unicode": 128683}, + {"name": "SMOKING SYMBOL", "unicode": 128684}, + {"name": "NO SMOKING SYMBOL", "unicode": 128685}, + {"name": "BICYCLE", "unicode": 128690}, + {"name": "PEDESTRIAN", "unicode": 128694}, + {"name": "MENS SYMBOL", "unicode": 128697}, + {"name": "WOMENS SYMBOL", "unicode": 128698}, + {"name": "RESTROOM", "unicode": 128699}, + {"name": "BABY SYMBOL", "unicode": 128700}, + {"name": "TOILET", "unicode": 128701}, + {"name": "WATER CLOSET", "unicode": 128702}, + {"name": "BATH", "unicode": 128704}, + {"name": "CIRCLED LATIN CAPITAL LETTER M", "unicode": 9410}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER A", "unicode": 127344}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER B", "unicode": 127345}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER O", "unicode": 127358}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER P", "unicode": 127359}, + {"name": "NEGATIVE SQUARED AB", "unicode": 127374}, + {"name": "SQUARED CL", "unicode": 127377}, + {"name": "SQUARED COOL", "unicode": 127378}, + {"name": "SQUARED FREE", "unicode": 127379}, + {"name": "SQUARED ID", "unicode": 127380}, + {"name": "SQUARED NEW", "unicode": 127381}, +]; +``` + +[](id:contact) + +## 联系我们 + +如果您在接入使用过程中有任何疑问,请扫码加入微信群,或加入QQ群:788910197 咨询。 + +![](https://qcloudimg.tencent-cloud.cn/raw/e830ae8c7b8d9253eb71e7c3d9f7b2be.png) + diff --git a/doc/表情_en.md b/doc/表情_en.md new file mode 100644 index 0000000..3f5ddc2 --- /dev/null +++ b/doc/表情_en.md @@ -0,0 +1,457 @@ + +## Overview + +Tencent Cloud Chat Flutter TUIKit provides powerful Emoji and Sticker modules to help you customize the Emoji and Sticker sharing of your app. + +Through simple configurations, you can easily choose and integrate those three types of stickers to your app. + +| Sticker Type | Message Type | Embed to text | Sending content | Render | Import | Provide by default | +|---------|---------|---------|---------|---------|---------|---------| +| [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) Emoji | Text Message | Yes | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) |[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) to Emoji can be compiled automatically by devices, while different devices or platforms have different Emoji rendering | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) List | Sample [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) List is provided [here](#unicode) | +| Small Image Emoji | Text Message | Yes | Image name | Match local Asset image resources according to name automatically | Image stored in asset, and define `List` | A set of QQ Emoji is provided by default | +| Big Image Sticker | Sticker Message | No | `baseURL` join with image name, as asset path | Render image from asset | Image stored in asset, and define `List` | - | + +![](https://qcloudimg.tencent-cloud.cn/raw/023c3c716b481401c8da763e66ba08d1.png) + +Now, let's start integrating Emoji and Sticker to your app with TUIKit. + +>? The usage of this module has been modified since the 1.1.0 version of [TUIKit](https://pub.dev/packages/tencent_cloud_chat_uikit). Please check all the parts in this tutorial if you upgrade the version. + +## STEP 1: Customize Image Emoji and Sticker + +>? **This step is optional:** +> This step is necessary only if image stickers, except default QQ emoji one, includes both small and big, are needed. +> QQ Emoji set is provided by default, and is unnecessary to import in this step. + +### Import Image File to Project + +Please add your image resources file to `assets/custom_face_resource/` of your project, including both small and big images. + +In this directory, separate subdirectory with different sticker packages, means each Tabs on Sticker panel. Only one type of sticker is allowed in each Tab(package or subdirectory). + +Name those subdirectories differently, and this name will be used as the `name` field of `CustomEmojiFaceData` and `CustomStickerPackage` in the following steps. Please allocate the name as you need. + +Also, please make sure that all image resource files do not have the same name. + +You can refer to our [sample project](https://github.com/TencentCloud/chat-demo-flutter/tree/main/assets/custom_face_resource), if not clear. + +![](https://qcloudimg.tencent-cloud.cn/raw/060d5846a3ad0f5078f40eb05686f9ec.png) + +### Add assets to app + +Open `pubspec.yaml`, add those following lines to `flutter` => `assets`. + +```yaml +flutter: + assets: + - assets/custom_face_resource/ +``` + +### Configure assets list + +>? The sample code for this part can be found [here](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/utils/constant.dart), mainly focused on `emojiList`. + +Define a static `List` in your project, aiming for transferring the local image assets to TUIKit, as List. + +In this `List`, each item is `CustomEmojiFaceData`, while it constitutes each Tab in the sticker panel. + +```dart +CustomEmojiFaceData( + { + String name, // The name of the package and subdirectory. + String icon, // The file name of the icon on the Tab. + List list, // The list of the files name. + bool isEmoji //Whether it contains small image emojis, default is big image stickers. + } +); +``` + +Sample Code: + +```dart +static final List emojiList = [ + // Small Image Emoji, embedded in text messages. + CustomEmojiFaceData( + name: '4349', + icon: "aircraft.png", + isEmoji: true, + list: [ + "aircraft.png", + "alarmClock.png", + "anger.png", + // ... + ]), + + // Big image stickers, sent as sticker messages independently. + CustomEmojiFaceData( + name: '4350', + icon: "menu@2x.png", + list: [ + "yz00@2x.png", + // ... + ]), +] +``` + +## Step 2: Customize the Unicode Emoji List + +>? **This step is optional:** +> This step is necessary only if Unicode Emoji is needed. + +Define a static `List>` of Unicode in your project, you can build it based on the [sample list we provided](#unicode). + +You can add, delete and modify some items in this List, based on the official [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html). + + +## Step 3: Cache the Emoji and Sticker to memory + +>? +> - Sample code for this step [can be found here](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/pages/app.dart), mainly focus on `setCustomSticker` function. +> - QQ Emoji has been embedded by default, and unnecessary to do this step. + +Cache those Emoji and Stickers to global `Provider`, memory, **just after your app launched, and before the first `TIMUIKitChat` shows**. + +**This steps should only be done once.** Aiming for reducing the load of memory IO, as rendering each sticker is a high frequency event, and will cost a lot. + +The instance of each sticker is generated by the following `CustomSticker` class. It will show Unicode Emoji if `unicode` is not null, otherwise it shows an image. + +```dart +class CustomSticker { + int? unicode; // Unicode int value。It will show Unicode Emoji if this field is not null, otherwise it shows an image. + String name; // The name of the sticker + int index; // The index of the sticker + bool isEmoji; // Whether it is a small image emoji, while a big image sticker is as default. +} +``` + +The instance of each Tab on sticker panel, each sticker package, is generated by the `CustomStickerPackage` class. + +```dart +class CustomStickerPackage { // Each Tab on sticker panel, each sticker package + String name; // The name of this sticker package, subdirectory, and the Tab. + String? baseUrl; // Sticker package baseUrl,recommend specify as "assets/custom_face_resource/${package name}" + List stickerList; // The list of the image files name + CustomSticker menuItem; // The file name of the icon of Tab + bool isEmoji; // Whether it contains small image emojis, while big image stickers are as default. +} +``` + +For the classes shown above, we provide sample codes as follows, for the code you may need to write. + +`Solution A` shows the usage of Unicode Emoji while `Solution B` shows the usage of image stickers. You can choose all or part of them as needed. + + +```dart +setCustomSticker() async { + // Define a list to store sticker packages. + List customStickerPackageList = []; + + // Solution A: Use Emoji Unicode list. Can be added to text messages. + // `emojiData` comes from step 2. + final defEmojiList = emojiData.asMap().keys.map((emojiIndex) { + final emoji = Emoji.fromJson(emojiData[emojiIndex]); + return CustomSticker( + index: emojiIndex, name: emoji.name, unicode: emoji.unicode); + }).toList(); + customStickerPackageList.add(CustomStickerPackage( + name: "defaultEmoji", + stickerList: defEmojiList, + menuItem: defEmojiList[0])); + + // Solution B: Use the image sticker. + // Please make sure `customEmojiPackage.name` is the name of the subdirectory. + customStickerPackageList.addAll(Const.emojiList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList()); + + Provider.of(context, listen: false) + .customStickerPackageList = customStickerPackageList; +} +``` + +## STEP 4: Adding those stickers to TIMUIKitChat + +>? +> - Sample code for this step [can be found here](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/chat.dart), mainly focus on `renderCustomStickerPanel`, `customStickerPanel` and `customEmojiList`. + +Copy the following codes to the class that contains the `TIMUIKitChat` widget directly. + +```dart +Widget renderCustomStickerPanel({ + sendTextMessage, + sendFaceMessage, + deleteText, + addCustomEmojiText, + addText, + List defaultCustomEmojiStickerList = const [], +}) { + final theme = Provider.of(context).theme; + final customStickerPackageList = + Provider.of(context).customStickerPackageList; + final defaultEmojiList = + defaultCustomEmojiStickerList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + isEmoji: customEmojiPackage.isEmoji, + isDefaultEmoji: true, + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList(); + return StickerPanel( + sendTextMsg: sendTextMessage, + sendFaceMsg: (index, data) => + sendFaceMessage(index + 1, (data.split("/")[3]).split("@")[0]), + deleteText: deleteText, + addText: addText, + addCustomEmojiText: addCustomEmojiText, + customStickerPackageList: [ + ...defaultEmojiList, + ...customStickerPackageList + ], + backgroundColor: theme.weakBackgroundColor, + lightPrimaryColor: theme.lightPrimaryColor); +} +``` + +### STEP 4.1: Render Small Image Emoji + +>? **This step is optional:** +> - This step is necessary only if small images emoji are needed for your app, except the QQ Emoji we provided by default. +> - Unicode Emoji and small image emoji are similar, it is not recommended to integrate these two types of emoji at the same time. + +- STEP 4.1(a) shows the usage of using custom small image emoji. +- STEP 4.1(b) shows the usage of using default QQ emojis. + +It is recommended to choose one of them. + +If you tend to use both of them, please make sure those image resource files do not have the same name. + +#### STEP 4.1(a): Render custom small image emoji + +Add a `List customEmojiList` field to the `build` function of the `Widget` that contains `TIMUIKitChat`, storing the list of small image emoji. + +```dart +List customEmojiList = + Const.emojiList.where((element) => element.isEmoji == true).toList(); +``` + +And transferring this list to `customEmojiStickerList` of `TIMUIKitChat`. + +```dart +return TIMUIKitChat( + customEmojiStickerList: customEmojiList, + // ...... +); +``` + +>? +> If this widget is a `StatefulWidget`, choosing to place this list to state, and execute the `where` method once, to improve the performance are recommended. + +#### STEP 4.1(b): Enable QQ Emoji + +Enable the `isUseDefaultEmoji` of `TIMUIKitChatConfig` from `TIMUIKitChat` to `true`. Meanwhile, a Tab shows the default QQ Emoji will occur on the left of the sticker panel. + +```dart +return TIMUIKitChat( + config: TIMUIKitChatConfig( + isUseDefaultEmoji: true, + // ...... + ), + // ...... +); +``` + +![](https://qcloudimg.tencent-cloud.cn/raw/ed14b886c08cb1c0e8371ba54925bd71.png) + +### STEP 4.2: Add the sticker panel to TIMUIKitChat + +Transfer the function, you copied in this step, to the `customStickerPanel` field of `TIMUIKitChat`. + +```dart +return TIMUIKitChat( + customStickerPanel: renderCustomStickerPanel, + // ...... +); +``` + +That's all you need to integrate Emoji and Sticker modules to your app, with Tencent Cloud Chat Flutter TUIKit. + +[](id:unicode) + +## Appendix: Sample list of Emoji Unicodes + +The list is for sample and presentation purposes only, you can modify it as you need. + +```dart +List> emojiData = [ + {"name": "GRINNING FACE WITH SMILING EYES", "unicode": 128513}, + {"name": "FACE WITH TEARS OF JOY", "unicode": 128514}, + {"name": "SMILING FACE WITH OPEN MOUTH", "unicode": 128515}, + {"name": "SMILING FACE WITH OPEN MOUTH AND SMILING EYES", "unicode": 128516}, + {"name": "SMILING FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128517}, + { + "name": "SMILING FACE WITH OPEN MOUTH AND TIGHTLY-CLOSED EYES", + "unicode": 128518 + }, + {"name": "WINKING FACE", "unicode": 128521}, + {"name": "SMILING FACE WITH SMILING EYES", "unicode": 128522}, + {"name": "FACE SAVOURING DELICIOUS FOOD", "unicode": 128523}, + {"name": "RELIEVED FACE", "unicode": 128524}, + {"name": "SMILING FACE WITH HEART-SHAPED EYES", "unicode": 128525}, + {"name": "SMIRKING FACE", "unicode": 128527}, + {"name": "UNAMUSED FACE", "unicode": 128530}, + {"name": "FACE WITH COLD SWEAT", "unicode": 128531}, + {"name": "PENSIVE FACE", "unicode": 128532}, + {"name": "CONFOUNDED FACE", "unicode": 128534}, + {"name": "FACE THROWING A KISS", "unicode": 128536}, + {"name": "KISSING FACE WITH CLOSED EYES", "unicode": 128538}, + {"name": "FACE WITH STUCK-OUT TONGUE AND WINKING EYE", "unicode": 128540}, + { + "name": "FACE WITH STUCK-OUT TONGUE AND TIGHTLY-CLOSED EYES", + "unicode": 128541 + }, + {"name": "DISAPPOINTED FACE", "unicode": 128542}, + {"name": "ANGRY FACE", "unicode": 128544}, + {"name": "POUTING FACE", "unicode": 128545}, + {"name": "CRYING FACE", "unicode": 128546}, + {"name": "PERSEVERING FACE", "unicode": 128547}, + {"name": "FACE WITH LOOK OF TRIUMPH", "unicode": 128548}, + {"name": "DISAPPOINTED BUT RELIEVED FACE", "unicode": 128549}, + {"name": "FEARFUL FACE", "unicode": 128552}, + {"name": "WEARY FACE", "unicode": 128553}, + {"name": "SLEEPY FACE", "unicode": 128554}, + {"name": "TIRED FACE", "unicode": 128555}, + {"name": "LOUDLY CRYING FACE", "unicode": 128557}, + {"name": "FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128560}, + {"name": "FACE SCREAMING IN FEAR", "unicode": 128561}, + {"name": "ASTONISHED FACE", "unicode": 128562}, + {"name": "FLUSHED FACE", "unicode": 128563}, + {"name": "DIZZY FACE", "unicode": 128565}, + {"name": "FACE WITH MEDICAL MASK", "unicode": 128567}, + {"name": "GRINNING CAT FACE WITH SMILING EYES", "unicode": 128568}, + {"name": "CAT FACE WITH TEARS OF JOY", "unicode": 128569}, + {"name": "SMILING CAT FACE WITH OPEN MOUTH", "unicode": 128570}, + {"name": "SMILING CAT FACE WITH HEART-SHAPED EYES", "unicode": 128571}, + {"name": "CAT FACE WITH WRY SMILE", "unicode": 128572}, + {"name": "KISSING CAT FACE WITH CLOSED EYES", "unicode": 128573}, + {"name": "POUTING CAT FACE", "unicode": 128574}, + {"name": "CRYING CAT FACE", "unicode": 128575}, + {"name": "WEARY CAT FACE", "unicode": 128576}, + {"name": "FACE WITH NO GOOD GESTURE", "unicode": 128581}, + {"name": "FACE WITH OK GESTURE", "unicode": 128582}, + {"name": "PERSON BOWING DEEPLY", "unicode": 128583}, + {"name": "SEE-NO-EVIL MONKEY", "unicode": 128584}, + {"name": "HEAR-NO-EVIL MONKEY", "unicode": 128585}, + {"name": "SPEAK-NO-EVIL MONKEY", "unicode": 128586}, + {"name": "HAPPY PERSON RAISING ONE HAND", "unicode": 128587}, + {"name": "PERSON RAISING BOTH HANDS IN CELEBRATION", "unicode": 128588}, + {"name": "PERSON FROWNING", "unicode": 128589}, + {"name": "PERSON WITH POUTING FACE", "unicode": 128590}, + {"name": "PERSON WITH FOLDED HANDS", "unicode": 128591}, + {"name": "BLACK SCISSORS", "unicode": 9986}, + {"name": "WHITE HEAVY CHECK MARK", "unicode": 9989}, + {"name": "AIRPLANE", "unicode": 9992}, + {"name": "ENVELOPE", "unicode": 9993}, + {"name": "RAISED FIST", "unicode": 9994}, + {"name": "RAISED HAND", "unicode": 9995}, + {"name": "VICTORY HAND", "unicode": 9996}, + {"name": "PENCIL", "unicode": 9999}, + {"name": "BLACK NIB", "unicode": 10002}, + {"name": "HEAVY CHECK MARK", "unicode": 10004}, + {"name": "HEAVY MULTIPLICATION X", "unicode": 10006}, + {"name": "SPARKLES", "unicode": 10024}, + {"name": "EIGHT SPOKED ASTERISK", "unicode": 10035}, + {"name": "EIGHT POINTED BLACK STAR", "unicode": 10036}, + {"name": "SNOWFLAKE", "unicode": 10052}, + {"name": "SPARKLE", "unicode": 10055}, + {"name": "CROSS MARK", "unicode": 10060}, + {"name": "NEGATIVE SQUARED CROSS MARK", "unicode": 10062}, + {"name": "BLACK QUESTION MARK ORNAMENT", "unicode": 10067}, + {"name": "WHITE QUESTION MARK ORNAMENT", "unicode": 10068}, + {"name": "WHITE EXCLAMATION MARK ORNAMENT", "unicode": 10069}, + {"name": "HEAVY EXCLAMATION MARK SYMBOL", "unicode": 10071}, + {"name": "HEAVY BLACK HEART", "unicode": 10084}, + {"name": "HEAVY PLUS SIGN", "unicode": 10133}, + {"name": "HEAVY MINUS SIGN", "unicode": 10134}, + {"name": "HEAVY DIVISION SIGN", "unicode": 10135}, + {"name": "BLACK RIGHTWARDS ARROW", "unicode": 10145}, + {"name": "CURLY LOOP", "unicode": 10160}, + {"name": "ROCKET", "unicode": 128640}, + {"name": "RAILWAY CAR", "unicode": 128643}, + {"name": "HIGH-SPEED TRAIN", "unicode": 128644}, + {"name": "HIGH-SPEED TRAIN WITH BULLET NOSE", "unicode": 128645}, + {"name": "METRO", "unicode": 128647}, + {"name": "STATION", "unicode": 128649}, + {"name": "BUS", "unicode": 128652}, + {"name": "BUS STOP", "unicode": 128655}, + {"name": "AMBULANCE", "unicode": 128657}, + {"name": "FIRE ENGINE", "unicode": 128658}, + {"name": "POLICE CAR", "unicode": 128659}, + {"name": "TAXI", "unicode": 128661}, + {"name": "AUTOMOBILE", "unicode": 128663}, + {"name": "RECREATIONAL VEHICLE", "unicode": 128665}, + {"name": "DELIVERY TRUCK", "unicode": 128666}, + {"name": "SHIP", "unicode": 128674}, + {"name": "SPEEDBOAT", "unicode": 128676}, + {"name": "HORIZONTAL TRAFFIC LIGHT", "unicode": 128677}, + {"name": "CONSTRUCTION SIGN", "unicode": 128679}, + {"name": "POLICE CARS REVOLVING LIGHT", "unicode": 128680}, + {"name": "TRIANGULAR FLAG ON POST", "unicode": 128681}, + {"name": "DOOR", "unicode": 128682}, + {"name": "NO ENTRY SIGN", "unicode": 128683}, + {"name": "SMOKING SYMBOL", "unicode": 128684}, + {"name": "NO SMOKING SYMBOL", "unicode": 128685}, + {"name": "BICYCLE", "unicode": 128690}, + {"name": "PEDESTRIAN", "unicode": 128694}, + {"name": "MENS SYMBOL", "unicode": 128697}, + {"name": "WOMENS SYMBOL", "unicode": 128698}, + {"name": "RESTROOM", "unicode": 128699}, + {"name": "BABY SYMBOL", "unicode": 128700}, + {"name": "TOILET", "unicode": 128701}, + {"name": "WATER CLOSET", "unicode": 128702}, + {"name": "BATH", "unicode": 128704}, + {"name": "CIRCLED LATIN CAPITAL LETTER M", "unicode": 9410}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER A", "unicode": 127344}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER B", "unicode": 127345}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER O", "unicode": 127358}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER P", "unicode": 127359}, + {"name": "NEGATIVE SQUARED AB", "unicode": 127374}, + {"name": "SQUARED CL", "unicode": 127377}, + {"name": "SQUARED COOL", "unicode": 127378}, + {"name": "SQUARED FREE", "unicode": 127379}, + {"name": "SQUARED ID", "unicode": 127380}, + {"name": "SQUARED NEW", "unicode": 127381}, +]; +``` + +[](id:contact) + +## Contact Us + +If there's anything unclear or you have more ideas, feel free to contact us! + +- Telegram Group: https://t.me/+1doS9AUBmndhNGNl +- WhatsApp Group: https://chat.whatsapp.com/Gfbxk7rQBqc8Rz4pzzP27A + + diff --git a/doc/表情_国际中文.md b/doc/表情_国际中文.md new file mode 100644 index 0000000..de10516 --- /dev/null +++ b/doc/表情_国际中文.md @@ -0,0 +1,454 @@ + +以下为您介绍,如何为腾讯云IM Flutter TUIKit引入表情能力。 + +我们的 `TIMUIKitChat` 组件中,支持发送及接收三种类型的表情: + +| 表情类型 | 发送形式 | 是否文字混排 | 发送内容 | 解析方式 | 引入方式 | TUIKit默认自带 | +|---------|---------|---------|---------|---------|---------|---------| +| [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) Emoji表情 | 文本消息 | 是 | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)编码 | 设备自动将[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)编码解析成小表情。不同的设备,对[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html)解析后的图形,略有不同 | [Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) List | 文档中提供一套[默认Unicode列表示例](#unicode) | +| 小图片表情 | 文本消息 | 是 | 表情图片名称 | 根据名称,自动匹配本地Asset图片资源 | 图片资源预存于Asset,并定义 `List` | 一套 QQ 同款小表情图库,可直接使用 | +| 大图片表情 | 表情消息 | 否 | `baseURL` 拼接图片文件名,表情图片Asset路径 | 通过路径,解析Asset资源 | 图片资源预存于Asset,并定义 `List` | - | + +![](https://qcloudimg.tencent-cloud.cn/raw/023c3c716b481401c8da763e66ba08d1.png) + +现在,我们就来动手接入TUIKit的表情能力。 + +>? [TUIKit](https://pub.dev/packages/tencent_cloud_chat_uikit)在升级至1.1.0版本后,对表情能力用法有较大改动。请您在升级最新版后,根据本文档指引,重新适配接入表情能力。谢谢~ + +## STEP1: 自定义表情图片资源 + +>? **本步骤选做:** +> 如果您需要用到非默认提供的QQ小表情外的其他图片表情,如自定义图片小表情及图片大表情,才需完成本步骤。 +> QQ小表情包,TUIKit已自带提供,无需在本步骤引入,请关注后续步骤。 + +### 将文件导入项目 + +请将您的表情资源文件,导入项目的 `assets/custom_face_resource/` 目录内。无论是图片大表情,亦或是图片小表情,都需按此步骤引入。 + +该目录内,请使用不同的文件夹,区分表情面板中的不同Tab。每个Tab内表情,仅支持一种类型,图片大表情或图片小表情。 + +文件夹的名称,请使用该Tab的 `name` 命名。该命名不会对客户展现,请根据开发需要,自定义即可。 + +请保证,所有表情资源文件,不要重名。 + +此路径的表情图片放置引入,可参考我们的[Demo](https://github.com/TencentCloud/chat-demo-flutter/tree/main/assets/custom_face_resource)。 + +![](https://qcloudimg.tencent-cloud.cn/raw/060d5846a3ad0f5078f40eb05686f9ec.png) + +### 声明表情文件 + +打开 `pubspec.yaml` 文件,在 `flutter` => `assets` 中,声明刚刚引入的表情资源文件。 + +```yaml +flutter: + assets: + - assets/custom_face_resource/ +``` + +### 在代码中配置图片资源List + +>? 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/utils/constant.dart),直接看 `emojiList` 即可。 + +在您代码定义静态参数或配置的地方,定义一个 `static List`,用于将本地图片资源,转换成TUIKit可以接受的格式,后续以 `List` 方式传入。 + +此 `List` 中,每个item都是 `CustomEmojiFaceData`,每个 `CustomEmojiFaceData` 构成一个表情面板中的Tab。具体参数说明如下: + +```dart +CustomEmojiFaceData( + { + String name, // 文件夹目录名称 + String icon, // Tab中的icon资源文件名 + List list, // 每个图片的文件名,以List + bool isEmoji // 是否为图片小表情,默认为false,即图片大表情 + } +); +``` + +示例代码如下: + +```dart +static final List emojiList = [ + // 使用图片小表情,支持图文混排,以文本消息形式发送 + CustomEmojiFaceData( + name: '4349', + icon: "aircraft.png", + isEmoji: true, + list: [ + "aircraft.png", + "alarmClock.png", + "anger.png", + // ... + ]), + + // 使用图片大表情,不支持图文混排,以表情消息形式发送 + CustomEmojiFaceData( + name: '4350', + icon: "menu@2x.png", + list: [ + "yz00@2x.png", + // ... + ]), +] +``` + +## STEP2: 自定义Emoji Unicode 字符串List + +>? **本步骤选做:** +> 如果您需要用到[Unicode](https://unicode.org/emoji/charts/full-emoji-list.html) Emoji表情,才需完成本步骤。 + +在您代码定义静态参数或配置的地方,定义一个 `List>` Unicode 列表,供传入。 + +该列表,我们提供一套示例,在[附录](#unicode)中。 + +您看直接复制引入这套示例列表,或基于此修改。 + +## STEP3: 表情资源预读入内存 + +>? +> 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/pages/app.dart),直接看 `setCustomSticker` 方法即可。 +> QQ小表情包,TUIKit已自带提供,无需在本步骤引入,请关注后续步骤。 + +在您的项目启动后,首个 `TIMUIKitChat` 组件渲染前,将上一步定义的图片表情资源List,转换成TUIKit表情的实例,放入全局Provider中,存储于内存里。 + +**本步骤方法仅需执行一次,一次性全部读入内存中。** 因展示渲染表情资源为高频操作,如每次展示前才动态读入内存,对资源与性能占用比较大。 + +单个表情内存实例,使用 `CustomSticker` 类生成。如果传入了 `unicode` 则为 Unicode Emoji表情,否则为图片类型表情。 + +```dart +class CustomSticker { + int? unicode; // Unicode int值。如果传入了 `unicode` 则为 Unicode Emoji表情,否则为图片类型表情 + String name; // 表情名称 + int index; // 表情序号 + bool isEmoji; // 是否为图片小表情,默认为图片大表情 +} +``` + +每个Tab的内存实例,使用 `CustomStickerPackage` 类生成。 + +```dart +class CustomStickerPackage { // 一个系列的表情包定义为一个package,占据一个表情面板Tab + String name; // 表情包package name,该Tab文件夹名称。 + String? baseUrl; // 表情包package baseUrl,建议配置成:"assets/custom_face_resource/${表情包文件夹名称 即 表情包package name}" + List stickerList; // 表情资源列表 + CustomSticker menuItem; // 表情面包Tab按钮icon + bool isEmoji; // 是否为图片小表情,默认为图片大表情 +} +``` + +综上所述,需要写的代码,我们给出示例版本。 + +表情项一演示如何使用Emoji Unicode表情包,表情项二演示如何使用图片类型(包含大或小)表情包。您可以根据需要,使用全部或部分代码。 + +```dart +setCustomSticker() async { + // 定义一个大List来承载各个表情包 package Tab + List customStickerPackageList = []; + + // 表情项一:使用Emoji Unicode表情列表。可以嵌入文字内容中。 + // `emojiData` 来自于STEP2。 + final defEmojiList = emojiData.asMap().keys.map((emojiIndex) { + final emoji = Emoji.fromJson(emojiData[emojiIndex]); + return CustomSticker( + index: emojiIndex, name: emoji.name, unicode: emoji.unicode); + }).toList(); + customStickerPackageList.add(CustomStickerPackage( + name: "defaultEmoji", + stickerList: defEmojiList, + menuItem: defEmojiList[0])); + + // 表情项二:使用您提供的图片表情包。 + // 务必保证 `customEmojiPackage.name` 为该Tab文件夹名称。 + // `Const.emojiList` 来自于STEP1。 + customStickerPackageList.addAll(Const.emojiList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList()); + + Provider.of(context, listen: false) + .customStickerPackageList = customStickerPackageList; +} +``` + +## STEP4: 为 TIMUIKitChat 组件添加表情解析能力 + +>? +> 本步骤代码示例请[参考此处](https://github.com/TencentCloud/chat-demo-flutter/blob/main/lib/src/chat.dart),重点浏览 `renderCustomStickerPanel`, `customStickerPanel` 及 `customEmojiList` 即可。 + +将以下代码,直接拷贝进入您用于承载 `TIMUIKitChat` 组件的类中。 + +```dart +Widget renderCustomStickerPanel({ + sendTextMessage, + sendFaceMessage, + deleteText, + addCustomEmojiText, + addText, + List defaultCustomEmojiStickerList = const [], +}) { + final theme = Provider.of(context).theme; + final customStickerPackageList = + Provider.of(context).customStickerPackageList; + final defaultEmojiList = + defaultCustomEmojiStickerList.map((customEmojiPackage) { + return CustomStickerPackage( + name: customEmojiPackage.name, + baseUrl: "assets/custom_face_resource/${customEmojiPackage.name}", + isEmoji: customEmojiPackage.isEmoji, + isDefaultEmoji: true, + stickerList: customEmojiPackage.list + .asMap() + .keys + .map((idx) => + CustomSticker(index: idx, name: customEmojiPackage.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: customEmojiPackage.icon, + )); + }).toList(); + return StickerPanel( + sendTextMsg: sendTextMessage, + sendFaceMsg: (index, data) => + sendFaceMessage(index + 1, (data.split("/")[3]).split("@")[0]), + deleteText: deleteText, + addText: addText, + addCustomEmojiText: addCustomEmojiText, + customStickerPackageList: [ + ...defaultEmojiList, + ...customStickerPackageList + ], + backgroundColor: theme.weakBackgroundColor, + lightPrimaryColor: theme.lightPrimaryColor); +} +``` + +### STEP4.1: 渲染图片小表情 + +>? **本步骤选做:** +> - 如果您的项目需要用到图片小表情,包括自定义图片小表情,或直接使用默认自带 QQ 同款图片小表情,才需完成本步骤。 +> - 图片小表情展现形式和Unicode Emoji类似,建议Unicode Emoji和图片小表情选用一个即可。即,如果您选用了Unicode Emoji,可直接跳过本步骤。 + +- STEP4.1(a) 为使用自定义图片小表情; +- STEP4.1(b) 为使用默认自带 QQ 同款图片小表情。 + +以上方案,建议直接选用一个方案即可。 + +如果需要同时使用,请保证您的自定义图片小表情名称,不要和我们默认提供的 QQ 同款图片小表情重复。 + +#### STEP4.1(a): 添加渲染解析自定义图片小表情的支持 + +在您用于承载 `TIMUIKitChat` 组件的 `build` 方法中,定义一个 `List customEmojiList` 变量,用于存放图片小表情列表。 + +```dart +List customEmojiList = + Const.emojiList.where((element) => element.isEmoji == true).toList(); +``` + +并将此列表,传入 `TIMUIKitChat` 组件的 `customEmojiStickerList` 参数内。 + +```dart +return TIMUIKitChat( + customEmojiStickerList: customEmojiList, + // ...... +); +``` + +>? 如果您用于承载 `TIMUIKitChat` 组件的类为 `StatefulWidget`,您可将 `customEmojiList` 变量,放如State中,仅在首次build时,才去执行 `where` 命令,优化性能。 + +#### STEP4.1(b): 启用 QQ 小表情包 + +将 `TIMUIKitChat` 的 `TIMUIKitChatConfig` 的 `isUseDefaultEmoji` 参数,设置为 `true` 即可。此时,会向表情包面板最左侧,自动生成一个承载 QQ 小表情包的 Tab。 + +```dart +return TIMUIKitChat( + config: TIMUIKitChatConfig( + isUseDefaultEmoji: true, + // ...... + ), + // ...... +); +``` + +![](https://qcloudimg.tencent-cloud.cn/raw/ed14b886c08cb1c0e8371ba54925bd71.png) + +### STEP4.2: 将表情包能力,注入 TIMUIKitChat + +将本步骤最开始让您复制的代码方法,传入 `TIMUIKitChat` 组件的 `customStickerPanel` 参数内。 + +```dart +return TIMUIKitChat( + customStickerPanel: renderCustomStickerPanel, + // ...... +); +``` + +此时,TUIKit表情能力接入完成。您可正常收发测试。如在接入过程中,有任何问题,欢迎随时[联系我们](#contact)。 + +[](id:unicode) + +## 附录: Emoji Unicode 列表示例 + +本列表仅用于示例演示,您可根据需要,增加或修改。 + +```dart +List> emojiData = [ + {"name": "GRINNING FACE WITH SMILING EYES", "unicode": 128513}, + {"name": "FACE WITH TEARS OF JOY", "unicode": 128514}, + {"name": "SMILING FACE WITH OPEN MOUTH", "unicode": 128515}, + {"name": "SMILING FACE WITH OPEN MOUTH AND SMILING EYES", "unicode": 128516}, + {"name": "SMILING FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128517}, + { + "name": "SMILING FACE WITH OPEN MOUTH AND TIGHTLY-CLOSED EYES", + "unicode": 128518 + }, + {"name": "WINKING FACE", "unicode": 128521}, + {"name": "SMILING FACE WITH SMILING EYES", "unicode": 128522}, + {"name": "FACE SAVOURING DELICIOUS FOOD", "unicode": 128523}, + {"name": "RELIEVED FACE", "unicode": 128524}, + {"name": "SMILING FACE WITH HEART-SHAPED EYES", "unicode": 128525}, + {"name": "SMIRKING FACE", "unicode": 128527}, + {"name": "UNAMUSED FACE", "unicode": 128530}, + {"name": "FACE WITH COLD SWEAT", "unicode": 128531}, + {"name": "PENSIVE FACE", "unicode": 128532}, + {"name": "CONFOUNDED FACE", "unicode": 128534}, + {"name": "FACE THROWING A KISS", "unicode": 128536}, + {"name": "KISSING FACE WITH CLOSED EYES", "unicode": 128538}, + {"name": "FACE WITH STUCK-OUT TONGUE AND WINKING EYE", "unicode": 128540}, + { + "name": "FACE WITH STUCK-OUT TONGUE AND TIGHTLY-CLOSED EYES", + "unicode": 128541 + }, + {"name": "DISAPPOINTED FACE", "unicode": 128542}, + {"name": "ANGRY FACE", "unicode": 128544}, + {"name": "POUTING FACE", "unicode": 128545}, + {"name": "CRYING FACE", "unicode": 128546}, + {"name": "PERSEVERING FACE", "unicode": 128547}, + {"name": "FACE WITH LOOK OF TRIUMPH", "unicode": 128548}, + {"name": "DISAPPOINTED BUT RELIEVED FACE", "unicode": 128549}, + {"name": "FEARFUL FACE", "unicode": 128552}, + {"name": "WEARY FACE", "unicode": 128553}, + {"name": "SLEEPY FACE", "unicode": 128554}, + {"name": "TIRED FACE", "unicode": 128555}, + {"name": "LOUDLY CRYING FACE", "unicode": 128557}, + {"name": "FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128560}, + {"name": "FACE SCREAMING IN FEAR", "unicode": 128561}, + {"name": "ASTONISHED FACE", "unicode": 128562}, + {"name": "FLUSHED FACE", "unicode": 128563}, + {"name": "DIZZY FACE", "unicode": 128565}, + {"name": "FACE WITH MEDICAL MASK", "unicode": 128567}, + {"name": "GRINNING CAT FACE WITH SMILING EYES", "unicode": 128568}, + {"name": "CAT FACE WITH TEARS OF JOY", "unicode": 128569}, + {"name": "SMILING CAT FACE WITH OPEN MOUTH", "unicode": 128570}, + {"name": "SMILING CAT FACE WITH HEART-SHAPED EYES", "unicode": 128571}, + {"name": "CAT FACE WITH WRY SMILE", "unicode": 128572}, + {"name": "KISSING CAT FACE WITH CLOSED EYES", "unicode": 128573}, + {"name": "POUTING CAT FACE", "unicode": 128574}, + {"name": "CRYING CAT FACE", "unicode": 128575}, + {"name": "WEARY CAT FACE", "unicode": 128576}, + {"name": "FACE WITH NO GOOD GESTURE", "unicode": 128581}, + {"name": "FACE WITH OK GESTURE", "unicode": 128582}, + {"name": "PERSON BOWING DEEPLY", "unicode": 128583}, + {"name": "SEE-NO-EVIL MONKEY", "unicode": 128584}, + {"name": "HEAR-NO-EVIL MONKEY", "unicode": 128585}, + {"name": "SPEAK-NO-EVIL MONKEY", "unicode": 128586}, + {"name": "HAPPY PERSON RAISING ONE HAND", "unicode": 128587}, + {"name": "PERSON RAISING BOTH HANDS IN CELEBRATION", "unicode": 128588}, + {"name": "PERSON FROWNING", "unicode": 128589}, + {"name": "PERSON WITH POUTING FACE", "unicode": 128590}, + {"name": "PERSON WITH FOLDED HANDS", "unicode": 128591}, + {"name": "BLACK SCISSORS", "unicode": 9986}, + {"name": "WHITE HEAVY CHECK MARK", "unicode": 9989}, + {"name": "AIRPLANE", "unicode": 9992}, + {"name": "ENVELOPE", "unicode": 9993}, + {"name": "RAISED FIST", "unicode": 9994}, + {"name": "RAISED HAND", "unicode": 9995}, + {"name": "VICTORY HAND", "unicode": 9996}, + {"name": "PENCIL", "unicode": 9999}, + {"name": "BLACK NIB", "unicode": 10002}, + {"name": "HEAVY CHECK MARK", "unicode": 10004}, + {"name": "HEAVY MULTIPLICATION X", "unicode": 10006}, + {"name": "SPARKLES", "unicode": 10024}, + {"name": "EIGHT SPOKED ASTERISK", "unicode": 10035}, + {"name": "EIGHT POINTED BLACK STAR", "unicode": 10036}, + {"name": "SNOWFLAKE", "unicode": 10052}, + {"name": "SPARKLE", "unicode": 10055}, + {"name": "CROSS MARK", "unicode": 10060}, + {"name": "NEGATIVE SQUARED CROSS MARK", "unicode": 10062}, + {"name": "BLACK QUESTION MARK ORNAMENT", "unicode": 10067}, + {"name": "WHITE QUESTION MARK ORNAMENT", "unicode": 10068}, + {"name": "WHITE EXCLAMATION MARK ORNAMENT", "unicode": 10069}, + {"name": "HEAVY EXCLAMATION MARK SYMBOL", "unicode": 10071}, + {"name": "HEAVY BLACK HEART", "unicode": 10084}, + {"name": "HEAVY PLUS SIGN", "unicode": 10133}, + {"name": "HEAVY MINUS SIGN", "unicode": 10134}, + {"name": "HEAVY DIVISION SIGN", "unicode": 10135}, + {"name": "BLACK RIGHTWARDS ARROW", "unicode": 10145}, + {"name": "CURLY LOOP", "unicode": 10160}, + {"name": "ROCKET", "unicode": 128640}, + {"name": "RAILWAY CAR", "unicode": 128643}, + {"name": "HIGH-SPEED TRAIN", "unicode": 128644}, + {"name": "HIGH-SPEED TRAIN WITH BULLET NOSE", "unicode": 128645}, + {"name": "METRO", "unicode": 128647}, + {"name": "STATION", "unicode": 128649}, + {"name": "BUS", "unicode": 128652}, + {"name": "BUS STOP", "unicode": 128655}, + {"name": "AMBULANCE", "unicode": 128657}, + {"name": "FIRE ENGINE", "unicode": 128658}, + {"name": "POLICE CAR", "unicode": 128659}, + {"name": "TAXI", "unicode": 128661}, + {"name": "AUTOMOBILE", "unicode": 128663}, + {"name": "RECREATIONAL VEHICLE", "unicode": 128665}, + {"name": "DELIVERY TRUCK", "unicode": 128666}, + {"name": "SHIP", "unicode": 128674}, + {"name": "SPEEDBOAT", "unicode": 128676}, + {"name": "HORIZONTAL TRAFFIC LIGHT", "unicode": 128677}, + {"name": "CONSTRUCTION SIGN", "unicode": 128679}, + {"name": "POLICE CARS REVOLVING LIGHT", "unicode": 128680}, + {"name": "TRIANGULAR FLAG ON POST", "unicode": 128681}, + {"name": "DOOR", "unicode": 128682}, + {"name": "NO ENTRY SIGN", "unicode": 128683}, + {"name": "SMOKING SYMBOL", "unicode": 128684}, + {"name": "NO SMOKING SYMBOL", "unicode": 128685}, + {"name": "BICYCLE", "unicode": 128690}, + {"name": "PEDESTRIAN", "unicode": 128694}, + {"name": "MENS SYMBOL", "unicode": 128697}, + {"name": "WOMENS SYMBOL", "unicode": 128698}, + {"name": "RESTROOM", "unicode": 128699}, + {"name": "BABY SYMBOL", "unicode": 128700}, + {"name": "TOILET", "unicode": 128701}, + {"name": "WATER CLOSET", "unicode": 128702}, + {"name": "BATH", "unicode": 128704}, + {"name": "CIRCLED LATIN CAPITAL LETTER M", "unicode": 9410}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER A", "unicode": 127344}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER B", "unicode": 127345}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER O", "unicode": 127358}, + {"name": "NEGATIVE SQUARED LATIN CAPITAL LETTER P", "unicode": 127359}, + {"name": "NEGATIVE SQUARED AB", "unicode": 127374}, + {"name": "SQUARED CL", "unicode": 127377}, + {"name": "SQUARED COOL", "unicode": 127378}, + {"name": "SQUARED FREE", "unicode": 127379}, + {"name": "SQUARED ID", "unicode": 127380}, + {"name": "SQUARED NEW", "unicode": 127381}, +]; +``` + +[](id:contact) + +## 联系我们 + +If there's anything unclear or you have more ideas, feel free to contact us! + +- Telegram Group: https://t.me/+1doS9AUBmndhNGNl +- WhatsApp Group: https://chat.whatsapp.com/Gfbxk7rQBqc8Rz4pzzP27A + diff --git a/images/add.svg b/images/add.svg new file mode 100644 index 0000000..84e4d21 --- /dev/null +++ b/images/add.svg @@ -0,0 +1,22 @@ + + + 编组 4 + + + + + + + + + + + + + + + + + + + diff --git a/images/arrow_back.png b/images/arrow_back.png new file mode 100644 index 0000000000000000000000000000000000000000..edfaddd899d485131b104e2a7e0bfd91d996754f GIT binary patch literal 1811 zcmV+u2kiKXP)Px*%}GQ-RCr$PolC4|~bVQU&%0SWEE$e3QYxnfK-`VH!op1L$>n1n1xjkop z`|jWQuf5J%Yws=CSJopSS@+I5hXKm`AuvD&DDx4>ynCh~1C;p)WZpefkO9hk1TycQ zDaZgd9|7mwK7f8ti->F9;@;Cj_6l;&Nr0mN)}ieaq^tos=jMsXv~%t>;AG%E5!oJo zzxU{%eowOn6u~(ccpEqv_z1YlLz-1q{XakdJF^7joSQEqJDqc91MdKb0{;U00vmxP zBJ#V=O(L=@|6#7*!|VV>$~oOD<}hF*bH1OBEL?ulCl95!8soI1kj%WM)48<8`vLME+SLGB<8Dw`aR16P*8w# zP8IVmaLNGX6czLnus}qRu07~zSl%sRa`@oq)loQO|Z@^_DvT2gx%)IlJj^GF{ zh?JvV+FIb8A#j4Z+YT%ekUh8 zpx_!i=X9cf1GsbuoM6@z&b1=4whzh)kU9qx!PyU316(u2tf_MJTitAh^IBh&GZsKT z*r_3!UCy~zfa?>KqjS$KBC>kY9fCpAeP;@aBhk*emB1ZCl%stOrtMA9Hhobv*3t9K+O zKAlh&2N;=&i$XXz0ndA7s7eNXW=JPYiOBN4o3)X@s7A`U8kmZp44ZQ_YkHlC>@d^z zKcdzZ6e(wkhjYM?$aGN72O7cwG>y=!qL5BaTm|0Y49eha^VOiA8=Q1Fl>rs%2-I6UF9FVNsHYA71I*%2Il`FtI?s3cXb z=2IgBSBl7bgHsPz(i{}Bp+4ZLMIMVw5^mb=Kfnzl@~WA&dT^3VT%;T=@_0$hZHK&s z#zh_qXN@VRKAcoQGwX1ib1wp#I+)8L2pLaR&ukrmz2}SwP@%|U1#nMEkw?h&+$SO{ zOgYWKlm<&9cY2w33RF3ojvh2`g?F6?MdS&E)5itB?I78`l{uY+hJbomBGomZ*%j5! zIWuvi82yAcOWo|KRChISqd5r)quSynDS&1wYn*fHX1}a;1LkJ8NT8&tQb2lt;YC%K ziY&4h)ovgq0rHT7k~Gw#(S!>v)H8ZW$$$!wbkS96u=EdLnFUMh!K?(xLkiooSh}I! zm{+>1GN6KzG-0Q!vdEoV!WoVzM?Up0wk?Z(5liS5(*c;@`a0AO-XZe zYXwjN(gMFJ#UWPuTUjuB+|U}J0;Gk0a|$b-Z^)=sKn3rv%JLH}-&$(RPxhqQI-mk1 zTfmi@fb8IV^8fLB&K;E;9i%?bpx0fXR!mcB>nNkg~wQjNP$f^%pX9|kV zoi8P}4gC^$bq*+)I}fSKmY9F}T^HB&DY!ZoK!seA);DMoif(>UR+w*_$|k6;wM!ao z%dC55Z|sAT#-^YFA)&+lnpdrFnCnOY+t|D{c~oeqa5R9X&h>DOxwerB@a$-S3S->( zQafB@@DPsO#Kqkz98aU=T*un+G>xDAb{nt*o^~J4tQ0gLO<_l*wfp?H*IJ|`-7G^l z2ed;5+Z*0?T8bdqF`exUY5T3%LAULXY3nW8QPHgrsWsTv2xRHSwe`nMLOTX1bBYGY z0A)S`nRm|=WPmasfy}#S3Nk>Mk3i<#GX-T1=s&8Rd7b0oYP + + 编组 5 + + + + + + + + + + + + + diff --git a/images/chat_permission_icon_camera.png b/images/chat_permission_icon_camera.png new file mode 100644 index 0000000000000000000000000000000000000000..96fa6fb459863ab8ed0f0ddff5f7ff85b4723099 GIT binary patch literal 3507 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 literal 0 HcmV?d00001 diff --git a/images/chat_permission_icon_file.png b/images/chat_permission_icon_file.png new file mode 100644 index 0000000000000000000000000000000000000000..f530ec6b497620c7aa8ba9dec1193e00cc949367 GIT binary patch literal 2209 zcmeHH`BT$Z5P$g+m~aI&ibWX+TT%lw0HyDN$4qIgJvIU>ZAh>_5={P;d5(2$FRt=D&ix$!E>fX9}EB@SLjH#-KY?J#YR&d~AL6VF4qa}I83Bkcmi zm-{pmcPl6awo=mXLBfK8s^JQa*$Mmg7+e2Cn9Z+P zzC9(sMNUN82Tf-@AijNM{%eHX^rj|bHDrQNMA`yP=R6*^#7HMq5PcT2`PVO2DhBvX9%^QR|n$4al-X#0XaALIJ|Cvm5d9TNc8Uuw_3bx zrp5ARwVTt-`&Ui}^eztLv^75=p<^IrC-5ABQ-ghm_g@EHkp&^SdJ_($C%4_sf-DC0 zYm=}cT`awDmtia_2mA2J2y1iZDXdti;@xqD?J~3DKrM+Hb^yLwSzBqk^b(6Ko3p%G z8tOZIev-%3s>&Ig4O2{AzNYkf-2S%E7ICd79DR;fzV95)`WSI>#5;d_6v%E&ZDfU9>h8J1-veo);zbGBWf%xhZ!64q>uuyb6 z_3dHy#oBqFn+DIuaz<;kf#wnFJgf4Iup@I6|#~v!W7^ zFlJQS&8scfz)WzL6Z-EA8YfeCL>KUx^om1<;b&S*2pOz0Lobn^kSi`<@0Cw$6nHXD z(#qu2hb{<gp7!USJTGfxt*P*OW#jn9!qV+Gpz@ z+f5xN3{C#{x}JE143dWWGfe1RuBHqh2;vW>304Ld3g^^EnxFSK!V!$<2Kb_8K(OV* zONa)kCrB1XY6NERlo)$;qmt0qBJ&iY92Y8;1^Vc%$pj?Z5FY5j$RG=6HqPEgUNhwv z+dL8+-Nm=(C=96LUKQoy^kA^Q(7asDbXmlwngxRxQ+qT@8RTSzLTm{+Wf7rQaJp!) z*Ju##C&}}fe5}AYgabrEscg+=Wx(d%QiXEppyLlORIm;j_@x{%f$xRE(qnGv_8r85 zn(ZYd1X0p1fu~zRCLIKu;=7eQBp-hP@DBs&X#)L;`zk2eY&V1fu&ey$@=w&0$w}}o z-FOwCc`S3H)4ZzP0V1L7D(kAhI l?(GyCI6@hXqXQW9#rXe7Fy6)xgG$7{XW3bWCI8;9e*wmHGu8kA literal 0 HcmV?d00001 diff --git a/images/chat_permission_icon_mic.png b/images/chat_permission_icon_mic.png new file mode 100644 index 0000000000000000000000000000000000000000..dbef7a3c0d3f90ee2192be62f5418bb5dbbe9a16 GIT binary patch literal 3678 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 VL-=K|5ap($6ZgQDu=C#;`#)QDfXx5^ literal 0 HcmV?d00001 diff --git a/images/clear.png b/images/clear.png new file mode 100644 index 0000000000000000000000000000000000000000..d9b5d06e41f1030af317ba7158c9d9bb456c5a4d GIT binary patch literal 2952 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 literal 0 HcmV?d00001 diff --git a/images/close.png b/images/close.png new file mode 100644 index 0000000000000000000000000000000000000000..7392796ced2e829ba99f147bccd657e4194eb6f5 GIT binary patch literal 4995 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 N002ovPDHLkV1nqJxjz5^ literal 0 HcmV?d00001 diff --git a/images/copy_message.png b/images/copy_message.png new file mode 100644 index 0000000000000000000000000000000000000000..8a3488454162bc8b1d0df42c48c97880ff637852 GIT binary patch literal 1192 zcmZ{kYc$(;7{~uvav}{`MQuW{3973e5|>##gK-<{KL%E1VPz2QC zswb+kB_*R3{%0-zHsLF|?TC5nRqPFNMzvqz?}FiLX(HJTPypT^d&& z&JW$$Vc*4zOuV7e20KarS+}7;wkcFP9Kg;ZP?;>i3SOO;2fRw_)BgjwnSHot0OnTc zn%8Y`rPr(@`ano8683i3=nV$L&Qgk@t&Y{RT@xv<8UD4HYMr+rfLNXHTd#2_+)6Si zYRv(A*_WEA|I36>F(2!8AoYWqu`OsHj;&mV`s9w^SLC9Y>FK-UJRa{FGFSWUp{OVI zX4NUJgwBq!H?JYSi@^!0#yxg-BKeuT?mw8o%B-&)Ly&G;1c6DWJ zsnu%c`cW)IjOJDqjddKXsSKk&??OUzV+eJbLlM z`)n(Di!2-1ww!5>hcLrF_lq(4?_dGhDHzH8)4{K%j^%`0yx-G#&>}pnvl$ zwzzB*Q$C70I)U#8EKZ3Ej7zuDo{|$kE4>E}LN|T-5ga^*(;%(2go41E&&Wi>6L4^U zF8(7p2rj>^X|ZM#ab`K#gm5zu$l;SyAzRNoYdv#Ve+t(cbq5-PuWj6h2|rc z+(T?;0HqVDV9}Dq>1k + + 编组 5 + + + + + + + + + + + + + + + + + + + + diff --git a/images/default_c2c_head.png b/images/default_c2c_head.png new file mode 100644 index 0000000000000000000000000000000000000000..a9a44d0219cc93c8986db12dea38902b7dd2ac6d GIT binary patch literal 1801 zcmXw)c|6qlAIHbB%2AA*OP2`AI?54#88NQhXY3}S#p*&4Ka-5yyYgdyirA)=4uo1p zvKF~!%nZgoj3VX8F=}$p=kwXG{XKq<$MgMszdxVPpRdp7@yT#=vD+o_tppB-+vQ+y zn#!|oKWvRicyhZ9zCbMhi$n>d7SA>>_#2u;YFiJa-klZMpd-fhz;JcKrkW|HJ>5T>v2j5yBdT2tXu&XbYkZfYt$86QETA zS`nbH0>sC>h4@?O%NF{)g?O82X%j7Op!p5-X#>rzqnULyy@o!np$}_lY86ecqKOss zZUv2fg(G}4ybT=U!@)0b;0x@>_zc;fVb3z`UWQBz9%S%f7Z1{TuyYA^EWx%#*ov_L zTNYsR0&JXz^`Btf9Hh>{npybAEPOEot7l-<46K}n&!=I_5Ag8^_;3nR z-h&74VbLTkoP-4v@ZJQxI{|-v2lC#+JL4dG9Q=Zr3$nN%lM7NuLGmadb3hUYB#wZD zVGutI;)cMDArLzRVg|vrK@d3rF86~={oo=-ABgA!KlOodHV9*b3v3Y53xa#W`5qA1 z1J3n;fF5wR8+_jl{4ukDKMVM=z!?_s#b5#-Ch%qgF9z^r01pOmX8K+e=nF^Q-PRuncG}C;+y_2RqMD#(>}7hlr#`RI@5Sw} zb`5I>lw){tjcWgr-YxqkNzr|}brP@MzD!d&Mb1$YE%>vUh|h4peR}^PKhf9ZN{Y@p z+IcTet~xc!L8XoG{_VYeRmVH$N*9u4W%PCTnwwAB)u;JbUKY+EM)fQf?p3vT>>diT z(@302346%dvgDO;|9)n-GB9n|ouLXk`d-hK5)v5g4+ZM4~$A0l_Dn0QR?qyAI`H#;Yd1bz-)swZ(ueuc} z=~wK?p;G5J?rurD;zjH&>&KrKpk5Ev41G1z;vFH#6bbS*lHbvK zsjh@RH{j_NeCLQ~rAYHpznfMzVS!#Jf4#I4GBg)(URj}_=-C}cdEtUm!cj`hkCbC1 zU)!p)(rS85L4mc}+V+Y35eJh$S2l7KY3ssNkoOK+jHmah7+*Z%w}Tx$tamy{M#>Vk zjq7mY#AB1R6F=}hy*oS z?fd0IxM9?_gwbiSq31<=()IL7=L8g~5^)pN1l^Jh=PJXF)`X{{vtgT%hb&H>iWN}O zXsc!Rc48-dJFl1)nMdzTjnsT-0crh4JZ8K0yGQv_6>*f15T~RSmvu+9VvxPtS#_%v zl5cQ&j|KR3_!EniEuSh&UJYJd7S(z3SM_vHfa+{jrs)ZNP6k0&i?Sy=x7N5LOyv4f z9{r-69bwZ@NwOt(*SFO^zD*Tos@4=ixXl@vpU;ID z`I841s!r^A(yLY@7W~rI;WtCw$D#6?S~zl{&F{i21e0(0_R0&A$qg~)GX6;;8HeNKhTj8I5i*d;=kP&d~%-yn)`^GI@jdV;bJy^lgv z4-zM!6th_g>?uv~WaM(RzQL{<;PkQdS literal 0 HcmV?d00001 diff --git a/images/default_group_head.png b/images/default_group_head.png new file mode 100644 index 0000000000000000000000000000000000000000..30d1732fbfbe93006ac74be541c7ad15bd052e5e GIT binary patch literal 8426 zcmd6MbyQr>66Oq$-~@-DgS$g;CrAhq+?@di4NTBr!Gl9^cME}Ja0%}27TgK$&JMr# z_PyP6_P_mO_s;35y4BTRb>FVrGv})?Rb@F$G!irb0D!3=FQW#RtrqtWk8U%>A{=JIMv0Dvbw0N@u00Nlb&emelbCr$uh&jbJvN(KOkoYEUU zh`>7nEp-&YN=g7`xQ+sN0mKC$!ZjfL0|1f$p3&eM;2n_kKe`%_@!viO06>Th0O{X8 zdT{xC$-~?8n14ycY~X(pvl0H|-I|T~U;TO5Z=gmWT%bD1>$(5{=y=Z!2uMvQhNJ1) zXy`z7l->)OJKD3GS~!|nvb)eJA-Mi*=AZ`s z;{vr6rPfhW1xY(PTY}!ObFy<%i=lx)AQ5K^u#lRJ?7xS@??kDsp-?9w4h}arH+DB3 zc1LF`4lY4KK@LuC4sLEXxCfhyhXd5qoz208=3gNH6Gz6<#oXD(32Ni$0D8tXHFNv~ z6{V(r4)mYvU-N|8fd3oG!R6n}f-jKcxrc*`os;7~*zlnu&sHH7XB$g+J>l@+QBFWLP5Dsfn}9`SwmaSfTvzfIaFWIvdEKh^blhG z0$EQ+Usa||lNt+2GAssw8pef)>W@L|Soh$&{U^iC*=5;1t3!k3Ls7P708TzkDmQQraI57?^6OP!C_OG z-ro>a2A32^A%uDfP?%2K^opPN?&V104~1%qjax~_?4Ci%rh<2mkNI3DB;rJgu5^E? zRf9W9d+%1E)~EAQD)r`0L87gIg zoQ&4l*&y&2``DyU*7EiYNxSud7U>;*{otWy$#^S6MVCu6xqmDU z3YmC#-!I<5F$|7)16(Oecxv>bV9*+qc7+z#IrgcM^&ijU!#0m6-p1e+qBSR5cP-JtDJ-%(Ls?qKY6w|Wtrwo z=uR=6n5q=XvcmmD25tsU1MzgmSrudh%_Lg~Uh^Ro1fDe{73iBw@x-^OxcL>o#?f6> z>=R{ehz+l_f=mIaJ6gj@`49$QhTaMIg@~aNK5|GQ)#VI!;e-+a-Td6`kLZXH4gtym z#W+sLq4W1f?rwl>iAj_?L>YS@jwWG07Z3#L4>0#l3(*pJs~|QX-1$S=GWrqL3Rx%# z9HDsVD98=j2}>r@Pgr7d>#DsGQ2;qtwv`+NIrCaw1g0Z?pjpAfuD)qLJ_HQ=|KEhU zn#rk2K{PCNZn#(t>)b{Bid=hlo*C@pt7NLrwuUwOvTndkn9^cK40ho)NHMXHysaXC zWq-)$mBV`SC2z1$Md1pwSNOWFiV!~U={4K zTc8!JQ6w@%$}IJ%`&E{qOmSg4-+I)YxR=TjsrgD2^1VC{n@>IA5_s=0g~vt0Z;fSm&mYXwyeCVV9oUK*2e|{L3Kc8ol0t zrr!7_LlWs$@@%gAIOW|V*HpugKftbhr_xG`Xl8NrHu12wpcDw;qQll!z7?7Udc$>LFepyg|_w^kvc`L!_=H+yL*qPp(j zMGQ%x0})hOe_P4z@E1s0jx??}zk(A%}73P(x0S#-ZSz zfU$e(oDK8cN0;hf8u2!NvIA)Br_$RyqW_8oeqz3ph0&o^(EoL)j%D`q^w_dDUOhtg zyHB&&=t`Bi9Y7y4@_~4&_p1hf*^hSQzI$v2^WW=~`%kZ`_+yk3*rFZq z!B6K#i<&hSm4O`z8PmtPu!(nTg-59m3(-SHys%m2Y|q?eDa<Qb@ba%^fgwLkY~%Y`3Zb?UUdjPhE>(yGEXwV5j3LQ;Ag zwMKDH^Uk)H#pNGW3v>F1d`?$H92SsZnYD$)(=2ZGzdardd-6Tl`m>iN{bHUf@J|g& zUwz~Z#_XmPfjw%W;ORg(k37JXUUGVAxN3pstM5<*jrq2RT2!A_$DkFzAPs@MeF05d zJQ$@-urCff@rn4)o-8A{??LpKcp&7yC72EUTr6ERaaH3Ss)1%LPPO!|z94qSWgf2t$Tz|NT?Tf|G zn=yh6DsfBVGK&jj=}IK^O14z|QhBFzgaiB!KQx9$CWt^lz6EzW>`q@;?de( zv{$g)=;HuB@ba~!Cv719+*vma^CsXCe&}py-T~O4W>q0rj-fXJhwFVwB5hb0ZMg5- zYJiMAd9ni)Oy3YyrA2SPLmqjCjGB16e&FCDolDyf$oG@6NrKK%@-enK^?vE(j28X2 zbiMN4U6RcJDf&F0dBx-@RFqIDeyGfs;oXXphgY-ybje@r$MpQA1Oebf&B(qpx8jmi zNrZ8X6P}Jxs}j1|LMPDvx_gY+rgtvN{22kPkvHONV5&xlEL(b-eZ71*uR@v#f$I)Ns+Oqh22vdHftNcT{wM^Goby;oI3RHN?1t>rvZ$%bM-wfSDGG&{cy=n>vsk4 z(VYq`?QnFEXPEk%pt#_$AkAliB-^?~-hW_iZG+CnP!~Zy%`T9N*9VT{vaR@bw$c^n z);>>e+u9e>d@AjLu97pWWb$(2dQImh@CPa#kgUBJ8R=+S66F5gWts0z98=fmf&`}B zBs0LuoAZWbZn?~KYT3Fpr((Bx8zvdHy>DORJi6WPjD?DS)(BLOR3!OkE1=NMn&AhJcM4b#7b(yMlVxkZ zt`=Ne6Y#_6?8N@7pGxpF4STU0U2_>wCl|KCU_-l^!*KC^q?-EfAk{F#{*AUWXVJUa z^2RDvr43`4l5&po;3%IWU!uyrDDF)_)DG`FA-w@lq9bR)zDc?U=Pmme{B9sFf;FR4 zV<}M`8P6~)h5#svMb4cV2#LuXB9zf9gppL5PX9(6Qu3?5vsNNYBHGSy5Awli-MYu z<<4m%lC?f5N5w9=CsF+!?*!I}EtaYkC4XUSh_2o*pC~=~RuY3o6$4nm|4=>I5LwQG zI}qG6j*yK?h1j4(xT~uj5Fl2RkUmmMx6<+Ej($gESYZjI{wZoK5h;Vh$F9mM`auM~ zW1YZmACDfR55&j-Q~64{jJ~^>eD`(yA#8|<{GjuyF-z2THxA@rHSml|*nt3J^7`f(gJBNcoz5U-FKKy zqV{Mk9DIy?Al+IoE+WH%rTD_+bWO5a~;XovG~SI6BJ5^-|TCU zq-qPC@fPHXC^(8(BJ+Urd`rTbGWFRE1M!0H2_~Wb&~DPn z>PlsqHMuO_LI?Nt+3F*tn+5Bo3UA)KY;qMbZ>-(^)Bs2t_qJ@IipD)CrVfn0Y0%b0 z!zNm5jKT8DDUN;ZnlJ7&eqTi=-y2h%8<8pxrcCqC_994nnjvo}KNjBY)DS&&$?NU- z8oTTh1$ijrbMZ?5p-mFQl^_4PNHB6wd?v)U-meM-Izkc9W|L}}Mp-PFwx;0nkiBv` zG&p#ghFow}<*((xz10fGi7L>)rR*P$f{=WE!BDUja!+@mQuo^^yraM)NlFMHn?sMT ze0-5L5X!yr8OJ&mGA!{U&|2tf#qq#|lvmlZeD)z~o$d>$3 zUsJtw8nc>hmse3rFW>tcNWa@!|z{IgzX%YOU0b#aO9i2c@)9Yt+v z$K9A?u?Y2*Pql0HmjGu5B7Rtt68YZ=6=m&DzbY{q07Sh*F)Zu%7@x+?##b>FxxVV1 zA%90Z0_0@Jm{tgtkX{vo@jSN4<9~Fe6gUP`rMzAIK2qZi5tNBoFYM!jer%qPuA1)8 zdAy~Dx&3Au0wSaFed>j2F3&anz8CkT9mr_VmUu|_q~Y!B{f55^CY(Nl<)>(7u-cgu zCvIX69&`6~35ZK)MC4B?mT7JZ#_h_k1N`1c5fqD$ACaIS-pkvA2WWdMql2;nn@^9qC2z&2rcuxtS~$)sr~qJhaW+KiKJy zh_+sgcv+*=_|4NJsWSA@a-6K>=7Arg`nVgII|FyHzp69~|A<3Bk0!~q9N^JV{JlNI zbOWPO5U3zu-jP}o05yC!%G&GpIS`Y%9(q6u#bWpy6M8VWMZ8O!6H{Z3HwL0G)dl_( zUXJl@>21>HV;q@#g_J|LB5kk3!fLNmrn>6wDBrWAkhkPfJyX5M?~#Xi_F^Z{0eTPr zWtOTJkkw`4u|y8|>SOh@r1^JmOP)0_PC$ni1obfBdOXuSz(c3Z`#>5hd4NpiYJ(8g z!zdL>w@0elC`Aa7AD_*!86BZ+i z7!J_uK;=?Uf?UfSs}#KR#EUb=YflyB7JJpZ*rWt2@g4pC*RkqYX&L=3u~1NqP2<*!rTce5O4Qg$ zrpvL3*l0O;4G+gJ6BLEY;bS z7?>Yi<=66YYxGLhBv7giP?@dh#N^@8?wm$IP6)D8h$gu@8+D={iHNK*N7mX>c4N;M zed-NQD&HfA{tD7=mq9Rp%DxNL3ATSzUMYmp&S$I4@MbcY*fUV;EcWZzQ`?9 zsxcCU5{HYB*J9@{*yO!rHd;iy=M4lH3?@*QC5tM2M)n_SA%fkwn9)8?$3P(2tLMnlsyDfnE+_?i?s`q($#^t-zYu=t!q&At0j)K?67r>b*ptNT|%IW!HAI^HRKDjJ{E=j2#) z(_8=bx0w)iHoxWcp@owTVET$rJlJ#U*CgX4_yTf2mi#`6zFLI5qi8ZVonJpEVGT0A zuYr)wBQ)N&OqWsHi;9aHRPso6Kr|b4`trT_vE>ERWIO&Q)5m&8U?m&5k(p0>rb)3$ z-55#ZWs_>2Gb!!QlW@YR%rXBKptSSp+IiXxBh`92QXQccvLcdQ8}awHrUqfrjMR$CZa+u>u>jMD3n@Cx;j6=6^jg;}6^j!1p!StekL9s?Do3O_g>&KNG;Jw3AO$&QN zo2rj&5{Yi>rL$Bi!IQ~`C*=RGNiz@_9p#>M|>qLIxrvSE;CdeGpN(?3Al zuP>ras7;PKvgJkr78ymWeKZ)F6EF^h%X$Z3s>25rPN8SEk*A(WARNvo5@Ie#VZjNS zo%e??hT#lL%-fFIJUHhA5{mcohrtgAi?QQ_9XP=gI=o@D!U|`pba_7u|38B0EkfM! z=QZ?XoTg}Q{i5wpy;^^&mXW2~6~xANtks3$72$L0l4ss6Jf z?JJ8^)}fgL-o_(In0VQ55KR+q^@lujlclO*^=J=iAxC`$qr{@XJ{k`UFO!*AA0Z9d zR~r5iJ70%)C^G+iRy~q!8@pKLIyY%i<`~rfZWVZmmL&3yog9#TXL)eriF)4RtG(Q8wa=W3yGot4X$wjfx~=u3Zw70DN zGwGiMsl{qV)MW^bK*oPreoX{(%W|yQ5J-`f>2)$Sa7De`$k37dd$LT#%IRh1q6fRY z-1V>Y^f;$&4vk7Q%QB+KnEbKcZ*xzSLvyQ?k}f31(SaC_`fG5QNScXq($&I^rV_97cuu9b%=}n^^&1tIlm!ND=3mA4M_rA0vRpg zJ~u~Wab;ZE4Em#<&@;A?_c?3pEi~}0eJ~dC%9ISxc_6sIML()uo03BFQRIMzb&e&( zUB;7v3Y~}-WH21yvT@~XnCF9CkSerNyx zQ0@N*MjwED+@mO~8`HAw!B? z-0D$htaZ>*^X(G;LeKj(S&iYKXy6Wq-)+9@)6zE1?k4p$AoC~2CiXjXYBBgRI}$g8 zrrE@R9kAIPAthlD0=Kft=}f_EV*QKV3;9h1PQm!({=hn{?Ny(fw3(zgrJYwRsJKVS zw4B#12sElC&JPrIlnpH`cjpZ%3b&1|1y{Lmqa_4aJ-f8N1fm!-dEecRGutnZi!$vZ zTNnoui|S!Xl*?2v8}9KS;d`qfDEQn|xk0Bc4P z>5$>fG-*k1_jca0;89V`p#B|)&+n2J6>p#?A|2&x1jeLv8bDgt`40z{-L5t5<;Rrq zA^$Ul!B;n4p3Ih58n*}Wcql>dATQMTdp&jyABu70`@{i7t8d6c{yI?@j1%CXN~IE- zIO{k+*UTCj<{Le)7!Ck;O)lvs8&A%`fSk{$r)BGR1q$_Vst6(pGAHAn*KEM3c=gyxfU0&p(2o}b&gLrfuNz-jVfA@kM}-H!hiJLMS798 zen4j)6x{u>bmk_B#%Tk`FH4~_Zt`~T_-hFZiWlw__aCfM3gwN!`vkQeJnNxO?${?n zLsd1M$pVGIU%x;;xtb`igU|05aI6^+V#u+$?IvFFuNM0xt+4*VgH{ufNcKViV2@d_?tK9`=w5NZY27K6OS_3>6Ay?QiML0iqRCx zrLsp!1fZy%9TCjA7M~gxC2#l;*8Hp~YN(P62{U`2#hXsa-13C{@f&8+Oy_sf=L){U MTVR literal 0 HcmV?d00001 diff --git a/images/delete.png b/images/delete.png new file mode 100644 index 0000000000000000000000000000000000000000..ed25567ba17459efdf26b845d08513aba5145b78 GIT binary patch literal 11825 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 P0SDw|R8U3I#xMULayiQZ literal 0 HcmV?d00001 diff --git a/images/delete_emoji.png b/images/delete_emoji.png new file mode 100644 index 0000000000000000000000000000000000000000..d04c45dc9d28a3ce263f12ef6e013f22ebcf2d93 GIT binary patch literal 1707 zcmV;c22}ZpP)Px*Wl2OqRA@uxnoVe2RTRhn=S?PU(-bSE;KD__60t6XqO_p2v1I0bOe07pR$PdP zZQZDdrWPbqL82A3JA>lFMQxgh+U9-C(6mWGrPWHYqF`$+ifFa2)Fe$a^NyUEcV{N! zd}q=xAA~I4eed4iz31b9&Kp9L*Tlp`+vd%i(~jdjz@QX>r2u>Y)Z{B$`B)m@^GuY8 z8OFgxBB3?4sF9J8P&^*b*mi0iD02kBZ2&R=Lakiw0^h~iP1op2OV{6P7*s)~QmM6b z>C7Q^`%CQZ{ap-||SxpYR{=}uWhNF%duPz8DD(4iHfa5x3V1^{UQOwhKP zW;ChBTy?^t303SEbKDOJcvx-`w7$APnK?UdbaXV_(b;(zj0k`$0NTN{&9d}inPAhV zO=7;+^qQQU3`vl*9q$u>d$NnsC|(m(AouLqLt?Uy;|?>>2VhowfWSLV(;QN}cufg| z$yE$-9QR`e9;ypeAV)_>wQPiSoFRa=1Gp^C&fwiK(;U2>AO)%^(1N`?g9O^8APLxO znnwTi2H6y7K@fPm7(A#THNrcFVQiP3>+S9JiAchEU6)vh_>c@fe*CyE?ojJNHIGPL3LKf9`p4?+?VZbp>;_L##@n`iD-*p$Oe>k&-iXDzqKFT6N5 zI8Jg0;O}J6r|hR+h{a;l)qv{)RgkvlMd0({Kv}dgaKcZ|b@%l2{M}5hRL|J1djO0_ z0;r1|(xS0w^y^|@SLQ7#V$5+81R4M!n;T|Af6OrUR*MZaLGyvf^EQyrqAHeWK^fnM z!=bK7B=ScykZSj30UgUbPXoAH`8$B$Iiy7ttmI*p2a6y@lnCfD0u7p`zPoAAyhu-_ z)*+`~R|mWP6WH&vEbGT|CRQtusQdZ%yKX-iuLX)qa<~MhJnYEGNP9dUzfuG#0z`sc z8-Vj|A?^NXH2PaprmIfL^Sm_~-#-S>+F*fy61dAW&2JV0sbJOOY&ZE9*nhSZRwL+! zVVt&Y_Z1Lc4hn?;k}t3yj)+g!vZ|`4`_cILs`l9{lX;QOnAS8z4a4}XmASHTvSne< z5pa}&9YLWWsizI#3mgtdV%_BfSE*H11+^XL1Aw*#yXY&C-bzT7UJ6RfdlTU20;rt! z&i$#}sx=B(RWNv-cSpwOuY%wsrO0D3!`R$~ zOV{=98*!bAC+fOxx6epsQzP)DX&Uz~7GyDOzRGaj&x4XrPErcx+<%R>S-L*#y6zIg zFr#7KPO;6PE1{jT)6KX26(P%&1!~PZDs(2Yo@{Cxf6UOr?1?+mx#YTM@Kc9t~;q zpswp@8VLrK7NO=TF9!Gm0a4R5&pM8~kC6>fa(e#}6${FvCGE=+Wz^nfSzB77jK*B| zMPj@jK**lV%af{VvYmYDQKl-&=w|}oXPV~4rj(I7fxOlo^EQyydaWCD4$lHuS*VES zivg)O%j8*smHt!*m>#z*{r#r3ZYgY#a>i8k0^p=Z=r#=FuUe*4YPxFov)#kC@OJ=i z&y6X_x?RrBa#}qFa4~ScOtX_) zK$%ww^ec1w^OanpxahWB_X#jXpkcqI>xXO2T{oyKaIrcJrNy=CE|v>mi{4=#gk*Ger_wQjYX^uOo=mR1{%;|(#m zsIN=AW2Sy{^mP?%rB1y@c-kb$Dn*P; zOmuckUrqtMQGHEQML?rDww_IHbj*Ujrj^15D(7g>(UbadiZ$BWxpe7?8~*#Kf>jAQ z*G%JyoAQfyDQq&CyepmY$8XBtFqDysH^i?}{smnXuNp#gkOu$&002ovPDHLkV1g~E BJ=FjJ literal 0 HcmV?d00001 diff --git a/images/delete_message.png b/images/delete_message.png new file mode 100644 index 0000000000000000000000000000000000000000..e06c3186686fa00e184f71e2cdd1c7b0900b0a26 GIT binary patch literal 1084 zcmeAS@N?(olHy`uVBq!ia0vp^6+m3a!3HGX+g?2nq!^2X+?^QKos)UVz`*>?)5S5Q zBJS;6$G*!BA}((|b~1`T;L(X_YyyOawU*9l1SvJ^N4 z?8=$`_`c1y&%5ue`~G@+u3LcEpOd#5jb875{EUks{T>SgTfsBFl)7pai32mOC;WUn zF@fR!xyHMB-yaoN*zCN^cWK?8xb^n$zW>(Gy!tsz*XrQxv(J35mzIByS`?qVHR`UZ z@8_`im+_1bIBnQII0$=waClsG+T%k7Kev$01s5Q;5VEWz+zWM1xeOUjxqKPU zc?X<ik`!i$XxQqjyLYXNejnPlQeW!B zhUlcw`;0&b^CLNUIbTTV*H5$5W3Sup4Lf}Pf7t?^)4#L#ZOQ%pFzW1z_BE?kSv_UC z;`iF&_O?q`rgq71<9d+UxH5|&OqM&0|3SimD{q-1N_8S^8=4t|w{s_4-I(x|fsf7S zu3kgv@usWH77_-fyTOX>*&q0G&a?4e@p5{N@ax4>?1jvK{fw%;z3u$^t6%E%>R0&) zhTEsFik-QsUG<92L3RddYsLfa#hxGhm1XQEBnYjs-q3P!$@{8bqTU}GS^Up`-LBMN z+3bI-+xW%|vvw0swSy`*Ca&3O;%NY67B4+pC@bXCz&DzyKw#;BzUHo9pJ;PZtv-YU*vb=40c!9;B;r~mAQ}6wE&NgD@e}8Zf z`|>X*GkX^o%g*Y_nESS06k_NMV~gqKUdLMsGFHuetk~VIvo+nKx!%&PsrCJ_yv;Jd z_wE!QF%-U@{P_~sxrB2Gdne2I_w$|oH`nru>@2Nb1J1HP%9^!6 zdl~Gk-uz%&UAAkn+yMr=m)h=&+a)jdwd^znhvmB|mS4e)b1gf!;ura6FBBtkv^srK z-ip6oJIl7!-&`tFUp=X>Jjz5{UC!0Y%T6D9(0mGfD@2z+q+|5(3ZT2lw_V5xBdDSkF-53A6 Ri2{oQ22WQ%mvv4FO#tle?f?J) literal 0 HcmV?d00001 diff --git a/images/download.png b/images/download.png new file mode 100644 index 0000000000000000000000000000000000000000..6f0529df2a195e2b1a0a95707b09c9eabf5db270 GIT binary patch literal 5840 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 ocY5;H_ + + 编组 6 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/file.svg b/images/file.svg new file mode 100644 index 0000000..cc0fb6d --- /dev/null +++ b/images/file.svg @@ -0,0 +1,14 @@ + + + 编组 5 + + + + + + + + + + + diff --git a/images/folder_open.png b/images/folder_open.png new file mode 100644 index 0000000000000000000000000000000000000000..9557b37a6dbb5b31198e12ec283e703717a2a921 GIT binary patch literal 440 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 literal 0 HcmV?d00001 diff --git a/images/hands-free_off.png b/images/hands-free_off.png new file mode 100644 index 0000000000000000000000000000000000000000..4ffbddc4df6608199d82bb5e9a5bf3fad11f4c0e GIT binary patch literal 17888 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 literal 0 HcmV?d00001 diff --git a/images/hands-free_on.png b/images/hands-free_on.png new file mode 100644 index 0000000000000000000000000000000000000000..629a1bac00b0a7e9d394339ed7f20519984e19b5 GIT binary patch literal 16297 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 P_mH}ZHm+FND)|2a)a3xT literal 0 HcmV?d00001 diff --git a/images/hangup.png b/images/hangup.png new file mode 100644 index 0000000000000000000000000000000000000000..1c6d506df0af8bcdf36743cd6452da1d00c9cdcd GIT binary patch literal 7040 zcmYLubzIa>@b~AAJ2{S!5)cp$q#Km3qeB{`TN=MK5)y|Zozh53i?nnraddark%9u! z;DgWedVaq@W@mP0cK7w#eeKTdd%`u;6!77cZ~y@Cl@w*Q?s>;Qf<3rzwH9Av-!qWA zmVz`;K1{uP-;uS}SF%x61=#Lo7yt^j2O$4k?uqiA001io1Yq5B&_7)c_WS-@k=K;xfy3zE z!v}vZ*tWMHEv^j@W?mnh->Ofkod1;Y9}a9gx%Iz0$yQ+hxcV6E`+L3bjA-gh(!`hN z{Ep)!q$N1$RqtrY@#ng%ON#lFiYM`uSI>@~7Q_(lN|d?kOqPb7tu0IBSCQKvOhx!6 z3N)3;FO}(5EKmK-pmvZ2gkBUwKyfYM;~s`g8>k&b^K=**10mjzCMU?HXqQ*D|J2 z&?~&?eQR{T>&5#C!&aGw0O(?V67tDw*k-T5@RnDh?| z@TETc>HVV~ zYTXaf>BX9BC`|awm8wcL^uU+UKl}%l;Qnd8(<8u2@6FrZ%k+Z}%B6EkjJJv{HrAuc z&RSMwwE}M>pam#vy};Z@DnH!wWx=ha?Xu%A>uru-1;vihNmy<6NZuRjO_sAhB1Fo+26$-q5n!&TLqbpC< zkAvwv6T;@htHO=66MD;Z*mbFu9jZu50N=ikyk{2I4jy*x<@~BD4qH%7t^&J<#-;T} zs?$pjQ(xXoZ9cZ53D*La#(0+{5*w4hxpRg|aw#GbvdBp-Bw05I920#)_YV9-S+`V! z!8;WJe}tOrk7&a(9L+aqKPC2uTM8pny~3lKIot<19e!lCY9>|@r&(42XD!`7Me_ba zod)}oNR8L0Lx9EN=EM@GN`bG-`bNrY5>JevqqxZdU3C*)otblFT)}@bMHh=ql9QU` z%Zx59spG>k!8_0up&qrP*6>7mY`vm&nD4k%2ywnr=|b6LFm17CeJgpakw8Hlbw!%xxshQd@{@j+|0u$S=SWyfkFP5$+hCnt(e5Gg>WP9I< ziHdPEPNs%|T;1TW3Wks2O+nMyBy`@D5Dieim>W&j>>`m!l`J0v*oG4Ss;TP1DY7Av-DYqqK(bfg6RxaIgw5N7F4^I&r4fXAE1H4hRp1R6Gm zi92*2w@2(of*VK{s@>nt-w+#*mpz1dM?{ILg zr?moGbWQy?$8*~h-hSnBp=$OAd?%4pCT#l_fgR7FmCf~m*x-K3WeP&pVS2vaR7caK zP7)IIQd8HP0LErP@0Xk()p#Muc|3!j?Uq@$0vjmW$?(q|*<5{*eo~)!1<|U1D2L-| zl%e3tmpMrP;Vjc9T_2OqkEX7Wiw^^f>@=mPOX;Mj^S$6^?K*aj=$fuyAP|Pey)rq! zR3OMZ1>@m6k{y4UXb6e3g$7X)&BiOMinY$0l$1Jw7CHMr-0)9liXDQ$+ESF|`7d$7gK`)vetVs- zz?SI{iEX)+_ElQN)X28%)N1I+xQLcYC!N+>#5`MljY;iq}0x*fONa#lLV2* z#aN$|CGaou*$+3Jdc~XikHLeQ7>7pnq$%(Y>&yG7){{kZMEVy3)TkuAMZ|O_B}9qnB2*h1RZyj?S1#_Al2~V27)5FiV9$g~@_I zUhZXm8%C26k3u8*?p+j*=G%49W_Z$#rT)+OF>cx26v?eaoJEy%dQgi^`ySYavU(PY)(P;#1C2SV&Fb{{jltZN+Eh+QC+(h zL+?ym8u}!@dRaK^y7F>WgmJ;}qya}LfxfCkwPEX(%U!euC$cb>i{yA)hGAO?i4q=K zq%lmkfTM<`uSXS+jR+ON;13PrilF=W%OqjBlpOqB`7tEO0)|@P>OFD7(?XqMJE@sR2}X-;D&(_Xa>nUGFEx?gaxoaBDYpjGrO&cUAPPwqpTK7#M1@^SMu03+MSWVNF3yi+BcjfkZnI_U4p+** z#pgMN>-d4wy;4`t6f@xyJwkhoF`@$f&zH!eMkB_^xfyTouJg@irH7rfm~XEoCtvNa zx|w!DMTGrleV!dYGOoShc@pJ5Lbq`<(h;K2TTAdh#J)>;RqWhoeB^KP&S;LDcDNkF zlvRe^oI-VGP*-xbMyL76v$g0GrJ)W`qu(5iNEK8!KOWI`E`MbO$DAg!ucLH=$X~b*=Du`!64@?M#{S(#n=1wwMB+ z95~IR+g{0dEU&!%KyLX%H@DRw-m|0jTLJqUO~J)fnQYMjF?nZ0rCPOtIy;`NO6I#! z{j3{ZRn>eh2X~_fx2F}X?tcSisy0^s>lz%zVH5WnK{X?dhsJk~@!4AW_`9zLo6E=Nv)56G_>GWotfbVW(eZ?~sf~$`;bQDXmw}bB z3k_=vjP#t-54MVL-=O(SvRN`Y0)To;{~0m~o=F{!NxlF+9w`OUJ;Z77YbvemC)J#&sFZsm^&zjwT14C{7(5TE+LSO?9eGZBiAKIHNsas`1El@o zxjeYjH_fH!3u7wlfTrop$i!yy?mVp~B6v;?@WT&JGcl#u2cGs~HF;yruN})cEgXIj zg5TlUpIVY^!{^)vX!)9uJoy#l$@hyjvWA<-!?v|NTPhL8@;dp?e>nejAqejQ; zF;0q_A%x@uQOwO&KFDY$)nL}k3W{qX9T>)?BIsIdfqkBdP^59XtUk^M$L~r?O>H6N zD_Mt$ED3liYC=*PQ}2X;S0DCs+sfn@i5^>$I9_@wKB2wQ&s>fK6*EAcK+^D@ZxiTL zcWcE)@{#06i1bnho^ktt>reTsKXF7FPCS-jF=Qnxu}4f^K=GWoR-bF z5fw`oCzSdkd>_E7tGog(`FpgV+kpje!c<26A4p`8HM?EkZBN ziTzjAGdD>YNxpLnux36}U9q-0o#%0)M&}sL3Ba3baSn2Ov{$Y@d<(lSE}rRt%(q$$ z#Aml3+w&O(&@hf9U_k5O&_!&Dj+=%Zzqdq-E4`*Uioz{Efti&u#Mgk>`y^ZE5E}1| zik)Ep2Tb4OOQB&MluHch$ircG>=pcM-tb_Ls!Sm<&-!1mm zSmj)=wwUO8Q`l!K>#fekP-Ykv;h@1Ofe2)ca^;r(lc0a3_k&#WPLuJA`52BlpFI7W zu`F>@1`{)T?;O$=182;2eZzHvJVnhy$zY>dZ3H9C8=b$DlL(WXbjK+sZ2dO(F(tKhxCDMy80tQUrUBYEIl~nML#9$ z>4r1HHlKD-+m>&aIm13}$SQ z%fgnoY4`(Hz^s=cH`?cXONUSq?_VmX0vdeat1+0g)oZkDNdK|pqh88!@6bT{gkw{ho@|S?S?}@M9JkA35 zKV2AVzK@!V$ww2qYM+-L708v0S|owcN20XqG3x$Y47T?MY^{3$MV1|LjYB4_UM@!l z!f?3PC!!c1A{L7@&EfO zz_gRW#V<<;kE2rvt`YB8pkc?Y zIwG-D3mEQIR=ofbh<-t9(-1*gWX%;M$xD@M9H1KePH!JRG}C)oejJCm&yWYDFqXA_ zFQ;&ZM>xc7*WT;fU3T9%x^clEK>+$%W3R0?W$L5e6ep5P`yP(La_3$iyiLwS5JJF% z!Wh#spQ*2a!;<=0oc%W$s%N$Erz5!FEySu@v~cfYDevT0AS2M>cCQ!B<5aFm=3`9A z@(|=Qv}Np571?tv3lhS1G(368OL}WJwzye{(0B?SnMANSh|IsXYytkax-1@z%h71LDgKGDPMGXp@;;`((z)`3HR!nl}=C+6U zOF)gs^GID##*l^~0Ri(41%$TT2AWBi62evD7kJN40AF*HGg%>iID6;tRmR{5E7fF@RFpB_h=Ag_e>6vP4fiQDkIa z?MP$RZx?IR_B&LOe{_2+(FfDiuWmM+5DK_=Z$rAcfc$$ayn3aCM- zoOWh(oEV~AU)A4TOVANL*;5-7P&9w8=8|hT>Mk-|oixNXuJqw>p!P}{^m3yF zNKE|vi$Q)Sesysnh4Sy_-bjOA(cwM;D)!~jXf!K8UKnWon7vc3mQ*b(`T-d6H!oS) zr!BQRa$N2^gwl}kklJX%$op>eJSVq$p-Tg0dHiWVW`DG7AWpRTsT5fs8NzJ#^rrh6 z>&ENJbhW{DuI({-)cMb~pKdMlt(_I)^3_l@HwTuTL7wfZ>apbI%$8UaY_;r)aK>P= zZv-aa21QrcydD0|zDj?^Tdvx7KS8{dIreh#jKO(Xt0W(B5+TDeC$jAZ?=>6$Zx6Yi z*im})InHq>&hL!}KmBL4M$B}}wQt0QY`K7+UB@W5yE zA#`={xF7RQX3y0{Jw8QIZ4%;O&;kB;rWv)aLm}pFr_3q(X~++VRd{8OfST73{bh_R zU6@#Fh#;mpivJaj*YPu+DreBKqn?*XRQ*Hi@@~cBT9;2T1MSS)wRAu|CLn5ac`blv z>DI>Mzg@YlU0GHnfljMcAH_Y`!f$^iLTX`o*hI~)0CcUqpV|DWo8Vb*Si5Jw#VUIp zF3W}_aPzR7H97$E0Yr3`_1JOZ-4bAvx?kY z{1|NP+}EzxyS=bBVD|sfdGR$3ulp&DSy0srqQf&*+&YFsAyj`F7F&IVzDN5Y*;|$3 zYp3UDFG)#(6_Ky)8ujovv#I+sN}&p^$%6trh2BQalKzf-h0r=&s}(ze(f1C2_I_Ln zO>Q)@bi^6Xd=g08#io%kvbQA@b|7;y13plQGZ=CNy!&+|Gs@gI`l*&rps3Ab7uVtJ zb((3G-rLWBk2$Hx7QwfFHIeFE!if1%=?q+>++2;t%8FdRYsAL0xT;8wS2g^#mvg>5 z=;?ROEXWT#RgJRtBAf}XhWsVFhMMeOw{;nwak5PG@mq(PN-r;=n~ma^9n$3m3wEq| z9ob4XG*SJ(*eI#`dFLcP8?HH77ubbJs{jVftGOFv4k{g=eDJQ)qbX3%9kY-aEKZ!| z#TMHyhcYWSu;q1ZP2RVzfwbeZk~7tn R;9vDiNls0+T-yBI{{cV@PT~Ln literal 0 HcmV?d00001 diff --git a/images/image_icon.png b/images/image_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e352797d1aa4f98d5dcf465f58bb1ac6a86325e8 GIT binary patch literal 1671 zcmZ|Qc|6p47zgk%Gu2?kO0A6?OZU1{IdV+IsLU8=7qW7cNW2u=3=LWtT0^TEav$TU z9F3KivrtJwCFB^hA``jBF`AiYfA#;qUhnVcd0x+-&-2IkCfU(WPG+kN3Wbs*+S^c& zO8)+E7$l#(L1!aHDvV-BKt1eIokIF9@-Y`%WQYQAEd|#Yu=pD+(%?`ESfqY;C=D!s zG#1Ps6D0p107wuL5KBOW5W_cw2-ZX({02*Fup|V55EfTq0kHz}D=@bVvrF)G31*gH z8c6|63*ZZ45%`NRwE&+NU~(QN=3(qBj84PQ7x>7Bfhp+w4DTkOdmLVkK?fJwN1<&5 z+J>QZ7@iHm(;;a32#tf#FaUKQpyoYPy@f};@URCSbVF$uc9 z&Vy5Va55K8qB@>)7ft(4X3~*$@(R<)<7wppENIDSGz#5SXR;l0j zZ24oi$nwoo!0azDMce|DTW}DO3|vsfwaeAP_XEd6RBV+u z%=u5;J5e0LAM9cr57aGmLC3jB=kBq;WVMI)%0o_oqMOk%+DuFN)*8are|rlY4wvrR z$2K6o{J6t$`kjA#*lg(uLSxE@w0gX`~|>Qu6AOik0S zud=vYZP_I5rF`(KT)SUQBxf9kbaoLIZk)8fz)}xUMFo(wtp4>%!Ymm(JGTtavSc1_ z;E(7%*XJe?O%`S>dg4k~-GA3oux&rm7)W_nIkKl=o3w{Go_GhX;xw`Nek*r}G;yU}PFAVUbugg2EdBHCgEaXQ_=I~qzg_q{`An_hrNzPh1T(Bp zk}};etf|g!Uy$x0TJ)-oK>rwCh0{Ni!BSAg<(6|ggZB?oYvneJ2FukC>2>IYbAFDG zI&ddk9Jx^?!ehssAYJi=V7=|zR}HXF8l*~{b%XlNRoQ{n8|#edQE7ZslJ10t)4X;{ z7cMo_mT+C87h@Hhx4sws^twkAbIWHhh9KGW26n2YTZgQ=+x8*1uiN_hS??`1Vkr~P zC>zh^bhX$D4es(>w@-yh+ykJj2S z7^2ev#{Hb#Xw=p4oIEl9v`T{P#QXIpl3)N5(!#Fh?p+WqIn z=2MKKoQ$jzI(}p%5YtgLd6FG#pmyx_igXyc$S*ro+FHABFUR+k5ZbktXp_TH>=(r9({6h$zg+1GPJH1= z%8vB-;ZA7kb3rN3@c>4O7_TB*W*(tuaw*7LkR zwb6Pu<0XZ%32i$Y`!~P4q#0UWdx>S=qObT*qHL@q)>|W(WZq@^Q_S@MQs<{kT(i~C z?yfZ-dwqqYMp?>g_|{?GVG>?`J-3#Ab!Hdm2xU|^{-zH9_g~)o9kl%W0`1#}u z*zOU{PEF>_U$6|t*j;5pmrbK8g^Dtg`yVG&Msjgqf@R^d!!iWaU_ zS8H4|NR&RkjS|)0c{D-WaMZkjscvRR(^nJrPzCBg=Sf8K2k`YK#k5Fz#`jNzXzOV6 Ikl;=K4<$B+=Kufz literal 0 HcmV?d00001 diff --git a/images/keyboard.svg b/images/keyboard.svg new file mode 100644 index 0000000..0f14e09 --- /dev/null +++ b/images/keyboard.svg @@ -0,0 +1,14 @@ + + + 椭圆形备份 25 + + + + + + + + + + + diff --git a/images/logo_bottom.png b/images/logo_bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0d36d6afba9f41ebe0b737937aa92d7d86d47f GIT binary patch literal 17082 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 Lds3ld_wxS$v|o9G literal 0 HcmV?d00001 diff --git a/images/merge_forward.png b/images/merge_forward.png new file mode 100644 index 0000000000000000000000000000000000000000..a2fedbe8f07f195f30150a9833d8c23c1566efe8 GIT binary patch literal 14438 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 LJ$$LU?Zf{ANBRwp literal 0 HcmV?d00001 diff --git a/images/mic_on.png b/images/mic_on.png new file mode 100644 index 0000000000000000000000000000000000000000..aabaebd31eacc1274f88fb750044289ebdf070ba GIT binary patch literal 15334 zcmZ9z2UL^K6E^xLgc6XFPz6N@y-1U$QX+&VUHl13RisE)P&yi^e004re z{Ls;WpSB>mH>bc(8FB2>1VT&32!pDKkihwMcx~E%dZ!!c=9&9 zqUughKc)*`k#yL3tH-$`QNdNnz){py-dcI!Wix|^qY5aOBCu2JcQ&zRB zo|hLcFQ_bJ3~U5Uk2OCY*i)NbI8xaO3R$?_Vhdq_3*xb|`SjR3z?03EvCWp`6ZIqO z$H0FOH|tgX6YFFB(1Krqz_LWp(#fmfrL|FD7fF2gXm)Giea}fY-c6Uqs3GkkH1Y3t zhfdC zP7@dGV?v_FBAP;$s6wRy!yAVL>FU<4zVB4Pn%`5xCmCbR<_azxrXhfa&@s9zGV<8_ z#07f-=Ygu7mM1;JvBc8jb42+yS8?n&RPHiDQqV3OpeE9H{Na}ESVXxsK4LxyhAeI} zMF&H#7|K^>h#GJrte-jZLu_H9Kufv9j<9FKHUQ{Al!wxXzAHyL=`M~Wl<})Dkk0~_ z>blMu>j+lgI{q$Py@1-B8S4~}M?qn)-?0?(;kzV< zdZZxqXqtl=u3Co3Tt9n)9aS$-v)ftSrCTMW7Cg*e37t^(eC}Trn+0Z z;qXi;fjAEjT84k5Lk#K`NQ&{HA-dN@xa~L1j@tnvpxl8z?Xz&5K3X764PsY_Uz~*K zKLr(tobaEyTWSwE10#v$^bsbzh1bQgcY&;V{>uAL1gZJ%9?h@JFHze8SXJb_+O(kv zjv9DQ!b0iZmiXS3ON z31Y=W$ojcDq>40wUZARDA_m78{ockB0Obkw4le87ugwQx&4+by0oQAF2{)htJ-gfh zb0l7hw^CUf3ng1baM{TK60y1~L~w)>Q z#L5D=1peVOKYpGvlCc(06E(hn4^mWS_602GZ!%o0FW1P(A(3dI7DWOx%bAsg?k>+%{;2M$Bs zV~Uu_j?(g%>UBP6eie+u&P(!AlaYu)*p4JWD>+Q`KG9`{>rHf{fd5k^=y(Z=5w?l> z>yOHfZkOao5`brPV_arD#LTH_R@D78qe~EWthd&Etu&sqPLrYlKWPW`I4h6e8!|$d zL!TyyD`Bx%3BS^sjL=!}y-ZD%GqX1kkbc;Kdu=#;4t_tCD3P|Pi{WrM`*fwo43I=B zbZl-v*_Gi*)+&?Ng7QN8uIFD3U*$(!1#d5hGPk`;K=!_W;uzF8jSmNf`M9HJX+*UQblR&$Fl1v~|C z0Bmy#>xgQ+iAlkB(CVty1)(o>$WzO3pd~KUZg3#^AdRqjV38lSv65!ksz8s940elI zf|I3y$=7}o(jpKf-3y{G(2qc5#yN;30@MUu6C0NfM1aPJE=#|&@ObnlZ1Zz)Xnw9F zlL|F~&Zr~h3cyQ6D#L&Ahl8U{uzV_IJ&aoH7~_s>0l>B>ARSPkcBoK7#Q=`V(K+wZ zu|u>u`-W}Vuqs|LvPZxfDgu1swk{TysD)-7WM)G!w)R(?1#fE~TDIfn#;2ieav?ti zsjC8+{@`1KFEI?#msG++O(2D`+LwF`VWbW@*E9hL690mj4#Xi4edvxK2m+AAL*J32 z3Q?gVsOivwTmPn$r1na}HzhZ`-g9H2CQ>=YT!s-@&&>Yu7W}J-)TMziN(Vp1H>I5M zZQDwOu6uWs4L4stDgD%LxGac;Gly%XiN+_Ayo2L{(ZM}46{~A<~k7-FCK+Nz>*p=TkMA1+F|w0^lPakdvVR7(4g}-RLDL@HsErJHc`i9DFii|1({Yn_ z{T%iv?9Cf$Bp)sb;dh?+9aSOhu=1|zaw-C_?_C2_2DpxIpPg_Mf$yRS_0&wP3DQB+ zndRwn{D6)8KsO+tzyMkTfX@jKA{`p;^QSrgrY3T21On24Gf~zU3Z7L9X#piv;)8C^ zZACUoeua`%4-0_8^sbqlVQ};z(t?IypQgoE4J8{MEVgB5AO1XKy*Diw?*`o$CZOVb z=H!al0MC!+wT~b6tA7g(+RTDk@ey31wD}M3DnEvj-M4~L8-uNO8*`Wi=%E5+?tI^zZ>4Uor>8FWyu&^H4L#Ofdcy)E@KJe26F6 z4`Qcs@|OA9zA-dXD1J8x8p#1jzZ^S?Jg?@H0n_DMg7vc`e0Jo}F9G~62R$B4esJ#P zu?XfwkFOpk-~3jJUPP|b3kB+|QUv?JfSfhe${k3aYD*#jzyFk&ZqtRY)_dSS1S8)C zR1=}eA(YSKz{GS}L9Em$2Ar}F;YwgD-+T#5SP9#Y?sC_24n~0+4MVUrev@)=lO+Lg zqkQ<`B6VE)?<>>`)WqoJ6Ar-o&;h(Xd^kZCQad@=YCwQoMG?R+x@9Gi^m8)-2Hw0L zM9S(#dQY!cQOnSEC19bR{zXUv04@iC3TVv*nTuVP06J2#0QoMSQ{NFxMN1>j{=7$( z!3NmJ60?(mb&aFjNx)itjfaDTJad!T`7T%MI1SmP@god1CEA^Nwm~#ImAyi}& zg#X7^9LgI6g^m%}Iuq8~3a6> zMC&SXPCeEDFIY1q<_Ux@7?hL`M@P4bNC-p~ig>brU;t4k0jrbGgS39(D92kvBPx6}l`;na)M$?C~;83&R;J$v-GI0DCI-E8Bn(@636S537)?Ufp<4I zkpw3Uu4IV=lHf<+f3y;(mU7wB+i)9L%)aqkNQ)3-qD)Lln26G}ZE6Zqzu*y0-z`8mWP<4SRx2=z0X#5e7|q5$GLJ zNrf++Y0#tWWy(rATsVm8GB&+=E)S5I(qvQZxdKj0G~%m}K_t*&Dd;I+&VS+U&Y2@H zRq)~B;UzGVE~W)upXK~xmB-!#>ZLaxn-hF01{{~Jb>C#%9e%_J`YCC0yZ*ob-`IsF z_nhc9H)A&Fx*)dfuHw@gJ&LhFkgU#s?j7JK#w$`#Za*$aSqlFYoh)Of`&<~9sWFb- zm{p;_UJ^#p@4QmF(7~YL2*P|$oMF_`>`ZpjWenty4x+T<)8U0cw zBj1p+S{DnB8TccHQlIefwt#wF!5+9@)eof1iZ_Ki;k&46COxddS^oi&0Et*rS*1f! z&RL;y7?BrM@zL?1jRnZ}ullB0r9~M~XD!cC8+u2%RZt5$0&idpMKWEYIPL{Ro+Dwa zreuNxy8#^bz4orsq)ZT8mw+xrcj;Ohl)yoi@&H$i+YM}M0wSJ35i%Wi-HAdemzI{xe8ohst5WZO7aBV~xCTTb_?g)ZL> zsI}Hy5kX-B3G{7D$`)Q~%7#S=t&iKk_+Ifvs_j#F!;)mnP~AiOGiT*sBAGrnf{e=N zC6Os8ROVAfm2%J|+tlmYCOUrVa5hcyu(5i3{X$kWoOLST`=5zwiPfeJqmd%Bf)vMf zZe;m2J9WgM%5qCIBg7Ae_MPQj*G7saV*CFxbII`4hIO{*Hby>YIv+LDtZ<~j0Dopg zHbq>%ta}j$M&XS>>9O}Tq4xYZ6S@-HR<#|I;KiH>jSrra&hKrSWn4x?ZaDLf)R3YW@)zk=Os&WC`tRa~-lUA75B54S7u0#LuGZN< z%E&L!6ZA^QZ|*GDd4edwjoFSGyK;D||3H`T0E z5pS@4-t?Xi=Ls3%Nbsb$sZV8iN(V59?D6*pMxoZbMY0LjjZZ9U+-{qWH3pc1E+!Ma z@AC&VNm+w#Yoal6=VLh^lu$h2o;VP9(JPiXW*f?wso}pf?Q8Abm#p5GrqGroX0|># zdptUJ)D#J$XYn(`_$WDcwVj3c|Z ztr*mc6{{a5+vBQB;DT~57nr`$ZnsLCaFu^>wA#3qHL?BS?gy5-+ZY;lGjA~{fnR5t z(>`e|9xV$iYu>(dhF7I7Mc*v2?>}?va8Mw*>J|I8d;H}$G4eMgrmaDZD|;>NdRrY8PWSOts=O4@ZQX; zoTYcF#kc?6v{iEIKUd{e^t~D^&AD;kQF7(+)lVt;&{d7bq&8mmI19Y}GC{juXU?yUOFAq-3VD85p~nXq(?8rv9JvJH$`!3NpK%!Q7C3mY6%cAyhIQ&b*EH7v?G> zU)Gecq{!OSOw_o$w%@uFhGwoe?3h-2Q+puON@3^-iEoFk-~(?p%-fV zil*32?-Y(fx=H5mO8GY|k7pAijE4Pjz^r3+)vFL_ znpnweyxX{ynM5X3uETjK*~ektDAY+LH~b9b!viH*K@94-$BoQCq9`+ccA(?f*v0qi zYOUIzvX*_9&gPg=)mYlMR$zFBL0`?&b?wl|@Bjn9BS<{AmGmIvJ}?uY;@BMFasZ}~ z8Ld_bm%Y!+n*I|`6%m$qlk!>D!LAWKTAa3~F?Ps{rGaVvKP^tB1FTZVIV$o4-$~7Z zL<1}o?6LgYH7s1~jOzM)kD5#agz7nKF4g=5D`GaFGs2mBy3rJ)*u7x$jfYqaT**&> zPO_;(=mBcJW7Cw&%=viTf~TG!ztKJ zcbDWMUNutJX2?GW|+eg+br!zLMbHRvd}XI&&;ERVkaVfK`IW!9IcAJMeg_ z9nR{~I7O6PaW0TSO&I#~7j_+5bE=n`4&=%%ie!mdEfoa>fXxkuIHd$MoMI*{@;QTa zwJ}){v1Drs*Z^MmqXy>$ScoCT4&+%(3CxBqp`V?a%nCfVe;obF=_x>sg~mtXRy_og zBfC6!PCXji5$VWe=X>>{LY66LhoZB+-ER0**gJ6)&m-m_djm}%I`JDvK&JfZQHDC) z=#Sh5QPE#u=!G!hg7O5n#IRGJp^b1Dwfc=hNhiV4p1_+*ZR|>H1Z6{(fiC;fU#EpY z4~%>TNJcQjG1pFgU{KpOV}^~fa^FrXsE z5q0_KPX%E68S{8fNX8m7kqjpm08jX3K_(+O-2LKtZZR|m1)WUex$@|vhfWxi#4Hv?~ zVJHC^Gip0U?5%|Vx})Tb?LOlXb&I*tasxRk*h$aU^PE;*kW|Vt^WZYHga=906=iO$ zT$dcr1Z@3&YREFv+{l9t481`n79c${JUr zjMcv$8hz~Pc_r~-*eFO6_Ga;LI=dJMVkA7@C>y#q3u`(2r!SE`U-Qi|eJZR+`@y#g zArKQewcY&TJ|0kcgGIq*gqWt_3cWNa_7*HMsB1K-j94v7q2(OJ8ly(N1t?;?{4Q0^ zgiA46vB%^1d&Gs3pc|?nT=N3!f!lw@mb8y|LQj-xTaZOY;BF#}G`zu^rWrm%dbsp^ z%ZA1U3&$JTC$Q}ZnhcS30czr&TE$PYN8`$KhcBxIZSB`D4q#-^TK}Rs1mvNtvm#9f}dF#Eu6&CPQrOwcU8BOy@AjlFG3IBuow6+qQ{^WR=h#$*Cd=IlD@MVtKh&NS($!C z_X%|SGodZmqiEt3P1=44P2AO0WrGwMelL9myVO#&hwE5bxRKJ~PU{KdA9Ez*;`fFD z^^>Uyk961f%^YF0%uF%4rx+$d2RfMpS%A;g;QsNYY#gyoeZ*1G-rHUOj$`~5qMYG7 z<6pPYf0gRs6^4q6<_xj?ZrY1(qsx19FLM}ru0_-24U>H&i$vnD(sh`y6skZ**R0ugx@< z?rCZEBfG+%+Q}$89du^dn-v&ob;f-hRysR2t@g?G0~UHUG^n)!5|_qC`@hG{Nj z1$sy+zAVixc=!0a-aruSqIBz_9FxjAk>KdpdH{R$k<7(3aTG`YC#PRwt!bklw^E2( z8ZLO_K3Q|ittf$A;ubmdcp)S%CjPn0XbC*_$liY0DK*`*d5hytt%rO2@~WI@iyqxq7v;o5K_~iVxfkO>aLI7qkaU zTBUxq1GnN)&${n4RslC4R?-&*O{ z_W5rWsQ7lNf3im?y!8TLhPXUy{_%X!ziyF-hkrY1m0)&5Z}pT9*V{_R#XaZ1Qn$@i z=lrW47Q|iIbmfPrIMrf} zV8>Y}3t3MIX&mJc^w8k{0G}IvR&3QD;&$QVRYr2fjU z`P^dmuUS?&?|4PEx69$~-})3&$y9!lAvOBNA@!{K>$8sC9)-gDB*0Da##I-|gT0Lf zOFhS|7r)hy|1vxG^6|KGnr>!28hqIoFrn0YPvW?BzfjL^vz)M!X??dQyM~HYS(ln{ z7V8U5&&7RaDQA~A;0c;fb^0+AxW?GL(%@^undL564IlmnR>K;aj;^~a<rp?gta!tbPLY^bcB*|l7#2{kbQc=AFxKhgXOceTfK!<0M; z4DBWIT~F*PHH=iZM$8sxTh%woDGYgc+n(1`J?T8hI_-ZwJ^T3b(a%q!QB$;31eetu z&zLJM)fW@5A z-?6Stw|73ydJm{x=HCVLM`^W4cFwf#?D>YwTlVwj#kVddUio5v&#dX|YU|07CwOk5 ze^1IkS-=fA6Sf_|MuocSy~h!+bgrX)N1W;A!x=d56f5;B|C`9-v<*VocgD51_ zG^3!HgKlxVNBwBYm_zc&O@ms?IB~*&<7emPOx^(S-g+bS|6`K}CP9#3nOFqo9I)Z* zl4N+~(*!me+GnH~w?``+^eOpXs9I>Y{qD2;n)!2#;d*v>yp@eGkap5%B$FUxo$p=@ z^Vxq3W;BaxmsbjncRQNkKi&#o9=ybGCA2JhHh?$R4Pj+ZUJg12vj*PUcbeTWU9m6N zw{-mazRjqVdu9II+)U_Lz!hXDrG9jPlFkz6bl*7$86oO^zbp3I`f6^up?4wQv*|C5 zy!ypxRq)!|{#0I?j0IzH$@@kl+vD4Ex`b$alZi0UNM(_DjV{%*BI|gh7D#92$fO5- z=$0hI+XsJ4g3X4-3c+AGc4xyU&1m+y&s7GOBE9W1y|MQQK} zOIeV{$s&&m_DT0sZgg}*r%F_vG813b>L)OHzMc%<1T&ds^~!IJ=C9gA7HUxGD16z$ z@pVA{wwwB+FTxOODzLSvT5=(OXeB8?4P7f;!Dfu!nRkWmeJwWU3^hZTj~1IVGjK?f z`?FCNdAo^|n75%?5M>6Us+fm-`YkN6^>|A<<57>;=;bAipPNg=IU>%t=~+o!Ix7>^ zVMcx5q?pxq`2x28JeZVR5sF58BD%xZFRD|Ia1+zJU1*!D4GCb50~-g=-7B4^>K;lx zg!^XB=@c^uXUYv*oYZ`Q(4LEDy@%)suIf{~yFG#LDv5%YGh(Y_=+nx}s{WK2sa8E0 z?$~dtY$!6l^w<#V2~A&!a z5c^&zU?>+G5!-2~H2tjg@RxK>JV^-)Uf_p@1gJG*P7d>lOnz|HJ1}0{C1N2tn_{uT zQA8->+pZWP7f@j$Kf@?Q^xqL7;J(U}zg0cp790&3bjPy_8(`-v9U$^@#lkN?G&9!5 zxF2k>+@Bjgd^2y;wY5Vs3aVEyTzuS|Z3;DAax&YA*QC}kmS)?cH-9tfySUULMM%{1p zU1JKSTLsfl>eI51i|Dqjfp|{H zZQEbEdEwc?pGxCG_lNDfU`l%kCfJCqp$hZ&Hhy4}&FX>TkyjX6iiYG!jXoLXszefv zJa(^3{}3k7e%?e6rF%Ya)U*hDc?9CYj=W@>voE?TNVv|_)`z;b2 ztVtjEp-FDB!6R4C1{5C&HN(6$YoB#j6iImKdQJ-{|h5k$dPAbE^qKC1YIVV%z1J{g=JOA@&*_)qY66Zj6X_B=Od#)d#UT3fc zkuPEY5Oe~OF}6P%w7t6cBj#FsCVzvmcX4dQaqZyW7isTFaa*v0n@a9GY`S)?`G1fy z0Gk~S`-fD%g;D$#Y4VK+Z3q-|olVkw$ASa<`2w|Hg!+w%=^LiC8ZK%2u^~l~9-B_= z;j@SHO5D#0pj{)o%(a>!i&`Bi-+(&~5eUoQ$ykqvBhy#zwln@)HovoG1{qd} zsLFh$>l%8QRCXCvu`_>ID1Ygh?0f)_-h1{kiK~IkhpU|lVCduxl-wk7>HEPpy~e;~ znVDLC_IpBa(k~v&+|Uhb0u@#)_0#7L8dQ1E-oL4Wq08R$u!^>HC1U~zSn8=9KfPWuV%Vg4=l;-DN123^|ROFo5Z8=!|qDc3pbZxkR89a5(NQYiUO!RyR zKf$TN=QvbT4@%(Vu}|T9uG!6r1I0NLe6ZaPK3D}$52S|(o4nCxjNw(~7Rey07@lKo zp*s|Z7-%v0S8DRu2T*aX6yKRi@`q*z{nL~3dFJ@$o>(Tf!v@3|5z4>Ju3VY<%Yaiu zJu-Gc)5D2n!0QBII7oe()Kq{~teU?NkI%5RwXQd2ty`W6VGRB#A6K?{{oYZb1nNsL z9IM1A&y3#qte72l^%}X2S-m&>1ztcqjunXV(Rf3c0ih77d|XnHC7w8m8bW2qHhBVB zz!Z$H<yGHG~=x^y@Ik~{}PK-QVWcjlZjOB#DDU55Rt#6Il{#*+^2{bx$t8}PK;G0;r%hw*m$Oq2u><|uQxdD32V>uH499LMymH{2Q z8Y`Qo<%o5leGmtG1l#Ci_SG<$*IOB-6i%OyaQ{h7m;#Cq0x5TDl5hJf#6k4*Kz7cvx7Uu$lsedNC=z)Q(Q z7ULiLA2c8i{d29DMFkB1{oxK};})5d>c0!$JYo*%Gb9Ed-in}#W!E8)SF`0V<q0mw~EQS!7gqVu`B1SZCN>Q?y?HDD>0J=x+9zDZr9s)=Mfa&owUyqE}_ zzLikrzD;n6xqXIkP4>AzBm9{mn)5}0adzD8O2_Y+zBApCVMgbd?r3m1ESJ{y3nhO8 zam6F%eK1nL(Trd+LD=y_bm$}z3D4X2Z@dF!bkHB}`&&!G6y3(-zs%)%(lykoISd)Cd8SZtQx^%Jf_R1gZAG3EwV+>TG&+35(Ig% z&xlzlxmF8XeI%gZ5;G_$n3-uk=01$%r6KO7zYvXXjjtF`W1inO`Sgs)0Gfz}peuO3JA}+VyR6nFg>jS-q*5d9q7tVsr&HIJ76L?^EBD?hRKQpLTu=A>TcfuD> zyo`EOdm;2G9dUc+-ujae@p;wLwlzA%=fNaDbT%Nmb$0h}cP-#IjHWc0&1Bd3Pdn(J z-s(>>A?ge<`v+Shg@AL{AjmJ=Tn5$za57*I^%LQGdK_XGffhgvnjE!U=Yy=zfXqA6 zwD}77R;d9t?&;u?eGQn?M_jz7BFxSfpuRK!dziSBh%N#}&qMy)f(w>s;}<;)W`)WD zKTpVeU~z7eVMZ7F8tmXpQsrG9qiYgJ(b7Ebc?}#wh|dW9>mkT;uqSI5Ex4k5oluNd zVINVlgc6>}!(M4O!3VK~fxUWhlyKoS36M}l_1DgV zG($cbrE{(+2jTXQgSA%X`<4IeLp|c-X-b7jvaScO7w2A3q(;AaCIu;v{?##b4rF_g z^7J{I);}?{tX^zf>XCT=8GNl{@1pS(hD5{EhO3(Lw2w+kLR_KbW@_hWCb4Wi8qg1{ zek;{uq#`pB=QLe)t%A_`f$SIOpNxMTSZRuYed6Nrvuon_a|E4qt4sJ&}3%oFWKImNqGG*eaq1G6|@dq6$Ng81P1J(giR%1|Y zR|H90@mMYBPp!A#h(a^4n6s(lm&k(DqZ zwMBklElo5t#tJ+Gpvsj```Lm*c)`N39w<3A7nTonSP`fS|GCGWm1-d0h7|wmESmJ3S|*%Z8A=@2F;K3 zV?k1v2=ojqVnNhy8dSe2EB^8Cf|3tL6#1su*|u2j2ZJ9!WWl?S8Mk*d2m%U_CMqoL z=O0{&6d}MjodB|{G-6LYs)zpT_ZW&2(=`6g#<1TkoZJlry%L5bwa;j_8T_>nXDtBP zq_`Y_=Jf=N%_)sCj+Me^Yn*!%4K-mWE;nebB1QK#D1HE;>xF=sv4q*KZNIet)VtV- zlECC0PgzEa5234IEmWjCn0Xyb_wN%R$<7!S0QD>PV)dh@IJ>eTC7M4R)O4Z-w`_$d z4`_L9Mp6tA#V2zE(=Yv_!9wbxHSi7~{Ty1km(zBg;?YEP+!FV^Hb!w*39*`KM7csH zotDg`#hg>7Sv(BNJ#Dsdr2hEZZIC<23h5I%l*3-Mey%r5F)l!S0Wd|$)xNj=9mxX9 z1JV({*XA^V7mPxbBiUj+q6^E=Uj?-R6@(ub`UU#pd>?pHK^ETAXEML}l{-dhz|9Sg z@X7IZwCm|m3Nd{qcWr|B8YLGmO1YUnvVJt!eT3{i1vrcCAN)vtje#GaR&8 zAT)&ryZ6kR+i)`+R-uR9*stMC*hw5c#L$DI5kLyeuK4Y>f>v;L5&t?BRsb{CE?6V_ zf#Tguz^XCIZ{GmCz4WIfFFkj?T#EjRA!m$SXi1)+IX@%ov@vA{B|8uFn(oC65*~7^ z#D!B%n9B!#86iN+)HLO>;4veJWk~0s#!zKB1B?G%Y#@u;E5r4}(b1-XIK>Uzo3y@D z9bw6;r{{n{7>$aeTb85hM)&k@2mo1hAc3c1wexY7%6UPMuE!1OyE(3fY0*pj`D!m+ z9I}ofG;k`%i3ttMUH`^}#AjpkeMSIJ*B`#^S+qs0p!P!+!edAyiKm8Rd4|n@G*WkL z3zPlvqZwr08pGsY5ZM4mE0}RMC_D0+9UL21*15H#F9%X~BZ+IOh{9z~mNk$w-6e^0 zm}xtCQ-##629j8DoV&vDqx`*3640Ws&6lB02)&gy+oC)hpvRDQX_c=G{_J0^#v8$S zap;wW_l?0BMhnhie!}31A=X?Wynrw5H?GT7*Pu&+gvMYd{hv?Q<{Km~fqP;-5W#T^ zu%W%=FSioVXlTR0$$FLhR&S@E8D*50BfW640m{TNV+5Lq7aH(sCq@4JKk z&g1xpG3RggvV2z%j1PcvPZn6-`E)gD$TIcxu-GYQZNx=~y7fCq-SaqEVE-+Dd3ttM zcL|X6QLaNK8=IZb7O2a&2SZ*MP#tltA@AW%V1LoUfOwE84Um0B-rxv9e#A6mYytOD;9f zU~>9)B}xmeij^?&2g=lZ19+K^O)8l)#MkGPLDz{CSQo(JP>(EssJ!U9OOJ+-I#QSb zURr{gCdKsKd;~L95wO0xU&r$znGK7oR>fdg>&uhSAS)kQLXRSRoGYW1 z;gd-H8kGCPU66$XLS?6n)vx64y(nryk48W3Na+VQtG$!4`F>ZyX!jR{C%P@D+`X3D zLEu+0c46k<-3j@eclP}l=AEB{aabMq*M@H4J)Jlgh&W|JS{o+0hIz2OSHAV<-cAEk z$!;pK7TAyMQia_E-}H%0ow2uKWMe@q=Vw&)zMhveM4KtP{CKUEnr~PCmks!W{+c1H zUB3KymHr-l zsKpZD2QZ%~9kWUs6v{PmJ_vb~2VkaLZG23p#j1=y|5teh>6J>foMF14y%tlRED9h# zNfq~SRZ?RwLC)MwS>n2a{jUKOQ_+@&tcKX@7V4}ZGf?w&*eHT*Kr%hR2^kmHHn&iK zV+_f(KrOWH7N#Y#;nC>MCGa~W)}F!1>naLSow3;MLVJsMos+QFSeE@tIbMUV4>tt<*FV)7>7m4^i zcHIyacGlA#Far_?jeiw9uYr)he8JS{e3~j~ToNoNe(BvJ;7jzKSckuf)1x&GzD6qt zNMfVGmXhEA$2te#6nE%dh!)a>IE4Gi1XS>UQ3IA)B8k0t?7TYu>1gNAp9uqxSec zl?J9XI5&2>!TL?tWA@cty{xt`b0&-+jb##=?v1Y0Kx@j*GLmIi=exC&&47x*fmWA1 z3hfZ&;m^U0&pLUXNph{o8Vv^Nr-;;SceZb?s?>ae!8WF6?ochRMqWFL`Pp@SBRPQi zNJs1bty|oEPdu(;p+wUU2_We>)cDLzmfuLjcXX0e>xChaUCTu-XL)|6gV#r?VRL5j zyqqfodV}>`_xecWiYsI5p(GI7dcw`@DpYep(>nvo!acFcrP%R@o=j(arol@op)$C* z(N{Q-`A82{F?HK`DTNiRp^SVqA7!lPzT^eSqQ)#f>0+IxD%Q@xqN3qZ(#aX@S=6V+ z^uL>0DA!*XJW!2&=L=$G6(Chqq?v=Ea<+zFN$qM7#;W}9ocaE?!&-9^2VY6|Zh^I3 z#n3RKdYHkoxz=}-V8Heys&K#RzYYKTi00M7>^_+<50-!UO=?2$$)Un7N*#Zg2-HBP z)X-YmMxAUyoZ^#KqhFFRLUheW(}PTZ)W5sJG;};?dBD)(wpeNX!B?*SHnl0~Tvr1* zWAtGWbAWZq*K_s1E^M#;n{%%@;r-zfeCLGp<41P@6>^;;bqk%Qt@u?sIFR(dBHQ(< zB+EY57J^oU^?gN;uum9 z_ja~-p0J~cYrbm|Q?f%&gP?VyzE0cT6!o4P4lF&BJ(q}RB?)WW-+%S(^VF#0K~oyz z_rIPKyxu-_X=nHphwf#i>&v{H(<%ty8JZUK{(af9}MMU-x7(vTOfuUvfTKU|!??^gZ?OmoL%xR!=m~ zoPB1t&<6YM-|pS_{H2=t$F8hVR`U8JyKUFjrJp+=eO`KX$;yW9lj`I-DpsDaV`^*Q zU%Pd7E0fvG?O_MxJ(+uoxpqzbQn$3>>{G=D7w;Z8sF|;`rsyGu!-3c4OPm6)#l`j+ezxpy>|~+^ziWR~iPpW$ z#HT+y=h)xH8?!bSZfsEX-(ozwpg(*2tnCp8-j$@tzOTzK@DYeVA)|cc_hptfqBeJ@ zGUqUHYzVw6qO_oG^<7a$vsJU4SgsUo-K})MEi|~JL26xDILnO{SzLkvhp)ctVvr8? zRbPx>PDw;TRCr$Poqv!VMHR>2oBfg7AZCM>LO2U9ilQLO4>59wO4Uy9j|&&D*aWIj zqoD*Tq4*Ig6_pdB6rzO)Vi{CIOoDjGkGtgVcBV^2?nH!87NsCqPznb5kurNyB;4(8 zKdW($S~vS+e(h{_uJ_-2@Ad2NXL_!Cdio85`h{8n40Z2R=Rg4t`9+`rE5IRFAmrUs zBUk}$p8~qBFC(Jk0pMBA`IGHyY2ilh*a%LgQkMY0wE)l~@r0j2%QocM}n7oznj-5b^H*{{A(?!^35}KFV%? z1=|5R?HD1I@W2-5ZccIzzv-9AJprcBfx9d zuI+i@g%`d~L@6Tr7XVzxIsZc=(bDPkVL}MQ3kXD9Z<^-!tJ~0Z{X8PNHH5I-c4;5y ze6c6M`FuW6EEY{7>YopUh!rAQU+?M}92{IeJ3DIvz;d@)TYisl&QGq6!5F(p2=POY zaYNxIOnG-z|EEP z1X$Pgn~3O3&6+^OX+->#5MntZ%Ky1beSiy^rk!gT##RNmO@J9=Ulu}q*A6U1{3ie` zC8C4u*tfDl#A{5`{AR74x<&7=oU0Mc7&}`Cu}!X8TEX!7?*sssHJTYJz&-$GjD4Jl zy*dt&$shXOadIA967ckseP1DvC3WfWcwW|Po1k4zF zmk{E)AmIYQtC2`#RiRM0zg4>mut&i8eEu!PVsSSSEovpUPk-Nyh*{G#AGH%t0rm*k zTFY(_LR@J_a4$A;<}II5JK6A0rBZUqc9WNO!{+OF7EMH$PN%PFCuBtYTPzmS$H&M2 z;Y9FECi9U>rScFFMV$DB*ml2%LGyiWx%^>_tr0@p1^@{^1wzC>A!6DvjF;WS&E<0M zEtN_~yYUT^4-w5K5{U=L#>V#6>sL#_*6sR4BD$A|dVLb-K>#?Nb3Wr!JQb#a0=584 z-&`(tXsJ}X$IZ51b=&eWW-^&PYh+|(pQ6%!1#APBsbsU+MWs?{BLIBHO=^gEM_*sx zXVse;Zq#$<;|TD46RA{cJtE2%=QIb+-ZC&SaMAGauyEj`Og$>#3b6HmIV*&?0|1uT z$YBHL{2Mmxl!?y@xCU%NKAMO|iRh>Xd6A}Rmm7w0gU@-Za5fe21X#9Vy&rWa5$QFP zDfu4Nd8TP@vYCrAYl8xw0n427`Fx~UEM5Zums`6}FG{$a^Brx-S%q|cnCSOaU z(deNoR;+kNz3%FOLK{te1>D9wRcMzAD8Md->lQZ!xLZiM%u@k&DO|U>DZt%A%4MDk zuuI{(#Z3Y37E&(rRDfLy*DY=eaJP_hnWqBmQn+q$Q-HgLl*>F7V3)#mi(5wlr&6gw zP18Om3!yDpvSiEr`QmO7uN(602w?ecSGio?Ohl`zM0z|PkEh4S$K_U|`uU_l;DF_^ zm_2*;+yelq+CCuSlZiy)w9(Pgmwf8qf)5iY;B-2Dh!8@a{{Ltr{)AWqFvq&w;W{AzLM#{3!C`Af*G(G#-!7 zZQs6qSHnA^o0Nx#hog31$;o6g$Cb-v0|4IXv@JyZOEeliWnyCDKh?hJboxC)h=+;j z5T`LiYQL3per~f8OhgPdu^)81nIF@EVgQ5VnUWtl7-OZk<(uQ zK+FNix~7OYVVY)60dB6OYrwJ$@SZ(;3IOoQ7O-B5Mx$p|DwX2^;1-AHmKueYm1a+^ zt%s>fIP5Q%E5P!UKVwYRcs|GGNR;LHJ&#(et=Bb8TWuJ|SZ&)1uup)Sl-{?=+npIA z%31S}X_^nzi=Y7e0GKhhP6+W+cZl43A>u2Vrez9+!efo1D8L>8TL-=6;`wdvQ91WS z#OEWCh(0+v`BXD+1=u5CYrXrdb3~pzp3*c;Hw@$XR#6mSkAUTqs_E(Jm;EXU+zJ=~ z$YoY$Vq)SI8@6&8rq;n;*THfvOGMjzTJALDhKLVDqtT(-$C1rI>$-jc5&hWC#jrqZ z%AE7MtCyyaDj8!dt#ZN#w@Ik&y}iBpO`A5o?j(LDlR2_dsr=rjitch7e{;z#R+|u9Q|2eyx7PZOCinu#>?mVPiF1PH%fl9@vyk?u- z{cMRNH|i^u%3T0(njc9a;?0I(T#Sga@VEN4Q2|?kW&3jCH8nMLBLH0JB_Bk*+BD6p zyu?tx_7$)Vcs`^x!I4{pP9I4UB3^2mro2ehFCYaR1D1K|y8a0wlDF))+sv~ZiA2^F z3Wd!!?Uk8N3b+C++m;VQtQuLZJ}>t=BCat_Q%=;>FJJ{+1D1JOufT2rfK?6hepRk< zCnqO=8*sl=bCv?00LvKGmf{8?`dW>u@|B*9VHi7=+MQFtGvKPp*AM`F3jmG)fT?&q ze#wp(3X7ze1P_E!KbAFhx`&yfED17D-iPTsS&IIhg^Y>cTbIA1vumi zguHud1S`NHS0LowQzKXb4!Hs$@17dL3UGTAkbB89Gc#WyqBjbmMIw=JPfkw$y*;fh z+^8KH!7Ep;JbZR`Ru*M>f0gtznx@I`2|v7Waroc!zya&JeiRXz*1mCVk7go~2rCo{ zfAqhr3o~M%fOTE}FaQ*Y=p9YuR*-$X76SvEN~Hz>V7tw)mCi(?QF)B?5g+@yAVUQL zSl9KO^_p$MfyzZh)J@ZTw4RT=8xYY49K;KysitXjLI{&{K2xuQjsfTM`NYi3%<-{U z>_t28TM+*-P&xbUsR{o? zEEbdJd!MPXzY+j8cpWi__-E5JKd1mVSJD$;#+dxl_jIG$kcjwiM9dq8@f+(s>F*KI zWnST0cZn^gX?|J(ZWG{KE_Z0DRCUKwW?0=1DL}Y&&A_$eSCcU=~_Pp*ayJYd0<&(F5?i4XLCgSt6T@q*S=JMTTANr ze=uY0dLhL6)}j5iK*UFTdU~>BV`IA;MOGu&;|Lxa8ajC2zJ0riDDE%5meKBCym;}d zM$4{wZw1&R;9M?u#N6Cm&|7!+_4f9jZKvXn0_+j6bk}wLX(BqR<(TuwVDrGhz&g7X zcNAbB0B5t=%6>LcP+rfFX7RAMV{W<+dNKEi3}uCphiIZe~#Alc{`uyn{~vu`bx zO4|Tn(8u1(`dAlp&TsZHrV3R@0b9E&S#M2GK_+0?~ zQcYM2I0h{JthXX1Z6vWNEl*;5=RB;ZyfQZaanSQ5qicm^z+(RF=@h-AgPHw%X&;?AC)o=me% z1A8edD=!N80$3(seaK@yA|8o|Q_GeuyTQ(LWiQDq-_{EF2Do)o%AykrD8QXiJ-{{< z-~h$$L{kcICsYrxO$9hWu{+U}0^AAJ18h?P4p8h)H1%K8ktfGXjG1`=0000w literal 0 HcmV?d00001 diff --git a/images/open_in_new.png b/images/open_in_new.png new file mode 100644 index 0000000000000000000000000000000000000000..b2193ddceb77e27e44de054735697f16af6cb4a4 GIT binary patch literal 374 zcmV-+0g3*JP)Px$FiAu~RA@u(nOP3PFboCVlL03KP6jsvS)wSak~odmg7Z@ek#xq-fi4Rid@Kj& zhZOKmFcXNLz!?Cp0D#AtQ6~JFS)iCx?Ur5(-9GS~^STN=7?oe8T?NX>EjN2B`Q0t# zA<%2v@B&--`(_V@7O27FXJi-jdYhcg`FSelw&3wAB!jm=4?esA1s_^q1U{_5D11nP zJ@DZK_QHn}n1K%?Fbf|-fCE4DKdJdgrVfvWS^_5US^|>r=GQ`!@aCIX8s1bu1Kv#l z@1#&jVwmoM6TDtJQvYfKZ!+_2 UG31ryw*UYD07*qoM6N<$f&?y?5dZ)H literal 0 HcmV?d00001 diff --git a/images/pdf.png b/images/pdf.png new file mode 100644 index 0000000000000000000000000000000000000000..f03d7dce0a897be99f0a84039755742eb16a05aa GIT binary patch literal 3248 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 igm|!aPD{6c4&%R6*Xj09B%34v0000 + + 编组 5 + + + + + + + + + + + + + diff --git a/images/play.png b/images/play.png new file mode 100644 index 0000000000000000000000000000000000000000..494b7d4687fd729f9593a6ae8bc448ce07da98ba GIT binary patch literal 25798 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

;P4_E_6gzISi5 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

nd(s$C>~7A1H&T<5T*=a!J6pdJp-ggo z3DTZRUIdKiJfemrt&nO(+ljrN0m|NPJ!j|An##)Rsxs`NyL?|B9O`~Sipm_`zgieX z-oX*E53HQ1sylbyJ3-@;aRR*U@dihZ@cSvBv^6tJ8FH;3fw*NZh|criOtOmaE>V;FZno@32%$gIl`YbNAx*H$G>2m})P4N-xG^ukb$Q zl(<5Pv%GQSpd#re4WG0xvfU;c}LM!c(`0zaa_K0jXDS*wQLcLlZdRw_!b%Y!CG) z1PB!!2F|m2?o-rK%;x<*Enj1Ea~7zcBa?85Ah&WC`jR}}LY-pyy=rKn=>}F!Q<;VN z36=>WIby@BU? zq6MUWgoTC0Ti6L2iNeB@U!hZyn&B4uF4X4V(B3F?{WzB zZslQFy@gTXpW&x}!#qq(UhacR1R>n>k-FEq6W5-$6H*Q)^xT)g^J48cATd$F%pMG-`4kpVtc|A&+NLgvH!`EX&Ep4Somg?zHb zE~N;?heAhC6 z7MCW$gVc)C3O%oRO9TWwA!tA4-#Jz9YQW;II_lSi4G`hI$gFy`-jp8KF-HH^*+B$4 zamQX5*2Oe*D(us^d?>G}pg|^{Q_fNm!|A3Pv(Yd<6O0s@wSN+QuDx~bQ916ed`B<9WK~?pX80_HldDDAMRCI&rgHgbEgEfDxfQ zPUzaL3^g${?92x*?*sq@`#@b0B_5c*R&sZp;c2|X<1V@WpW<(2bJOO2{iy;Ln+?oN z20&!i#y}i;F1cV26*wbx%~3Goe!|ojYX= z*S)sKUD`9nwX%!)Z5qYh|M6o?*Gw=Mu(O3w1}wI}`9f}?(1c7|)Hy%W_ujt18w3rS z_VH&-?YD$ThXfy0%@Df!8x>XpZ!bnv=uo)zQ8Q$<((-b>TTh@U86WF^7#{+}b~bQn z+MjpXSqWgQ^EzCwgRgSonOtw=O$(mBGOoc@o7(ucW#Pk*{$DN0vF!(g;-0~b&KvFm zt(!z~nu>zMlz2V8@Dx|xdmpc;(cs`26~7*~%xAa{tZpnBgAr;p;)mIDikg3GwVvwT z8-%mqx==oHk2 z1_yt_gE7WU>HY-KiXN7pivZ>^ct)-N_!DiuJUBEIRtu~W->UxG;o$=9==Gp&FhgDk z<1mgGdo}W-iEw`>SJ!>JZ4p5f%i2r$u;c95OcY*zjr842d!B|kK9=T8L%->4L(sE( z@X#X|mt6~xs}MLwf90U5T^j#b-H1TYIqu#O;GoJ-bnhN}lc)$4QHD9sn!u!8UG%JJ z_J4GBhrq%8zq`7Tq!-CZHZtp0A>F=r>#*kUd2KK|KOcLeYQm9DI~;=^uV{~rD(y2y z5P$ELB;yAwtr#&<#H|ALE+j_&p)viwKc?xkuXGCr5%JFa&O^^U@B2*TV!({S)~?1Y)s@;k0IY4Af|Iae9IIY~1UrDB*KkTpp=aQ!Xhx z!_;+sZYEQ#+pGP8> zH1Hh>?&+~DPdlb*FUEVKwJfm4@u7qZu7N1*z9rg;Hz7irNVkm;k1NP7q8d8Vqi@Ae zOKBO?ct+PuruCRvgLx4wSn8rhfIeFTkf!zDQe z6}m7fb=JCvAOC44caQf?Upi-)enbGC@2>5I(y!Ca0u8muxtpSA-w?QK4p2TBRAsER z+TY01I+ET-zY|ku65Pgor8uswi@H*6%|o`8HF!{w)aAj1W}#C$7+2+1(%A1T`Pw@z zuYyAUwO0&#!yDCs+-p&lMf36{NgqYCE*SCD&sWCad( z@cIPvF08Vys`ft#tYZnO8&?^b(p(rna)w&qRqIZRmRyJ2f31S6zVnD#Y;A-G>Uh4T2d& yg(^D3M7T(UAcn39x5nMR$pKL^@V2(L-rnAs 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 literal 0 HcmV?d00001 diff --git a/images/poke.svg b/images/poke.svg new file mode 100644 index 0000000..a2c6a5d --- /dev/null +++ b/images/poke.svg @@ -0,0 +1,24 @@ + + + 编组 5 + + + + + + + + + + + + + + + + + + + + + diff --git a/images/ppt.png b/images/ppt.png new file mode 100644 index 0000000000000000000000000000000000000000..65c8719281842a581998c07b58639131ad08be45 GIT binary patch literal 1838 zcmV+}2hsS6P)Px*=t)FDRCr$PoqLE>RUF5^=iYhj>}!;Y{t%0(ECU7o!_oeU*y*2y2x(kFrBqa+ zOan7QU1=?aLW}^4k?uV1%)R%-?>=T{N7uP` z?%a87=fJQ#JLh-ip3nWB*YBMDU528>0ft0ELQjv*0Ddh#ce=#2Cpg%{0Hy)B6+pPy zZi2j501N{-&A|I~Chu=MdhFL?CWUong>USPtfxmC>4?o_(7QO;d;m=o1d!WmWEl7t z98z~{Z~qCNNXWLZ?1J(D4R}^cJrdi@b>0O)2EYO6Fh93nZZD*f@>6~7mt@shV-o9b zKGMIn0378pq2=Kv9tfr`x9{OxPXN%Y%yuD-1ff#bYKw=;lNpnGnkQdff}WE(3m(q{ zr2cLsK4%{|76PCQ0UL0ouBL&@A&@G7AQ(PwJ$k&8L`TdRwQK70k{z)w4*s$QFWA79 z_4Cq6SV0E1rKPvO+h9lEmW4@zG zc&F9x(ZC_n_ylNdkO8)@wYPr}8;Q@G0aJYuYxEg2wGkw*BS0!7MrklP#**>5n>g2L zc(%w2WDW=ztQeCvvyqNDUjv|32F>+o$|6!}0ye8Y0yqF*ifF#2sTJg~oADq4%uL|5 ztt%>JWL5~EJv5hLFH8hLj~WmF1wfAkJemsy3VW`pt5cu#+K zoH(GW;K?NT#ebj=o&!I72Kt}BgO6MTYitZBR8_EO6p=ljg0;4q zYHWYjR2zWpQABnh0BesLYBB(p+b3#dFPUX9 z27YlE${X9E_I?T0*5-TPSQ~(a3M7SS8nk2IfTvS{RKlk#$zasqwMQXMnF=;(vZ*mr z8>u-!yRm@s`c~u)>;-F!`r4f917IeDWap#c1LuHHsPrHp%V43f(M+X#??>RV1qeJ8 zH|=N;p_`y-NI$m%`iY;+zSgHvx;_9Z8KhP$0v{T52G9njY2aDK=mrAwA4mAbEnwj$ zfSR=&fZG2pl=aU5O--e5N|}9S{Q!JwA^6aMGk|twsox{DQQvprJc6A|4Txh@xN0%< z)2Dz)#1|DdHo!vF&_JT*5ZUz+q zSOjFVhMH+0QC{m7084gJ7QoPxs}Op69gxc!6Ka~yWHvr806GS^WI005t{(;1_Z2cP ztrGy90t~Nt0l~$~Mw{@VgHT@CC;&PFm`oz_)-K4i=K#4J5DX&w?wiPd_`U!*Ef z1Onh!E<>I<8Rn^~D5aBZXfY3sbkj}>Sr3$IUoAKUqC2adeu=V;z;ge^kJ^0X~rx_Q|~Zs z4BsLhJPSZQ{5h0YHvvtN(l^Al?nVu253mycKyrymw5&6?337)HK-sYkjFx%y^3g** zF#uYFhJNZ7WcPdkweJWRt$lrlM_*ev0C^_uOew~{!013GZH&D7-$T%UI|=QF zu@q@Gz0+0N52+6Y8UQm$EnNUU=+K>Ix$li8YPqxW&QuQrLOG_jZ^AafWu5N*fmLXt zrJ&Xiz;Ya_rD|7&&jmn_kOe>i&?5nl=0br2php58&4mI5K#v4Gn%72w_TeHrw_>ep zU7bwuSm{cd*9Ktm;UYyrYFWbhQm&E&JeE~`fW-$aX*rh7BK!7E@XMFX(^Yk_M03iu zx&ZB~y6%WC18ZsVe$distt*(XHUOyt#Rn>V+Ow!2s~Z+oJAi)ItV-nzfF3~$fC8XL z0v^qU0tG;i1U#Ax1y%r{2zydKL?i5p2)yGS;F!QWBJ`7cfTN+GMDQ{9^z1-wX7Di) z{?0AIvElE?S_EKp#c*r@Mluy4Kpg|TJ_M);LhBeFWxR0^T05k#2!lK>%<;n@lZgn# zeN%9DIFRuJagTurK!k#K845nXRbf5JK|G8guSn+)BPfPwKea%K&z-LRPalU7VDkQO ciN|652Y#A$gwegFUf07*qoM6N<$f}HC_(*OVf literal 0 HcmV?d00001 diff --git a/images/reply_message.png b/images/reply_message.png new file mode 100644 index 0000000000000000000000000000000000000000..9627de4275c8269110b1e73c222acfe09f77de45 GIT binary patch literal 1539 zcmcgsSu`666iqupENwy+weL%*MWyKt@#t00107 zdLaDvFlBG9gCzI%izNhc5B4Saxw`=B2h?W(0Eu=a0`8xJo@Y#4Ru53@T`YZ%`ikuy z#WZ?WlPX6sImYYMOUd{`eX#Gu)))H8qm-Q#Kb|YKf55x334BvmrpY|`u)%rAr7);- zW+p?=wOK_RYi-p2G=a~EV=7z~y<6NSE>NuhIBRR2TmE1&>DA}|VxoVp$&d^7W(_}h0yk38Po9>5PaU@m0*Ns%x zZy1zGQ64&r!DywZf?<_9!6nI{(NA9GN~E5bi7)`Cm6$u^(x6gWFQpw^Vu=d{Dd`QZ z{$>os3}d++k+By#t8qHPo@YW} zK+{8pw`4hf=leeE1eaP}JPW$5@KYd|)0c^ya%m`{|4IM_8lF*ACe?Hojkq>gpL^W` zF*B%rtVt@Crg5{dCeRD3|G}DIU@zB&F#u5Y9g>3EZ!^0@ABwSIv@x)k*N-60Q`-Dr z5;G=Y@uSWcjKC|EL8kbn`AnS_zCM4y`A2_a!l0`aQ0v>`Nc+U}*giLQp!%SFCAog| zgsh@_Vn=^kSb+RwL6Exn0HD(Szy)53Y`5!obE;k zJQHqjHW18VdCnu{Eg4{1K+lBmEfz8-{(J-L-Xpjv!D;qzd@DuLI2>WB1QhDdL~DWY z{`st6NK9UJRjRhY7utLt-e68G`1XGZK9x^ugG5(Q1E;lgH%$trUR@)&`6fD|hI*HW z3m#^do-ED}Sg%Ma$voma{{jv{ypLUbmBGE-6w0o?sHzsRpz2%ilKr3(>x$R~!XaGT zR=@T`U&Z#K+KZjMWM!AsOq8if3x91fl!dn|m|Fa{Tu>>_LbKOOp(3U{!8oSwsO-R9 zH2cjgJIRreHwN}qgq76JO!J6sRv-0Z&n9ukaH%Xby8<~>^x78I%Uh!e;P<)$(D4-u z72A|JVuR0H16%T>!d=Q$50Iw8yaHE2_Fz{-FApvq0(dF!YV&T^0PVrZOO+}Q*h~ly4 zSv6??w7D5SpTl=rqX&^}T74B6;#I;lCEf_5p{72!2R$cA22`yFdbiKvym_llr~w5^wLyBzWHNG2MCned4~L59OP+fEyb zHW;jkECto6oXCxC70%z>X=J9y&D5Y}2gKRu*6C1@Y^3v(3Oo5Ll5i`H-Y@)`Fk`Fq zHPUyd4e7|8KSv)0>9!6>+m6RP69&otJd2q11Pyptnwib3yMS=i+=J%@eVn~k2O!V literal 0 HcmV?d00001 diff --git a/images/revoke_message.png b/images/revoke_message.png new file mode 100644 index 0000000000000000000000000000000000000000..5e40b04beee1cb586ced43c1a5629694137a4dbb GIT binary patch literal 3106 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 literal 0 HcmV?d00001 diff --git a/images/screen.svg b/images/screen.svg new file mode 100644 index 0000000..60c1170 --- /dev/null +++ b/images/screen.svg @@ -0,0 +1,12 @@ + + + 编组 3 + + + + + + + + + diff --git a/images/svg/message_history.svg b/images/svg/message_history.svg new file mode 100644 index 0000000..3754fc4 --- /dev/null +++ b/images/svg/message_history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/svg/send_code.svg b/images/svg/send_code.svg new file mode 100644 index 0000000..a682ddb --- /dev/null +++ b/images/svg/send_code.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/svg/send_face.png b/images/svg/send_face.png new file mode 100644 index 0000000000000000000000000000000000000000..11b1dbbfc80d9fcbb889253fe222a682b4cd48b1 GIT binary patch literal 9112 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 y0-cv2dHaRj^PXqpEmQW3mjs^F=;ik_p<82*Os< + + + + + + diff --git a/images/svg/send_file.svg b/images/svg/send_file.svg new file mode 100644 index 0000000..1fd6a48 --- /dev/null +++ b/images/svg/send_file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/svg/send_image.svg b/images/svg/send_image.svg new file mode 100644 index 0000000..5677fce --- /dev/null +++ b/images/svg/send_image.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/svg/send_screenshot.svg b/images/svg/send_screenshot.svg new file mode 100644 index 0000000..2ec0c61 --- /dev/null +++ b/images/svg/send_screenshot.svg @@ -0,0 +1,4 @@ +] + + + \ No newline at end of file diff --git a/images/svg/send_video.svg b/images/svg/send_video.svg new file mode 100644 index 0000000..afc43a6 --- /dev/null +++ b/images/svg/send_video.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/take_video.png b/images/take_video.png new file mode 100644 index 0000000000000000000000000000000000000000..2e96016cdc0788de94491aeb9babb0dfbe936c13 GIT binary patch literal 1968 zcmbtUdpOkj8h?K?hQXKyxu(o$%(`Z@LY*9Am@#mtu{{<pU)rP=e>O1?**32K_vxk z1prWDIy$%lAiPar(NZN_i*ZGoEY?-oD3e4>}^E1{j zSzRZdn2h75#&c7amX`ig;La$%thC&{QLZk>fI#Jh^M7g`-&lBD#beP<5)(5b1b^B{ z^%0e*o6@1lGMNr`ZfB7>5hiJhibJz*NV;@gM^|;D^MkoZ(Q#JT=SJka*=|prMd$jp zsyro^PKrb=SrX}G&RmVQ$*5r1e!#&O(yR84z8&rn5#9r>?vdfxekHL=R+^E>U zFtyR|G*)tU|2)cDJ8Y#h7B4K;*VNhXnPf3R8dAFGQ>hsiu5Yj2#rSvHt!;JdjgeLJ zM=bS4_xU8_sr#0vQMWyN?+(h0_}7_c*;_W@%k^Jr*12p{CqU?Y0bSSd%^d3S0 z;RqoB_ZvSsH*if>Kv~4Mh0-zfFuGP`k`otmb=yZZCq0`7GVb^-{3kdg*u_TpYHIMq zP3SUG1NKdn_+rW90WYfr%nOCp?(LCH*Rd}n8B{izK^+bqx5hqvdcG@bAAucq(#wu? zEkT(x)a-%ejQ;Rrvg|7(A+bF$X=J0yI z%**QKS70*hVlbIeFNrGkiYW%QTl7N^l$-AJ#!KX8Un(Sv4C0g5^3u+|{A{IYSQwhr zTK?O(T2qfY+ObGp>Jb#s0I*X315xUG-~kZ+8_~8(x5JD1By>zi=+}Gvj@KSc^_XO) zphARr9BFJTi~bsFy}l8-gL>nT`xUr+2W29H=TjlG#ju7PvR8%GIOM7`NG@)k=a;b@ zj(jP6!Se8(lR(6YbH-w0@I+b83VWdewVZ716^P~m@2?5d)+j96z#Gyrj)6OQRNl}O zmbjI8P01JF&?WP&Kx^8EL4kqoBo(0a(JTvKIut7dEN({;z|e>x9%v-Z2LL&{{17Oq zB@uwOqLqdM$3S~c;0^U@Dgg1C_okpx0}vy{Cl7!d(s$}lk%2cxwNT+C1nQ~FQ(OE0 z4sd3{w1a`#@AXRh=dAnNQ*eT{? zqau!(OgcaoPVDPM;OxCvrWt{=E-nZm(-yABNRDA<^KU4i;P;LG#~Q-$k8jxOTv+2i zvbVMMezgU8c0AIY*6!<;uLeCAoA=LtMX_ zWs3tMgT*yA-8D0mC6={&jBLc_x)Nw^oxVNMQ8xI5L%Hbx-A~|CDqPeSiayNBG-ndh zk)J`8;F9*nXVs*8-OSRs)@*F_naQVvuV=ChDrS(N`J)E@7&(Oz#oehUr%L&zj$3cD z9TZL+6!0;EL4R!?J=+j=J5$h(7WsjYNl``Wt6AuOwH?ANRBWwLTZk@jl=DK-m2|fU zshBMr43d9*dsM*0Ii*rqRNqp0RV?-j#fiw29YIt2OarR&$n|;0zY8nsHhR9tq(W@? z#6oR9!{{8sqm#^Fz;F^XP{q>#O}BR_gB^4q=r-kqagtu>+{LuAf@4|cD~9ERZx82} z1&a;cMz1_Ygrt)9m%B;iO!K`d}cK zkYSG?c6Y)3GOjStkuw|ofS4E zko&9g{09C literal 0 HcmV?d00001 diff --git a/images/translate.png b/images/translate.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef6042150b09d1255bc50d79e96851c74972f8f GIT binary patch literal 6022 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 B0XP5v literal 0 HcmV?d00001 diff --git a/images/txt.png b/images/txt.png new file mode 100644 index 0000000000000000000000000000000000000000..157a7fe7b4f234e3746e3b5a3d2c2fc77a68b469 GIT binary patch literal 929 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r53?z4+XPVEzz!(ULzcf(S&&yF@W$r+g-#0ma-{|sXljFO!E9&Y}9 zZ|$Gki+^9A`TN?m-&d#pzB=Xi)ycoFO#FSR_t)v_Unfd_ohbf!BzaSa0%Ujc(>9lV89f(=Q3~3kK3ifDsCq znVH!%b@dEuYU}D&tX#Eb-;;0OfBgFM_g~{dsSCi!Iqm7<7*cWT?alLf!iECJKi=w8&cN#;Ud?R7SdQ(NU6_HgBQJqVbUp>M++6l|ImapL~nt!EW=9|>^g8aXxm*&FrC zV_MCIv=z;zS2KU4UN`aBdqgjG_XfSsYU}sEW?Qk@;Ovvvo0rwK*8U37ec#uWTInIB z8!NY+eWv=R8*6n~wk^K7cjYsMS5kMYY?-3p1V1zUVX^+yoPwA+dnL8HYfs1gdT6$D zdELEwYahjn2DSF@R(;K^J|jLMqh|3{S?S4D+!NRM_dbkcY76LDa{N_MisBU6SS>pS zN1a2AlXX1SBq&a6T~t1ySMVT*+lTL&Ip?e8so;W*H?eFzbjn z4RY9ef9lSRImTv{lbw6AEE$#~B%uSXtsuHffiIH}g29HftZh z%p~VjDH7azxO3Oe1{cZN$hyaqPad-Bc=9HxNXf2DOunbP|J~E++?QVW%gZJmbb0tE R?gS_qc)I$ztaD0e0s!le@I(Lr literal 0 HcmV?d00001 diff --git a/images/unknown.png b/images/unknown.png new file mode 100644 index 0000000000000000000000000000000000000000..a4b61714055414fa3eab11de5baca4190d6d5af5 GIT binary patch literal 1307 zcmZ|OSx}Q#6bJARV+B#C6cGVYkYd5Gi-R}{5(NQU2~b58M^PLckq&|jAXcfei6c_$ zU>Qb1tFlN$1r+K6MG!EUFJvJJNxm#(Av+`?#H_ukcKXyazkANhxi9}a_iA!d{4z%m zM+kzJB__nCf|+bP4k*xj;RTHlWY>`V!=5-2gG;1wh!Y$bps-;wLLjs7FuS>J7;GW} z36b_?VT{BYgIOP7*3ifVOhsS{LZBcdCU1PgcKvfFP9X4SR1T4f5UCv42Khf$tJPw) znnAY!7PG}_HUk!u$!s#2jDXQ(Fq%FYjQUT2VP0?0&41F(>$P+98bAx^)LPxFW^P73 zH#4hM&1#i18ifi_%av-GVpb}jkpQxpX_-na1(YI*QYcXfrxgM~ET0m|`63x#DB}sG zTmc~A2&9}T3BsRd^8hi6D`wh=m>kihjc^hXG7ur1EugamG@Gdj<`i|3PhkK&GJ{8= zb4fJrxXpjbfHXiR|3twi{2L&F!u|-50oE7*LMAcsz&JChrL-Qr0s2g8(vM(%DZ4L) zLZKF|4+;*u@bkr%*0%Py0+5QqWVXoaTmv8o^;KdlW>3+if`QFj73VfQ%L#|&UL`Vm zO~#hJS4-QRgIZcv+<%kLUDtke+HPpS)1m@c%RuhX*XCYSC9*OzbFrL&F& z`a5oJeg)raQ%7|~ljJo!JHsiMltNU)d{m=8e52$p4xh74;PViojPCVgd6gSZcS%Zq zQ|UBgG-Cr}2UE{yAGy5!#fBhJiwz$d%Ti8c0tsZ|=|8BCdwbgmR+v`#=%ABC%j&;yaw_UHj_6?DS zFmExhh%Lwg0aU6i4+uDsQLDXiBmba*Mt)4w)HPXn^$`v}u&X^$xAJs2FCtSw*VRkOW=szaFu)%L84y7H!& z=&4;<5-RTS)I$)#5I{Ufc(FcRcDwbxV4?RWs1~&w6TiN6-LQ3Ah7oELc_;V#v@DKa zopnf4JVMQgDsSn}uMgzox-k-2u4^Kb5p zq90lQ=6HJckgnQj)kpMf{;)1Bw;!*B&$=TAcBd~CNO@cR&wCx-npstfyc7TF#`0KQ ztl->Jtx5^CHXb{i#u|%dB6so2DNwQr}YzzAY%8yK{7O?`2IW#t-N1u)(u0Y+Lkk?nwu= z&c0TD_xbObcK7zSlpXCJ-RNU~ZD+hX8UE~>4t94=$^gc{vH+7q${I{<6mWYzln3C^52rr3yc5& literal 0 HcmV?d00001 diff --git a/images/video-call.svg b/images/video-call.svg new file mode 100644 index 0000000..d2c8035 --- /dev/null +++ b/images/video-call.svg @@ -0,0 +1,18 @@ + + + 编组 6 + + + + + + + + + + + + + + + diff --git a/images/video_call.png b/images/video_call.png new file mode 100644 index 0000000000000000000000000000000000000000..b36fadaa72e798b56f72c3b52c866e6f346621e7 GIT binary patch literal 1438 zcmaKs|3A}t0LR~-t;LOPh;oNz=tv>Sm%hk0!>!^l^DSTdg2k;l-$O`3!U&X0tomhtZ2jpgtWq1ytl(#E(ItNi_hWzsTgZc{5>Z@p#{$edSL<$+I~#S_#&u z!X+mH!<-^DoJ<_(__T{HPcS(0JxS!DnQDLZsN(iTmitE?Z-GoElgQ=r7i>8Vc$h@z zvAhjgv+RD#!^Hf67T5wT_O}QszgT`sw>M8U<}zAv7$m4t8mjz-8z*&plO3Pc ziZ29;7uF<7JQabI%-ZOAtSu$hi5qtjlVUB&tl?n1v9WQaNF*9O?-!q&%}vM;pL1X* zb;6mjYI(DFbt;*Z77(`h%&*9vK$2?qnH@1Avpj80YWOH%{PNM5+<-m^k<*m2Yh&cnsNryutG0-x?15Rjt6Om2I{2v27xPlF1RmRiyKje_vNo6c{h z54olZcbqOPCO}~3QoF>)^3}H$I*OWp9l_Yxm==XXp>=jkR^nUr;##MI?u=D+tHK0j z48NjO+YQ7W)-~ zLAg~=fHb!~kFxo2O0;UnRJyL+dC(b3gcTPTH(jFBB~-9AoL>Doi*}4G=ogiOh+EQJ z!V79*O6Qtiqy=c=^8_vF)8Mro*D}VOGG)l-k8X--{koe#c89mQQ-Wf$r9IIp+~70P zW`4W{vp(rewMM-bD82qh#=t(}@Qoqer(eoGh5NQdAGnLFpxj<_d7TyM5Ct&!4EcYuI6 zs#cE=vBv|8B&MDyWC$M66I-V`>L!;uh$IWe<+J`!0SMWX#s8abu&#=?zi4g3)PVN; zTu}tRJV{#JuD-OWM1%@dL&WW&81-iJLl`;?Lh7r5pGP;3R}4kyKB-&OGiag1)#mxs zKm2_%8ccoZ*>N!D0zT<|rTD_N2ESfYN22WlZxp+2xhX%l;@!CiBDve7n1j$C<~r-x zm^rG^#_pT-e~t_0oFD(xan)wYkVTuA2N9DeW*ZC988-AnS}3RHM)hz@qWCJy=7Z#6 zI_35JL`Mh7>c;(v2140q_%(x~^_zS7j^%e7oGseZDp7T46X5p`AC|f41CaO$C^WTQ d>3{EP!{H&UXxP(M8@6@0fb8T(;u1qL{sqqSkYNA- literal 0 HcmV?d00001 diff --git a/images/video_call_self.png b/images/video_call_self.png new file mode 100644 index 0000000000000000000000000000000000000000..608e36fb76d2f9b9a83a9158df7c328ab3c94733 GIT binary patch literal 1462 zcmbu9X*}C^0LFh2i7QCzuA`A@D-;o>?lWQ05yD(GGjj`bOwnomtE$ni%hFcW*`kWA zK~SxtNl|rcT}4YYod`ZU_fcItd$Cu0xfjp#y!k$_p3mpW@o;yMl|Cp90Fb48Nuup% z@IR77?z`Ytiur!Pl4vfDpk`2G0RTiih2-F!8nj&eO=KWh@%d15N{7$^vlfnFDK%oN z2n{_>B_#A=k5z4Kl@eQtPDW!`WzG)i_Z1OOL!SO7xra5_aX{N?wbxIod3Z*V%kTId zB8_&Lao*byU(*Yif8EM2o}Qd8N}D#B@Bf&QBphsO-WET9${Rt5{!va2gD3NEvzE|oN?d&kCh_We*r^0!PGSQYqn2|0tg4Q0k|my$Rm$; zn|>tXK1l;Rz}t%|D5>(+<2EJ|(P{)6o3@y+u(>o+I>ey8Qj@Q@!HqLS6FWOQNis4r z$o_lEn$V3zdC*EG<;mCoI{k|*W=M?J=30GS{VW-Q%}PZ9Ik^4sviPOD8A!m4VKa|+ zsmntCyQGa7xdYtNv}{I-DN$P*G|bo5)RY|DjIErzsIp48z0HrUk@5tR+CdV~rR>nx zqA>z9n!UZP$(!rpiz8Wt6FMDRsoGUY^&*3Ev_Jtos-TEpWk}qhUC}O=(bm1owcY)o zN4b^)oBiras&Bae=@a0M$WrSDt>kFgsr*^CLU^B2(!+!*cdf)8!1##QQ(wQp=fb5U+9L~mwI&Px>giit!ij!SQs82&M<2V0^`_Q&wsBK zCN@Xt<@WLopZ>gD7Y2<_EmXa?w5vo-=$hd^Ig_Z7Y<|M|Sxb8rep{WUA6UX??f4hm zlhDuYeP|{|fClu^B6!LiBdp+UPEL-BaDM*tLVSQ|5f=xyxI4g$;N6T}h^QDHL-Ec@ zW%WXvjAEbux>yI->dq~Z8%g-2cVPhr7uES;5+2eP8wUz*(t@a@N;F!a*Tn-58KBi!PaBj>?*$R#hL3a25=4Yw0t5knru%F z0dE^O);d9JsLZPzSk}mJp8|a4nnB=EB-;I?5f#Nd*%V)_YHx1PW^Mfc3wT{XN_WCP zsGVMM{48r(q?llhfI$p9oS!bT%-l<`YcvBmac?Udw+OEP9KDpqb*_`7=8#yBA51Ui}aA%i$LR3AUq?Z z$wRl5zSrihZ{LcDN5^g)k znj-5E_8h;~qkY76;h$%ev4Wg*+l)K@tmZgMe08RqwBd=%$#0Ng8xFJuuU+x$mZzx) zRhAy<#I*UjP`%^cdAs(^MUQXz8~KhM6Et1%P9->0gm8~(gPW)5^ zE@`s2J4|0z-0;mAPF4b+>Y#446ZqJi2ss~#UwwbB#Hp}Tu(rv99dBQ^i&sUXt)f&t zjoPg!BA=u%!19H`g8@s4p;2N2!AL&X#MD86MEjrbero|&wGTz$F3qF<1+YZuGi)Xy VutGXw$z}hD0fp>Ns&Nd?{2MYvjXeMW literal 0 HcmV?d00001 diff --git a/images/video_icon.png b/images/video_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..adeacddff6541412810214d9daabd2665e83cd97 GIT binary patch literal 1502 zcmX}sdoGh-wnk5p5L)ld?pEsM%7wRz-S z-j$7Xa13eXkR7k4ykGMDsb#&7m#&+eY{x!?P}_x^Y9IX466joz)Ks|16=b^{(L zKWGl!9te3TN8RsjAHd`N(5|qCfqfjP_u)JVZqOzSCQD6~5s8p*INS<0a1}%1df}N^ z5*tHY#1fboKPEtAV~K2lz`+t&7=IQ9N&*Y&+sl8k|11CRY!{hK20}pD7Gz5%g-B%* zh(xw20mYl3SPY6ppil@3AOdhh0IqL<>+2wYeT&ZrS9x2jt6Pg)DT5_pFr^HpWOiOM zJ-azEwK+byN&hMy8Wj(Xh-t%O+K_l)P}KjI=>4C<&JV)QZee?;prvi&b<;+51OIs~ zzp{p3QOz%Z!7qEUR#LfET(Op4!pkq_Juc+s74p(ES5oeNOG;f%ytPb8S&mCuioUjV zDUN&bD)+)wZd436;>uzec`@|DV#tNXv*$TMk(@IToWO8Sz&Vb8DBC}T;~&B%hOi02 zY`-A3PXNn1faL`xiRA_HXL&-1EG&_Qf%2al1Mn;lh#w2>%X0T+qH!#D9McWr!$d*6 znWrFLOjn2}(*=TMI%6OVCk)dGN`UDEFdPAf1H^-2k7iiR$N4~yY4DSuHy#F;lUGyM zFfp?@>fq>l<7P?eAI)te^w|Z0Xj3BH0vjLRJq&}%=K&}eLgLu+*m$&_+rA#&v{YtI z?g5710J=@p1{YCj&3=06fi12R)H4Bbbq6e+?cqLydg?Vro5xz_citutG9Ke{4xZS#Dk5O-b2@opKSw z;Ur~c_S>#5%Baa{q~+fB;2`JePuhIp8zk!EgxSK3OGE5I`7p{t?f8krW49z@lO`^m zom#tg3}hw8Dn1M9ayVtxfrvr!f0aWd9#mJC>^`c|xN4$Y1`FOXM;5v5RoI#45~^;O z+YMI`t}5E5@b~(7 zIy?=jV5nMe-l={9VV7xFCwjt6vk8{Z(NWIoNL4hwr%yn8Ek)4)C5<_Q`tY8VsT8|} za}5I-^122?Umlh|W$2U2W_mg0qZRPRs2+0d?u-ZNu{M)IV~OsX_YEFdI15J5FV>6J zlmIeM>0Zg&cyWc1o-@^0Ri%<7nh^6gdMb?CE(x{Gv=VFwPwEuRu|%Tv_f6XS=MQOY zI1VbnF?rU-At^p&x0m(bDcTl#o44EC_$K;{1hPek=E583j`po>U~b(@ho_@>es7-e z2eEj^6(ufStI4WB)?ZyV?wtPB6dP&v?B|bPw3_rkkEwmdQtyVJqBPsIw?D66Uz+^1 z3+G-w4UbAg3^P{fHJTbUAq<~s+U-*?USU+S0?%~k7d`1qNT&nj8>%$H5UA%}5N� znL4L7lS+vac`|Yqs^}Re#Lx{8P38C;QE~GNgc&6{qYVY~&e`+DXOrlcOylv>C=00&(+e5Ni6Y_*i+m>com2<>C*a42Xc8I}U e%Ua<&-I7l-PH4Y!)swRQH3M$ms0P>2+kXQ&xDB@e literal 0 HcmV?d00001 diff --git a/images/voice-call.svg b/images/voice-call.svg new file mode 100644 index 0000000..2a704aa --- /dev/null +++ b/images/voice-call.svg @@ -0,0 +1,14 @@ + + + 编组 3 + + + + + + + + + + + diff --git a/images/voice.svg b/images/voice.svg new file mode 100644 index 0000000..4f711d4 --- /dev/null +++ b/images/voice.svg @@ -0,0 +1,23 @@ + + + 编组 9 + + + + + + + + + + + + + + + + + + + + diff --git a/images/voice_call.png b/images/voice_call.png new file mode 100644 index 0000000000000000000000000000000000000000..43ebc6a1272f446b17ff1d16958ac469db1255ec GIT binary patch literal 1581 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 M07*qoM6N<$f_D$mH~;_u literal 0 HcmV?d00001 diff --git a/images/voice_send.png b/images/voice_send.png new file mode 100644 index 0000000000000000000000000000000000000000..4da69b1d4f345740a9830f9deee1307cf85535c7 GIT binary patch literal 2521 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 literal 0 HcmV?d00001 diff --git a/lib/base_widgets/tim_callback.dart b/lib/base_widgets/tim_callback.dart new file mode 100644 index 0000000..9beb31e --- /dev/null +++ b/lib/base_widgets/tim_callback.dart @@ -0,0 +1,20 @@ +enum TIMCallbackType { API_ERROR, FLUTTER_ERROR, INFO } + +class TIMCallback { + TIMCallbackType? type; + String? errorMsg; + int? errorCode; + StackTrace? stackTrace; + Object? catchError; + int? infoCode; + String? infoRecommendText; + + TIMCallback( + {this.catchError, + this.infoRecommendText, + this.errorMsg, + this.errorCode, + this.stackTrace, + this.infoCode, + this.type}); +} diff --git a/lib/base_widgets/tim_state.dart b/lib/base_widgets/tim_state.dart new file mode 100644 index 0000000..9a0e903 --- /dev/null +++ b/lib/base_widgets/tim_state.dart @@ -0,0 +1,51 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:io'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; + + +abstract class TIMState extends State { + @override + initState() { + super.initState(); + } + + // override这个函数来承接处理onTIMCallback回调。 + // 目前base层,会自动触发`TIMCallbackType.FLUTTER_ERROR`类型Flutter Framework异常。 + // 其他类型callback请自行处理。 + void onTIMCallback(TIMCallback callbackValue) { + // TODO: 这里后续看看要不要默认加上报逻辑。 + } + + bool isAndroidDevice(){ + return !kIsWeb && Platform.isAndroid; + } + + bool isIosDevice(){ + return !kIsWeb && Platform.isIOS; + } + + bool isWebDevice(){ + return kIsWeb; + } + + setTIMState(VoidCallback fn){ + + } + + @override + Widget build(BuildContext context) { + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + onTIMCallback(TIMCallback( + type: TIMCallbackType.FLUTTER_ERROR, + stackTrace: details.stack, + errorMsg: "Error from Flutter")); + }; + + return timBuild(context); + } + + // 请override这个方法来render界面 + Widget timBuild(BuildContext context); +} diff --git a/lib/base_widgets/tim_stateless_widget.dart b/lib/base_widgets/tim_stateless_widget.dart new file mode 100644 index 0000000..45b8fce --- /dev/null +++ b/lib/base_widgets/tim_stateless_widget.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; + +abstract class TIMStatelessWidget extends StatelessWidget { + const TIMStatelessWidget({Key? key}) : super(key: key); + + // override这个函数来承接处理onTIMCallback回调。 + // 目前base层,会自动触发`TIMCallbackType.FLUTTER_ERROR`类型Flutter Framework异常。 + // 其他类型callback请自行处理。 + void onTIMCallback(TIMCallback callbackValue) { + } + + bool isAndroidDevice(){ + return !kIsWeb && Platform.isAndroid; + } + + bool isIosDevice(){ + return !kIsWeb && Platform.isIOS; + } + + bool isWebDevice(){ + return kIsWeb; + } + + @override + Widget build(BuildContext context) { + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + onTIMCallback(TIMCallback( + type: TIMCallbackType.FLUTTER_ERROR, + stackTrace: details.stack, + errorMsg: "Error from Flutter")); + }; + + return timBuild(context); + } + + // 请override这个方法来render界面 + Widget timBuild(BuildContext context); +} diff --git a/lib/base_widgets/tim_ui_kit_base.dart b/lib/base_widgets/tim_ui_kit_base.dart new file mode 100644 index 0000000..9514a57 --- /dev/null +++ b/lib/base_widgets/tim_ui_kit_base.dart @@ -0,0 +1,8 @@ +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TUIKitBuildValue { + // 这里预留以后可以扩展全局通用能力 + TUITheme theme; + + TUIKitBuildValue({required this.theme}); +} diff --git a/lib/base_widgets/tim_ui_kit_class.dart b/lib/base_widgets/tim_ui_kit_class.dart new file mode 100644 index 0000000..5a2e8c3 --- /dev/null +++ b/lib/base_widgets/tim_ui_kit_class.dart @@ -0,0 +1,12 @@ +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +class TIMUIKitClass { + static final CoreServicesImpl _coreServices = + serviceLocator(); + + static void onTIMCallback(TIMCallback callbackValue) { + _coreServices.callOnCallback(callbackValue); + } +} diff --git a/lib/base_widgets/tim_ui_kit_state.dart b/lib/base_widgets/tim_ui_kit_state.dart new file mode 100644 index 0000000..5a44548 --- /dev/null +++ b/lib/base_widgets/tim_ui_kit_state.dart @@ -0,0 +1,39 @@ +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class TIMUIKitState extends TIMState { + final CoreServicesImpl _coreServices = serviceLocator(); + + + @override + void onTIMCallback(TIMCallback callbackValue) { + super.onTIMCallback(callbackValue); + _coreServices.callOnCallback(callbackValue); + } + + @override + Widget timBuild(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: serviceLocator()), + ], + builder: (BuildContext context, Widget? w) { + final theme = Provider.of(context).theme; + final value = TUIKitBuildValue(theme: theme); + return tuiBuild(context, value); + }, + ); + } + + @required + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Container(); + } +} diff --git a/lib/base_widgets/tim_ui_kit_statelesswidget.dart b/lib/base_widgets/tim_ui_kit_statelesswidget.dart new file mode 100644 index 0000000..27f8e5d --- /dev/null +++ b/lib/base_widgets/tim_ui_kit_statelesswidget.dart @@ -0,0 +1,40 @@ +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class TIMUIKitStatelessWidget extends TIMStatelessWidget { + final CoreServicesImpl _coreServices = serviceLocator(); + + TIMUIKitStatelessWidget({Key? key}) : super(key: key); + + @override + void onTIMCallback(TIMCallback callbackValue) { + super.onTIMCallback(callbackValue); + _coreServices.callOnCallback(callbackValue); + } + + @override + Widget timBuild(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: serviceLocator()), + ], + builder: (BuildContext context, Widget? w) { + final theme = Provider.of(context).theme; + final value = TUIKitBuildValue(theme: theme); + return tuiBuild(context, value); + }, + ); + } + + @required + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Container(); + } +} diff --git a/lib/business_logic/life_cycle/add_friend_life_cycle.dart b/lib/business_logic/life_cycle/add_friend_life_cycle.dart new file mode 100644 index 0000000..00bb273 --- /dev/null +++ b/lib/business_logic/life_cycle/add_friend_life_cycle.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class AddFriendLifeCycle { + /// Before requesting to add a user as friend or a contact, + /// `true` means can add continually, while `false` will not add. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function( + String userID, String? remark, String? friendGroup, String? addWording, + [BuildContext? context]) shouldAddFriend; + + AddFriendLifeCycle({ + this.shouldAddFriend = DefaultLifeCycle.defaultAddFriend, + }); +} diff --git a/lib/business_logic/life_cycle/add_group_life_cycle.dart b/lib/business_logic/life_cycle/add_group_life_cycle.dart new file mode 100644 index 0000000..6c44c35 --- /dev/null +++ b/lib/business_logic/life_cycle/add_group_life_cycle.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class AddGroupLifeCycle { + /// Before requesting to add or join to a group, + /// `true` means can add continually, while `false` will not add. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String groupID, String message, [BuildContext? context]) + shouldAddGroup; + + AddGroupLifeCycle({ + this.shouldAddGroup = DefaultLifeCycle.defaultAddGroup, + }); +} diff --git a/lib/business_logic/life_cycle/base_life_cycle.dart b/lib/business_logic/life_cycle/base_life_cycle.dart new file mode 100644 index 0000000..c790ed4 --- /dev/null +++ b/lib/business_logic/life_cycle/base_life_cycle.dart @@ -0,0 +1,89 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_class.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; + +typedef MessageFunction = Future Function(V2TimMessage message); + +typedef MessageFunctionNullCallback = Function(V2TimValueCallback res); + +typedef MessageFunctionOptional = Future Function(V2TimMessage message); + +typedef MessageListFunction = Future> Function(List messageList); + +typedef MessageListFunctionAsync = List Function(List messageList); + +typedef FutureBool = Future; + +typedef AddFriendFunction = Function(String userID, String? remark, String? friendGroup, String? addWording); + +typedef ConversationListFunction = Future> Function(List conversationList); + +typedef FriendListFunction = Future> Function(List friendList); + +typedef FriendInfoFunction = Future Function(V2TimFriendInfo? friendInfo); + +/// Here is the default life cycle hooks implementation for all the hooks in TUIKit. +abstract class DefaultLifeCycle { + static Future> defaultConversationListSolution(List list) async { + return list; + } + + static Future> defaultFriendListSolution(List list) async { + return list; + } + + static Future defaultMessageSolution(V2TimMessage message) async { + return message; + } + + static Future defaultTwoMessagesSolution(V2TimMessage message, [V2TimMessage? repliedMessage]) async { + return message; + } + + static Future> defaultMessageListSolution(List list) async { + return list; + } + + static List defaultMessageListSolutionAsync(List list) { + return list; + } + + static Future defaultAsyncBooleanSolution(dynamic) async { + return true; + } + + static bool defaultBooleanSolution(dynamic) { + return true; + } + + static defaultNullCallbackSolution(dynamic) {} + + static Future defaultAddFriend(String userID, String? remark, String? friendGroup, String? addWording, + [BuildContext? context]) async { + return true; + } + + static Future defaultAddGroup(String groupID, String message, [BuildContext? context]) async { + return true; + } + + static Future defaultFriendInfoSolution(V2TimFriendInfo? friendInfo) async { + return friendInfo; + } + + static Future defaultPopBackRemind() async { + // You have to implement the exact life cycle hook in this case. + TIMUIKitClass.onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, infoRecommendText: TIM_t("请传入离开群组生命周期函数,提供返回首页或其他页面的导航方法。"), infoCode: 6661402)); + return; + } +} diff --git a/lib/business_logic/life_cycle/block_list_life_cycle.dart b/lib/business_logic/life_cycle/block_list_life_cycle.dart new file mode 100644 index 0000000..6c2baa5 --- /dev/null +++ b/lib/business_logic/life_cycle/block_list_life_cycle.dart @@ -0,0 +1,12 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class BlockListLifeCycle { + /// Before requesting to delete a user from block list, + /// `true` means can delete continually, while `false` will not delete. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(List userIDList) shouldDeleteFromBlockList; + + BlockListLifeCycle({ + this.shouldDeleteFromBlockList = DefaultLifeCycle.defaultAsyncBooleanSolution, + }); +} diff --git a/lib/business_logic/life_cycle/chat_life_cycle.dart b/lib/business_logic/life_cycle/chat_life_cycle.dart new file mode 100644 index 0000000..fb7e5f1 --- /dev/null +++ b/lib/business_logic/life_cycle/chat_life_cycle.dart @@ -0,0 +1,54 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class ChatLifeCycle { + /// Before a new message will be added to historical message list from long connection. + /// You may not render this message by return null. + MessageFunctionOptional newMessageWillMount; + + /// Before a modified message updated to historical message list UI. + MessageFunction modifiedMessageWillMount; + + /// Before a new message will be sent. + /// Returns null can block the message from sending. + // Future Function(V2TimMessage message, [V2TimMessage? repliedMessage]) messageWillSend; + + /// After a new message been sent. + MessageFunctionNullCallback messageDidSend; + + /// After getting the latest message list from API, + /// and before historical message list will be rendered. + /// You may add or delete some messages here. + MessageListFunction didGetHistoricalMessageList; + + /// Before deleting a message from historical message list, + /// `true` means can delete continually, while `false` will not delete. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String msgID) shouldDeleteMessage; + + /// Before clearing the historical message list, + /// `true` means can clear continually, while `false` will not clear. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String conversationID) shouldClearHistoricalMessageList; + + /// Before rendering a message to message list. + bool Function(V2TimMessage msg) messageShouldMount; + + /// Before all message will be rendered on the message list. + /// You may add or delete some messages here. + MessageListFunctionAsync messageListShouldMount; + + ChatLifeCycle({ + this.shouldClearHistoricalMessageList = DefaultLifeCycle.defaultAsyncBooleanSolution, + this.shouldDeleteMessage = DefaultLifeCycle.defaultAsyncBooleanSolution, + this.messageDidSend = DefaultLifeCycle.defaultNullCallbackSolution, + this.didGetHistoricalMessageList = DefaultLifeCycle.defaultMessageListSolution, + // this.messageWillSend = DefaultLifeCycle.defaultTwoMessagesSolution, + this.modifiedMessageWillMount = DefaultLifeCycle.defaultMessageSolution, + this.newMessageWillMount = DefaultLifeCycle.defaultMessageSolution, + this.messageShouldMount = DefaultLifeCycle.defaultBooleanSolution, + this.messageListShouldMount = DefaultLifeCycle.defaultMessageListSolutionAsync, + }); +} diff --git a/lib/business_logic/life_cycle/conversation_life_cycle.dart b/lib/business_logic/life_cycle/conversation_life_cycle.dart new file mode 100644 index 0000000..4f8612b --- /dev/null +++ b/lib/business_logic/life_cycle/conversation_life_cycle.dart @@ -0,0 +1,25 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class ConversationLifeCycle { + /// Before deleting a conversation, or a channel, from the conversation list, + /// `true` means can delete continually, while `false` will not delete. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String conversationID) shouldDeleteConversation; + + /// Before clearing the historical message for a specific conversation, provided in parameter, + /// `true` means can clear continually, while `false` will not clear. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String conversationID) + shouldClearHistoricalMessageForConversation; + + /// Before conversation list will mount or update to conversation page. + ConversationListFunction conversationListWillMount; + + ConversationLifeCycle({ + this.conversationListWillMount = + DefaultLifeCycle.defaultConversationListSolution, + this.shouldClearHistoricalMessageForConversation = + DefaultLifeCycle.defaultAsyncBooleanSolution, + this.shouldDeleteConversation = DefaultLifeCycle.defaultAsyncBooleanSolution, + }); +} diff --git a/lib/business_logic/life_cycle/friend_list_life_cycle.dart b/lib/business_logic/life_cycle/friend_list_life_cycle.dart new file mode 100644 index 0000000..d7bed84 --- /dev/null +++ b/lib/business_logic/life_cycle/friend_list_life_cycle.dart @@ -0,0 +1,10 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class FriendListLifeCycle { + /// Before friend list (contacts list) will mount or update to contacts page. + FriendListFunction friendListWillMount; + + FriendListLifeCycle({ + this.friendListWillMount = DefaultLifeCycle.defaultFriendListSolution, + }); +} diff --git a/lib/business_logic/life_cycle/group_profile_life_cycle.dart b/lib/business_logic/life_cycle/group_profile_life_cycle.dart new file mode 100644 index 0000000..f421962 --- /dev/null +++ b/lib/business_logic/life_cycle/group_profile_life_cycle.dart @@ -0,0 +1,11 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class GroupProfileLifeCycle { + /// In this case, you have better navigating to you home page or conversation list page, + /// due to user request to leave the group, as quitting or disbanding. + Future Function() didLeaveGroup; + + GroupProfileLifeCycle({ + this.didLeaveGroup = DefaultLifeCycle.defaultPopBackRemind, + }); +} diff --git a/lib/business_logic/life_cycle/new_contact_life_cycle.dart b/lib/business_logic/life_cycle/new_contact_life_cycle.dart new file mode 100644 index 0000000..34d4714 --- /dev/null +++ b/lib/business_logic/life_cycle/new_contact_life_cycle.dart @@ -0,0 +1,20 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class NewContactLifeCycle { + /// Before accepting a friend or contact requirement from other user, + /// `true` means can accept continually, while `false` will not accept. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String userID) shouldAcceptContactApplication; + + /// Before refusing a friend or contact requirement from other user, + /// `true` means can refuse continually, while `false` will not refuse. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String userID) shouldRefuseContactApplication; + + NewContactLifeCycle({ + this.shouldAcceptContactApplication = + DefaultLifeCycle.defaultAsyncBooleanSolution, + this.shouldRefuseContactApplication = + DefaultLifeCycle.defaultAsyncBooleanSolution, + }); +} diff --git a/lib/business_logic/life_cycle/profile_life_cycle.dart b/lib/business_logic/life_cycle/profile_life_cycle.dart new file mode 100644 index 0000000..80b6850 --- /dev/null +++ b/lib/business_logic/life_cycle/profile_life_cycle.dart @@ -0,0 +1,33 @@ +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/base_life_cycle.dart'; + +class ProfileLifeCycle { + /// Before adding a contact to block list, + /// `true` means can add continually, while `false` will not add. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String userID) shouldAddToBlockList; + + /// Before deleting a contact or friend, + /// `true` means can delete continually, while `false` will not delete. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String userID) shouldDeleteFriend; + + /// Before requesting to add a user as friend or a contact, + /// `true` means can add continually, while `false` will not add. + /// You can make a second confirmation here by a modal, etc. + FutureBool Function(String userID) shouldAddFriend; + + /// After getting the user info of friend or contact, + /// and before rendering it to the profile page. + FriendInfoFunction didGetFriendInfo; + + /// The callback after the remark to a friend changed. + FutureBool Function(String userID) didRemarkUpdated; + + ProfileLifeCycle({ + this.didRemarkUpdated = DefaultLifeCycle.defaultAsyncBooleanSolution, + this.didGetFriendInfo = DefaultLifeCycle.defaultFriendInfoSolution, + this.shouldAddToBlockList = DefaultLifeCycle.defaultAsyncBooleanSolution, + this.shouldAddFriend = DefaultLifeCycle.defaultAsyncBooleanSolution, + this.shouldDeleteFriend = DefaultLifeCycle.defaultAsyncBooleanSolution, + }); +} diff --git a/lib/business_logic/listener_model/tui_group_listener_model.dart b/lib/business_logic/listener_model/tui_group_listener_model.dart new file mode 100644 index 0000000..055bd80 --- /dev/null +++ b/lib/business_logic/listener_model/tui_group_listener_model.dart @@ -0,0 +1,173 @@ +// ignore_for_file: unnecessary_getters_setters + +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimGroupListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_change_info_type.dart'; +import 'package:tencent_cloud_chat_sdk/manager/v2_tim_manager.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_topic_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_topic_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +enum UpdateType { groupInfo, memberList, joinApplicationList, groupDismissed, kickedFromGroup } + +class NeedUpdate { + final String groupID; + final UpdateType updateType; + final String extraData; + int? groupInfoSubType; + String? ownerID; + + NeedUpdate(this.groupID, this.updateType, this.extraData); +} + +class TUIGroupListenerModel extends ChangeNotifier { + final GroupServices _groupServices = serviceLocator(); + V2TimGroupListener? _groupListener; + NeedUpdate? _needUpdate; + final TUIChatGlobalModel chatViewModel = serviceLocator(); + late CoreServicesImpl coreInstance = TIMUIKitCore.getInstance(); + late V2TIMManager sdkInstance = TIMUIKitCore.getSDKInstance(); + + NeedUpdate? get needUpdate => _needUpdate; + + set needUpdate(NeedUpdate? value) { + Future.delayed(const Duration(seconds: 0), () { + _needUpdate = value; + }); + } + + TUIGroupListenerModel() { + _groupListener = V2TimGroupListener(onMemberInvited: (groupID, opUser, memberList) { + _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); + notifyListeners(); + }, onMemberKicked: (groupID, opUser, memberList) async { + if (_isLoginUserKickedFromGroup(groupID, memberList)) { + _deleteGroupConversation(groupID); + + final groupName = await _getGroupName(groupID); + _needUpdate = NeedUpdate(groupID, UpdateType.kickedFromGroup, groupName); + notifyListeners(); + } else { + // 其他成员被踢出时,也需要更新成员列表 + _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); + notifyListeners(); + } + }, onMemberEnter: (String groupID, List memberList) { + _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); + notifyListeners(); + }, onMemberLeave: (String groupID, V2TimGroupMemberInfo member) { + _needUpdate = NeedUpdate(groupID, UpdateType.memberList, ""); + notifyListeners(); + }, onGroupInfoChanged: (groupID, changeInfos) { + _needUpdate = NeedUpdate(groupID, UpdateType.groupInfo, ""); + for (V2TimGroupChangeInfo info in changeInfos) { + if (info.type == GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_OWNER) { + _needUpdate!.groupInfoSubType = GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_OWNER; + _needUpdate!.ownerID = info.value; + } + } + notifyListeners(); + }, onReceiveJoinApplication: (String groupID, V2TimGroupMemberInfo member, String opReason) async { + _onReceiveJoinApplication(groupID, member, opReason); + chatViewModel.refreshGroupApplicationList(); + notifyListeners(); + }, onGroupDismissed: (String groupID, V2TimGroupMemberInfo opUser) async { + _deleteGroupConversation(groupID); + final groupName = await _getGroupName(groupID); + _needUpdate = NeedUpdate(groupID, UpdateType.groupDismissed, groupName); + notifyListeners(); + }); + } + + setGroupListener() { + _groupServices.addGroupListener(listener: _groupListener!); + } + + removeGroupListener() { + _groupServices.removeGroupListener(listener: _groupListener!); + } + + getCommunityCategoryList(String groupID) async { + final Map? customInfo = await getCommunityCustomInfo(groupID); + if (customInfo != null) { + final String? categoryListString = customInfo["categoryList"]; + if (categoryListString != null && categoryListString.isNotEmpty) { + return jsonDecode(categoryListString); + } + } + } + + Future?> getCommunityCustomInfo(String groupID) async { + V2TimValueCallback> res = + await TencentImSDKPlugin.v2TIMManager.getGroupManager().getGroupsInfo(groupIDList: [groupID]); + if (res.code != 0) { + final V2TimGroupInfoResult? groupInfo = res.data?[0]; + if (groupInfo != null) { + Map? customInfo = groupInfo.groupInfo?.customInfo; + return customInfo; + } + } + return null; + } + + setCommunityCategoryList(String groupID, String groupType, List newCategoryList) async { + final Map? customInfo = await getCommunityCustomInfo(groupID); + customInfo?["categoryList"] = jsonEncode(newCategoryList); + TencentImSDKPlugin.v2TIMManager.getGroupManager().setGroupInfo( + info: V2TimGroupInfo( + customInfo: customInfo, + groupID: groupID, + groupType: groupType, + // ...其他资料 + )); + } + + addCategoryForTopic(String groupID, String categoryName) { + TencentImSDKPlugin.v2TIMManager.getGroupManager().setTopicInfo( + topicInfo: V2TimTopicInfo(customString: categoryName), + ); + } + + _onReceiveJoinApplication(String groupID, V2TimGroupMemberInfo member, String opReason) { + Future.delayed(const Duration(milliseconds: 500), () => chatViewModel.refreshGroupApplicationList()); + } + + Future _getGroupName(String groupID) async { + final groupInfoList = await sdkInstance.getGroupManager().getGroupsInfo(groupIDList: [groupID]); + String groupName = TIM_t("群组"); + if (groupInfoList.data != null) { + groupName = groupInfoList.data!.first!.groupInfo!.groupName!; + } + return groupName; + } + + void _deleteGroupConversation(String groupID) async { + sdkInstance.getConversationManager().deleteConversation(conversationID: "group_${groupID}"); + } + + bool _isLoginUserKickedFromGroup(String groupID, List memberList) { + final loginUserInfo = coreInstance.loginInfo; + int index = memberList.indexWhere((element) => element.userID == loginUserInfo.userID); + if (index > -1) { + return true; + } + return false; + } +} diff --git a/lib/business_logic/model/profile_model.dart b/lib/business_logic/model/profile_model.dart new file mode 100644 index 0000000..eba3771 --- /dev/null +++ b/lib/business_logic/model/profile_model.dart @@ -0,0 +1,12 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class UserProfile { + late V2TimFriendInfo? friendInfo; + late V2TimConversation? conversation; + + UserProfile({required this.friendInfo, required this.conversation}); +} diff --git a/lib/business_logic/separate_models/tui_chat_model_tools.dart b/lib/business_logic/separate_models/tui_chat_model_tools.dart new file mode 100644 index 0000000..8ddaf38 --- /dev/null +++ b/lib/business_logic/separate_models/tui_chat_model_tools.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +class TUIChatModelTools { + final TUIChatGlobalModel globalModel = serviceLocator(); + final CoreServicesImpl _coreServices = serviceLocator(); + + OfflinePushInfo buildMessagePushInfo(V2TimMessage message, String convID, ConvType convType) { + String createJSON(String convID) { + return "{\"conversationID\": \"$convID\"}"; + } + + if (globalModel.chatConfig.offlinePushInfo != null) { + final customData = globalModel.chatConfig.offlinePushInfo!(message, convID, convType); + if (customData != null) { + return customData; + } + } + + String title = globalModel.chatConfig.notificationTitle; + + // If user provides null, use default ext. + String ext = globalModel.chatConfig.notificationExt != null + ? globalModel.chatConfig.notificationExt!(message, convID, convType) ?? + (convType == ConvType.c2c ? createJSON("c2c_${message.sender}") : createJSON("group_$convID")) + : (convType == ConvType.c2c ? createJSON("c2c_${message.sender}") : createJSON("group_$convID")); + + String desc = message.userID ?? message.groupID ?? ""; + String messageSummary = ""; + switch (message.elemType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + messageSummary = TIM_t("自定义消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + messageSummary = TIM_t("表情消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + messageSummary = TIM_t("文件消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: + messageSummary = TIM_t("群提示消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + messageSummary = TIM_t("图片消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + messageSummary = TIM_t("位置消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + messageSummary = TIM_t("合并转发消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + messageSummary = TIM_t("语音消息"); + break; + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + messageSummary = message.textElem!.text!; + break; + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + messageSummary = TIM_t("视频消息"); + break; + } + + if (globalModel.chatConfig.notificationBody != null) { + desc = globalModel.chatConfig.notificationBody!(message, convID, convType) ?? messageSummary; + } else { + desc = messageSummary; + } + + return OfflinePushInfo( + title: title, + desc: desc, + disablePush: false, + ext: ext, + iOSSound: globalModel.chatConfig.notificationIOSSound, + androidSound: globalModel.chatConfig.notificationAndroidSound, + ignoreIOSBadge: false, + androidOPPOChannelID: globalModel.chatConfig.notificationOPPOChannelID, + androidVIVOClassification: 1, + ); + } + + V2TimMessage setUserInfoForMessage(V2TimMessage messageInfo, String? id) { + final loginUserInfo = _coreServices.loginUserInfo; + if (loginUserInfo != null) { + messageInfo.faceUrl = loginUserInfo.faceUrl; + messageInfo.nickName = loginUserInfo.nickName; + messageInfo.sender = loginUserInfo.userID; + } + messageInfo.timestamp = (DateTime.now().millisecondsSinceEpoch / 1000).ceil(); + messageInfo.isSelf = true; + messageInfo.id = id; + + return messageInfo; + } + + String getMessageSummary(V2TimMessage message, String? Function(V2TimMessage message)? abstractMessageBuilder) { + final String? customAbstractMessage = abstractMessageBuilder != null ? abstractMessageBuilder(message) : null; + if (customAbstractMessage != null) { + return customAbstractMessage; + } + + final elemType = message.elemType; + switch (elemType) { + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return "[表情消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return "[自定义消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + return "[文件消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: + return "[群消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return "[图片消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return "[位置消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return "[合并消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_NONE: + return "[没有元素]"; + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return "[语音消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return message.textElem?.text ?? "[文本消息]"; + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return "[视频消息]"; + default: + return ""; + } + } + + String getMessageAbstract(V2TimMessage message, String? Function(V2TimMessage message)? abstractMessageBuilder) { + final messageAbstract = RepliedMessageAbstract( + summary: TIM_t(getMessageSummary(message, abstractMessageBuilder)), + elemType: message.elemType, + msgID: message.msgID, + timestamp: message.timestamp, + seq: message.seq); + return jsonEncode(messageAbstract.toJson()); + } + + Future getExistingMessageByID( + {required String msgID, required String conversationID, required ConvType conversationType}) async { + final currentHistoryMsgList = globalModel.messageListMap[conversationID] ?? []; + final int? targetIndex = currentHistoryMsgList.indexWhere((item) { + return item.msgID == msgID; + }); + + if (targetIndex != null && targetIndex > -1 && currentHistoryMsgList.isNotEmpty) { + return currentHistoryMsgList[targetIndex]; + } else { + return null; + } + } + + Future hasZeroSize(String filePath) async { + try { + final file = File(filePath); + final fileSize = await file.length(); + return fileSize == 0; + } catch (e) { + return false; + } + } +} diff --git a/lib/business_logic/separate_models/tui_chat_separate_view_model.dart b/lib/business_logic/separate_models/tui_chat_separate_view_model.dart new file mode 100644 index 0000000..511c3b7 --- /dev/null +++ b/lib/business_logic/separate_models/tui_chat_separate_view_model.dart @@ -0,0 +1,1398 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/get_group_message_read_member_list_filter.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_custom_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_message_read_member_list.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_message_read_member_list.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_receipt.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_msg_create_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/chat_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_model_tools.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:uuid/uuid.dart'; + +enum LoadDirection { previous, latest } + +class TUIChatSeparateViewModel extends ChangeNotifier { + final FriendshipServices _friendshipServices = serviceLocator(); + final MessageService _messageService = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + final TUIChatGlobalModel globalModel = serviceLocator(); + final TUIChatModelTools tools = serviceLocator(); + final TUISelfInfoViewModel selfModel = serviceLocator(); + final TUIConversationViewModel conversationViewModel = serviceLocator(); + final _uuid = const Uuid(); + + ChatLifeCycle? lifeCycle; + int _totalUnreadCount = 0; + bool _isMultiSelect = false; + bool _isInit = false; + String conversationID = ""; + ConvType? conversationType; + bool haveMoreData = false; + bool haveMoreLatestData = false; + String _currentPlayedMsgId = ""; + GroupReceiptAllowType? _groupType; + // List _multiSelectedMessageList = []; + Map _selectedPositions = {}; + V2TimMessage? _repliedMessage; + String _jumpMsgID = ""; + bool _isGroupExist = true; + bool _isNotAMember = false; + bool showC2cMessageEditStatus = true; + TIMUIKitChatConfig chatConfig = const TIMUIKitChatConfig(); + ValueChanged? setInputField; + String? Function(V2TimMessage message)? abstractMessageBuilder; + Function(String userID, TapDownDetails tapDetails)? onTapAvatar; + V2TimGroupMemberFullInfo? _currentChatUserInfo; + V2TimGroupInfo? _groupInfo; + String groupMemberListSeq = "0"; + List? groupMemberList = []; + V2TimGroupMemberFullInfo? selfMemberInfo; + double atPositionX = 0.0; + double atPositionY = 0.0; + int _activeAtIndex = -1; + List _showAtMemberList = []; + Map _groupUserShowName = {}; + String? _groupID; + + Map get groupUserShowName => _groupUserShowName; + // value 的 bool 值表示是否已经延迟显示过发送进度 + final Map _sendingMessageIDMap = {}; + Map _readReceiptMap = {}; + + set groupUserShowName(Map value) { + _groupUserShowName = value; + _notify(); + } + + int get activeAtIndex => _activeAtIndex; + + set activeAtIndex(int value) { + _activeAtIndex = value; + _notify(); + } + + List get showAtMemberList => _showAtMemberList; + + set showAtMemberList(List value) { + _showAtMemberList = value; + _notify(); + } + + V2TimGroupInfo? get groupInfo => _groupInfo; + + set groupInfo(V2TimGroupInfo? value) { + _groupInfo = value; + _notify(); + } + + int get totalUnreadCount => _totalUnreadCount; + + set totalUnreadCount(int value) { + _totalUnreadCount = value; + _notify(); + } + + bool get isMultiSelect => _isMultiSelect; + + set isMultiSelect(bool value) { + _isMultiSelect = value; + _notify(); + } + + String get currentPlayedMsgId => _currentPlayedMsgId; + + set currentPlayedMsgId(String value) { + _currentPlayedMsgId = value; + _notify(); + } + + GroupReceiptAllowType? get groupType => _groupType; + + set groupType(GroupReceiptAllowType? value) { + _groupType = value; + _notify(); + } + + List getSelectedMessageList() { + List selectList = []; + if (_selectedPositions.isEmpty) { + return selectList; + } + + List currentHistoryMsgList = getOriginMessageList(); + for (var v2TimMessage in currentHistoryMsgList) { + if (_selectedPositions.containsKey(v2TimMessage.msgID) && _selectedPositions[v2TimMessage.msgID]!) { + selectList.add(v2TimMessage); + } + } + + return selectList.reversed.toList(); + } + + List getSelectedMessageIDList() { + List selectList = []; + if (_selectedPositions.isEmpty) { + return selectList; + } + + for (String msgID in _selectedPositions.keys) { + if (_selectedPositions[msgID]!) { + selectList.add(msgID); + } + } + + return selectList; + } + + V2TimMessage? get repliedMessage => _repliedMessage; + + set repliedMessage(V2TimMessage? value) { + _repliedMessage = value; + _notify(); + } + + String get jumpMsgID => _jumpMsgID; + + set jumpMsgID(String value) { + _jumpMsgID = value; + _notify(); + } + + bool get isGroupExist => _isGroupExist; + + set isGroupExist(bool value) { + _isGroupExist = value; + _notify(); + } + + bool get isNotAMember => _isNotAMember; + + set isNotAMember(bool value) { + _isNotAMember = value; + _notify(); + } + + V2TimGroupMemberFullInfo? get currentChatUserInfo => _currentChatUserInfo; + + set currentChatUserInfo(V2TimGroupMemberFullInfo? value) { + _currentChatUserInfo = value; + _notify(); + } + + setLoadingMessageMap(String conversationID, V2TimMessage messageInfo) { + if (PlatformUtils().isWeb) { + if (globalModel.loadingMessage[conversationID] != null && + globalModel.loadingMessage[conversationID]!.isNotEmpty) { + globalModel.loadingMessage[conversationID]!.add(messageInfo); + } else { + globalModel.loadingMessage[conversationID] = [messageInfo]; + } + } + } + + void getUserShowName(List userIDs) async { + final List filteredList = userIDs.where((element) => !_groupUserShowName.containsKey(element)).toList(); + for (final element in filteredList) { + _groupUserShowName[element] = element; + } + + final String groupID = TencentUtils.checkString(_groupID) ?? conversationID; + + if (filteredList.isNotEmpty) { + final res = await TencentImSDKPlugin.manager + ?.getGroupManager() + .getGroupMembersInfo(groupID: groupID, memberList: filteredList); + if (res?.code == 0 && res?.data != null) { + final data = res!.data; + for (final userInfo in data!) { + final showName = TencentUtils.checkString(userInfo.nameCard) ?? + TencentUtils.checkString(userInfo.nickName) ?? + TencentUtils.checkString(userInfo.userID); + if (TencentUtils.checkString(showName) != null) { + _groupUserShowName[userInfo.userID] = showName ?? userInfo.userID; + } + } + if (data.isNotEmpty) { + _notify(); + } + } + } + } + + void initForEachConversation(ConvType convType, String convID, ValueChanged? onChangeInputField, + {String? groupID, List? preGroupMemberList}) async { + if (_isInit) { + return; + } + setInputField = onChangeInputField; + conversationType = convType; + conversationID = convID; + + _groupType = null; + isGroupExist = true; + _groupInfo = null; + groupMemberList = null; + selfMemberInfo = null; + + globalModel.setCurrentConversation(CurrentConversation(conversationID, conversationType ?? ConvType.c2c)); + globalModel.lifeCycle = lifeCycle; + globalModel.setMessageListPosition(conversationID, HistoryMessagePosition.bottom); + globalModel.setChatConfig(chatConfig); + globalModel.clearReceivedNewMessageCount(); + + if (conversationType == ConvType.group) { + _groupID = groupID; + _notify(); + Future.delayed(const Duration(milliseconds: 10), () async { + globalModel.refreshGroupApplicationList(); + loadGroupInfo(groupID ?? convID); + if (preGroupMemberList != null) { + groupMemberList = preGroupMemberList; + selfMemberInfo = preGroupMemberList.firstWhereOrNull((e) => e?.userID == selfModel.loginInfo?.userID); + } else { + await loadSelfMemberInfo(groupID: groupID ?? convID); + loadGroupMemberList(groupID: groupID ?? convID); + } + if (selfMemberInfo == null) { + await loadSelfMemberInfo(groupID: groupID ?? convID); + } + }); + } else { + Future.delayed(const Duration(milliseconds: 10), () async { + final List? friendRes = await _friendshipServices.getFriendsInfo(userIDList: [convID]); + if (friendRes != null && friendRes.isNotEmpty) { + final V2TimFriendInfoResult friendInfoResult = friendRes[0]; + currentChatUserInfo = V2TimGroupMemberFullInfo( + userID: convID, + faceUrl: friendInfoResult.friendInfo?.userProfile?.faceUrl, + nickName: friendInfoResult.friendInfo?.userProfile?.nickName, + friendRemark: friendInfoResult.friendInfo?.friendRemark); + } else { + final List? userRes = await _friendshipServices.getUsersInfo(userIDList: [convID]); + if (userRes != null && userRes.isNotEmpty) { + final V2TimUserFullInfo userFullInfo = userRes[0]; + currentChatUserInfo = V2TimGroupMemberFullInfo( + userID: convID, + faceUrl: userFullInfo.faceUrl, + nickName: userFullInfo.nickName, + ); + } + } + _notify(); + }); + } + + _isInit = true; + Future.delayed(const Duration(milliseconds: 300), () { + markMessageAsRead(); + }); + } + + Future loadListForSpecificMessage({ + required int seq, + }) async { + List msgList = []; + bool tempHaveMoreData = false; + + final previousResponse = await _messageService.getHistoryMessageListWithComplete( + count: 20, + getType: HistoryMsgGetTypeEnum.V2TIM_GET_CLOUD_OLDER_MSG, + userID: conversationType == ConvType.c2c ? conversationID : null, + groupID: conversationType == ConvType.group ? conversationID : null, + lastMsgSeq: max(seq, 0)); + msgList = previousResponse?.messageList ?? []; + tempHaveMoreData = !(previousResponse?.isFinished ?? false); + haveMoreLatestData = true; + globalModel.setMessageListPosition(conversationID, HistoryMessagePosition.notShowLatest); + + msgList = await lifeCycle?.didGetHistoricalMessageList(msgList) ?? msgList; + msgList.insert( + msgList.length - 1, + V2TimMessage( + userID: '', isSelf: false, elemType: 101, msgID: msgList[0].msgID, seq: msgList[0].seq, timestamp: 9999)); + globalModel.setMessageList(conversationID, msgList, needResetNewMessageCount: false); + + if (chatConfig.isShowReadingStatus) { + _getMsgReadReceipt(msgList); + } + + haveMoreData = tempHaveMoreData; + return haveMoreData; + } + + // 加载聊天记录 + Future loadChatRecord({ + HistoryMsgGetTypeEnum? getType, + int lastMsgSeq = -1, + required int count, + String? lastMsgID, + LoadDirection direction = LoadDirection.previous, + }) async { + try { + bool tempHaveMoreData = false; + // 根据加载方向设置是否还能继续加载更多消息 + direction == LoadDirection.latest ? haveMoreLatestData = false : tempHaveMoreData = false; + + // 获取当前聊天对话的历史消息列表 + final currentRecordList = globalModel.messageListMap[conversationID]; + + // 调用MessageService获取聊天记录 + final response = await _messageService.getHistoryMessageListWithComplete( + count: count, + getType: getType ?? + (direction == LoadDirection.previous + ? HistoryMsgGetTypeEnum.V2TIM_GET_CLOUD_OLDER_MSG + : HistoryMsgGetTypeEnum.V2TIM_GET_CLOUD_NEWER_MSG), + userID: conversationType == ConvType.c2c ? conversationID : null, + groupID: conversationType == ConvType.group ? conversationID : null, + lastMsgID: lastMsgID, + lastMsgSeq: lastMsgSeq, + ); + + if (response == null) { + return false; + } + + // 根据加载方向更新是否还能继续加载更多消息 + if (direction == LoadDirection.latest) { + haveMoreLatestData = !response.isFinished; + } else { + tempHaveMoreData = !response.isFinished; + } + + _notify(); + + // 根据lastMsgID判断是否为分页加载 + if (lastMsgID != null && currentRecordList != null) { + List messageList = response.messageList; + List newList = []; + + // 根据加载方向拼接消息列表 + if (direction == LoadDirection.latest) { + globalModel.receivedNewMessageCount = globalModel.receivedNewMessageCount + messageList.length; + messageList = messageList.reversed.toList(); + newList = _combineMessageList(messageList, currentRecordList); + } else { + newList = _combineMessageList(currentRecordList, messageList); + } + + // 处理新获取的消息列表后回调 + final List msgList = await lifeCycle?.didGetHistoricalMessageList(newList) ?? newList; + + // 更新聊天记录到全局model + globalModel.setMessageList( + conversationID, + msgList, + needResetNewMessageCount: false, + ); + } else { + // 处理新获取的消息列表后回调 + List receivedList = + await lifeCycle?.didGetHistoricalMessageList(response.messageList) ?? response.messageList; + globalModel.loadingMessage.remove(conversationID); + + // 更新聊天记录到全局model + globalModel.setMessageList( + conversationID, + receivedList, + needResetNewMessageCount: false, + ); + } + + // 获取已读未读状态 + if (chatConfig.isShowReadingStatus && response.messageList.isNotEmpty) { + _getMsgReadReceipt(response.messageList); + } + + // 根据加载方向更新是否还能继续加载更多消息 + if (direction == LoadDirection.latest && !haveMoreLatestData) { + globalModel.setMessageListPosition(conversationID, HistoryMessagePosition.inTwoScreen); + } + _notify(); + + haveMoreData = tempHaveMoreData; + return haveMoreData; + } catch (e) { + // ignore: avoid_print + outputLogger.i('loadChatRecord error: $e'); + return false; + } + } + + // 拼接聊天记录 + List _combineMessageList(List first, List second) { + return [...first, ...second]; + } + + Future loadDataFromController({int? count}) { + return loadChatRecord( + count: count ?? HistoryMessageDartConstant.getCount, //20 + ); + } + + Future>> getMessageReadReceipts(List messageIDList) { + return _messageService.getMessageReadReceipts(messageIDList: messageIDList); + } + + _getMsgReadReceipt(List message) async { + final msgID = message + .where((e) => + (e.isSelf ?? true) && + (e.needReadReceipt ?? false) && + (e.status == MessageStatus.V2TIM_MSG_STATUS_SEND_SUCC)) + .map((e) => e.msgID ?? '') + .toList(); + if (msgID.isNotEmpty) { + final res = await getMessageReadReceipts(msgID); + if (res.code == 0) { + final receiptList = res.data; + if (receiptList != null) { + for (var item in receiptList) { + globalModel.messageReadReceiptMap[item.msgID!] = item; + } + } + } + _notify(); + } + } + + translateText(V2TimMessage message) async { + final String originText = message.textElem?.text ?? ""; + final String deviceLocale = TIM_getCurrentDeviceLocale(); + final String targetMessage = deviceLocale.split("-")[0]; + final translatedText = await _messageService.translateText(originText, targetMessage); + + final LocalCustomDataModel localCustomData = + LocalCustomDataModel.fromMap(json.decode(TencentUtils.checkString(message.localCustomData) ?? "{}")); + localCustomData.translatedText = translatedText; + message.localCustomData = json.encode(localCustomData.toMap()); + globalModel.onMessageModified(message); + TencentImSDKPlugin.v2TIMManager.v2TIMMessageManager + .setLocalCustomData(msgID: message.msgID!, localCustomData: message.localCustomData ?? ""); + } + + addToMessageReadReceiptList(V2TimMessage message) { + if (chatConfig.isShowReadingStatus) { + if (message.msgID != null) { + _readReceiptMap[message.msgID!] = message; + } + + Future.delayed(const Duration(milliseconds: 200), () { + _setMsgReadReceipt(_readReceiptMap.values.toList()); + }); + } + } + + _setMsgReadReceipt(List messageList) async { + final msgIDList = List.empty(growable: true); + for (var item in messageList) { + final isSelf = item.isSelf ?? true; + final needReadReceipt = item.needReadReceipt ?? false; + final isRead = item.isRead ?? false; + if (!isRead && !isSelf && needReadReceipt && item.msgID != null) { + msgIDList.add(item.msgID!); + item.needReadReceipt = false; + } + } + if (msgIDList.isNotEmpty) { + sendMessageReadReceipts(msgIDList); + } + } + + sendMessageReadReceipts(List messageIDList) async { + final res = await _messageService.sendMessageReadReceipts(messageIDList: messageIDList); + return res; + } + + markMessageAsRead() async { + if (conversationType == ConvType.c2c) { + return _messageService.markC2CMessageAsRead(userID: conversationID); + } + + final res = await _messageService.markGroupMessageAsRead(groupID: conversationID); + if (res.code == 10015) { + isGroupExist = false; + } + } + + Future loadSelfMemberInfo({required String groupID}) async { + V2TimValueCallback> getGroupMembersInfoRes = + await TencentImSDKPlugin.v2TIMManager.getGroupManager().getGroupMembersInfo( + groupID: groupID, + memberList: [selfModel.loginInfo?.userID ?? ""], + ); + if (getGroupMembersInfoRes.code == 0) { + final userList = getGroupMembersInfoRes.data; + selfMemberInfo = userList?.firstWhereOrNull((e) => e.userID == selfModel.loginInfo?.userID); + _notify(); + } + return; + } + + Future loadGroupMemberList({required String groupID, int count = 100, String? seq}) async { + final String? nextSeq = await _loadGroupMemberListFunction(groupID: groupID, seq: seq, count: count); + if (nextSeq != null && nextSeq != "0" && nextSeq != "") { + return await loadGroupMemberList(groupID: groupID, count: count, seq: nextSeq); + } else { + selfMemberInfo = groupMemberList?.firstWhereOrNull((e) => e?.userID == selfModel.loginInfo?.userID); + _notify(); + } + } + + void _notify() { + try { + notifyListeners(); + } catch (e) { + debugPrint(e.toString()); + } + } + + Future _loadGroupMemberListFunction({required String groupID, int count = 100, String? seq}) async { + if (seq == null || seq == "" || seq == "0") { + groupMemberList?.clear(); + } + try { + final res = await _groupServices.getGroupMemberList( + groupID: groupID, + filter: GroupMemberFilterTypeEnum.V2TIM_GROUP_MEMBER_FILTER_ALL, + count: count, + nextSeq: seq ?? groupMemberListSeq); + final groupMemberListRes = res.data; + if (res.code == 0 && groupMemberListRes != null) { + final groupMemberListTemp = groupMemberListRes.memberInfoList ?? []; + groupMemberList = [...?groupMemberList, ...groupMemberListTemp]; + groupMemberListSeq = groupMemberListRes.nextSeq ?? "0"; + } else if (res.code == 10010) { + isGroupExist = false; + } else if (res.code == 10007) { + isNotAMember = true; + } + return groupMemberListRes?.nextSeq; + } catch (e) { + return ""; + } + } + + Future<(V2TimGroupInfo?, GroupReceiptAllowType?)> loadGroupInfo(String groupID) async { + final groupInfoList = await _groupServices.getGroupsInfo(groupIDList: [groupID]); + if (groupInfoList != null && groupInfoList.isNotEmpty) { + final groupRes = groupInfoList.first; + if (groupRes.resultCode == 0) { + _groupInfo = groupRes.groupInfo; + + const groupTypeMap = { + "Meeting": GroupReceiptAllowType.meeting, + "Public": GroupReceiptAllowType.public, + "Work": GroupReceiptAllowType.work + }; + _groupType = groupTypeMap[groupRes.groupInfo?.groupType]; + + _notify(); + return (_groupInfo, _groupType); + } + } + return (null, null); + } + + Future updateMessageFromController({required String msgID, V2TimMessage? message}) async { + V2TimMessage? newMessage = message ?? + await tools.getExistingMessageByID( + msgID: msgID, conversationType: conversationType ?? ConvType.c2c, conversationID: conversationID); + if (newMessage != null) { + globalModel.onMessageModified(newMessage, conversationID); + } else { + loadChatRecord( + count: HistoryMessageDartConstant.getCount, + ); + } + } + + Future?> modifyMessage({required V2TimMessage message}) async { + return _messageService.modifyMessage(message: message); + } + + Future> _sendMessage({ + required String id, + required String convID, + required ConvType convType, + V2TimMessage? messageInfo, + OfflinePushInfo? offlinePushInfo, + bool? onlineUserOnly = false, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool? isExcludedFromUnreadCount, + bool? needReadReceipt, + String? cloudCustomData, + String? localCustomData, + bool? isEditStatusMessage = false, + bool? isExcludedFromContentModeration, + }) async { + String receiver = convType == ConvType.c2c ? convID : ''; + String groupID = convType == ConvType.group ? convID : ''; + if (convType == ConvType.group && _groupType == null) { + await loadGroupInfo(groupID); + } + if (messageInfo != null) { + setLoadingMessageMap(convID, messageInfo); + } + final sendMsgRes = await _messageService.sendMessage( + priority: priority, + localCustomData: localCustomData, + isExcludedFromUnreadCount: isExcludedFromUnreadCount ?? false, + id: id, + receiver: receiver, + needReadReceipt: needReadReceipt ?? chatConfig.isShowReadingStatus, + groupID: groupID, + offlinePushInfo: offlinePushInfo, + onlineUserOnly: onlineUserOnly ?? false, + isExcludedFromContentModeration: isExcludedFromContentModeration ?? false, + cloudCustomData: cloudCustomData ?? + (showC2cMessageEditStatus == true + ? json.encode({ + "messageFeature": { + "needTyping": 1, + "version": 1, + } + }) + : ""), + ); + removeSendingMessageID(id); + if (isEditStatusMessage == false && + globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + globalModel.updateMessage(sendMsgRes, convID, id, convType, groupType, setInputField); + } + if (lifeCycle?.messageDidSend != null) { + lifeCycle!.messageDidSend(sendMsgRes); + } + + return sendMsgRes; + } + + List getOriginMessageList() { + return globalModel.messageListMap[conversationID] ?? []; + } + + int getConversationUnreadCount() { + return globalModel.unreadCountForTongue; + } + + Future?> sendTextAtMessage( + {required String text, + required String convID, + required ConvType convType, + required List atUserList}) async { + if (text.isEmpty) { + return null; + } + final textATMessageInfo = await _messageService.createTextAtMessage(text: text, atUserList: atUserList); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = textATMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, textATMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + id: textATMessageInfo.id as String, + convType: ConvType.group, + offlinePushInfo: tools.buildMessagePushInfo(textATMessageInfo.messageInfo!, convID, convType)); + } + return null; + } + + Future?> sendCustomMessage( + {required String data, required String convID, required ConvType convType}) async { + final customMessageInfo = await _messageService.createCustomMessage(data: data); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = customMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, customMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + id: customMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(customMessageInfo.messageInfo!, convID, convType)); + } + return null; + } + + Future?> sendFaceMessage( + {required int index, required String data, required String convID, required ConvType convType}) async { + final faceMessageInfo = await _messageService.createFaceMessage(index: index, data: data); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = faceMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, faceMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + id: faceMessageInfo.id as String, + convType: convType, + messageInfo: messageInfoWithSender, + offlinePushInfo: tools.buildMessagePushInfo(faceMessageInfo.messageInfo!, convID, convType)); + } + return null; + } + + Future?> sendSoundMessage({ + required String soundPath, + required int duration, + required String convID, + required ConvType convType, + }) async { + final soundMessageInfo = await _messageService.createSoundMessage(soundPath: soundPath, duration: duration); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = soundMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, soundMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + id: soundMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(soundMessageInfo.messageInfo!, convID, convType), + ); + } + return null; + } + + Future?> sendReplyMessage({ + required String text, + required String convID, + required ConvType convType, + List? atUserIDList, + }) async { + if (text.isEmpty) { + return null; + } + if (_repliedMessage != null) { + V2TimMsgCreateInfoResult? textMessageInfo = await _messageService.createTextMessage(text: text); + if (atUserIDList != null && atUserIDList.isNotEmpty) { + textMessageInfo = await _messageService.createTextAtMessage(text: text, atUserList: atUserIDList); + } + final V2TimMessage? messageInfo = textMessageInfo!.messageInfo; + final receiver = convType == ConvType.c2c ? convID : ''; + final groupID = convType == ConvType.group ? convID : ''; + if (messageInfo != null) { + V2TimMessage messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, textMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + final hasNickName = _repliedMessage?.nickName != null && _repliedMessage?.nickName != ""; + final cloudCustomData = { + "messageReply": { + "messageID": _repliedMessage!.msgID, + "messageAbstract": tools.getMessageAbstract(_repliedMessage!, abstractMessageBuilder), + "messageSender": hasNickName ? _repliedMessage!.nickName : _repliedMessage?.sender, + "messageType": _repliedMessage?.elemType, + "version": 1 + } + }; + messageInfoWithSender.cloudCustomData = json.encode(cloudCustomData); + List currentHistoryMsgList = getOriginMessageList(); + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + + _repliedMessage = null; + final sendMsgRes = await _messageService.sendMessage( + cloudCustomData: + TencentUtils.checkString(messageInfoWithSender?.cloudCustomData) ?? json.encode(cloudCustomData), + id: textMessageInfo.id as String, + offlinePushInfo: tools.buildMessagePushInfo(messageInfoWithSender, convID, convType), + needReadReceipt: chatConfig.isShowReadingStatus, + groupID: groupID, + receiver: receiver); + _notify(); + globalModel.updateMessage( + sendMsgRes, convID, messageInfoWithSender.id ?? "", convType, groupType, setInputField); + if (lifeCycle?.messageDidSend != null) { + lifeCycle!.messageDidSend(sendMsgRes); + } + return sendMsgRes; + } + } + return null; + } + + double getFileSize(File file) { + int sizeInBytes = file.lengthSync(); + double sizeInMb = sizeInBytes / (1024 * 1024); + return sizeInMb; + } + + Future getTempPath() async { + final id = _uuid.v4(); + return getTemporaryDirectory().then((appDocDir) { + String filePath = appDocDir.path + id + ".jpeg"; + return filePath; + }); + } + + Future?> sendImageMessage( + {String? imagePath, + String? imageName, + required String convID, + dynamic inputElement, + required ConvType convType}) async { + String? image; + if ((PlatformUtils().isAndroid || PlatformUtils().isIOS) && imagePath != null && imagePath.isNotEmpty) { + try { + final size = getFileSize(File(imagePath)); + final format = imagePath.split(".")[imagePath.split(".").length - 1].toLowerCase(); + if (size > 20 || (format != "jpg" && format != "png" && format != "gif")) { + final target = await getTempPath(); + final result = await FlutterImageCompress.compressAndGetFile(imagePath, target, + format: CompressFormat.jpeg, quality: 85); + image = result?.path; + } + // ignore: empty_catches + } catch (e) {} + } + final imageMessageInfo = await _messageService.createImageMessage( + imageName: imageName, imagePath: image ?? imagePath, inputElement: inputElement); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = imageMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, imageMessageInfo.id); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + messageInfo: messageInfoWithSender, + id: imageMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(imageMessageInfo.messageInfo!, convID, convType), + ); + } + return null; + } + + Future?> sendVideoMessage( + {String? videoPath, + int? duration, + String? snapshotPath, + required String convID, + required ConvType convType, + dynamic inputElement}) async { + List currentHistoryMsgList = getOriginMessageList(); + final videoMessageInfo = await _messageService.createVideoMessage( + videoPath: videoPath, + type: videoPath != null ? videoPath.split(".")[videoPath.split(".").length - 1] : 'mp4', + duration: duration, + inputElement: inputElement, + snapshotPath: snapshotPath); + final messageInfo = videoMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, videoMessageInfo.id); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + messageInfo: messageInfoWithSender, + id: videoMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(videoMessageInfo.messageInfo!, convID, convType), + ); + } + return null; + } + + Future?> sendFileMessage( + {String? filePath, + String? fileName, + int? size, + dynamic inputElement, + required String convID, + required ConvType convType}) async { + if (await tools.hasZeroSize(filePath ?? "")) { + final CoreServicesImpl _coreServices = serviceLocator(); + _coreServices.callOnCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: "不支持 0KB 文件的传输", infoCode: 6660417)); + return null; + } + final fileMessageInfo = await _messageService.createFileMessage( + inputElement: inputElement, fileName: fileName ?? filePath?.split('/').last ?? "", filePath: filePath); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = fileMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, fileMessageInfo.id); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + messageInfoWithSender.fileElem!.fileSize = size; + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + messageInfo: messageInfoWithSender, + id: fileMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(fileMessageInfo.messageInfo!, convID, convType), + ); + } + return null; + } + + Future?> sendLocationMessage( + {required String desc, + required double longitude, + required double latitude, + required String convID, + required ConvType convType}) async { + List currentHistoryMsgList = getOriginMessageList(); + final locationMessageInfo = + await _messageService.createLocationMessage(desc: desc, longitude: longitude, latitude: latitude); + final messageInfo = locationMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, locationMessageInfo.id); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + return _sendMessage( + convID: convID, + id: locationMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(locationMessageInfo.messageInfo!, convID, convType), + ); + } + return null; + } + + /// 逐条转发 + sendForwardMessage({ + required List conversationList, + }) async { + final selectedMessages = getSelectedMessageList(); + for (var conversation in conversationList) { + final convID = conversation.groupID ?? conversation.userID ?? ""; + final convType = conversation.type; + List currentHistoryMsgList = globalModel.messageListMap[conversationID] ?? []; + for (var message in selectedMessages) { + final forwardMessageInfo = await _messageService.createForwardMessage(msgID: message.msgID!); + final messageInfo = forwardMessageInfo!.messageInfo; + if (messageInfo != null) { + tools.setUserInfoForMessage(messageInfo, forwardMessageInfo.id); + messageInfo.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + // 如果转发的会话是当前会话,则直接添加到当前会话的消息列表中 + if (convID == conversationID) { + if (globalModel.getMessageListPosition(convID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfo, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + } + await Future.delayed(Duration(milliseconds: 100), () { + _sendMessage( + id: forwardMessageInfo.id!, + convID: convID, + convType: convType == 1 ? ConvType.c2c : ConvType.group, + offlinePushInfo: tools.buildMessagePushInfo( + forwardMessageInfo.messageInfo!, convID, convType == 1 ? ConvType.c2c : ConvType.group), + ); + }); + } + } + } + } + + /// 合并转发 + Future?> sendMergerMessage({ + required List conversationList, + required String title, + required List abstractList, + required BuildContext context, + }) async { + final List msgIDList = + getSelectedMessageList().map((e) => e.msgID ?? "").where((element) => element != "").toList(); + for (var conversation in conversationList) { + final convID = conversation.groupID ?? conversation.userID ?? ""; + final convType = conversation.type; + List currentHistoryMsgList = globalModel.messageListMap[conversationID] ?? []; + final mergerMessageInfo = await _messageService.createMergerMessage( + msgIDList: msgIDList, title: title, abstractList: abstractList, compatibleText: TIM_t("该版本不支持此消息")); + final messageInfo = mergerMessageInfo!.messageInfo; + if (messageInfo != null) { + tools.setUserInfoForMessage(messageInfo, mergerMessageInfo.id); + messageInfo.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + // 如果转发的会话是当前会话,则直接添加到当前会话的消息列表中 + if (convID == conversationID) { + if (globalModel.getMessageListPosition(convID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfo, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + } + _sendMessage( + id: mergerMessageInfo.id!, + convID: convID, + convType: convType == 1 ? ConvType.c2c : ConvType.group, + offlinePushInfo: tools.buildMessagePushInfo( + mergerMessageInfo.messageInfo!, convID, convType == 1 ? ConvType.c2c : ConvType.group), + ); + } + } + return null; + } + + Future?> reSendFailMessage({ + required V2TimMessage message, + required String convID, + required ConvType convType, + }) async { + List currentHistoryMsgList = getOriginMessageList(); + if (currentHistoryMsgList.isEmpty) { + return null; + } + + currentHistoryMsgList.removeWhere((element) => element.msgID == message.msgID); + message.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(message.msgID); + globalModel.setMessageList(convID, currentHistoryMsgList); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [message, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + // 重发该消息 + final res = await _messageService.reSendMessage(msgID: message.msgID ?? "", onlineUserOnly: false); + removeSendingMessageID(message.msgID ?? ""); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + globalModel.updateMessage(res, convID, message.msgID!, convType, groupType, setInputField); + } + + if (lifeCycle?.messageDidSend != null) { + lifeCycle!.messageDidSend(res); + } + return res; + } + + Future?> sendTextMessage( + {required String text, required String convID, required ConvType convType}) async { + if (text.isEmpty) { + return null; + } + final textMessageInfo = await _messageService.createTextMessage(text: text); + List currentHistoryMsgList = getOriginMessageList(); + final messageInfo = textMessageInfo!.messageInfo; + if (messageInfo != null) { + final messageInfoWithSender = tools.setUserInfoForMessage(messageInfo, textMessageInfo.id!); + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + _notify(); + } + + return _sendMessage( + convID: convID, + id: textMessageInfo.id as String, + convType: convType, + offlinePushInfo: tools.buildMessagePushInfo(textMessageInfo.messageInfo!, convID, convType)); + } + return null; + } + + Future?>? sendMessageFromController({ + required V2TimMessage? messageInfo, + + /// Offline push info + OfflinePushInfo? offlinePushInfo, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool? onlineUserOnly, + bool? isExcludedFromUnreadCount, + bool? needReadReceipt, + String? cloudCustomData, + String? localCustomData, + }) { + List currentHistoryMsgList = getOriginMessageList(); + if (messageInfo != null) { + final messageInfoWithSender = + messageInfo.sender == null ? tools.setUserInfoForMessage(messageInfo, messageInfo.id!) : messageInfo; + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + addSendingMessageID(messageInfo.id); + if (globalModel.getMessageListPosition(conversationID) != HistoryMessagePosition.notShowLatest) { + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + globalModel.setMessageList(conversationID, currentHistoryMsgList); + } + + return _sendMessage( + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + cloudCustomData: cloudCustomData, + localCustomData: localCustomData, + convID: conversationID, + id: messageInfo.id as String, + convType: conversationType ?? ConvType.c2c, + offlinePushInfo: offlinePushInfo ?? + tools.buildMessagePushInfo(messageInfo, conversationID, conversationType ?? ConvType.c2c), + isExcludedFromContentModeration: messageInfo.isExcludedFromContentModeration, + ); + } + return null; + } + + deleteMsg(String msgID, {String? id, Object? webMessageInstance}) async { + if (lifeCycle?.shouldDeleteMessage != null && await lifeCycle!.shouldDeleteMessage(msgID) == false) { + return; + } + final messageList = getOriginMessageList(); + final res = await _messageService.deleteMessages(msgIDs: [msgID], webMessageInstanceList: [webMessageInstance]); + if (res.code == 0) { + messageList.removeWhere((element) { + return element.msgID == msgID || (id != null && element.id == id); + }); + } + globalModel.setMessageList(conversationID, messageList); + } + + clearHistory() async { + if (lifeCycle?.shouldClearHistoricalMessageList != null && + await lifeCycle!.shouldClearHistoricalMessageList(conversationID) == false) { + return; + } + globalModel.setMessageList(conversationID, []); + } + + Future revokeMsg(String msgID, bool isAdmin, [Object? webMessageInstance]) async { + if (chatConfig.isGroupAdminRecallEnabled) { + final V2TimMessage? message = + globalModel.messageListMap[conversationID]?.firstWhere((element) => element.msgID == msgID); + if (message != null) { + if (PlatformUtils().isWeb) { + final decodedMessage = jsonDecode(message.messageFromWeb!); + decodedMessage["cloudCustomData"] = jsonEncode({"isRevoke": true, "revokeByAdmin": isAdmin}); + message.messageFromWeb = jsonEncode(decodedMessage); + } else { + message.cloudCustomData = jsonEncode({"isRevoke": true, "revokeByAdmin": isAdmin}); + } + return await modifyMessage(message: message); + } + } + + final res = await _messageService.revokeMessage(msgID: msgID, webMessageInstance: webMessageInstance); + if (res.code == 0) { + globalModel.onMessageRevoked(msgID, conversationID); + } + return res; + } + + setMessageItemChecked(V2TimMessage message, bool isChecked) { + if (message.msgID != null) { + _selectedPositions[message.msgID!] = isChecked; + } + + _notify(); + } + + deleteSelectedMsg() async { + List messageList = getOriginMessageList(); + final msgIDs = getSelectedMessageIDList(); + final webMessageInstanceList = getSelectedMessageIDList(); + + final res = await _messageService.deleteMessages(msgIDs: msgIDs, webMessageInstanceList: webMessageInstanceList); + if (res.code == 0) { + for (var msgID in msgIDs) { + messageList.removeWhere((element) => element.msgID == msgID); + } + globalModel.setMessageList(conversationID, messageList, isDeleteMsg: true); + } + } + + updateMultiSelectStatus(bool isSelect) { + _isMultiSelect = isSelect; + if (!isSelect) { + _selectedPositions.clear(); + } + _notify(); + } + + Future> getGroupMessageReadMemberList( + String messageID, GetGroupMessageReadMemberListFilter fileter, int nextSeq) async { + final res = + await _messageService.getGroupMessageReadMemberList(nextSeq: nextSeq, messageID: messageID, filter: fileter); + return res; + } + + Future?> downloadMergerMessage(String msgID) async { + await _messageService.getHistoryMessageList( + count: 100, + getType: HistoryMsgGetTypeEnum.V2TIM_GET_CLOUD_OLDER_MSG, + userID: conversationType == ConvType.c2c ? conversationID : null, + groupID: conversationType == ConvType.group ? conversationID : null, + ); + return _messageService.downloadMergerMessage(msgID: msgID); + } + + Future findMessage(String msgID) async { + List messageList = getOriginMessageList(); + final repliedMessage = messageList.where((element) => element.msgID == msgID).toList(); + if (repliedMessage.isNotEmpty) { + return repliedMessage.first; + } + final message = await _messageService.findMessages(messageIDList: [msgID]); + if (message != null && message.isNotEmpty) { + return message.first; + } + return null; + } + + showLatestUnread() { + globalModel.unreadCountForTongue = 0; + markMessageAsRead(); + globalModel.setMessageListPosition(conversationID, HistoryMessagePosition.bottom); + } + + // 添加发送中的消息的 id 或者 msgID(id 不存在时使用 msgID) + void addSendingMessageID(String? id) { + if (id?.isNotEmpty == true) { + _sendingMessageIDMap[id!] = false; + } + } + + // 移除发送中的消息的 id 或者 msgID(id 不存在时使用 msgID) + void removeSendingMessageID(String id) { + _sendingMessageIDMap.remove(id); + } + + // 是否已经延迟渲染 + bool? hasDelayedRenderSendingStatus(String id) { + if (_sendingMessageIDMap.containsKey(id)) { + return _sendingMessageIDMap[id]; + } + + return true; + } + + // 设置已经延迟渲染过的消息 + void setDelayedRenderSendingStatus(String id) { + if (_sendingMessageIDMap.containsKey(id)) { + _sendingMessageIDMap[id] = true; + } + } + + bool isVoteMessage(V2TimMessage message) { + bool isVote = false; + V2TimCustomElem? custom = message.customElem; + + if (custom != null) { + String? data = custom.data; + if (data != null && data.isNotEmpty) { + try { + Map mapData = json.decode(data); + if (mapData["businessID"] == "group_poll") { + isVote = true; + } + } catch (err) { + // err + } + } + } + return isVote; + } + + @override + void dispose() { + markMessageAsRead(); + globalModel.unreadCountForTongue = 0; + globalModel.clearCurrentConversation(); + _isInit = false; + super.dispose(); + } +} diff --git a/lib/business_logic/separate_models/tui_group_profile_model.dart b/lib/business_logic/separate_models/tui_group_profile_model.dart new file mode 100644 index 0000000..8f39023 --- /dev/null +++ b/lib/business_logic/separate_models/tui_group_profile_model.dart @@ -0,0 +1,372 @@ +// ignore_for_file: unnecessary_getters_setters, avoid_print + +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/receive_message_opt_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/group_profile_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; + +class TUIGroupProfileModel extends ChangeNotifier { + final CoreServicesImpl _coreServices = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + final ConversationService _conversationService = serviceLocator(); + final MessageService _messageService = serviceLocator(); + final FriendshipServices _friendshipServices = serviceLocator(); + GroupProfileLifeCycle? _lifeCycle; + + V2TimConversation? _conversation; + String _groupID = ""; + List? _contactList; + List? _groupMemberList; + String _groupMemberListSeq = "0"; + V2TimGroupInfo? _groupInfo; + Function(V2TimGroupMemberFullInfo groupMemberFullInfo, TapDownDetails? tapDetails)? onClickUser; + + GroupProfileLifeCycle? get lifeCycle => _lifeCycle; + + set lifeCycle(GroupProfileLifeCycle? value) { + _lifeCycle = value; + } + + V2TimConversation? get conversation => _conversation; + + set conversation(V2TimConversation? value) { + _conversation = value; + } + + String get groupID => _groupID; + + set groupID(String value) { + _groupID = value; + } + + List get contactList => _contactList ?? []; + + set contactList(List value) { + _contactList = value; + } + + List get groupMemberList => _groupMemberList ?? []; + + set groupMemberList(List value) { + _groupMemberList = value; + } + + V2TimGroupInfo? get groupInfo => _groupInfo; + + set groupInfo(V2TimGroupInfo? value) { + _groupInfo = value; + } + + void loadData(String groupID) { + _groupID = groupID; + loadGroupInfo(groupID); + loadGroupMemberList(groupID: groupID); + _loadConversation(); + _loadContactList(); + } + + loadGroupInfo(String groupID) async { + final groupInfo = await _groupServices.getGroupsInfo(groupIDList: [groupID]); + if (groupInfo != null) { + final groupRes = groupInfo.first; + if (groupRes.resultCode == 0) { + _groupInfo = groupRes.groupInfo; + } + } + notifyListeners(); + } + + Future loadGroupMemberList({required String groupID, int count = 100, String? seq}) async { + final String? nextSeq = await _loadGroupMemberListFunction(groupID: groupID, seq: seq, count: count); + if (nextSeq != null && nextSeq != "0" && nextSeq != "") { + return await loadGroupMemberList(groupID: groupID, count: count, seq: nextSeq); + } else { + notifyListeners(); + } + } + + Future _loadGroupMemberListFunction({required String groupID, int count = 100, String? seq}) async { + if (seq == null || seq == "" || seq == "0") { + _groupMemberList?.clear(); + } + final res = await _groupServices.getGroupMemberList( + groupID: groupID, + filter: GroupMemberFilterTypeEnum.V2TIM_GROUP_MEMBER_FILTER_ALL, + count: count, + nextSeq: seq ?? _groupMemberListSeq); + final groupMemberListRes = res.data; + if (res.code == 0 && groupMemberListRes != null) { + final groupMemberListTemp = groupMemberListRes.memberInfoList ?? []; + // TODO + outputLogger.i( + "loadGroupMemberListfinish,groupMemberListTemp, ${groupMemberListRes.nextSeq}, ${groupMemberListTemp.length}"); + _groupMemberList = [...?_groupMemberList, ...groupMemberListTemp]; + _groupMemberListSeq = groupMemberListRes.nextSeq ?? "0"; + } + return groupMemberListRes?.nextSeq; + } + + _loadConversation() async { + conversation = await _conversationService.getConversation(conversationID: "group_$_groupID"); + } + + _loadContactList() async { + final res = await _friendshipServices.getFriendList(); + _contactList = res; + } + + pinedConversation(bool isPined) async { + await _conversationService.pinConversation(conversationID: "group_$_groupID", isPinned: isPined); + conversation?.isPinned = isPined; + notifyListeners(); + } + + setMessageDisturb(bool value) async { + final res = await _messageService.setGroupReceiveMessageOpt( + groupID: _groupID, + opt: value ? ReceiveMsgOptEnum.V2TIM_RECEIVE_NOT_NOTIFY_MESSAGE : ReceiveMsgOptEnum.V2TIM_RECEIVE_MESSAGE); + if (res.code == 0) { + conversation?.recvOpt = + (value ? ReceiveMsgOptEnum.V2TIM_RECEIVE_NOT_NOTIFY_MESSAGE : ReceiveMsgOptEnum.V2TIM_RECEIVE_MESSAGE).index; + } + notifyListeners(); + } + + Future> searchGroupMember( + V2TimGroupMemberSearchParam searchParam) async { + final res = await _groupServices.searchGroupMembers(searchParam: searchParam); + + if (res.code == 0) {} + return res; + } + + Future setGroupName(String groupName) async { + if (_groupInfo != null) { + String? originalGroupName = _groupInfo?.groupName; + _groupInfo?.groupName = groupName; + V2TimGroupInfo v2timGroupInfo = V2TimGroupInfo(groupID: _groupID, groupType: _groupInfo!.groupType); + v2timGroupInfo.groupName = groupName; + final response = await _groupServices.setGroupInfo(info: v2timGroupInfo); + if (response.code != 0) { + _groupInfo?.groupName = originalGroupName; + } + notifyListeners(); + return response; + } + return null; + } + + setGroupNotification(String notification) async { + if (_groupInfo != null) { + V2TimGroupInfo v2timGroupInfo = V2TimGroupInfo(groupID: _groupID, groupType: _groupInfo!.groupType); + v2timGroupInfo.notification = notification; + final response = await _groupServices.setGroupInfo(info: v2timGroupInfo); + if (response.code == 0) { + notifyListeners(); + _groupInfo?.notification = notification; + } + } + } + + String getSelfNameCard() { + try { + final loginUserID = _coreServices.loginUserInfo?.userID; + String nameCard = ""; + if (_groupMemberList != null) { + nameCard = groupMemberList.firstWhere((element) => element?.userID == loginUserID)?.nameCard ?? ""; + } + + return nameCard; + } catch (err) { + return ""; + } + } + + Future setNameCard(String nameCard) async { + final loginUserID = _coreServices.loginUserInfo?.userID; + if (loginUserID != null) { + final res = await _groupServices.setGroupMemberInfo(groupID: _groupID, userID: loginUserID, nameCard: nameCard); + if (res.code == 0) { + final targetIndex = _groupMemberList?.indexWhere((element) => element?.userID == loginUserID); + if (targetIndex != -1) { + _groupMemberList![targetIndex!]!.nameCard = nameCard; + notifyListeners(); + } + } + return res; + } + return null; + } + + Future setGroupAddOpt(int addOpt) async { + if (_groupInfo != null) { + int? originalAddopt = _groupInfo?.groupAddOpt; + _groupInfo?.groupAddOpt = addOpt; + V2TimGroupInfo v2timGroupInfo = V2TimGroupInfo(groupID: _groupID, groupType: _groupInfo!.groupType); + v2timGroupInfo.groupAddOpt = addOpt; + final response = await _groupServices.setGroupInfo(info: v2timGroupInfo); + if (response.code != 0) { + _groupInfo?.groupAddOpt = originalAddopt; + } + notifyListeners(); + return response; + } + return null; + } + + Future setMemberToNormal(String userID) async { + final res = await _groupServices.setGroupMemberRole( + groupID: _groupID, userID: userID, role: GroupMemberRoleTypeEnum.V2TIM_GROUP_MEMBER_ROLE_MEMBER); + if (res.code == 0) { + final targetIndex = _groupMemberList!.indexWhere((e) => e!.userID == userID); + if (targetIndex != -1) { + final targetElem = _groupMemberList![targetIndex]; + targetElem?.role = GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER; + _groupMemberList![targetIndex] = targetElem; + } + notifyListeners(); + } + return res; + } + + Future setMemberToAdmin(String userID) async { + final res = await _groupServices.setGroupMemberRole( + groupID: _groupID, userID: userID, role: GroupMemberRoleTypeEnum.V2TIM_GROUP_MEMBER_ROLE_ADMIN); + if (res.code == 0) { + final targetIndex = _groupMemberList!.indexWhere((e) => e!.userID == userID); + if (targetIndex != -1) { + final targetElem = _groupMemberList![targetIndex]; + targetElem?.role = GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN; + _groupMemberList![targetIndex] = targetElem; + } + notifyListeners(); + } + return res; + } + + void onOwnerChanged(String? userID) { + if (userID == null) { + return; + } + + // 把之前的群主更新为普通成员 + final preOwnerIndex = + _groupMemberList!.indexWhere((e) => e!.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER); + if (preOwnerIndex != -1) { + final preOwnerElem = _groupMemberList![preOwnerIndex]; + preOwnerElem?.role = GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER; + + print("preOwnerUserID: ${preOwnerElem?.userID}"); + } + + // 设置新的群主 + final targetIndex = _groupMemberList!.indexWhere((e) => e!.userID == userID); + if (targetIndex != -1) { + final targetElem = _groupMemberList![targetIndex]; + targetElem?.role = GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER; + _groupMemberList![targetIndex] = targetElem; + + print("newOwnerUserID: ${targetElem?.userID}"); + } + + notifyListeners(); + } + + bool canInviteMember() { + final groupType = _groupInfo?.groupType; + return groupType == GroupType.Work || groupType == "Private"; + } + + bool canKickOffMember() { + final isGroupOwner = _groupInfo?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER; + final isAdmin = _groupInfo?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN; + if (_groupInfo?.groupType == GroupType.Work) { + /// work 群主才能踢人 + return isGroupOwner; + } + + if (_groupInfo?.groupType == GroupType.Public || _groupInfo?.groupType == GroupType.Meeting) { + /// public || meeting 群主和管理员可以踢人 + return isGroupOwner || isAdmin; + } + + return false; + } + + Future setMuteAll(bool muteAll) async { + if (_groupInfo != null) { + _groupInfo?.isAllMuted = muteAll; + V2TimGroupInfo v2timGroupInfo = V2TimGroupInfo(groupID: _groupID, groupType: _groupInfo!.groupType); + v2timGroupInfo.isAllMuted = muteAll; + final response = await _groupServices.setGroupInfo(info: v2timGroupInfo); + if (response.code != 0) { + _groupInfo?.isAllMuted = muteAll; + } + notifyListeners(); + return response; + } + return null; + } + + Future muteGroupMember(String userID, bool isMute, int? serverTime) async { + const muteTime = 315360000; + final res = await _groupServices.muteGroupMember(groupID: _groupID, userID: userID, seconds: isMute ? muteTime : 0); + if (res.code == 0) { + final targetIndex = _groupMemberList!.indexWhere((e) => e!.userID == userID); + if (targetIndex != -1) { + final targetElem = _groupMemberList![targetIndex]; + targetElem?.muteUntil = isMute ? (serverTime ?? 0) + muteTime : 0; + _groupMemberList![targetIndex] = targetElem; + } + notifyListeners(); + } + return null; + } + + Future kickOffMember(List userIDs) async { + final res = await _groupServices.kickGroupMember(groupID: _groupID, memberList: userIDs); + if (res.code == 0) { + // 从本地群成员列表中移除被删除的成员 + _groupMemberList?.removeWhere((member) => userIDs.contains(member?.userID)); + // 更新群成员数量 + if (_groupInfo != null && _groupInfo!.memberCount != null) { + _groupInfo!.memberCount = _groupInfo!.memberCount! - userIDs.length; + } + notifyListeners(); + } + return res; + } + + Future>> inviteUserToGroup(List userIDS) async { + final res = await _groupServices.inviteUserToGroup(groupID: _groupID, userList: userIDS); + return res; + } +} diff --git a/lib/business_logic/separate_models/tui_profile_view_model.dart b/lib/business_logic/separate_models/tui_profile_view_model.dart new file mode 100644 index 0000000..7225952 --- /dev/null +++ b/lib/business_logic/separate_models/tui_profile_view_model.dart @@ -0,0 +1,243 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/receive_message_opt_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/profile_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/model/profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +class TUIProfileViewModel extends ChangeNotifier { + final ConversationService _conversationService = serviceLocator(); + final FriendshipServices _friendshipServices = serviceLocator(); + final TUIFriendShipViewModel _friendShipViewModel = serviceLocator(); + final CoreServicesImpl _coreServices = serviceLocator(); + final MessageService _messageService = serviceLocator(); + + UserProfile? _userProfile; + ProfileLifeCycle? _lifeCycle; + bool? _shouldAddToBlackList; + int _friendType = 0; + bool? _isDisturb; + + UserProfile? get userProfile { + return _userProfile; + } + + set userProfile(UserProfile? value) { + _userProfile = value; + notifyListeners(); + } + + bool? get isDisturb { + return _isDisturb; + } + + bool? get isAddToBlackList { + return _shouldAddToBlackList; + } + + int get friendType { + return _friendType; + } + + set lifeCycle(ProfileLifeCycle? value) { + _lifeCycle = value; + } + + loadData({required String userID, bool isNeedConversation = true}) async { + if (userID.isEmpty) { + return; + } + V2TimFriendInfo? friendUserInfo; + V2TimConversation? conversation; + final userInfoList = await _friendshipServices.getFriendsInfo(userIDList: [userID]); + final checkFriend = + await _friendshipServices.checkFriend(userIDList: [userID], checkType: FriendTypeEnum.V2TIM_FRIEND_TYPE_SINGLE); + + if (checkFriend != null) { + final res = checkFriend.first; + if (res.resultCode == 0) { + _friendType = res.resultType; + } + } + + if (userInfoList != null) { + friendUserInfo = userInfoList[0].friendInfo; + } + + if (isNeedConversation) { + conversation = await _conversationService.getConversation(conversationID: "c2c_$userID"); + _isDisturb = conversation?.recvOpt == 2; + } + + final friendInfo = await _lifeCycle?.didGetFriendInfo(friendUserInfo) ?? friendUserInfo; + + _isDisturb = conversation?.recvOpt == 2; + _userProfile = UserProfile(friendInfo: friendInfo, conversation: conversation); + + _shouldAddToBlackList = _friendShipViewModel.blockList.indexWhere((element) => element.userID == userID) > -1; + + notifyListeners(); + } + + Future pinedConversation(bool isPined, String convID) async { + final res = await _conversationService.pinConversation(conversationID: convID, isPinned: isPined); + _userProfile?.conversation!.isPinned = isPined; + notifyListeners(); + return res; + } + + Future?> addToBlackList(bool shouldAdd, String userID) async { + if (_lifeCycle?.shouldAddToBlockList != null && await _lifeCycle!.shouldAddToBlockList(userID) == false) { + return null; + } + if (shouldAdd) { + final res = await _friendshipServices.addToBlackList(userIDList: [userID]); + if (res != null && res.isNotEmpty) { + final result = res.first; + if (result.resultCode == 0) { + _shouldAddToBlackList = true; + _friendType = 0; + } + } + notifyListeners(); + return res; + } else { + final res = await _friendshipServices.deleteFromBlackList(userIDList: [userID]); + if (res != null && res.isNotEmpty) { + final result = res.first; + if (result.resultCode == 0) { + _shouldAddToBlackList = false; + final checkFriend = await _friendshipServices + .checkFriend(userIDList: [userID], checkType: FriendTypeEnum.V2TIM_FRIEND_TYPE_SINGLE); + if (checkFriend != null) { + final res = checkFriend.first; + _friendType = res.resultType; + } + } + } + _friendShipViewModel.loadBlockListData(); + notifyListeners(); + return res; + } + } + + Future deleteFriend(String userID, {bool needUpdateData = true}) async { + if (_lifeCycle?.shouldDeleteFriend != null && await _lifeCycle!.shouldDeleteFriend(userID) == false) { + return null; + } + final res = await _friendshipServices + .deleteFromFriendList(userIDList: [userID], deleteType: FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + if (res != null) { + _conversationService.deleteConversation(conversationID: "c2c_$userID"); + if (needUpdateData) { + loadData(userID: userID); + } + return res.first; + } + + return null; + } + + Future changeFriendVerificationMethod(int allowType) async { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.allowType = allowType; + final res = await _coreServices.setSelfInfo(userFullInfo: userFullInfo); + if (res.code == 0) { + _userProfile?.friendInfo!.userProfile!.allowType = allowType; + notifyListeners(); + } + return res; + } + + Future addFriend(String userID) async { + if (_lifeCycle?.shouldAddFriend != null && await _lifeCycle!.shouldAddFriend(userID) == false) { + return null; + } + final res = await _friendshipServices.addFriend(userID: userID, addType: FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + if (res.code == 0) { + loadData(userID: userID); + return res.data; + } + + return null; + } + + Future updateRemarks(String userID, String remark) async { + final res = await _friendshipServices.setFriendInfo(userID: userID, friendRemark: remark); + + if (res.code == 0) { + _userProfile?.friendInfo!.friendRemark = remark; + notifyListeners(); + } + return res; + } + + Future setMessageDisturb(String userID, bool isDisturb) async { + final res = await _messageService.setC2CReceiveMessageOpt( + userIDList: [userID], + opt: isDisturb ? ReceiveMsgOptEnum.V2TIM_RECEIVE_NOT_NOTIFY_MESSAGE : ReceiveMsgOptEnum.V2TIM_RECEIVE_MESSAGE); + if (res.code == 0) { + _isDisturb = isDisturb; + } + notifyListeners(); + return res; + } + + updateUserInfo(V2TimUserFullInfo userFullInfo) { + if (userFullInfo.nickName != null) { + _userProfile?.friendInfo!.userProfile?.nickName = userFullInfo.nickName; + } + if (userFullInfo.faceUrl != null) { + _userProfile?.friendInfo!.userProfile?.faceUrl = userFullInfo.faceUrl; + } + if (userFullInfo.selfSignature != null) { + _userProfile?.friendInfo!.userProfile?.selfSignature = userFullInfo.selfSignature; + } + if (userFullInfo.gender != null) { + _userProfile?.friendInfo!.userProfile?.gender = userFullInfo.gender; + } + if (userFullInfo.allowType != null) { + _userProfile?.friendInfo!.userProfile?.allowType = userFullInfo.allowType; + } + if (userFullInfo.customInfo != null) { + _userProfile?.friendInfo!.userProfile?.customInfo = userFullInfo.customInfo; + } + if (userFullInfo.role != null) { + _userProfile?.friendInfo!.userProfile?.role = userFullInfo.role; + } + if (userFullInfo.level != null) { + _userProfile?.friendInfo!.userProfile?.level = userFullInfo.level; + } + if (userFullInfo.birthday != null) { + _userProfile?.friendInfo!.userProfile?.birthday = userFullInfo.birthday; + } + } + + Future updateSelfInfo(V2TimUserFullInfo userFullInfo) async { + final res = await _coreServices.setSelfInfo(userFullInfo: userFullInfo); + + if (res.code == 0) { + updateUserInfo(userFullInfo); + notifyListeners(); + } + + return res; + } +} diff --git a/lib/business_logic/view_models/tui_chat_global_model.dart b/lib/business_logic/view_models/tui_chat_global_model.dart new file mode 100644 index 0000000..d186b59 --- /dev/null +++ b/lib/business_logic/view_models/tui_chat_global_model.dart @@ -0,0 +1,1087 @@ +// ignore_for_file: avoid_print, unnecessary_getters_setters, unused_element +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_custom_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_image.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_image.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_download_progress.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_download_progress.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_receipt.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_msg_create_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_class.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/chat_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_model_tools.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; + +enum ConvType { none, c2c, group } + +enum HistoryMessagePosition { bottom, inTwoScreen, awayTwoScreen, notShowLatest } + +class CurrentConversation { + final String conversationID; + final ConvType conversationType; + + CurrentConversation(this.conversationID, this.conversationType); +} + +class TUIChatGlobalModel extends ChangeNotifier implements TIMUIKitClass { + final MessageService _messageService = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + final Map?> _messageListMap = {}; + final Map _messageReadReceiptMap = {}; + final Map _messageListProgressMap = {}; + final Map _fileListLocationMap = {}; + final Map _preloadImageMap = {}; + final Map _historyMessagePositionMap = {}; + final List _currentConversationList = []; + + Map get preloadImageMap => _preloadImageMap; + + ChatLifeCycle? _lifeCycle; + bool _isDownloading = false; + final List> _waitingDownloadList = + List.empty(growable: true); // example {"savePath":"","url":"",msgId:""} + int _totalUnreadCount = 0; + String localKeyPrefix = "TUIKit_conversation_stored_"; + String localMsgIDListKey = "TUIKit_conversation_list"; + + late V2TimAdvancedMsgListener advancedMsgListener; + int _unreadCountForTongue = 0; + + // use for generate a new sliver list to show received message list + int _receivedNewMessageCount = 0; + final List _receivedUnreadMessageList = []; + + TIMUIKitChatConfig chatConfig = const TIMUIKitChatConfig(); + List? _groupApplicationList; + String Function(V2TimMessage message)? _abstractMessageBuilder; + final Map _c2cMessageEditStatusMap = Map.from({}); // 0 normal 1 sending + final Map _c2cMessageFromUserActiveMap = Map.from({}); + final Map _c2cMessageActiveTimer = Map.from({}); + bool _showC2cMessageEditStatus = true; + final Map _c2cMessageStatusShowTimer = Map.from({}); + Map loadingMessage = {}; + + TUIChatGlobalModel() { + advancedMsgListener = V2TimAdvancedMsgListener( + onRecvC2CReadReceipt: (List receiptList) { + _onReceiveC2CReadReceipt(receiptList); + }, + onRecvMessageRevoked: (String msgID) { + onMessageRevoked(msgID); + }, + onRecvNewMessage: (V2TimMessage newMsg) { + _onReceiveNewMsg(newMsg); + }, + onSendMessageProgress: (V2TimMessage messagae, int progress) { + _onSendMessageProgress(messagae, progress); + }, + onRecvMessageReadReceipts: (List receiptList) { + _onReceiveMessageReadReceipts(receiptList); + }, + onRecvMessageModified: (V2TimMessage newMsg) { + onMessageModified(newMsg); + }, + onMessageDownloadProgressCallback: (V2TimMessageDownloadProgress messageProgress) { + onMessageDownloadProgressCallback(messageProgress); + }, + ); + } + + bool get isDownloading => _isDownloading; + + bool get hasWaiting => _waitingDownloadList.isNotEmpty; + + Map get currentDownLoad => _waitingDownloadList.first; + + int getWaitingListLength() { + return _waitingDownloadList.length; + } + + void addWaitingList(String msgID) { + outputLogger.i("add to waiting list success"); + bool contains = false; + for (Map element in _waitingDownloadList) { + String msgIDItem = element["msgID"] ?? ""; + if (msgIDItem.isNotEmpty) { + if (msgID == msgIDItem) { + contains = true; + break; + } + } + } + if (!contains) { + _waitingDownloadList.add(Map.from({ + "msgID": msgID, + })); + // setMessageProgress(msgID, 1); // 有一点进度条,表示等待中 + } + } + + downloadFile() async { + if (_isDownloading || _waitingDownloadList.isEmpty) { + return; + } + + final nextDownload = _waitingDownloadList.first; + final msgID = nextDownload["msgID"] ?? ""; + if (msgID.isEmpty || _messageListProgressMap[msgID] == 100) { + return; + } + + _isDownloading = true; + await _messageService.downloadMessage( + msgID: msgID, + messageType: 6, + imageType: 0, + isSnapshot: false, + ); + + outputLogger.i("start another download"); + } + + int getReceived(msgID) { + return messageListProgressMap[msgID] ?? 0; + } + + bool isWaiting(String msgID) { + return _waitingDownloadList.where((element) { + String msgIDItem = element["msgID"] ?? ""; + if (msgIDItem.isNotEmpty) { + if (msgID == msgIDItem) { + return true; + } + } + return false; + }).isNotEmpty; + } + + Map get messageListProgressMap { + return _messageListProgressMap; + } + + Map?> get messageListMap { + return _messageListMap; + } + + int get totalUnReadCount { + return _totalUnreadCount; + } + + set totalUnReadCount(int newValue) { + _totalUnreadCount = newValue; + notifyListeners(); + } + + int get receivedNewMessageCount => _receivedNewMessageCount; + + set receivedNewMessageCount(int value) { + _receivedNewMessageCount = value; + } + + int get unreadCountForTongue => _unreadCountForTongue; + + set unreadCountForTongue(int value) { + _unreadCountForTongue = value; + notifyListeners(); + } + + List get groupApplicationList => _groupApplicationList ?? []; + + String Function(V2TimMessage message)? get abstractMessageBuilder => _abstractMessageBuilder; + + Map get messageReadReceiptMap => _messageReadReceiptMap; + + String get currentSelectedConv => _currentConversationList.isNotEmpty + ? _currentConversationList[_currentConversationList.length - 1].conversationID + : ""; + + ConvType? get currentSelectedConvType => _currentConversationList.isNotEmpty + ? _currentConversationList[_currentConversationList.length - 1].conversationType + : null; + + setCurrentConversation(CurrentConversation value) { + _currentConversationList.add(value); + notifyListeners(); + } + + clearCurrentConversation() { + // Only keep the last 20 messages when existing a chat. + _messageListMap[currentSelectedConv] = (_messageListMap[currentSelectedConv] ?? []) + .sublist(0, max(0, min(10, ((_messageListMap[currentSelectedConv] ?? []).length - 1)))); + if (_currentConversationList.isNotEmpty) { + _currentConversationList.removeLast(); + } + + _receivedUnreadMessageList.clear(); + // notifyListeners(); + } + + void removeMessageList(String conversationID) { + _messageListMap.remove(conversationID); + } + + V2TimMessageReceipt? getMessageReadReceipt(String msgID) { + return messageReadReceiptMap[msgID]; + } + + setShowC2cEditStatus(bool show) { + _showC2cMessageEditStatus = show; + } + + /// set edit status from chats + setC2cMessageEditStatus(String userID, int status) { + _c2cMessageEditStatusMap[userID] = status; + if (status == 1) { + if (_c2cMessageStatusShowTimer[userID] != null) { + if (_c2cMessageStatusShowTimer[userID]!.isActive) { + _c2cMessageStatusShowTimer[userID]!.cancel(); + _c2cMessageEditStatusMap[userID] = 0; + } + } + _c2cMessageStatusShowTimer[userID] = Timer.periodic(const Duration(seconds: 5), (timer) { + _c2cMessageEditStatusMap[userID] = 0; + Timer? t = _c2cMessageStatusShowTimer[userID]; + if (t != null && t.isActive) { + // 取消当前的定时器 + t.cancel(); + } + }); + } + notifyListeners(); + } + + int getC2cMessageEditStatus(String userID) { + return _c2cMessageEditStatusMap[userID] ?? 0; + } + + set abstractMessageBuilder(String Function(V2TimMessage message)? value) { + _abstractMessageBuilder = value; + } + + set lifeCycle(ChatLifeCycle? value) { + _lifeCycle = value; + } + + set groupApplicationList(List value) { + _groupApplicationList = value; + } + + setChatConfig(TIMUIKitChatConfig config) { + chatConfig = config; + } + + initMessageMapFromLocalDatabase(List conversations) async { + int index = 0; + for (V2TimConversation? conversationItem in conversations) { + if (conversationItem == null || conversationItem.type == null) { + return; + } + final conversationID = TencentUtils.checkString(conversationItem.userID) ?? + TencentUtils.checkString(conversationItem.groupID) ?? + conversationItem.conversationID; + if (messageListMap[conversationID] == null || messageListMap[conversationID]!.isEmpty) { + index++; + Future.delayed(Duration(milliseconds: 500 * index), () { + preloadMessageForConversation( + conversationID: conversationID, conversationType: ConvType.values[conversationItem.type!]); + }); + } + } + } + + preloadMessageForConversation({ + required ConvType conversationType, + required String conversationID, + }) async { + final response = await _messageService.getHistoryMessageList( + count: 10, + getType: HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + userID: conversationType == ConvType.c2c ? conversationID : null, + groupID: conversationType == ConvType.group ? conversationID : null); + if (_messageListMap[conversationID] == null || _messageListMap[conversationID]!.isEmpty) { + _messageListMap[conversationID] = response; + } + } + + clearMessageMapFromLocal() async { + final prefs = await SharedPreferences.getInstance(); + final List? localMsgIDList = prefs.getStringList(localMsgIDListKey); + + if (localMsgIDList != null) { + for (String convID in localMsgIDList) { + prefs.remove("$localKeyPrefix$convID"); + } + } + + prefs.remove(localMsgIDListKey); + } + + Future updateMessageFromController( + {required String msgID, required String conversationID, required ConvType conversationType}) async { + final TUIChatModelTools tools = serviceLocator(); + V2TimMessage? newMessage = await tools.getExistingMessageByID( + msgID: msgID, conversationID: conversationID, conversationType: conversationType); + if (newMessage != null) { + onMessageModified(newMessage, currentSelectedConv); + } + } + + clearData() { + _messageListMap.clear(); + _currentConversationList.clear(); + _totalUnreadCount = 0; + _groupApplicationList?.clear(); + _totalUnreadCount = 0; + _receivedNewMessageCount = 0; + _messageReadReceiptMap.clear(); + _messageListProgressMap.clear(); + notifyListeners(); + } + + clearReceivedNewMessageCount() { + _receivedNewMessageCount = 0; + } + + _preLoadImage(List msgList) { + List needPreViewList = msgList.sublist(0, max(0, min(5, msgList.length - 1))); + for (var msgItem in needPreViewList) { + V2TimImage? getImageFromList(V2TimImageTypesEnum imgType) { + V2TimImage? img = MessageUtils.getImageFromImgList(msgItem.imageElem?.imageList, + HistoryMessageDartConstant.imgPriorMap[imgType] ?? HistoryMessageDartConstant.oriImgPrior); + return img; + } + + V2TimImage? originalImg = getImageFromList(V2TimImageTypesEnum.small); + if (originalImg?.localUrl != null && originalImg!.localUrl != "") { + try { + ImageConfiguration configuration = const ImageConfiguration(); + final image = FileImage(File((originalImg.localUrl!))); + + image.resolve(configuration).addListener(ImageStreamListener((ImageInfo image, bool synchronousCall) { + final tempImg = image.image; + _preloadImageMap[msgItem.seq! + msgItem.timestamp.toString() + (msgItem.msgID ?? "")] = tempImg; + outputLogger.i("cacheImage ${msgItem.msgID}"); + })); + } catch (e) { + outputLogger.i("cacheImage error ${msgItem.msgID}"); + } + } + } + } + + int getMessageProgress(String? msgID) { + return _messageListProgressMap[msgID] ?? 0; + } + + String getFileMessageLocation(String? msgID) { + return _fileListLocationMap[msgID] ?? ''; + } + + setMessageProgress(String msgID, int progress) { + _messageListProgressMap[msgID] = progress; + if (progress > 0 && progress < 100) { + _isDownloading = true; + } else { + _isDownloading = false; + _waitingDownloadList.removeWhere((element) { + String msgIDItem = element["msgID"] ?? ""; + if (msgIDItem.isNotEmpty) { + if (msgID == msgIDItem) { + outputLogger.i("remove download"); + return true; + } + } + return false; + }); + } + } + + setFileMessageLocation(String msgID, String location) { + _fileListLocationMap[msgID] = location; + notifyListeners(); + } + + _editStatusCheck(V2TimMessage msg) { + bool isStatusMessage = false; + if (msg.customElem != null && TencentUtils.checkString(msg.groupID) == null) { + V2TimCustomElem customElem = msg.customElem!; + String sender = msg.sender ?? ""; + if (customElem.data!.isNotEmpty) { + try { + Map? data = json.decode(customElem.data ?? ""); + if (data != null) { + var businessID = data["businessID"]; + int? userAction = data["userAction"]; + String? actionParam = data["actionParam"]; + if (businessID.toString() == "user_typing_status") { + int? typingStatus = data["typingStatus"]; + if (sender != "") { + if (typingStatus != null) { + setC2cMessageEditStatus(sender, typingStatus); + } else { + // 兼容旧版本逻辑 + if (userAction != null) { + if (userAction == 14) { + if (actionParam != null) { + setC2cMessageEditStatus(sender, actionParam == "EIMAMSG_InputStatus_Ing" ? 1 : 0); + } + } + } + } + } + return true; + } + } + } catch (err) { + // err; + } + } + } + return isStatusMessage; + } + + _checkFromUserisActive(V2TimMessage msg) async { + // check message is c2c message and message cloudcustomdata field is not null + if (msg.groupID == null && msg.cloudCustomData != null) { + try { + Map data = json.decode(msg.cloudCustomData ?? ""); + Map? messageFeature = data["messageFeature"]; + if (messageFeature != null) { + int needTyping = messageFeature["needTyping"]; + if (needTyping == 1) { + _c2cMessageFromUserActiveMap[msg.sender ?? ""] = true; + + if (_c2cMessageActiveTimer[msg.sender ?? ""] != null) { + Timer? t = _c2cMessageActiveTimer[msg.sender ?? ""]; + if (t != null && t.isActive) { + //取消原来的定时器 + t.cancel(); + } + } + _c2cMessageActiveTimer[msg.sender ?? ""] = Timer.periodic(const Duration(seconds: 30), (timer) { + _c2cMessageFromUserActiveMap[msg.sender ?? ""] = false; + Timer? t = _c2cMessageActiveTimer[msg.sender ?? ""]; + if (t != null && t.isActive) { + // 取消当前的定时器 + t.cancel(); + } + }); + } + } + } catch (err) { + // err + } + } + } + + sendEditStatusMessage(bool isEditing, String toUser) async { + if (!_showC2cMessageEditStatus) { + return; + } + if (!(_c2cMessageFromUserActiveMap[toUser] ?? false)) { + return; + } + V2TimMsgCreateInfoResult? res = await _messageService.createCustomMessage( + data: json.encode({ + "businessID": "user_typing_status", + "typingStatus": isEditing == true ? 1 : 0, + "userAction": 14, + "version": 0, + "actionParam": isEditing == true ? "EIMAMSG_InputStatus_Ing" : "EIMAMSG_InputStatus_End" + })); + if (res != null) { + _sendMessage( + id: res.id!, + convID: toUser, + convType: ConvType.c2c, + onlineUserOnly: true, + isEditStatusMessage: true, + ); + } + } + + void refreshGroupApplicationList() async { + final res = await _groupServices.getGroupApplicationList(); + _groupApplicationList = res.data?.groupApplicationList?.map((item) { + final V2TimGroupApplication applicationItem = item!; + return applicationItem; + }).toList() ?? + []; + notifyListeners(); + } + + cancelAllTimer() { + _c2cMessageActiveTimer.forEach((key, value) { + if (value.isActive) { + value.cancel(); + } + }); + _c2cMessageStatusShowTimer.forEach((key, value) { + if (value.isActive) { + value.cancel(); + } + }); + } + + _onReceiveNewMsg(V2TimMessage msgComing) async { + final convID = TencentUtils.checkString(msgComing.userID) ?? msgComing.groupID; + if (convID != currentSelectedConv) { + return; + } + + final V2TimMessage? newMsg = + _lifeCycle?.newMessageWillMount != null ? await _lifeCycle?.newMessageWillMount(msgComing) : msgComing; + if (newMsg == null) { + return; + } + // check the message is editing status msg. and flutter is only support the latest version + bool isEditMessage = _editStatusCheck(msgComing); + + // if the message is edit status message don't up to screen + if (isEditMessage) { + return; + } + + _checkFromUserisActive(msgComing); + final convType = TencentUtils.checkString(newMsg.groupID) != null ? ConvType.group : ConvType.c2c; + if (convID != null && convID == currentSelectedConv) { + // when receive new message in the current chat page, we need to mark the message as read. + if (chatConfig.isAutoReportRead) { + Future.delayed(const Duration(seconds: 1), () { + markMessageAsRead( + convID: convID, + convType: convType, + ); + }); + } + + final position = getMessageListPosition(convID); + if (position == HistoryMessagePosition.notShowLatest) { + return; + } + if (position == HistoryMessagePosition.bottom && unreadCountForTongue == 0) { + _unreadCountForTongue = 0; + _receivedNewMessageCount = 0; + final tempCurrentMsgList = _messageListMap[convID] ?? []; + _messageListMap[convID] = [newMsg, ...tempCurrentMsgList]; + notifyListeners(); + } else { + if (convID == currentSelectedConv) { + unreadCountForTongue++; + _receivedNewMessageCount++; + _receivedUnreadMessageList.add(newMsg); + final currentMsg = _messageListMap[convID] ?? []; + _messageListMap[convID] = [newMsg, ...currentMsg]; + notifyListeners(); + } + } + } else if (convID != null) { + final tempCurrentMsgList = _messageListMap[convID] ?? []; + _messageListMap[convID] = [newMsg, ...tempCurrentMsgList]; + notifyListeners(); + } + } + + onMessageRevoked(String msgID, [String? convID]) { + final activeMessageList = _messageListMap[convID ?? currentSelectedConv]; + if (activeMessageList != null) { + final findIndex = activeMessageList.indexWhere((element) => element.msgID == msgID); + if (findIndex != -1) { + final targetItem = activeMessageList[findIndex]; + targetItem.status = MessageStatus.V2TIM_MSG_STATUS_LOCAL_REVOKED; + targetItem.id = DateTime.now().millisecondsSinceEpoch.toString(); + activeMessageList[findIndex] = targetItem; + + bool isUnreadMessage = _receivedUnreadMessageList.any((element) => element.msgID == msgID); + if (!(targetItem.isSelf ?? true) && isUnreadMessage) { + if (_unreadCountForTongue > 0) { + if (_unreadCountForTongue > 0) { + _unreadCountForTongue--; + } + if (_receivedNewMessageCount > 0) { + _receivedNewMessageCount--; + } + + _receivedUnreadMessageList.removeWhere((element) => element.msgID == targetItem.msgID); + } + } + } + + _messageListMap[convID ?? currentSelectedConv] = activeMessageList; + notifyListeners(); + } + } + + onMessageModified(V2TimMessage modifiedMessage, [String? convID]) async { + modifiedMessage.id = DateTime.now().millisecondsSinceEpoch.toString(); + final String? exactId = + TencentUtils.checkString(modifiedMessage.userID) ?? TencentUtils.checkString(modifiedMessage.groupID); + final activeMessageList = _messageListMap[convID ?? exactId]; + if (activeMessageList == null || activeMessageList.isEmpty) { + return; + } + final V2TimMessage newMsg = await _lifeCycle?.modifiedMessageWillMount(modifiedMessage) ?? modifiedMessage; + final msgID = newMsg.msgID; + _messageListMap[convID ?? exactId ?? ""] = activeMessageList.map((item) { + if (item.msgID == msgID) { + return newMsg; + } + return item; + }).toList(); + notifyListeners(); + } + + _onReceiveC2CReadReceipt(List receiptList) { + for (var receipt in receiptList) { + final convID = receipt.userID; + final isNotEmpty = _messageListMap[convID]?.isNotEmpty; + if (isNotEmpty != null && isNotEmpty) { + _messageListMap[convID] = _messageListMap[convID]!.map((element) { + final isSelf = element.isSelf ?? true; + final isPeerRead = element.isPeerRead ?? false; + if (isSelf && !isPeerRead) { + element.isPeerRead = true; + } + return element; + }).toList(); + } + } + notifyListeners(); + } + + _onReceiveMessageReadReceipts(List receiptList) { + try { + for (var receipt in receiptList) { + final msgID = receipt.msgID; + if (msgID != null) { + _messageReadReceiptMap[msgID] = receipt; + } + } + notifyListeners(); + // ignore: empty_catches + } catch (e) {} + } + + _onSendMessageProgress(V2TimMessage messagae, int progress) { + outputLogger.i("message progress: $progress"); + } + + Future onMessageDownloadProgressCallback(V2TimMessageDownloadProgress messageProgress) async { + final currentProgress = getMessageProgress(messageProgress.msgID); + print( + "onMessageDownloadProgressCallback, ${messageProgress.type} - ${messageProgress.isFinish} - ${messageProgress.currentSize} - $currentProgress - "); + + if (messageProgress.isError || messageProgress.errorCode != 0) { + V2TimMessage? message = await _findAndRetrieveMessage(messageProgress.msgID); + _handleDownloadError(messageProgress, message); + return; + } + + if (messageProgress.isFinish && currentProgress < 100) { + V2TimMessage? message = await _findAndRetrieveMessage(messageProgress.msgID); + _handleFinishedDownload(messageProgress, message); + return; + } + + _updateProgressIfNeeded(messageProgress, currentProgress); + } + + Future _findAndRetrieveMessage(String messageId) async { + final messages = await _messageService.findMessages(messageIDList: [messageId]); + return messages?.first; + } + + void _handleFinishedDownload(V2TimMessageDownloadProgress messageProgress, V2TimMessage? message) { + if (message != null) { + bool isImageType = message.elemType == MessageElemType.V2TIM_ELEM_TYPE_IMAGE; + bool isVideoType = message.elemType == MessageElemType.V2TIM_ELEM_TYPE_VIDEO; + const originalImageType = 0; + if (!isImageType && !isVideoType) { + _updateMessageLocationAndDownloadFile(messageProgress); + } else if ((isImageType && messageProgress.type == originalImageType) || + (isVideoType && !messageProgress.isSnapshot)) { + Future.delayed(const Duration(seconds: 1), () => _updateMessageAndDownloadFile(message, messageProgress)); + } else { + return; + } + } else { + _updateMessageLocationAndDownloadFile(messageProgress); + } + } + + void _handleDownloadError(V2TimMessageDownloadProgress messageProgress, V2TimMessage? message) { + setMessageProgress(messageProgress.msgID, 0); + downloadFile(); + } + + void _updateMessageAndDownloadFile(V2TimMessage message, V2TimMessageDownloadProgress messageProgress) { + updateAsyncMessage( + message, TencentUtils.checkString(message.userID) ?? TencentUtils.checkString(message.groupID) ?? ""); + + _updateMessageLocationAndDownloadFile(messageProgress); + } + + void _updateMessageLocationAndDownloadFile(V2TimMessageDownloadProgress messageProgress) { + setFileMessageLocation(messageProgress.msgID, messageProgress.path); + setMessageProgress(messageProgress.msgID, 100); + downloadFile(); + } + + void _updateProgressIfNeeded(V2TimMessageDownloadProgress messageProgress, int currentProgress) { + try { + if (messageProgress.totalSize != -1 && !messageProgress.isFinish) { + int progress = min(99, (messageProgress.currentSize / messageProgress.totalSize * 100).floor()); + if (progress > 1 && progress > currentProgress) { + setMessageProgress(messageProgress.msgID, progress); + } + } + } catch (e) { + outputLogger.i("calculate error: ${messageProgress.toJson()}"); + } + } + + void addAdvancedMsgListener() { + _messageService.addAdvancedMsgListener(listener: advancedMsgListener); + } + + void removeAdvanceMsgListener() { + _messageService.removeAdvancedMsgListener(listener: advancedMsgListener); + } + + markMessageAsRead({ + required String convID, + required ConvType convType, + }) async { + if (convType == ConvType.c2c) { + return _messageService.markC2CMessageAsRead(userID: convID); + } + _messageService.markGroupMessageAsRead(groupID: convID); + } + + Future?>? sendMessageFromController({ + required V2TimMessage? messageInfo, + required ConvType convType, + required String convID, + ValueChanged? setInputField, + OfflinePushInfo? offlinePushInfo, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool? onlineUserOnly, + bool? isExcludedFromUnreadCount, + bool? needReadReceipt, + String? cloudCustomData, + String? localCustomData, + }) { + final TUIChatModelTools tools = serviceLocator(); + List currentHistoryMsgList = _messageListMap[convID] ?? []; + if (messageInfo != null) { + final messageInfoWithSender = + messageInfo.sender == null ? tools.setUserInfoForMessage(messageInfo, messageInfo.id!) : messageInfo; + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + setMessageList(convID, currentHistoryMsgList); + if (loadingMessage[convID] != null && loadingMessage[convID]!.isNotEmpty) { + loadingMessage[convID]!.add(messageInfoWithSender); + } else { + loadingMessage[convID] = [messageInfoWithSender]; + } + return _sendMessage( + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + cloudCustomData: cloudCustomData, + localCustomData: localCustomData, + isExcludedFromContentModeration: messageInfo.isExcludedFromContentModeration ?? false, + convID: convID, + setInputField: setInputField, + id: messageInfo.id as String, + convType: ConvType.values[convType.index], + offlinePushInfo: + offlinePushInfo ?? tools.buildMessagePushInfo(messageInfo, convID, ConvType.values[convType.index]), + ); + } + return null; + } + + Future?> sendReplyMessageFromController({ + required String text, + required V2TimMessage messageBeenReplied, + required String convID, + required ConvType convType, + ValueChanged? setInputField, + OfflinePushInfo? offlinePushInfo, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool? onlineUserOnly, + bool? isExcludedFromUnreadCount, + bool? needReadReceipt, + String? localCustomData, + }) async { + if (text.isEmpty) { + return null; + } + final TUIChatModelTools tools = serviceLocator(); + List currentHistoryMsgList = _messageListMap[convID] ?? []; + V2TimMsgCreateInfoResult? textMessageInfo = await _messageService.createTextMessage(text: text); + + textMessageInfo = await _messageService.createTextAtMessage( + text: text + + "\n@${TencentUtils.checkString(messageBeenReplied.nickName) ?? TencentUtils.checkString(messageBeenReplied.sender) ?? TencentUtils.checkString(messageBeenReplied.userID)}", + atUserList: [ + TencentUtils.checkString(messageBeenReplied.sender) ?? + TencentUtils.checkString(messageBeenReplied.userID) ?? + "" + ]); + + final V2TimMessage? messageInfo = textMessageInfo!.messageInfo; + + if (messageInfo != null) { + final messageInfoWithSender = messageInfo.sender == null + ? tools.setUserInfoForMessage(messageInfo, messageInfo.id ?? textMessageInfo.id ?? "") + : messageInfo; + messageInfoWithSender.status = MessageStatus.V2TIM_MSG_STATUS_SENDING; + final hasNickName = messageBeenReplied.nickName != null && messageBeenReplied.nickName != ""; + final cloudCustomData = { + "messageReply": { + "messageID": messageBeenReplied.msgID, + "messageAbstract": tools.getMessageAbstract(messageBeenReplied, abstractMessageBuilder), + "messageSender": hasNickName ? messageBeenReplied.nickName : messageBeenReplied.sender, + "messageType": messageBeenReplied.elemType, + "version": 1 + } + }; + messageInfoWithSender.cloudCustomData = json.encode(cloudCustomData); + + currentHistoryMsgList = [messageInfoWithSender, ...currentHistoryMsgList]; + setMessageList(convID, currentHistoryMsgList); + + return _sendMessage( + cloudCustomData: json.encode(cloudCustomData), + id: textMessageInfo.id as String, + offlinePushInfo: + offlinePushInfo ?? tools.buildMessagePushInfo(messageInfo, convID, ConvType.values[convType.index]), + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + localCustomData: localCustomData, + convID: convID, + setInputField: setInputField, + convType: ConvType.values[convType.index], + ); + } + return null; + } + + Future setLocalCustomData(String msgID, String localCustomData, String conversationID) async { + final res = await _messageService.setLocalCustomData(msgID: msgID, localCustomData: localCustomData); + List messageList = _messageListMap[conversationID] ?? []; + if (res.code == 0) { + messageList = messageList.map((item) { + if (item.msgID == msgID) { + item.localCustomData = localCustomData; + // item.id = DateTime.now().millisecondsSinceEpoch.toString(); + } + return item; + }).toList(); + setMessageList(conversationID, messageList, needResetNewMessageCount: false); + return true; + } + return false; + } + + Future setLocalCustomInt(String msgID, int localCustomInt, String conversationID) async { + final res = await _messageService.setLocalCustomInt(msgID: msgID, localCustomInt: localCustomInt); + List messageList = _messageListMap[conversationID] ?? []; + if (res.code == 0) { + messageList = messageList.map((item) { + if (item.msgID == msgID) { + item.localCustomInt = HistoryMessageDartConstant.read; + // item.id = DateTime.now().millisecondsSinceEpoch.toString(); + } + return item; + }).toList(); + setMessageList(conversationID, messageList, needResetNewMessageCount: false); + return true; + } + return false; + } + + Future> _sendMessage({ + required String id, + required String convID, + required ConvType convType, + OfflinePushInfo? offlinePushInfo, + bool? onlineUserOnly = false, + bool? isEditStatusMessage = false, + GroupReceiptAllowType? groupType, + ValueChanged? setInputField, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool? isExcludedFromUnreadCount, + bool? needReadReceipt, + String? cloudCustomData, + String? localCustomData, + bool isExcludedFromContentModeration = false, + }) async { + String receiver = convType == ConvType.c2c ? convID : ''; + String groupID = convType == ConvType.group ? convID : ''; + final sendMsgRes = await _messageService.sendMessage( + id: id, + receiver: receiver, + needReadReceipt: needReadReceipt ?? chatConfig.isShowReadingStatus, + groupID: groupID, + priority: priority, + localCustomData: localCustomData, + isExcludedFromUnreadCount: isExcludedFromUnreadCount ?? false, + offlinePushInfo: offlinePushInfo, + isExcludedFromContentModeration: isExcludedFromContentModeration, + onlineUserOnly: onlineUserOnly ?? false, + cloudCustomData: cloudCustomData ?? + json.encode({ + "messageFeature": { + "needTyping": 1, + "version": 1, + } + })); + if (isEditStatusMessage == false) { + updateMessage(sendMsgRes, convID, id, convType, groupType, setInputField); + } + if (_lifeCycle?.messageDidSend != null) { + _lifeCycle!.messageDidSend(sendMsgRes); + } + + return sendMsgRes; + } + + void setMessageList(String conversationID, List messageList, + {bool needResetNewMessageCount = true, bool isDeleteMsg = false}) { + _messageListMap[conversationID] = messageList; + if (needResetNewMessageCount) { + _receivedNewMessageCount = 0; + } + + if (isDeleteMsg) { + HistoryMessagePosition position = getMessageListPosition(conversationID); + if (position == HistoryMessagePosition.awayTwoScreen) { + _historyMessagePositionMap[conversationID] = HistoryMessagePosition.notShowLatest; + } + } + + notifyListeners(); + } + + updateMessage(V2TimValueCallback sendMsgRes, String convID, String id, ConvType convType, + GroupReceiptAllowType? groupType, ValueChanged? setInputField) { + List currentHistoryMsgList = _messageListMap[convID] ?? []; + final V2TimMessage sendMsgResData = sendMsgRes.data as V2TimMessage; + final findIdIndex = currentHistoryMsgList.indexWhere((element) => element.id == id); + final targetIndex = findIdIndex == -1 + ? currentHistoryMsgList.indexWhere((element) => element.msgID == sendMsgResData.msgID) + : findIdIndex; + if (targetIndex != -1) { + currentHistoryMsgList[targetIndex] = sendMsgResData; + } else { + currentHistoryMsgList = [sendMsgResData, ...currentHistoryMsgList]; + } + if (loadingMessage[convID] != null && loadingMessage[convID]!.isNotEmpty) { + loadingMessage[convID]!.removeWhere((element) => element.id == id); + } + if (chatConfig.isShowReadingStatus && sendMsgRes.data?.msgID != null) { + _messageReadReceiptMap[sendMsgRes.data!.msgID!] = V2TimMessageReceipt(timestamp: 0, userID: "", readCount: 0); + } + _messageListMap[convID] = currentHistoryMsgList; + notifyListeners(); + } + + void updateAsyncMessage( + V2TimMessage message, + String convID, + ) { + message.id = DateTime.now().millisecondsSinceEpoch.toString(); + + final activeMessageList = _messageListMap[convID]; + if (activeMessageList == null || activeMessageList.isEmpty) { + return; + } + final msgID = message.msgID; + _messageListMap[convID] = activeMessageList.map((item) { + if (item.msgID == msgID) { + return message; + } + return item; + }).toList(); + if (convID == currentSelectedConv) { + notifyListeners(); + } + } + + List? getMessageList(String conversationID) { + final list = (messageListMap[conversationID]?.reversed.toList() ?? []) + .where((element) => _lifeCycle?.messageShouldMount(element) ?? true) + .toList(); + final finalList = _lifeCycle?.messageListShouldMount(list) ?? list; + final List listWithTimestamp = []; + final interval = chatConfig.timeDividerConfig?.timeInterval ?? 300; + for (var item in finalList) { + { + if (listWithTimestamp.isEmpty || + (listWithTimestamp[listWithTimestamp.length - 1].timestamp != null && + item.timestamp != null && + (item.timestamp! - listWithTimestamp[listWithTimestamp.length - 1].timestamp! > interval))) { + listWithTimestamp.add(V2TimMessage( + userID: '', + isSelf: false, + elemType: 11, + msgID: 'time-divider-${item.timestamp}', + timestamp: item.timestamp, + )); + } + listWithTimestamp.add(item); + } + } + return listWithTimestamp.reversed.toList(); + } + + HistoryMessagePosition getMessageListPosition(String? conversationID) { + final HistoryMessagePosition? position = _historyMessagePositionMap[conversationID]; + if (position == null) { + _historyMessagePositionMap[conversationID ?? currentSelectedConv] = HistoryMessagePosition.bottom; + return HistoryMessagePosition.bottom; + } else { + return position; + } + } + + void setMessageListPosition(String conversationID, HistoryMessagePosition position) { + _historyMessagePositionMap[conversationID] = position; + notifyListeners(); + } +} diff --git a/lib/business_logic/view_models/tui_conversation_view_model.dart b/lib/business_logic/view_models/tui_conversation_view_model.dart new file mode 100644 index 0000000..0e34e70 --- /dev/null +++ b/lib/business_logic/view_models/tui_conversation_view_model.dart @@ -0,0 +1,335 @@ +// ignore_for_file: unnecessary_getters_setters + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimConversationListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/conversation_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; + +List removeDuplicates(List list, bool Function(T first, T second) isEqual) { + List output = []; + for (var i = 0; i < list.length; i++) { + bool found = false; + for (var j = 0; j < output.length; j++) { + if (isEqual(list[i], output[j])) { + found = true; + } + } + if (!found) { + output.add(list[i]); + } + } + + return output; +} + +class TUIConversationViewModel extends ChangeNotifier { + static const String conversationC2CPrefix = "c2c_"; + static const String conversationGroupPrefix = "group_"; + + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + final ConversationService _conversationService = serviceLocator(); + final FriendshipServices _friendshipServices = serviceLocator(); + final TUIChatGlobalModel _chatGlobalModel = serviceLocator(); + final MessageService _messageService = serviceLocator(); + late V2TimConversationListener _conversationListener; + List _conversationList = []; + static V2TimConversation? _selectedConversation; + Map webDraftMap = {}; + + bool _haveMoreData = true; + int _totalUnReadCount = 0; + String? _scrollToConversation; + final TUIChatGlobalModel globalChatModel = serviceLocator(); + + String _nextSeq = "0"; + ConversationLifeCycle? _lifeCycle; + + List get conversationList { + if (PlatformUtils().isWeb) { + try { + _conversationList.sort((a, b) { + return b!.lastMessage!.timestamp!.compareTo(a!.lastMessage!.timestamp!); + }); + + final pinnedConversation = _conversationList.where((element) => element?.isPinned == true).toList(); + _conversationList.removeWhere((element) => element?.isPinned == true); + _conversationList = [...pinnedConversation, ..._conversationList]; + // ignore: empty_catches + } catch (e) {} + } else { + _conversationList.sort((a, b) => b!.orderkey!.compareTo(a!.orderkey!)); + } + return _conversationList; + } + + V2TimConversation? getConversation(String conversationID) { + return _conversationList.firstWhereOrNull((element) => element?.conversationID == conversationID); + } + + String? get scrollToConversation => _scrollToConversation; + + set scrollToConversation(String? value) { + _scrollToConversation = value; + notifyListeners(); + } + + void clearScrollToConversation() { + _scrollToConversation = null; + } + + bool get haveMoreData { + return _haveMoreData; + } + + int get totalUnReadCount => _totalUnReadCount; + + set totalUnReadCount(int value) { + _totalUnReadCount = value; + } + + set lifeCycle(ConversationLifeCycle? value) { + _lifeCycle = value; + } + + set conversationList(List conversationList) { + _conversationList = conversationList; + notifyListeners(); + } + + set selectedConversation(V2TimConversation? value) { + _selectedConversation = value; + notifyListeners(); + } + + V2TimConversation? get selectedConversation { + return _selectedConversation; + } + + static V2TimConversation? of() { + return _selectedConversation; + } + + TUIConversationViewModel() { + _conversationListener = V2TimConversationListener(onConversationChanged: (conversationList) { + _onConversationListChanged(conversationList); + }, onNewConversation: (conversationList) { + _addNewConversation(conversationList); + }, onTotalUnreadMessageCountChanged: (totalUnread) { + _totalUnReadCount = totalUnread; + _chatGlobalModel.totalUnReadCount = totalUnread; + notifyListeners(); + }, onSyncServerFinish: () { + // Remove the process to load such a many of conversations after launching + if (!PlatformUtils().isWeb) { + loadInitConversation(); + } + }, onConversationDeleted: (List conversationIDList) { + _onConversationDeleted(conversationIDList); + for (var conversationID in conversationIDList) { + String resultID = ""; + if (conversationID.startsWith(conversationC2CPrefix)) { + resultID = conversationID.replaceFirst(conversationC2CPrefix, ""); + } else if (conversationID.startsWith(conversationGroupPrefix)) { + resultID = conversationID.replaceFirst(conversationGroupPrefix, ""); + } + + if (resultID != "") { + _chatGlobalModel.removeMessageList(resultID); + } + } + }); + } + + loadInitConversation() async { + await loadData(count: 40); + // Remove the process to load such a many of conversations after launching + // if (selfInfoViewModel.globalConfig?.isPreloadMessagesAfterInit ?? true) { + // _chatGlobalModel.initMessageMapFromLocalDatabase(_conversationList); + // } + } + + initConversation() async { + clearData(); + loadInitConversation(); + } + + Future loadData({required int count}) async { + _haveMoreData = true; + final isRefresh = _nextSeq == "0"; + final conversationResult = await _conversationService.getConversationList(nextSeq: _nextSeq, count: count); + _nextSeq = conversationResult?.nextSeq ?? ""; + final conversationList = conversationResult?.conversationList; + if (conversationList != null) { + if (conversationList.isEmpty || conversationList.length < count) { + _haveMoreData = false; + } + List combinedConversationList = []; + if (isRefresh) { + combinedConversationList = conversationList; + } else { + combinedConversationList = [..._conversationList, ...conversationList]; + } + final List finalConversationList = + await _lifeCycle?.conversationListWillMount(combinedConversationList) ?? combinedConversationList; + _conversationList = removeDuplicates( + finalConversationList, (item1, item2) => item1?.conversationID == item2?.conversationID); + notifyListeners(); + } + _totalUnReadCount = await _conversationService.getTotalUnreadCount(); + notifyListeners(); + return; + } + + void setSelectedConversation(V2TimConversation conversation) { + _selectedConversation = conversation; + notifyListeners(); + } + + Future pinConversation({ + required String conversationID, + required bool isPinned, + }) { + return _conversationService.pinConversation(conversationID: conversationID, isPinned: isPinned); + } + + Future clearHistoryMessage({required String convID, required int convType}) async { + if (_lifeCycle?.shouldClearHistoricalMessageForConversation != null && + await _lifeCycle!.shouldClearHistoricalMessageForConversation(convID) == false) { + return null; + } + + globalChatModel.setMessageList(convID, []); + + if (convType == 1) { + return _messageService.clearC2CHistoryMessage(userID: convID); + } else { + return _messageService.clearGroupHistoryMessage(groupID: convID); + } + } + + searchFriends(String searchKey) async { + final res = await _friendshipServices.searchFriends(searchParam: V2TimFriendSearchParam(keywordList: [searchKey])); + return res; + } + + Future deleteConversation({required String conversationID}) async { + if (_lifeCycle?.shouldDeleteConversation != null && + await _lifeCycle!.shouldDeleteConversation(conversationID) == false) { + return null; + } + final res = await _conversationService.deleteConversation(conversationID: conversationID); + if (res.code == 0) { + _conversationList.removeWhere((element) => element?.conversationID == conversationID); + notifyListeners(); + } + return res; + } + + _onConversationListChanged(List list) { + for (int element = 0; element < list.length; element++) { + int index = _conversationList.indexWhere((item) => item!.conversationID == list[element].conversationID); + if (index > -1) { + _conversationList.setAll(index, [list[element]] as List); + } else { + _conversationList.add(list[element]); + } + } + + notifyListeners(); + } + + _onConversationDeleted(List list) { + for (int i = 0; i < list.length; i++) { + int index = _conversationList.indexWhere((item) => item!.conversationID == list[i]); + if (index > -1) { + _conversationList.removeAt(index); + _conversationList = removeDuplicates( + _conversationList, (item1, item2) => item1?.conversationID == item2?.conversationID); + } + } + notifyListeners(); + } + + _addNewConversation(List list) { + _conversationList.addAll(list); + _conversationList = removeDuplicates( + _conversationList, (item1, item2) => item1?.conversationID == item2?.conversationID); + notifyListeners(); + } + + setConversationListener() { + _conversationService.addConversationListener(listener: _conversationListener); + } + + removeConversationListener() { + _conversationService.removeConversationListener(listener: _conversationListener); + } + + Future setConversationDraft({ + required String conversationID, + String? draftText, + bool isTopic = false, + String? groupID, + bool isAllowWeb = true, + }) async { + assert(!isTopic || (groupID != null && groupID.isNotEmpty), + "When 'isTopic' is true, 'groupID' must not be null or empty."); + if (PlatformUtils().isWeb && isAllowWeb) { + webDraftMap[conversationID] = draftText ?? ""; + return V2TimCallback(code: 0, desc: ""); + } else { + if (isTopic) { + final topicInfoList = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .getTopicInfoList(groupID: groupID!, topicIDList: [conversationID]); + final topicInfo = topicInfoList.data?.first.topicInfo; + topicInfo?.draftText = draftText; + final res = await TencentImSDKPlugin.v2TIMManager.getGroupManager().setTopicInfo(topicInfo: topicInfo!); + return res; + } else { + return _conversationService.setConversationDraft(conversationID: conversationID, draftText: draftText); + } + } + } + + clearWebDraft({ + required String conversationID, + }) { + webDraftMap[conversationID] = ""; + } + + String? getWebDraft({ + required String conversationID, + }) { + return TencentUtils.checkString(webDraftMap[conversationID]); + } + + clearData() { + _conversationList = []; + _selectedConversation = null; + _nextSeq = "0"; + _haveMoreData = true; + notifyListeners(); + } + + refresh({int count = 100}) { + _nextSeq = "0"; + _haveMoreData = true; + loadData(count: count); + } +} diff --git a/lib/business_logic/view_models/tui_friendship_view_model.dart b/lib/business_logic/view_models/tui_friendship_view_model.dart new file mode 100644 index 0000000..0aab526 --- /dev/null +++ b/lib/business_logic/view_models/tui_friendship_view_model.dart @@ -0,0 +1,266 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_response_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_application.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/block_list_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/friend_list_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/new_contact_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +class TUIFriendShipViewModel extends ChangeNotifier { + final FriendshipServices _friendshipServices = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + late V2TimFriendshipListener friendShipListener; + List? _friendApplicationList; + List? _friendList; + List? _groupList; + List? _userStatusList; + int _friendApplicationAmount = 0; + List? _blockList; + NewContactLifeCycle? _newContactLifeCycle; + FriendListLifeCycle? _contactListLifeCycle; + BlockListLifeCycle? _blockListLifeCycle; + + set newContactLifeCycle(NewContactLifeCycle? value) { + _newContactLifeCycle = value; + } + + set contactListLifeCycle(FriendListLifeCycle? value) { + _contactListLifeCycle = value; + } + + set blockListLifeCycle(BlockListLifeCycle? value) { + _blockListLifeCycle = value; + } + + set userStatusList(List value) { + _userStatusList = value; + notifyListeners(); + } + + List get userStatusList => _userStatusList ?? []; + + List get blockList { + return _blockList ?? []; + } + + List get groupList { + return _groupList ?? []; + } + + List? get friendList { + return _friendList; + } + + int get friendApplicationAmount => _friendApplicationAmount; + + List? get friendApplicationList => _friendApplicationList; + + TUIFriendShipViewModel() { + friendShipListener = V2TimFriendshipListener( + onFriendApplicationListAdded: (applicationList) { + loadContactApplicationData(); + }, + onFriendApplicationListDeleted: (userIDList) { + loadContactApplicationData(); + }, + onFriendApplicationListRead: () { + loadContactApplicationData(); + }, + onFriendInfoChanged: (infoList) { + loadContactListData(); + }, + onFriendListAdded: (users) async { + await loadContactListData(); + loadUserStatus(); + }, + onFriendListDeleted: (userList) async { + await loadContactListData(); + loadUserStatus(); + }, + onBlackListAdd: (infoList) async { + await loadBlockListData(); + loadUserStatus(); + }, + onBlackListDeleted: (userList) async { + await loadBlockListData(); + loadUserStatus(); + }, + ); + } + + initFriendShipModel() { + loadData(); + } + + loadData() async { + loadContactApplicationData(); + loadBlockListData(); + await loadContactListData(); + loadUserStatus(); + } + + clearData() { + _friendApplicationList = []; + _friendApplicationAmount = 0; + _friendList = []; + _userStatusList = []; + _blockList = []; + notifyListeners(); + } + + loadUserStatus() async { + if (selfInfoViewModel.globalConfig?.isShowOnlineStatus == false || friendList == null || friendList!.isEmpty) { + return; + } + + final List> userIDSet = []; + final int needHowManyRequest = ((friendList!.length) / 500).ceil(); + final int amountEachRequest = ((friendList!.length) / needHowManyRequest).ceil(); + + for (int i = 0; i < needHowManyRequest; i++) { + userIDSet.add(friendList! + .getRange(i * amountEachRequest, min(friendList!.length, (i + 1) * amountEachRequest)) + .map((e) => e.userID) + .toList()); + } + + final List> userStatus = await Future.wait([ + ...userIDSet.map((userIDList) async { + return await _friendshipServices.getUserStatus(userIDList: userIDList); + }) + ]); + + final List flatUserStatus = []; + for (var e in userStatus) { + flatUserStatus.addAll(e); + } + userStatusList = flatUserStatus; + } + + loadContactApplicationData() async { + final newContactRes = await _friendshipServices.getFriendApplicationList(); + // Only Received Application + _friendApplicationList = newContactRes?.friendApplicationList + ?.where((item) => item!.type == FriendApplicationTypeEnum.V2TIM_FRIEND_APPLICATION_COME_IN.index) + .toList(); + _friendApplicationAmount = _friendApplicationList?.length ?? 0; + notifyListeners(); + } + + Future loadContactListData() async { + final List res = await _friendshipServices.getFriendList() ?? []; + final memberList = await _contactListLifeCycle?.friendListWillMount(res) ?? res; + _friendList = memberList; + notifyListeners(); + return; + } + + Future isFriend(String userID) async { + final List res = await _friendshipServices.getFriendList() ?? []; + for (V2TimFriendInfo info in res) { + if (info.userID == userID) { + return true; + } + } + + return false; + } + + Future loadBlockListData() async { + final blockListRes = await _friendshipServices.getBlackList(); + _blockList = blockListRes ?? []; + notifyListeners(); + return; + } + + loadGroupListData() async { + final groupListRes = await _groupServices.getJoinedGroupList(); + _groupList = groupListRes ?? []; + if (_groupList != null && _groupList!.isNotEmpty) { + notifyListeners(); + } + return; + } + + Future?> deleteFromBlockList(List userIDList) async { + if (_blockListLifeCycle?.shouldDeleteFromBlockList != null && + await _blockListLifeCycle!.shouldDeleteFromBlockList(userIDList) == false) { + return null; + } + final res = await _friendshipServices.deleteFromBlackList(userIDList: userIDList); + if (res != null) { + return res; + } + return null; + } + + Future acceptFriendApplication( + String userID, + int type, + ) async { + if (_newContactLifeCycle?.shouldAcceptContactApplication != null && + await _newContactLifeCycle!.shouldAcceptContactApplication(userID) == false) { + return null; + } + final res = await _friendshipServices.acceptFriendApplication( + responseType: FriendResponseTypeEnum.V2TIM_FRIEND_ACCEPT_AGREE_AND_ADD, + type: FriendApplicationTypeEnum.values[type], + userID: userID, + ); + if (res != null) { + return res; + } + return null; + } + + Future refuseFriendApplication( + String userID, + int type, + ) async { + if (_newContactLifeCycle?.shouldRefuseContactApplication != null && + await _newContactLifeCycle!.shouldRefuseContactApplication(userID) == false) { + return null; + } + final res = await _friendshipServices.refuseFriendApplication( + type: FriendApplicationTypeEnum.values[type], + userID: userID, + ); + if (res != null) { + return res; + } + return null; + } + + Future> getGroupMembersInfo( + {required String groupID, required List memberList}) async { + final res = await _groupServices.getGroupMembersInfo(groupID: groupID, memberList: memberList); + return res.data ?? []; + } + + addFriendListener({V2TimFriendshipListener? listener}) { + _friendshipServices.addFriendListener(listener: friendShipListener); + } + + removeFriendshipListener({V2TimFriendshipListener? listener}) { + _friendshipServices.removeFriendListener(listener: friendShipListener); + } +} diff --git a/lib/business_logic/view_models/tui_search_view_model.dart b/lib/business_logic/view_models/tui_search_view_model.dart new file mode 100644 index 0000000..0d18d53 --- /dev/null +++ b/lib/business_logic/view_models/tui_search_view_model.dart @@ -0,0 +1,149 @@ +// ignore_for_file: constant_identifier_names + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_result_item.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_result_item.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; + +enum KeywordListMatchType { V2TIM_KEYWORD_LIST_MATCH_TYPE_OR, V2TIM_KEYWORD_LIST_MATCH_TYPE_AND } + +class TUISearchViewModel extends ChangeNotifier { + final FriendshipServices _friendshipServices = serviceLocator(); + final MessageService _messageService = serviceLocator(); + final ConversationService _conversationService = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + + List? friendList = []; + + List? msgList = []; + int msgPage = 0; + int totalMsgCount = 0; + + int totalMsgInConversationCount = 0; + List currentMsgListForConversation = []; + + List? groupList = []; + + List conversationList = []; + + Future?> initConversationMsg() async { + final conversationResult = await _conversationService.getConversationList(nextSeq: "0", count: 500); + final conversationListData = conversationResult?.conversationList; + conversationList = conversationListData ?? []; + notifyListeners(); + return conversationListData; + } + + void initSearch() { + friendList = []; + msgList = []; + groupList = []; + totalMsgCount = 0; + notifyListeners(); + } + + void searchFriendByKey(String searchKey) async { + final searchResult = + await _friendshipServices.searchFriends(searchParam: V2TimFriendSearchParam(keywordList: [searchKey])); + friendList = searchResult; + notifyListeners(); + } + + void searchGroupByKey(String searchKey) async { + final searchResult = + await _groupServices.searchGroups(searchParam: V2TimGroupSearchParam(keywordList: [searchKey])); + groupList = searchResult.data ?? []; + notifyListeners(); + } + + void getMsgForConversation(String keyword, String conversationId, int page) async { + void clearData() { + currentMsgListForConversation = []; + totalMsgInConversationCount = 0; + } + + if (page == 0) { + clearData(); + } + if (keyword.isEmpty) { + clearData(); + return; + } + final searchResult = await _messageService.searchLocalMessages( + searchParam: V2TimMessageSearchParam( + keywordList: [keyword], + pageIndex: page, + pageSize: 30, + searchTimePeriod: 0, + searchTimePosition: 0, + conversationID: conversationId, + type: KeywordListMatchType.V2TIM_KEYWORD_LIST_MATCH_TYPE_OR.index, + )); + if (searchResult.code == 0 && searchResult.data != null) { + final messageSearchResultItems = searchResult.data!.messageSearchResultItems! + .firstWhereOrNull((element) => element.conversationID == conversationId); + totalMsgInConversationCount = messageSearchResultItems?.messageCount ?? 0; + currentMsgListForConversation = [ + ...currentMsgListForConversation, + ...(messageSearchResultItems?.messageList ?? []) + ]; + } + notifyListeners(); + } + + void searchMsgByKey(String searchKey, bool isFirst) async { + if (isFirst == true) { + msgPage = 0; + msgList = []; + totalMsgCount = 0; + } + final searchResult = await _messageService.searchLocalMessages( + searchParam: V2TimMessageSearchParam( + keywordList: [searchKey], + pageIndex: msgPage, + pageSize: 5, + searchTimePeriod: 0, + searchTimePosition: 0, + type: KeywordListMatchType.V2TIM_KEYWORD_LIST_MATCH_TYPE_OR.index, + )); + if (searchResult.code == 0 && searchResult.data != null) { + msgPage++; + msgList = [...?msgList, ...?searchResult.data!.messageSearchResultItems]; + totalMsgCount = searchResult.data!.totalCount ?? 0; + } + notifyListeners(); + } + + void searchByKey(String? searchKey) async { + if (searchKey == null || searchKey.isEmpty) { + friendList = []; + groupList = []; + msgList = []; + totalMsgCount = 0; + notifyListeners(); + } else { + searchFriendByKey(searchKey); + searchMsgByKey(searchKey, true); + searchGroupByKey(searchKey); + } + } +} diff --git a/lib/business_logic/view_models/tui_self_info_view_model.dart b/lib/business_logic/view_models/tui_self_info_view_model.dart new file mode 100644 index 0000000..adb435a --- /dev/null +++ b/lib/business_logic/view_models/tui_self_info_view_model.dart @@ -0,0 +1,25 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_config.dart'; + +class TUISelfInfoViewModel extends ChangeNotifier { + V2TimUserFullInfo? _loginInfo; + TIMUIKitConfig? _globalConfig; + + TIMUIKitConfig? get globalConfig => _globalConfig; + + set globalConfig(TIMUIKitConfig? value) { + _globalConfig = value; + notifyListeners(); + } + + V2TimUserFullInfo? get loginInfo { + return _loginInfo; + } + + setLoginInfo(V2TimUserFullInfo? value) { + _loginInfo = value; + notifyListeners(); + } +} diff --git a/lib/business_logic/view_models/tui_setting_model.dart b/lib/business_logic/view_models/tui_setting_model.dart new file mode 100644 index 0000000..a893aed --- /dev/null +++ b/lib/business_logic/view_models/tui_setting_model.dart @@ -0,0 +1,26 @@ +import 'package:flutter/cupertino.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TUISettingModel extends ChangeNotifier { + final Future _prefs = SharedPreferences.getInstance(); + final String keyTitle = "tencent_chat_uikit_"; + + double? _keyboardHeight; + double get keyboardHeight => _keyboardHeight ?? 0; + set keyboardHeight(double value) { + if(value > 40 && _keyboardHeight != value){ + _keyboardHeight = value; + updateLocalSetting("${keyTitle}keyboardHeight", value.toString()); + } + } + + updateLocalSetting(String key, String value) async { + SharedPreferences prefs = await _prefs; + prefs.setString(key, value); + } + + init() async { + SharedPreferences prefs = await _prefs; + _keyboardHeight = double.parse(prefs.getString("${keyTitle}keyboardHeight") ?? "0"); + } +} \ No newline at end of file diff --git a/lib/data_services/conversation/conversation_services.dart b/lib/data_services/conversation/conversation_services.dart new file mode 100644 index 0000000..5ff171e --- /dev/null +++ b/lib/data_services/conversation/conversation_services.dart @@ -0,0 +1,39 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimConversationListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation_result.dart'; + +abstract class ConversationService { + Future getConversationList({ + required String nextSeq, + required int count, + }); + + Future getConversation({ + required String conversationID, + }); + + Future pinConversation({ + required String conversationID, + required bool isPinned, + }); + + Future deleteConversation({ + required String conversationID, + }); + + Future addConversationListener({ + required V2TimConversationListener listener, + }); + + Future setConversationDraft({required String conversationID, String? draftText}); + + Future removeConversationListener({V2TimConversationListener? listener}); + + Future getTotalUnreadCount(); + + Future getConversationListByConversationId({required String convID}); +} diff --git a/lib/data_services/conversation/conversation_services_implements.dart b/lib/data_services/conversation/conversation_services_implements.dart new file mode 100644 index 0000000..f7213ab --- /dev/null +++ b/lib/data_services/conversation/conversation_services_implements.dart @@ -0,0 +1,123 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimConversationListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation_result.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +class ConversationServicesImpl extends ConversationService { + final CoreServicesImpl _coreService = serviceLocator(); + + @override + Future getConversationList({ + required String nextSeq, + required int count, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .getConversationList(nextSeq: nextSeq, count: count); + if (result.code == 0) { + return result.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + return null; + } + } + + @override + Future pinConversation({ + required String conversationID, + required bool isPinned, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .pinConversation(conversationID: conversationID, isPinned: isPinned); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future deleteConversation({ + required String conversationID, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .deleteConversation(conversationID: conversationID); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future addConversationListener({ + required V2TimConversationListener listener, + }) { + return TencentImSDKPlugin.v2TIMManager.getConversationManager().addConversationListener(listener: listener); + } + + @override + Future getConversation({ + required String conversationID, + }) async { + final res = + await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversation(conversationID: conversationID); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return null; + } + + @override + Future setConversationDraft({required String conversationID, String? draftText}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .setConversationDraft(conversationID: conversationID, draftText: draftText); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future removeConversationListener({V2TimConversationListener? listener}) { + return TencentImSDKPlugin.v2TIMManager.getConversationManager().removeConversationListener(listener: listener); + } + + @override + Future getConversationListByConversationId({required String convID}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .getConversationListByConversationIds(conversationIDList: [convID]); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return (result.data != null && result.data!.isNotEmpty) ? result.data![0] : null; + } + + @override + Future getTotalUnreadCount() async { + final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getTotalUnreadMessageCount(); + if (res.code == 0) { + return res.data ?? 0; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return 0; + } +} diff --git a/lib/data_services/core/core_services.dart b/lib/data_services/core/core_services.dart new file mode 100644 index 0000000..bff40e4 --- /dev/null +++ b/lib/data_services/core/core_services.dart @@ -0,0 +1,106 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimSDKListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/log_level_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_config.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +enum AppStatus { foreground, background } + +enum LanguageEnum { + /// Chinese, Traditional + zhHant, + + /// Chinese, Simplified + zhHans, + + /// English + en, + + /// Korean + ko, + + /// Japanese + ja, +} + +const languageEnumToString = { + LanguageEnum.zhHant: "zh-Hant", + LanguageEnum.zhHans: "zh-Hans", + LanguageEnum.en: "en", + LanguageEnum.ja: "ja", + LanguageEnum.ko: "ko", +}; + +abstract class CoreServices { + Future init({ + required int sdkAppID, + required LogLevelEnum loglevel, + required V2TimSDKListener listener, + + /// Callback from TUIKit invoke, includes IM SDK API error, notify information, Flutter error. + ValueChanged? onTUIKitCallbackListener, + TIMUIKitConfig? config, + + /// only support "en" and "zh" temporally + LanguageEnum? language, + }); + + Future setDataFromNative({ + required String userId, + + /// Callback from TUIKit invoke, includes IM SDK API error, notify information, Flutter error. + ValueChanged? onTUIKitCallbackListener, + TIMUIKitConfig? config, + + /// only support "en" and "zh" temporally + LanguageEnum? language, + }); + + Future login({ + required String userID, + required String userSig, + }); + + Future logout(); + + Future logoutWithoutClearData(); + + Future unInit(); + + Future>> getUsersInfo({ + required List userIDList, + }); + + // 注意:uikit的离线推送不支持TPNS + // Note: uikit's offline push do not supports TPNS + Future setOfflinePushConfig({ + bool isTPNSToken = false, + int businessID, + required String token, + }); + + Future setSelfInfo({ + required V2TimUserFullInfo userFullInfo, + }); + + Future setOfflinePushStatus({ + required AppStatus status, + int? totalCount, + }); + + setTheme({required TUITheme theme}); + + setDarkTheme(); + + setLightTheme(); + + setDeviceType(DeviceType deviceType); +} diff --git a/lib/data_services/core/core_services_implements.dart b/lib/data_services/core/core_services_implements.dart new file mode 100644 index 0000000..a8f9288 --- /dev/null +++ b/lib/data_services/core/core_services_implements.dart @@ -0,0 +1,420 @@ +// ignore_for_file: avoid_print + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:tencent_chat_i18n_tool/tools/i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimSDKListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/log_level_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_setting_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_config.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/web_support/uikit_web_support.dart' + if (dart.library.html) 'package:tencent_cloud_chat_uikit/data_services/core/web_support/uikit_web_support_implement.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef EmptyAvatarBuilder = Widget Function(BuildContext context); + +class LoginInfo { + final String userID; + final String userSig; + final int sdkAppID; + final V2TimUserFullInfo? loginUser; + + LoginInfo({this.sdkAppID = 0, this.userSig = "", this.userID = "", this.loginUser}); +} + +class CoreServicesImpl implements CoreServices { + V2TimUserFullInfo? _loginInfo; + late int _sdkAppID; + late String _userID; + late String _userSig; + ValueChanged? onCallback; + VoidCallback? webLoginSuccess; + bool isLoginSuccess = false; + + V2TimUserFullInfo? get loginUserInfo { + return _loginInfo; + } + + LoginInfo get loginInfo { + return LoginInfo(sdkAppID: _sdkAppID, userID: _userID, userSig: _userSig, loginUser: _loginInfo); + } + + EmptyAvatarBuilder? _emptyAvatarBuilder; + + EmptyAvatarBuilder? get emptyAvatarBuilder { + return _emptyAvatarBuilder; + } + + setEmptyAvatarBuilder(EmptyAvatarBuilder builder) { + _emptyAvatarBuilder = builder; + } + + setGlobalConfig(TIMUIKitConfig? config) { + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + final TUISettingModel settingModel = serviceLocator(); + selfInfoViewModel.globalConfig = config; + settingModel.init(); + } + + addIdentifier() { + TUIKitWebSupport.addSetterToWindow(); + TUIKitWebSupport.addIdentifierToWindow(); + } + + @override + Future init( + { + /// Callback from TUIKit invoke, includes IM SDK API error, notify information, Flutter error. + ValueChanged? onTUIKitCallbackListener, + required int sdkAppID, + required LogLevelEnum loglevel, + required V2TimSDKListener listener, + LanguageEnum? language, + String? extraLanguage, + TIMUIKitConfig? config, + + /// Specify the current device platform, mobile or desktop, based on your needs. + /// TUIKit will automatically determine the platform if no specification is provided. DeviceType? platform, + DeviceType? platform, + String? uikitLogPath, + VoidCallback? onWebLoginSuccess}) async { + if (platform != null) { + TUIKitScreenUtils.deviceType = platform; + } + addIdentifier(); + if (extraLanguage != null) { + Future.delayed(const Duration(milliseconds: 1), () { + I18nUtils(null, extraLanguage); + }); + } else if (language != null) { + Future.delayed(const Duration(milliseconds: 1), () { + I18nUtils(null, languageEnumToString[language]); + }); + } + if (onTUIKitCallbackListener != null) { + onCallback = onTUIKitCallbackListener; + } + setGlobalConfig(config); + _sdkAppID = sdkAppID; + webLoginSuccess = onWebLoginSuccess; + final result = await TencentImSDKPlugin.v2TIMManager.initSDK( + sdkAppID: sdkAppID, + loglevel: loglevel, + listener: V2TimSDKListener( + onConnectFailed: listener.onConnectFailed, + onConnectSuccess: () { + if (PlatformUtils().isWeb) { + didLoginSuccess(); + if (onWebLoginSuccess != null) { + onWebLoginSuccess(); + } + } + listener.onConnectSuccess(); + }, + onConnecting: listener.onConnecting, + onKickedOffline: listener.onKickedOffline, + onUserStatusChanged: (List userStatusList) { + updateUserStatusList(userStatusList); + listener.onUserStatusChanged(userStatusList); + }, + onSelfInfoUpdated: (V2TimUserFullInfo info) { + listener.onSelfInfoUpdated(info); + serviceLocator().setLoginInfo(info); + _loginInfo = info; + }, + onUserSigExpired: listener.onUserSigExpired)); + if (result.code == 0) { + return true; + } else { + return false; + } + } + + /// This method is used for init the TUIKit after you initialized the IM SDK from Native SDK. + @override + Future setDataFromNative({ + /// Callback from TUIKit invoke, includes IM SDK API error, notify information, Flutter error. + ValueChanged? onTUIKitCallbackListener, + LanguageEnum? language, + TIMUIKitConfig? config, + String? extraLanguage, + required String userId, + }) async { + _userID = userId; + if (extraLanguage != null) { + Future.delayed(const Duration(milliseconds: 1), () { + I18nUtils(null, extraLanguage); + }); + } else if (language != null) { + Future.delayed(const Duration(milliseconds: 1), () { + I18nUtils(null, languageEnumToString[language]); + }); + } + if (onTUIKitCallbackListener != null) { + onCallback = onTUIKitCallbackListener; + } + setGlobalConfig(config); + if (!PlatformUtils().isWeb) { + didLoginSuccess(); + } + } + + void addInitListener() { + final TUIFriendShipViewModel tuiFriendShipViewModel = serviceLocator(); + final TUIConversationViewModel tuiConversationViewModel = serviceLocator(); + final TUIChatGlobalModel tuiChatViewModel = serviceLocator(); + final TUIGroupListenerModel tuiGroupListenerModel = serviceLocator(); + + tuiFriendShipViewModel.addFriendListener(); + tuiConversationViewModel.setConversationListener(); + tuiChatViewModel.addAdvancedMsgListener(); + tuiGroupListenerModel.setGroupListener(); + } + + void removeListener() { + final TUIFriendShipViewModel tuiFriendShipViewModel = serviceLocator(); + final TUIConversationViewModel tuiConversationViewModel = serviceLocator(); + final TUIChatGlobalModel tuiChatViewModel = serviceLocator(); + final TUIGroupListenerModel tuiGroupListenerModel = serviceLocator(); + + tuiFriendShipViewModel.removeFriendshipListener(); + tuiConversationViewModel.removeConversationListener(); + tuiChatViewModel.removeAdvanceMsgListener(); + tuiGroupListenerModel.removeGroupListener(); + } + + callOnCallback(TIMCallback callbackValue) { + if (onCallback != null) { + Future.delayed(const Duration(milliseconds: 500), () { + onCallback!(callbackValue); + }); + } else { + outputLogger.i("TUIKit Callback: ${callbackValue.type} - ${callbackValue.stackTrace}"); + } + } + + initDataModel() { + final TUIFriendShipViewModel tuiFriendShipViewModel = serviceLocator(); + final TUIConversationViewModel tuiConversationViewModel = serviceLocator(); + + tuiFriendShipViewModel.initFriendShipModel(); + tuiConversationViewModel.initConversation(); + } + + clearData() { + final TUIFriendShipViewModel tuiFriendShipViewModel = serviceLocator(); + final TUIConversationViewModel tuiConversationViewModel = serviceLocator(); + final TUIChatGlobalModel tuiChatViewModel = serviceLocator(); + + tuiFriendShipViewModel.clearData(); + tuiConversationViewModel.clearData(); + tuiChatViewModel.clearData(); + } + + updateUserStatusList(List newUserStatusList) { + try { + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + if (selfInfoViewModel.globalConfig?.isShowOnlineStatus == false) { + return; + } + + final TUIFriendShipViewModel tuiFriendShipViewModel = serviceLocator(); + final currentUserStatusList = tuiFriendShipViewModel.userStatusList; + + for (int i = 0; i < newUserStatusList.length; i++) { + final int indexInCurrentUserList = + currentUserStatusList.indexWhere((element) => element.userID == newUserStatusList[i].userID); + if (indexInCurrentUserList == -1) { + currentUserStatusList.add(newUserStatusList[i]); + } else { + currentUserStatusList[indexInCurrentUserList] = newUserStatusList[i]; + } + } + + tuiFriendShipViewModel.userStatusList = currentUserStatusList; + // ignore: empty_catches + } catch (e) {} + } + + @override + Future login({ + required String userID, + required String userSig, + }) async { + _userID = userID; + _userSig = userSig; + V2TimCallback result = await TencentImSDKPlugin.v2TIMManager.login(userID: userID, userSig: userSig); + if (!PlatformUtils().isWeb) { + didLoginSuccess(); + } + if (result.code != 0) { + callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorCode: result.code, errorMsg: result.desc)); + } + return result; + } + + void didLoginSuccess() async { + if (isLoginSuccess == true) { + return; + } + isLoginSuccess = true; + addInitListener(); + initDataModel(); + + if (TencentUtils.checkString(_userID) == null) { + V2TimValueCallback getLoginUserRes = await TencentImSDKPlugin.v2TIMManager.getLoginUser(); + if (getLoginUserRes.code == 0) { + _userID = getLoginUserRes.data ?? ""; + } + } + + getUsersInfoWithRetry(); + } + + void getUsersInfoWithRetry() async { + V2TimValueCallback>? res; + bool success = false; + + while (!success) { + res = await getUsersInfo(userIDList: [_userID]); + if (res.code == 0 && + res.data != null && + res.data!.isNotEmpty && + res.data!.firstWhereOrNull((element) => element.userID == _userID) != null) { + success = true; + } else { + await Future.delayed(const Duration(seconds: 2)); + } + } + + _loginInfo = res?.data!.firstWhereOrNull((element) => element.userID == _userID); + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + if (_loginInfo != null) { + selfInfoViewModel.setLoginInfo(_loginInfo); + } + } + + // Deprecated + void didLoginOut() { + removeListener(); + clearData(); + _loginInfo = null; + serviceLocator().setLoginInfo(_loginInfo); + } + + @override + Future logout() async { + final result = await TencentImSDKPlugin.v2TIMManager.logout(); + isLoginSuccess = false; + removeListener(); + clearData(); + serviceLocator().setLoginInfo(null); + return result; + } + + @override + Future logoutWithoutClearData() async { + final result = await TencentImSDKPlugin.v2TIMManager.logout(); + isLoginSuccess = false; + removeListener(); + serviceLocator().setLoginInfo(null); + return result; + } + + @override + Future unInit() async { + final result = await TencentImSDKPlugin.v2TIMManager.unInitSDK(); + return result; + } + + @override + Future>> getUsersInfo({ + required List userIDList, + }) { + return TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: userIDList); + } + + @override + Future setOfflinePushConfig({ + required String token, + bool isTPNSToken = false, + int? businessID, + }) { + return TencentImSDKPlugin.v2TIMManager.getOfflinePushManager().setOfflinePushConfig( + businessID: businessID?.toDouble() ?? 0, + token: token, + isTPNSToken: isTPNSToken, + ); + } + + @override + Future setSelfInfo({ + required V2TimUserFullInfo userFullInfo, + }) { + return TencentImSDKPlugin.v2TIMManager.setSelfInfo(userFullInfo: userFullInfo); + } + + @override + setTheme({required TUITheme theme}) { + // 合并传入Theme和默认Theme + final TUIThemeViewModel _theme = serviceLocator(); + Map jsonMap = Map.from(CommonColor.defaultTheme.toJson()); + Map jsonInputThemeMap = Map.from(theme.toJson()); + + jsonInputThemeMap.forEach((key, value) { + if (value != null) { + jsonMap.update(key, (v) => value); + } + }); + _theme.theme = TUITheme.fromJson(jsonMap); + } + + @override + setDarkTheme() { + final TUIThemeViewModel _theme = serviceLocator(); + _theme.theme = TUITheme.dark; //Dark + } + + @override + setLightTheme() { + final TUIThemeViewModel _theme = serviceLocator(); + _theme.theme = TUITheme.light; //Light + } + + @override + Future setOfflinePushStatus({required AppStatus status, int? totalCount}) { + if (status == AppStatus.foreground) { + return TencentImSDKPlugin.v2TIMManager.getOfflinePushManager().doForeground(); + } else { + return TencentImSDKPlugin.v2TIMManager.getOfflinePushManager().doBackground(unreadCount: totalCount ?? 0); + } + } + + @override + setDeviceType(DeviceType deviceType) { + TUIKitScreenUtils.deviceType = deviceType; + } +} diff --git a/lib/data_services/core/tim_uikit_config.dart b/lib/data_services/core/tim_uikit_config.dart new file mode 100644 index 0000000..68e7cb5 --- /dev/null +++ b/lib/data_services/core/tim_uikit_config.dart @@ -0,0 +1,55 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitConfig { + /// Control if show online status of friends or contacts. + /// This only works with [Ultimate Edition]. + /// [Default]: true. + final bool isShowOnlineStatus; + + /// Controls if allows to check the disk memory after login. + /// If the storage space is less than 1GB, + /// an callback from `onTUIKitCallbackListener` will be invoked, + /// type is `INFO`, while code is 6661403. + final bool isCheckDiskStorageSpace; + + /// The asset path of the default avatar image. + final String? defaultAvatarAssetPath; + + /// The configuration of border radius for all the avatar shows in TUIKit. + final BorderRadius? defaultAvatarBorderRadius; + + /// You can use this function to customize the Modal that shows on desktop. + /// Do not specified or return `false` will use our default implementation. + final Future Function( + TUIKitWideModalOperationKey operationKey, + BuildContext context, + Widget Function(VoidCallback closeFunc) child, + TUITheme? theme, + double? width, + double? height, + Offset? offset, + String? initText, + BorderRadius? borderRadius, + bool? isDarkBackground, + String? title, + VoidCallback? onSubmit, + Widget? submitWidget, + VoidCallback? onConfirm, + VoidCallback? onCancel, + )? showDesktopModalFunc; + + /// Determines whether TUIKit should preload some messages after initialization for faster message display, + /// with a default value of `true`, and backward-compatibility. + final bool isPreloadMessagesAfterInit; + + const TIMUIKitConfig( { + this.defaultAvatarAssetPath, + this.showDesktopModalFunc, + this.isPreloadMessagesAfterInit = true, + this.defaultAvatarBorderRadius, + this.isCheckDiskStorageSpace = true, + this.isShowOnlineStatus = true, + }); +} diff --git a/lib/data_services/core/tim_uikit_wide_modal_operation_key.dart b/lib/data_services/core/tim_uikit_wide_modal_operation_key.dart new file mode 100644 index 0000000..98627a4 --- /dev/null +++ b/lib/data_services/core/tim_uikit_wide_modal_operation_key.dart @@ -0,0 +1,43 @@ + +enum TUIKitWideModalOperationKey{ + + /// You could use this value for your own Modal usage. + custom, + + // The following values are used in TUIKit + + conversationSecondaryMenu, + chooseCountry, + beforeSendScreenShot, + showUserProfileFromChat, + addNewContact, + showBlockedUsers, + chooseContacts, + addFriend, + addGroup, + chooseGroupType, + settings, + contactUs, + aboutUs, + showConditionsAndTerms, + secondaryClickUserAvatar, + forward, + messageReadDetails, + mergerMessageList, + chooseMentionedMembers, + chatHistory, + groupAddOpt, + setMute, + setUnmute, + setAdmins, + deleteAdmin, + groupMembersList, + addGroupMembers, + kickOffGroupMembers, + confirmDeleteMessages, + confirmClearChatHistory, + confirmExitGroup, + confirmDisbandGroup, + confirmGeneral, + unableToSendDueToFolders +} \ No newline at end of file diff --git a/lib/data_services/core/web_support/uikit_web_support.dart b/lib/data_services/core/web_support/uikit_web_support.dart new file mode 100644 index 0000000..5a35f28 --- /dev/null +++ b/lib/data_services/core/web_support/uikit_web_support.dart @@ -0,0 +1,4 @@ +class TUIKitWebSupport { + static void addSetterToWindow() {} + static void addIdentifierToWindow() {} +} diff --git a/lib/data_services/core/web_support/uikit_web_support_implement.dart b/lib/data_services/core/web_support/uikit_web_support_implement.dart new file mode 100644 index 0000000..d391bb4 --- /dev/null +++ b/lib/data_services/core/web_support/uikit_web_support_implement.dart @@ -0,0 +1,12 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:js'; + +class TUIKitWebSupport { + static void addSetterToWindow() {} + static void addIdentifierToWindow() { + // var object = newObject(); + // setProperty(object, "platform", "flutter_uikit"); + context['tencent_cloud_im_csig_flutter_for_web_25F_cy'] = + "flutter_web_uikit"; + } +} diff --git a/lib/data_services/friendShip/friendship_services.dart b/lib/data_services/friendShip/friendship_services.dart new file mode 100644 index 0000000..0bb182e --- /dev/null +++ b/lib/data_services/friendShip/friendship_services.dart @@ -0,0 +1,94 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_response_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_application_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_check_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_check_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; + +abstract class FriendshipServices { + Future?> getFriendsInfo({ + required List userIDList, + }); + + Future?> getUsersInfo({ + required List userIDList, + }); + + Future?> addToBlackList({ + required List userIDList, + }); + + Future> addFriend({ + required String userID, + required FriendTypeEnum addType, + String? remark, + String? friendGroup, + String? addSource, + String? addWording, + }); + + Future?> deleteFromBlackList({ + required List userIDList, + }); + + Future?> deleteFromFriendList({ + required List userIDList, + required FriendTypeEnum deleteType, + }); + + Future?> getFriendList(); + + Future?> searchFriends({ + required V2TimFriendSearchParam searchParam, + }); + + Future?> getBlackList(); + + Future?> checkFriend({ + required List userIDList, + required FriendTypeEnum checkType, + }); + + Future addFriendListener({ + required V2TimFriendshipListener listener, + }); + Future removeFriendListener({ + V2TimFriendshipListener? listener, + }); + + Future getFriendApplicationList(); + + Future acceptFriendApplication( + {required FriendResponseTypeEnum responseType, required FriendApplicationTypeEnum type, required String userID}); + + Future refuseFriendApplication( + {required FriendApplicationTypeEnum type, required String userID}); + + Future setFriendInfo({ + required String userID, + String? friendRemark, + Map? friendCustomInfo, + }); + + Future> getUserStatus({ + required List userIDList, + }); +} diff --git a/lib/data_services/friendShip/friendship_services_implements.dart b/lib/data_services/friendShip/friendship_services_implements.dart new file mode 100644 index 0000000..2d22bf0 --- /dev/null +++ b/lib/data_services/friendShip/friendship_services_implements.dart @@ -0,0 +1,311 @@ +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_response_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_application_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_check_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_check_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/error_message_converter.dart'; + +class FriendshipServicesImpl implements FriendshipServices { + final CoreServicesImpl _coreService = serviceLocator(); + + @override + Future?> getFriendsInfo({ + required List userIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().getFriendsInfo(userIDList: userIDList); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> getUsersInfo({ + required List userIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: userIDList); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> addToBlackList({ + required List userIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().addToBlackList(userIDList: userIDList); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future> addFriend({ + required String userID, + required FriendTypeEnum addType, + String? remark, + String? friendGroup, + String? addSource, + String? addWording, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().addFriend( + userID: userID, + addType: addType, + remark: remark, + addWording: addWording, + friendGroup: friendGroup, + addSource: addSource, + ); + if (result.code != 0) { + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: result.desc, + errorCode: result.code, + infoRecommendText: TIM_t("好友添加失败"), + )); + } else if (result.code == 0 && result.data?.resultCode != 0) { + String recommendText = ""; + if (result.data != null && result.data!.resultCode != null) { + recommendText = ErrorMessageConverter.getErrorMessage(result.data!.resultCode!); + } + + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: result.code == 0 ? result.data?.resultInfo : result.desc, + errorCode: result.code == 0 ? result.data?.resultCode : result.code, + infoRecommendText: recommendText, + )); + } else { + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: result.desc, + errorCode: result.code, + infoRecommendText: TIM_t("好友添加成功"), + )); + } + + return result; + } + + @override + Future?> deleteFromBlackList({ + required List userIDList, + }) async { + final res = + await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().deleteFromBlackList(userIDList: userIDList); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> deleteFromFriendList({ + required List userIDList, + required FriendTypeEnum deleteType, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getFriendshipManager() + .deleteFromFriendList(userIDList: userIDList, deleteType: deleteType); + if (res.code == 0) { + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: res.desc, + errorCode: res.code, + infoRecommendText: TIM_t("好友删除成功"))); + return res.data; + } else { + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: res.desc, + errorCode: res.code, + infoRecommendText: TIM_t("好友删除失败"))); + return null; + } + } + + @override + Future?> getFriendList() async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().getFriendList(); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> getBlackList() async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().getBlackList(); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> checkFriend({ + required List userIDList, + required FriendTypeEnum checkType, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getFriendshipManager() + .checkFriend(userIDList: userIDList, checkType: checkType); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future addFriendListener({ + required V2TimFriendshipListener listener, + }) { + return TencentImSDKPlugin.v2TIMManager.getFriendshipManager().addFriendListener(listener: listener); + } + + @override + Future removeFriendListener({ + V2TimFriendshipListener? listener, + }) { + return TencentImSDKPlugin.v2TIMManager.getFriendshipManager().removeFriendListener(listener: listener); + } + + @override + Future getFriendApplicationList() async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().getFriendApplicationList(); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future acceptFriendApplication({ + required FriendResponseTypeEnum responseType, + required FriendApplicationTypeEnum type, + required String userID, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().acceptFriendApplication( + responseType: responseType, + type: type, + userID: userID, + ); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future refuseFriendApplication( + {required FriendApplicationTypeEnum type, required String userID}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getFriendshipManager() + .refuseFriendApplication(type: type, userID: userID); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future setFriendInfo({ + required String userID, + String? friendRemark, + Map? friendCustomInfo, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getFriendshipManager() + .setFriendInfo(friendRemark: friendRemark, friendCustomInfo: friendCustomInfo, userID: userID); + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return res; + } + + @override + Future?> searchFriends({ + required V2TimFriendSearchParam searchParam, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getFriendshipManager().searchFriends(searchParam: searchParam); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future> getUserStatus({ + required List userIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getUserStatus(userIDList: userIDList); + if (res.code == 0) { + return res.data ?? []; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return []; + } + } +} diff --git a/lib/data_services/group/group_services.dart b/lib/data_services/group/group_services.dart new file mode 100644 index 0000000..885781f --- /dev/null +++ b/lib/data_services/group/group_services.dart @@ -0,0 +1,121 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimGroupListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; + +abstract class GroupServices { + Future?> getJoinedGroupList(); + + Future?> getGroupsInfo({ + required List groupIDList, + }); + + Future> getGroupMemberList({ + required String groupID, + required GroupMemberFilterTypeEnum filter, + required String nextSeq, + int count = 15, + int offset = 0, + }); + + Future>> getGroupMembersInfo( + {required String groupID, required List memberList}); + + Future setGroupInfo({ + required V2TimGroupInfo info, + }); + + Future setGroupMemberRole({ + required String groupID, + required String userID, + required GroupMemberRoleTypeEnum role, + }); + + getGroupMembersInfoThrottle({required String groupID, required List memberList, Function? callBack}); + + Future muteGroupMember({ + required String groupID, + required String userID, + required int seconds, + }); + + Future setGroupMemberInfo({ + required String groupID, + required String userID, + String? nameCard, + Map? customInfo, + }); + + Future kickGroupMember({ + required String groupID, + required List memberList, + String? reason, + }); + + Future>> inviteUserToGroup({ + required String groupID, + required List userList, + }); + + Future>> searchGroups({ + required V2TimGroupSearchParam searchParam, + }); + + Future> searchGroupMembers({ + required V2TimGroupMemberSearchParam searchParam, + }); + + Future joinGroup({ + required String groupID, + required String message, + }); + + Future addGroupListener({ + required V2TimGroupListener listener, + }); + + Future removeGroupListener({ + V2TimGroupListener? listener, + }); + + Future> getGroupApplicationList(); + + Future acceptGroupApplication({ + required String groupID, + required String fromUser, + required String toUser, + required int addTime, + required int type, + String? reason, + }); + + Future refuseGroupApplication( + {String? reason, + required int addTime, + required String groupID, + required String fromUser, + required String toUser, + required GroupApplicationTypeEnum type}); +} diff --git a/lib/data_services/group/group_services_implement.dart b/lib/data_services/group/group_services_implement.dart new file mode 100644 index 0000000..69d8503 --- /dev/null +++ b/lib/data_services/group/group_services_implement.dart @@ -0,0 +1,321 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimGroupListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/error_message_converter.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; + +class GroupServicesImpl extends GroupServices { + static List groupInfoCallBackList = []; + final CoreServicesImpl _coreService = serviceLocator(); + final throttleGetGroupInfo = OptimizeUtils.throttle((val) async { + String groupID = val["groupID"]; + List memberList = val["memberList"]; + final res = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .getGroupMembersInfo(groupID: groupID, memberList: memberList); + emitGroupCbList(res.data ?? []); + clearGroupCbList(); + }, 1000); + + static emitGroupCbList(List list) { + for (var cb in groupInfoCallBackList) { + cb!(list); + } + } + + static clearGroupCbList() { + groupInfoCallBackList = []; + } + + @override + Future?> getJoinedGroupList() async { + final res = await TencentImSDKPlugin.v2TIMManager.getGroupManager().getJoinedGroupList(); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + Future?> getGroupsInfo({ + required List groupIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getGroupManager().getGroupsInfo(groupIDList: groupIDList); + if (res.code == 0) { + return res.data; + } else { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + } + + @override + void getGroupMembersInfoThrottle( + {required String groupID, required List memberList, Function? callBack}) async { + if (callBack != null) { + groupInfoCallBackList.add(callBack); + throttleGetGroupInfo({"groupID": groupID, "memberList": memberList}); + } + } + + @override + Future>> getGroupMembersInfo( + {required String groupID, required List memberList}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .getGroupMembersInfo(groupID: groupID, memberList: memberList); + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return res; + } + + @override + Future> getGroupMemberList({ + required String groupID, + required GroupMemberFilterTypeEnum filter, + required String nextSeq, + int count = 15, + int offset = 0, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .getGroupMemberList(groupID: groupID, filter: filter, nextSeq: nextSeq, count: count, offset: offset); + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return res; + } + + @override + Future setGroupInfo({ + required V2TimGroupInfo info, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().setGroupInfo(info: info); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future setGroupMemberRole({ + required String groupID, + required String userID, + required GroupMemberRoleTypeEnum role, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .setGroupMemberRole(groupID: groupID, userID: userID, role: role); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future muteGroupMember({ + required String groupID, + required String userID, + required int seconds, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .muteGroupMember(groupID: groupID, userID: userID, seconds: seconds); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future setGroupMemberInfo({ + required String groupID, + required String userID, + String? nameCard, + Map? customInfo, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .setGroupMemberInfo(groupID: groupID, userID: userID, nameCard: nameCard, customInfo: customInfo); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future kickGroupMember({ + required String groupID, + required List memberList, + String? reason, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .kickGroupMember(groupID: groupID, memberList: memberList, reason: reason); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future>> inviteUserToGroup({ + required String groupID, + required List userList, + }) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getGroupManager().inviteUserToGroup(groupID: groupID, userList: userList); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future>> searchGroups({ + required V2TimGroupSearchParam searchParam, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().searchGroups(searchParam: searchParam); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future joinGroup({ + required String groupID, + required String message, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.joinGroup(groupID: groupID, message: message); + if (result.code != 0) { + String recommendText = ErrorMessageConverter.getErrorMessage(result.code); + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: result.desc, + errorCode: result.code, + infoRecommendText: recommendText)); + } + return result; + } + + @override + Future> searchGroupMembers({ + required V2TimGroupMemberSearchParam searchParam, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().searchGroupMembers(param: searchParam); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future addGroupListener({ + required V2TimGroupListener listener, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.addGroupListener(listener: listener); + return result; + } + + @override + Future removeGroupListener({ + V2TimGroupListener? listener, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.removeGroupListener(listener: listener); + return result; + } + + @override + Future> getGroupApplicationList() async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().getGroupApplicationList(); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future acceptGroupApplication( + {required String groupID, + required String fromUser, + required String toUser, + required int type, + required int addTime, + String? reason}) async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().acceptGroupApplication( + groupID: groupID, + fromUser: fromUser, + toUser: toUser, + addTime: addTime, + type: GroupApplicationTypeEnum.values[type], + reason: reason ?? "", + ); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future refuseGroupApplication( + {String? reason, + required int addTime, + required String groupID, + required String fromUser, + required String toUser, + required GroupApplicationTypeEnum type}) async { + final result = await TencentImSDKPlugin.v2TIMManager.getGroupManager().refuseGroupApplication( + groupID: groupID, fromUser: fromUser, toUser: toUser, type: type, addTime: addTime, reason: reason); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } +} diff --git a/lib/data_services/message/message_service_implement.dart b/lib/data_services/message/message_service_implement.dart new file mode 100644 index 0000000..48d226a --- /dev/null +++ b/lib/data_services/message/message_service_implement.dart @@ -0,0 +1,685 @@ +// ignore_for_file: deprecated_member_use + +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimSimpleMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/get_group_message_read_member_list_filter.dart'; +import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/enum/receive_message_opt_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_message_read_member_list.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_message_read_member_list.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_list_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_list_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_online_url.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_online_url.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_receipt.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_msg_create_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/error_message_converter.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; + +class MessageServiceImpl extends MessageService { + final CoreServicesImpl _coreService = serviceLocator(); + final Map> messageListMap = {}; + final Map> sendingMessage = {}; + + @override + Future getHistoryMessageListV2({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq = -1, + required int count, + String? lastMsgID, + List? messageTypeList, + }) async { + bool haveMoreData = true; + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().getHistoryMessageList( + count: count, + getType: getType, + userID: userID, + groupID: groupID, + lastMsgID: lastMsgID, + lastMsgSeq: lastMsgSeq, + messageTypeList: messageTypeList); + final List responseMessageList = res.data ?? []; + final conversationID = userID ?? groupID; + final cachedMessageList = messageListMap[conversationID]; + List combinedMessageList = []; + // 加载更多 + if (lastMsgID != null && cachedMessageList != null) { + combinedMessageList = [...cachedMessageList, ...responseMessageList]; + // 首次加载 + } else { + final bool existSendingMessage = + sendingMessage[conversationID] != null && sendingMessage[conversationID]!.isNotEmpty; + // 存在未发送完成的消息 + if (existSendingMessage) { + combinedMessageList = [...sendingMessage[conversationID]!, ...responseMessageList]; + } else { + sendingMessage.remove(conversationID); + combinedMessageList = responseMessageList; + } + } + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + if (responseMessageList.isEmpty || + (!PlatformUtils().isWeb && responseMessageList.length < count) || + (PlatformUtils().isWeb && responseMessageList.length < min(count, 20))) { + haveMoreData = false; + } else { + haveMoreData = true; + } + return MessageListResponse(haveMoreData: haveMoreData, data: combinedMessageList); + } + + @override + Future> getHistoryMessageList({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq = -1, + required int count, + String? lastMsgID, + List? messageTypeList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().getHistoryMessageList( + count: count, + getType: getType, + userID: userID, + groupID: groupID, + lastMsgID: lastMsgID, + lastMsgSeq: lastMsgSeq, + messageTypeList: messageTypeList); + final reponseMessageList = res.data ?? []; + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return reponseMessageList; + } + + @override + Future getHistoryMessageListWithComplete({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq = 0, + required int count, + String? lastMsgID, + List? messageTypeList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().getHistoryMessageListV2( + count: count, + getType: getType, + userID: userID, + groupID: groupID, + lastMsgID: lastMsgID, + lastMsgSeq: lastMsgSeq, + messageTypeList: messageTypeList); + final responseMessageList = res.data; + if (res.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + } + return responseMessageList; + } + + @override + Future addSimpleMsgListener({ + required V2TimSimpleMsgListener listener, + }) async { + return TencentImSDKPlugin.v2TIMManager.addSimpleMsgListener(listener: listener); + } + + @override + Future removeSimpleMsgListener({V2TimSimpleMsgListener? listener}) { + return TencentImSDKPlugin.v2TIMManager.removeSimpleMsgListener(listener: listener); + } + + @override + Future addAdvancedMsgListener({ + required V2TimAdvancedMsgListener listener, + }) { + return TencentImSDKPlugin.v2TIMManager.getMessageManager().addAdvancedMsgListener(listener: listener); + } + + @override + Future> getGroupMessageReadMemberList({ + required String messageID, + required GetGroupMessageReadMemberListFilter filter, + int nextSeq = 0, + int count = 100, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .getGroupMessageReadMemberList(messageID: messageID, filter: filter, nextSeq: nextSeq, count: count); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future>> getMessageReadReceipts({ + required List messageIDList, + }) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getMessageManager().getMessageReadReceipts(messageIDList: messageIDList); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future sendMessageReadReceipts({ + required List messageIDList, + }) async { + return _retryMarkMessageAsRead(action: () { + return TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessageReadReceipts(messageIDList: messageIDList); + }); + } + + @override + Future createTextMessage({required String text}) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createTextMessage(text: text); + if (res.code == 0) { + final messageResult = res.data; + return messageResult; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createCustomMessage({required String data}) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createCustomMessage(data: data); + if (res.code == 0) { + final messageResult = res.data; + return messageResult; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createFaceMessage({required int index, required String data}) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createFaceMessage(index: index, data: data); + if (res.code == 0) { + final messageResult = res.data; + return messageResult; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future> reSendMessage({required String msgID, bool? onlineUserOnly}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .reSendMessage(msgID: msgID, onlineUserOnly: onlineUserOnly ?? false); + if (res.code != 0) { + String recommendText = ErrorMessageConverter.getErrorMessage(res.code); + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code, infoRecommendText: recommendText)); + } + return res; + } + + @override + Future createTextAtMessage( + {required String text, required List atUserList}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createTextAtMessage(text: text, atUserList: atUserList); + if (res.code == 0) { + final messageResult = res.data; + return messageResult; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createImageMessage( + {String? imageName, String? imagePath, dynamic inputElement}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createImageMessage(imageName: imageName, imagePath: imagePath ?? "", inputElement: inputElement); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createSoundMessage({ + required String soundPath, + required int duration, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createSoundMessage(soundPath: soundPath, duration: duration); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future> sendMessage({ + required String id, // 自己创建的ID + required String receiver, + required String groupID, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool onlineUserOnly = false, + bool isExcludedFromUnreadCount = false, + bool needReadReceipt = false, + OfflinePushInfo? offlinePushInfo, + String? cloudCustomData, + String? localCustomData, + bool isExcludedFromContentModeration = false, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( + id: id, + receiver: receiver, + groupID: groupID, + priority: priority, + onlineUserOnly: onlineUserOnly, + offlinePushInfo: offlinePushInfo, + needReadReceipt: needReadReceipt, + localCustomData: localCustomData, + cloudCustomData: cloudCustomData, + isExcludedFromContentModeration: isExcludedFromContentModeration, + ); + if (result.code != 0) { + String recommendText = ErrorMessageConverter.getErrorMessage(result.code); + _coreService.callOnCallback(TIMCallback( + type: TIMCallbackType.API_ERROR, + errorMsg: result.desc, + errorCode: result.code, + infoRecommendText: recommendText)); + } + return result; + } + + @override + Future deleteMessageFromLocalStorage({ + required String msgID, + Object? webMessageInstance, + }) async { + V2TimCallback result; + if (kIsWeb) { + result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .deleteMessages(msgIDs: [], webMessageInstanceList: [webMessageInstance]); + } else { + result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().deleteMessageFromLocalStorage(msgID: msgID); + } + + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future revokeMessage({required String msgID, Object? webMessageInstance}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .revokeMessage(msgID: msgID, webMessageInstatnce: webMessageInstance); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future clearC2CHistoryMessage({ + required String userID, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().clearC2CHistoryMessage(userID: userID); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future clearGroupHistoryMessage({ + required String groupID, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().clearGroupHistoryMessage(groupID: groupID); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + Future _retryMarkMessageAsRead({ + required Future Function() action, + int retries = 3, + }) async { + V2TimCallback result; + int attempts = 0; + do { + result = await action(); + if (result.code == 0) { + return result; + } + attempts++; + await Future.delayed(const Duration(milliseconds: 500)); + } while (attempts < retries); + + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + + return result; + } + + @override + Future markC2CMessageAsRead({ + required String userID, + }) { + return _retryMarkMessageAsRead(action: () { + return TencentImSDKPlugin.v2TIMManager.getConversationManager().cleanConversationUnreadMessageCount( + conversationID: "${TUIConversationViewModel.conversationC2CPrefix}$userID", + cleanTimestamp: 0, + cleanSequence: 0, + ); + }); + } + + @override + Future markGroupMessageAsRead({ + required String groupID, + }) { + return _retryMarkMessageAsRead(action: () { + return TencentImSDKPlugin.v2TIMManager.getConversationManager().cleanConversationUnreadMessageCount( + conversationID: "${TUIConversationViewModel.conversationGroupPrefix}$groupID", + cleanTimestamp: 0, + cleanSequence: 0, + ); + }); + } + + @override + Future removeAdvancedMsgListener({V2TimAdvancedMsgListener? listener}) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getMessageManager().removeAdvancedMsgListener(listener: listener); + return result; + } + + @override + Future?> downloadMergerMessage({ + required String msgID, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().downloadMergerMessage(msgID: msgID); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createForwardMessage({ + required String msgID, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createForwardMessage(msgID: msgID); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createMergerMessage({ + required List msgIDList, + required String title, + required List abstractList, + required String compatibleText, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createMergerMessage( + msgIDList: msgIDList, title: title, abstractList: abstractList, compatibleText: compatibleText); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future deleteMessages({required List msgIDs, List? webMessageInstanceList}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .deleteMessages(msgIDs: msgIDs, webMessageInstanceList: webMessageInstanceList); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future createVideoMessage( + {String? videoPath, String? type, int? duration, String? snapshotPath, dynamic inputElement}) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createVideoMessage( + videoFilePath: videoPath ?? "", + type: type ?? "", + duration: duration ?? 1, + snapshotPath: snapshotPath ?? "", + inputElement: inputElement); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future> sendReplyMessage({ + required String id, // 自己创建的ID + required String receiver, + required String groupID, + OfflinePushInfo? offlinePushInfo, + bool needReadReceipt = false, + required V2TimMessage replyMessage, // 被回复的消息 + }) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendReplyMessage( + id: id, + receiver: receiver, + offlinePushInfo: offlinePushInfo, + groupID: groupID, + needReadReceipt: needReadReceipt, + replyMessage: replyMessage); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future createFileMessage( + {String? filePath, required String fileName, dynamic inputElement}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createFileMessage(filePath: filePath ?? "", fileName: fileName, inputElement: inputElement); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future createLocationMessage( + {required String desc, required double longitude, required double latitude}) async { + final res = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createLocationMessage(desc: desc, longitude: longitude, latitude: latitude); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future> searchLocalMessages( + {required V2TimMessageSearchParam searchParam}) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getMessageManager().searchLocalMessages(searchParam: searchParam); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future?> findMessages({ + required List messageIDList, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().findMessages(messageIDList: messageIDList); + if (res.code == 0) { + return res.data; + } + _coreService.callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: res.desc, errorCode: res.code)); + return null; + } + + @override + Future setLocalCustomInt({required String msgID, required int localCustomInt}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .setLocalCustomInt(msgID: msgID, localCustomInt: localCustomInt); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future setC2CReceiveMessageOpt({ + required List userIDList, + required ReceiveMsgOptEnum opt, + }) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .setC2CReceiveMessageOpt(userIDList: userIDList, opt: opt); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future setGroupReceiveMessageOpt({ + required String groupID, + required ReceiveMsgOptEnum opt, + }) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getMessageManager().setGroupReceiveMessageOpt(groupID: groupID, opt: opt); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future> modifyMessage({required V2TimMessage message}) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().modifyMessage(message: message); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future setLocalCustomData({required String msgID, required String localCustomData}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .setLocalCustomData(msgID: msgID, localCustomData: localCustomData); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future> getMessageOnlineUrl({required String msgID}) async { + final result = await TencentImSDKPlugin.v2TIMManager.getMessageManager().getMessageOnlineUrl(msgID: msgID); + + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future downloadMessage( + {required String msgID, required int messageType, required int imageType, required bool isSnapshot}) async { + final result = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .downloadMessage(msgID: msgID, messageType: messageType, imageType: imageType, isSnapshot: isSnapshot); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result; + } + + @override + Future translateText(String text, String target) async { + final result = + await TencentImSDKPlugin.v2TIMManager.getMessageManager().translateText(texts: [text], targetLanguage: target); + if (result.code != 0) { + _coreService + .callOnCallback(TIMCallback(type: TIMCallbackType.API_ERROR, errorMsg: result.desc, errorCode: result.code)); + } + return result.data?[text] ?? ""; + } +} diff --git a/lib/data_services/message/message_services.dart b/lib/data_services/message/message_services.dart new file mode 100644 index 0000000..b138fbf --- /dev/null +++ b/lib/data_services/message/message_services.dart @@ -0,0 +1,220 @@ +import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimSimpleMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/get_group_message_read_member_list_filter.dart'; +import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/enum/receive_message_opt_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_message_read_member_list.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_message_read_member_list.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_list_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_list_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_online_url.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_online_url.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_receipt.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_msg_create_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; + +class MessageListResponse { + final bool haveMoreData; + final List data; + + MessageListResponse({required this.haveMoreData, required this.data}); +} + +abstract class MessageService { + Future> getHistoryMessageList({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq, + required int count, + String? lastMsgID, + List? messageTypeList, + }); + + Future getHistoryMessageListWithComplete({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq, + required int count, + String? lastMsgID, + List? messageTypeList, + }); + + Future getHistoryMessageListV2({ + HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq, + required int count, + String? lastMsgID, + List? messageTypeList, + }); + + Future addSimpleMsgListener({ + required V2TimSimpleMsgListener listener, + }); + + Future addAdvancedMsgListener({ + required V2TimAdvancedMsgListener listener, + }); + + Future removeSimpleMsgListener({V2TimSimpleMsgListener? listener}); + + Future createTextMessage({required String text}); + + Future createFaceMessage({required int index, required String data}); + + Future createCustomMessage({required String data}); + + Future createTextAtMessage({required String text, required List atUserList}); + + Future> sendMessage( + {required String id, // 自己创建的ID + required String receiver, + required String groupID, + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + bool onlineUserOnly = false, + bool isExcludedFromUnreadCount = false, + bool needReadReceipt = false, + OfflinePushInfo? offlinePushInfo, + String? cloudCustomData, // 云自定义消息字段,只能在消息发送前添加 + String? localCustomData, + bool isExcludedFromContentModeration}); + + Future> sendReplyMessage({ + required String id, // 自己创建的ID + required String receiver, + required String groupID, + OfflinePushInfo? offlinePushInfo, + bool needReadReceipt = false, + required V2TimMessage replyMessage, // 被回复的消息 + }); + + Future> reSendMessage({required String msgID, bool onlineUserOnly}); + + Future> modifyMessage({required V2TimMessage message}); + + Future createImageMessage({String? imageName, String? imagePath, dynamic inputElement}); + + Future createVideoMessage( + {String? videoPath = "", String? type = "", int? duration = 0, String? snapshotPath = "", dynamic inputElement}); + + Future createFileMessage( + {String? filePath, required String fileName, dynamic inputElement}); + + Future createLocationMessage( + {required String desc, required double longitude, required double latitude}); + + Future createSoundMessage({ + required String soundPath, + required int duration, + }); + + Future createForwardMessage({ + required String msgID, + }); + + Future createMergerMessage({ + required List msgIDList, + required String title, + required List abstractList, + required String compatibleText, + }); + + Future deleteMessageFromLocalStorage({ + required String msgID, + Object? webMessageInstance, + }); + + Future revokeMessage({required String msgID, Object? webMessageInstance}); + + Future clearC2CHistoryMessage({ + required String userID, + }); + + Future clearGroupHistoryMessage({ + required String groupID, + }); + + Future markC2CMessageAsRead({ + required String userID, + }); + + Future markGroupMessageAsRead({ + required String groupID, + }); + + Future removeAdvancedMsgListener({V2TimAdvancedMsgListener? listener}); + + Future?> downloadMergerMessage({ + required String msgID, + }); + + Future deleteMessages({required List msgIDs, List? webMessageInstanceList}); + + Future?> findMessages({ + required List messageIDList, + }); + + Future> searchLocalMessages({ + required V2TimMessageSearchParam searchParam, + }); + + Future setLocalCustomInt({required String msgID, required int localCustomInt}); + + Future setLocalCustomData({required String msgID, required String localCustomData}); + + Future setC2CReceiveMessageOpt({ + required List userIDList, + required ReceiveMsgOptEnum opt, + }); + + Future setGroupReceiveMessageOpt({ + required String groupID, + required ReceiveMsgOptEnum opt, + }); + + Future> getGroupMessageReadMemberList({ + required String messageID, + required GetGroupMessageReadMemberListFilter filter, + int nextSeq = 0, + int count = 100, + }); + + Future>> getMessageReadReceipts({ + required List messageIDList, + }); + + Future sendMessageReadReceipts({ + required List messageIDList, + }); + + Future> getMessageOnlineUrl({ + required String msgID, + }); + + Future downloadMessage({ + required String msgID, + required int messageType, + required int imageType, // 图片类型,仅messageType为图片消息是有效 + required bool isSnapshot, // 是否是视频封面,仅messageType为视频消息是有效 + }); + + Future translateText(String text, String target); +} diff --git a/lib/data_services/services_locatar.dart b/lib/data_services/services_locatar.dart new file mode 100644 index 0000000..cfa9fb9 --- /dev/null +++ b/lib/data_services/services_locatar.dart @@ -0,0 +1,55 @@ +import 'package:get_it/get_it.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_model_tools.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_setting_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services_implement.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_service_implement.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +final serviceLocator = GetIt.instance; +bool boolIsInitailized = false; + +void setupServiceLocator() { + if (!boolIsInitailized) { + // setting + serviceLocator.registerSingleton(TUISettingModel()); + + // services + serviceLocator.registerSingleton(CoreServicesImpl()); + serviceLocator + .registerSingleton(TUISelfInfoViewModel()); + serviceLocator + .registerSingleton(ConversationServicesImpl()); + serviceLocator.registerSingleton(MessageServiceImpl()); + serviceLocator + .registerSingleton(FriendshipServicesImpl()); + serviceLocator.registerSingleton(GroupServicesImpl()); + + // view models + serviceLocator.registerSingleton(TUIChatGlobalModel()); + serviceLocator.registerSingleton(TUIChatModelTools()); + serviceLocator.registerSingleton( + TUIConversationViewModel()); + serviceLocator + .registerSingleton(TUIFriendShipViewModel()); + serviceLocator.registerSingleton(TUIThemeViewModel()); + serviceLocator.registerSingleton(TUISearchViewModel()); + + // listener models + serviceLocator + .registerSingleton(TUIGroupListenerModel()); + boolIsInitailized = true; + } +} diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json new file mode 100644 index 0000000..9ea9555 --- /dev/null +++ b/lib/i18n/strings.i18n.json @@ -0,0 +1 @@ +{"k_1fdhj9g":"This version does not support the message","k_06pujtm":"Accept all friend requests","k_0gyhkp5":"Require approval for friend requests","k_121ruco":"Reject all friend requests","k_05nspni":"Custom field","k_03fchyy":"Group profile photo","k_03i9mfe":"Group introduction","k_03agq58":"Group name","k_039xqny":"Group notification","k_003tr0a":"Group owner","k_002wddw":"Mute","k_0got6f7":"Unmute","k_1uaqed6":"[Custom]","k_0z2z7rx":"[Voice]","k_0y39ngu":"[Emoji]","k_0y1a2my":"[Image]","k_0z4fib8":"[Video]","k_0y24mcg":"[Location]","k_0pewpd1":"[Chat history]","k_13s8d9p":"Unknown message","k_003qkx2":"Calendar","k_003n2pz":"Camera","k_03idjo0":"Contact","k_003ltgm":"Location","k_02k3k86":"Mic","k_003pm7l":"Album","k_15ao57x":"Album write","k_164m3jd":"Local storage","k_03r6qyx":"We need your approval to get information.","k_02noktt":"Reject","k_00043x4":"Agree","k_003qzac":"Yesterday","k_003r39d":"2 days ago","k_03fqp9o":"Sun","k_03ibg5h":"Mon","k_03i7hu1":"Tue","k_03iaiks":"Wed","k_03el9pa":"Thu","k_03i7ok1":"Fri","k_03efxyg":"Sat","k_003q7ba":"Afternoon","k_003q7bb":"Morning","k_003pu3h":"Now","k_002rflt":"Delete","k_1don84v":"Failed to locate the original message","k_003q5fi":"Copy","k_003prq0":"Forward","k_002r1h2":"Multiple-choice","k_003j708":"Reference","k_003pqpr":"Recall","k_03ezhho":"Copied","k_11ctfsz":"Not implemented","k_1hbjg5g":"[Group system message]","k_03tvswb":"[Unknown message]","k_155cj23":"You've recalled a message.","k_0gapun3":"Edit it again","k_0003z7x":"You","k_002wfe4":"Read","k_002wjlg":"Unread","k_003nevv":"Cancel","k_001nmhu":"Open with another app","k_105682d":"Failed to load the image","k_0pytyeu":"Image saved successfully","k_0akceel":"Failed to save the image","k_003rk1s":"Save","k_04a0awq":"[Voice message]","k_105c3y3":"Failed to load the video","k_176rzr7":"Chat history","k_002r305":"Send","k_003n8b0":"Shoot","k_003tnp0":"File","k_0ylosxn":"Custom message","k_0jhdhtp":"Sending failed. The video cannot exceed 100 MB.","k_0am7r68":"Slide up to cancel","k_13dsw4l":"Release to cancel","k_15jl6qw":"Too short","k_0gx7vl6":"Press and hold to talk","k_15dlafd":"One-by-one forward","k_15dryxy":"Combine and forward","k_1eyhieh":"Are you sure you want to delete the selected message?","k_118prbn":"Search globally","k_003kv3v":"Search","k_17fmlyf":"Clear chat","k_0dhesoz":"Unpin from top","k_002sk7x":"Pin to top","k_003ll77":"Draft","k_003kfai":"Unknown","k_13dq4an":"Automatic approval","k_0l13cde":"Admin approval","k_11y8c6a":"Disallow group joining","k_1kvyskd":"Modification failed due to network disconnection","k_16payqf":"Group joining mode","k_0vzvn8r":"Modify group name","k_003rzap":"OK","k_038lh6u":"Group management","k_0k5wyiy":"Set admin","k_0goiuwk":"Mute all","k_1g889xx":"If you mute all, only the group owner and admin can speak.","k_0wlrefq":"Add group members to mute","k_0goox5g":"Mute","k_08daijh":"Admin role canceled successfully","k_0k5u935":"Add admin","k_003ngex":"Complete","k_03enyx5":"Group member","k_03erpei":"Admin","k_0qi9tno":"Group owner and admin","k_0uj7208":"Failed to view the group members due to network disconnection","k_0ef2a12":"Modify my nickname in group","k_1aajych":"2–20 characters, including digits, letters, and underscores","k_137pab5":"My nickname in group","k_0ivim6d":"No group notice","k_03eq6cn":"Group notice","k_002vxya":"Modify","k_03gu05e":"Chat room","k_03b4f3p":"Meeting group","k_03avj1p":"Public group","k_03asq2g":"Work group","k_03b3hbi":"Unknown group","k_03es1ox":"Group type","k_003mz1i":"Agree","k_003lpre":"Reject","k_003qk66":"Profile photo","k_003lhvk":"Nickname","k_003ps50":"Account","k_15lx52z":"Status","k_003qgkp":"Gender","k_003m6hr":"Date of birth","k_0003v6a":"Male","k_00043x2":"Female","k_03bcjkv":"Not set","k_11s0gdz":"Modify nickname","k_0p3j4sd":"Allows only letters, digits, and underscores","k_15lyvdt":"Modify status","k_0vylzjp":"None","k_1hs7ese":"Modify it later","k_03exjk7":"Remarks","k_0s3skfd":"Add to blocklist","k_17fpl3y":"Pin chat to top","k_0p3b31s":"Modify remarks","k_0003y9x":"None","k_11zgnfs":"Profile","k_1tez2xl":"No status","k_0vjj2kp":"Group chat history","k_003n2rp":"Select","k_1m9exwh":"Recent contacts","k_119nwqr":"The input cannot be empty","k_0pzwbmg":"Video saved successfully","k_0aktupv":"Failed to save the video","k_1yemzyd":"Received a message","k_13sajrj":"Emoji message","k_13sjeb7":"File message","k_0yd2ft8":"Group notification","k_13s7mxn":"Image message","k_13satlt":"Location message","k_00bbtsx":"Combined message","k_13sqwu4":"Voice message","k_13sqjjp":"Video message","k_03iqsh4":" $s to ","k_191t5n4":"$opUserNickName changed ","k_1pg6aoj":"$opUserNickName quit group chat","k_1f6zt3v":"Invite $invitedMemberString to the group","k_0y7zd07":"Remove $invitedMemberString from the group","k_1d5mshh":"User $joinedMemberString joined the group","k_0yenqf0":"$userName was","k_0spotql":"Set $adminMember as admin","k_0pg5zzj":"System message: $operationType","k_1c7z88n":"[File] $fileName","k_1c3us5n":"The current group does not support @all","k_11k579v":"Invalid statements detected","k_0qba4ns":" attempted to access your $yoursItem","k_0oozw9x":"$diffMinutes minutes ago","k_13hzn00":"$yesterday, yesterday","k_0n9pyxz":"The user does not exist","k_1bjwemh":"Search by user ID","k_02owlq8":"My user ID: $userID","k_1wu8h4x":"Me: $showName","k_16758qw":"Add friend","k_1shx4d9":"Status: $selfSignature","k_0i553x0":"Enter verification information","k_031ocwx":"Enter remarks and list","k_003ojje":"Remarks","k_003lsav":"List","k_167bdvq":"My friends","k_156b4ut":"Friend request sent","k_1loix7s":"Group type: $groupType","k_1lqbsib":"The group chat does not exist","k_03h153m":"Search by group ID","k_0oxak3r":"Group request sent","k_1uh417q":"$displayName recalled a message","k_1aszp2k":"Are you sure you want to send the message again?","k_0h1ygf8":"Call initiated","k_0h169j0":"Call canceled","k_0h13jjk":"Call accepted","k_0h19hfx":"Call rejected","k_0obi9lh":"No answer","k_0ohzb9l":"Call duration: $callTime","k_0y9u662":"$appName currently does not support this file type. You can use another app to open and preview the file.","k_1ht1b80":"Receiving","k_0d5z4m5":"Select reminder receiver","k_1665ltg":"Initiate call","k_003kthh":"Photo","k_119ucng":"The image cannot be empty","k_0w9x8gw":"Selected successfully: $successPath","k_1np495n":"$messageString[Someone@me]","k_1m797yi":"$messageString[@all]","k_1uaov41":"Search for chat content","k_0bxm97s":"Admin ($adminNum/10)","k_0jayw3z":"Group members ($groupMemberNum members)","k_0h1svv1":"Delete group member","k_0h1g636":"Add group member","k_01yfa4o":"$memberCount members","k_0hpukyx":"View more group members","k_0qtsar0":"Mute notifications","k_03xd79d":"Status: $signature","k_1m9dftc":"All contacts","k_0em4gyz":"All group chats","k_002twmj":"Group chat","k_09kga0d":"More chat history","k_1ui5lzi":"$count messages are found","k_09khmso":"Related chat records","k_1kevf4k":"Chat history with $receiver","k_03ignw6":"All","k_03icaxo":"Custom","k_1969986":"[Voice Call]:$callingLastMsgShow","k_1960dlr":"[Video Call]:$callingLastMsgShow","k_1qbg9xc":"$option8 to ","k_1wq5ubm":"$option7 changed ","k_0y5pu80":"$option6 quit group chat","k_0nl7cmd":"Invite $option5 to the group","k_1ju5iqw":"Remove $option4 from the group","k_1ovt677":"User $option3 joined the group","k_0k05b8b":"$option2 was ","k_0wm4xeb":"System message: $option2","k_0nbq9v3":"Call duration: $option2","k_0i1kf53":"[File] $option2","k_1gnnby6":" attempted to access your $option2","k_1wh4atg":"$option2 minutes ago","k_07sh7g1":"$option2, yesterday","k_1pj8xzh":"My user ID: $option2","k_0py1evo":"Status: $option2","k_1kvj4i2":"$option2 recalled a message","k_1v0lbpp":"$option2 currently does not support this file type. You can use another app to open and preview the file.","k_0torwfz":"Selected successfully: $option2","k_0i1bjah":"$option1 recalled a message","k_1qzxh9q":"Call duration: $option3","k_0wrgmom":"[Voice Call]:$option1","k_06ix2f0":"[Video Call]:$option2","k_08o3z5w":"[File] $option1","k_0ezbepg":"$option2[Someone@me]","k_1ccnht1":"$option2[@all]","k_1k3arsw":"Admin ($option2/10)","k_1d4golg":"Group members ($option1 members)","k_1bg69nt":"$option1 members","k_00gjqxj":"Status: $option1","k_0c29cxr":"$option1 messages are found","k_1twk5rz":"Chat history with $option1","k_18o68ro":"Allow ","k_1onpf8u":" to access your camera to take photos, record videos, and make video calls.","k_17irga5":" to access your microphone to send voice messages, record videos, and make voice/video calls.","k_0572kc4":" to access your photos to send images and videos.","k_0slykws":" to access your album to save images and videos.","k_119pkcd":" to access your files to view, select and send files in a chat.","k_03c49qt":"Authorize now","k_0nt2uyg":"Back to the bottom","k_04l16at":"$option1 new messages","k_13p3w93":"Someone @ me","k_18w5uk6":"@ all","k_0jmujgh":"You are receiving other files","k_12s5ept":"Message details","k_0mxa4f4":"$option1 read","k_061tue3":"$option2 unread","k_1vn4xq1":"remove $adminMember from admin","k_0e35hsw":"Please allow us to use your camera to capture photos and videos sending to your friends and make video calls.","k_0dj6yr7":"Please allow us to use your microphone for sending voice message, make video/audio calls.","k_003qnsl":"Save","k_0s3rtpw":"Please allow us to access the media and files on your devices, in order to select and send to your friend, or save from them.","k_0tezv85":" Would like to access $option2","k_002rety":" permission. ","k_0gqewd3":"Later","k_03eq4s1":"Authorize Now","k_18qjstb":"Transfer Group","k_0on1aj2":"$option2 messages @ me","k_09j4izl":"[Someone @ me] ","k_1oqtjw0":"[@ all] ","k_1x5a9vb":"This is: $option1","k_14n31e7":"Add Group","k_08nc5j1":"Group type: $option1","k_1josu12":"$option1 group joining request(s)","k_0n2x5s0":"Verification message: $option2","k_03c1nx0":"Agreed","k_03aw9w8":"Rejected","k_038ryos":"Handle now","k_0gw8pum":"Add Group","k_1gcvfrj":"Please fill in the remarks","k_002v9zj":"确认","k_10oqrki":"轻触拍照","k_0f8b3ws":"加载失败","k_11cm5lm":"手动聚焦","k_002uzrd":"预览","k_003qkn3":"录像","k_003k6a7":"拍照","k_0bqpqco":"拍照按钮","k_1626ozl":"停止录像","k_003lvmu":"前置","k_003lued":"后置","k_003lwzh":"外置","k_002qzi3":"关闭","k_003pufb":"自动","k_0apm0ze":"拍照时闪光","k_157zog5":"始终闪光","k_0cfyqhy":"$option1 画面预览","k_0phctlz":"闪光模式: $option2","k_02vfqe0":"切换至 $option3 摄像头","k_0f0y9ex":"说话时间太短","k_0ln70tk":"无法打开URL","k_11a3jdv":"轻触拍照,长按摄像","k_1k18miv":"请传入离开群组生命周期函数,提供返回首页或其他页面的导航方法。","k_1fu9ahv":"全员禁言状态","k_0gmwbnd":"全员禁言中","k_0got2zr":"您被禁言","k_0y9jck8":"你必须自定义search bar,并处理点击跳转","k_0yum3tv":"如使用自定义区域,请在profileWidgetBuilder传入对应组件","k_09kalj0":"清空聊天记录","k_14j5iul":"删除并退出","k_125ru1w":"解散该群","k_0jtutmw":"退出后不会接收到此群聊消息","k_0jtzmqa":"解散后不会接收到此群聊消息","k_0r8fi93":"好友添加成功","k_02qw14e":"好友申请已发出","k_0n3md5x":"当前用户在黑名单","k_094phq4":"好友添加失败","k_129scag":"好友删除成功","k_129uzfn":"好友删除失败","k_1666isy":"清除好友","k_1679vrd":"加为好友","k_1ualc52":"看看对方带来的数据是啥","k_0szluvp":"设置对方在线状态","k_0f4rnf8":"该用户已是好友","k_1tdkom4":"您已是群成员","k_1p2lyuz":"对方正在输入中...","k_1g8wfpy":"...共$option1人","k_12rv9vw":"回应详情","k_0havgi0":"[查看详情 >>](${linkMessage.link})","k_0n9p7g8":"群组不存在","k_1tdh5vn":"您不是群成员","k_0h1q57v":"暂无群成员","k_0y5drq1":"[查看详情 >>]($option1)","k_03pjp61":"[表情消息]","k_1jpvzul":"[自定义消息]","k_03u3bh1":"[文件消息]","k_1odsnsw":"[群消息]","k_03sel4t":"[图片消息]","k_03sfw3r":"[位置消息]","k_03xpuwq":"[合并消息]","k_07ycxwo":"[没有元素]","k_03rc9vz":"[文本消息]","k_046uopf":"[视频消息]","k_0ehmsun":"设备存储空间不足,建议清理,以获得更好使用体验","k_003kmos":"图片","k_002s86q":"视频","k_06bk5ei":"视频消息仅限 mp4 格式","k_13opfxf":"Web网页端不支持搜索","k_1i0o0y2":"暂时仅限 Android/iOS 端","k_045dtzl":"$option1的聊天记录","k_0t0131u":"群资料信息","k_18ok8xz":"消息接收方式","k_03ax3ks":"群资料","k_0sqvoqo":"将 $option1 设置为管理员","k_1gbg1v8":"将 $option1 取消管理员","k_17k64g4":"群聊创建成功!","k_05mn217":"暂未安装表情包插件,如需使用表情相关功能,请根据本文档安装:https://cloud.tencent.com/document/product/269/70746","k_14j17nz":"暂无表情包","k_0fvjexh":"正在下载中","k_1cdagzz":"已加入待下载队列,其他文件下载中","k_0g4vojc":"开始下载","k_1g32es3":"[调皮]@2x.png","k_1g8qorz":"[爱你]@2x.png","k_1g4hmx6":"[爱情]@2x.png","k_1g6b558":"[爱心]@2x.png","k_1g3m4su":"[傲慢]@2x.png","k_1g2jym7":"[白眼]@2x.png","k_0cgkxuw":"[棒棒糖]@2x.png","k_1g48br2":"[抱抱]@2x.png","k_1g49ol8":"[抱拳]@2x.png","k_1g0ras3":"[爆筋]@2x.png","k_1ghy881":"[鄙视]@2x.png","k_1g86bmv":"[闭嘴]@2x.png","k_1g1xs1p":"[鞭炮]@2x.png","k_1g8i6ri":"[便便]@2x.png","k_1g2u5kf":"[擦汗]@2x.png","k_1g60uwh":"[彩带]@2x.png","k_1g1o0d0":"[彩球]@2x.png","k_1g6a6yq":"[菜刀]@2x.png","k_1g6vqo2":"[差劲]@2x.png","k_1g0kvjc":"[钞票]@2x.png","k_1g65x7e":"[车厢]@2x.png","k_0e1tjol":"[打哈欠]@2x.png","k_1g65n58":"[大兵]@2x.png","k_1g7se7o":"[大哭]@2x.png","k_1g03868":"[蛋糕]@2x.png","k_1h8nm66":"[刀]@2x.png","k_1g3dlpi":"[得意]@2x.png","k_1g3u434":"[灯泡]@2x.png","k_1giuqs7":"[凋谢]@2x.png","k_1g8r0r9":"[多云]@2x.png","k_1g7k6i1":"[发呆]@2x.png","k_1g44zsp":"[发抖]@2x.png","k_1g5l96i":"[飞机]@2x.png","k_1g7wsqj":"[飞吻]@2x.png","k_1g49luq":"[奋斗]@2x.png","k_1gixbsm":"[风车]@2x.png","k_1g6cqbq":"[尴尬]@2x.png","k_1g6jbw5":"[勾引]@2x.png","k_1g3lwo1":"[鼓掌]@2x.png","k_1g13nkj":"[害羞]@2x.png","k_1g0mt47":"[憨笑]@2x.png","k_0bxujkf":"[红灯笼]@2x.png","k_0hhaeh8":"[红双喜]@2x.png","k_1g0jnts":"[坏笑]@2x.png","k_1g46g9c":"[挥手]@2x.png","k_1g4vi9g":"[回头]@2x.png","k_1gf7hes":"[饥饿]@2x.png","k_1g6mvsm":"[激动]@2x.png","k_1gku5mf":"[街舞]@2x.png","k_1g4hidg":"[惊恐]@2x.png","k_1gjbrtu":"[惊讶]@2x.png","k_1g6sand":"[咖啡]@2x.png","k_1g4s8rj":"[磕头]@2x.png","k_1g1wn34":"[可爱]@2x.png","k_1g3l0wd":"[可怜]@2x.png","k_1ggaon9":"[抠鼻]@2x.png","k_1ggvcb0":"[骷髅]@2x.png","k_1h8yqjt":"[酷]@2x.png","k_0jac97i":"[快哭了]@2x.png","k_1h8oiby":"[困]@2x.png","k_1g0s5hg":"[蜡烛]@2x.png","k_1g1iuer":"[篮球]@2x.png","k_1g2xjfi":"[冷汗]@2x.png","k_0s5oyqw":"[礼品袋]@2x.png","k_1g1qqvf":"[礼物]@2x.png","k_1g2slew":"[流汗]@2x.png","k_1g3z9xx":"[流泪]@2x.png","k_1g6pabn":"[麻将]@2x.png","k_0pkaxul":"[麦克风]@2x.png","k_1g7m0zj":"[猫咪]@2x.png","k_0ibvtpo":"[么么哒]@2x.png","k_1g1hoh1":"[玫瑰]@2x.png","k_1gfzeow":"[米饭]@2x.png","k_1g5l15p":"[面条]@2x.png","k_1g2hfa6":"[奶瓶]@2x.png","k_1gix9pj":"[难过]@2x.png","k_1giqn6g":"[闹钟]@2x.png","k_1h8kd64":"[怒]@2x.png","k_1g0vui9":"[怄火]@2x.png","k_1g1jsj7":"[皮球]@2x.png","k_1ghdluw":"[啤酒]@2x.png","k_1gl6ec7":"[瓢虫]@2x.png","k_1g7gg5p":"[撇嘴]@2x.png","k_1g8psin":"[乒乓]@2x.png","k_1gjzu3p":"[汽车]@2x.png","k_1h8mr0k":"[强]@2x.png","k_1g45y2n":"[敲打]@2x.png","k_1gkaxsl":"[青蛙]@2x.png","k_0jcfnoo":"[糗大了]@2x.png","k_1g4njy1":"[拳头]@2x.png","k_1h8mqr3":"[弱]@2x.png","k_1h926fg":"[色]@2x.png","k_1g6rtbq":"[沙发]@2x.png","k_1giirh6":"[删除]@2x.png","k_1g14ny9":"[闪电]@2x.png","k_1g6bmsr":"[胜利]@2x.png","k_1g1rytx":"[示爱]@2x.png","k_1g52fbz":"[手枪]@2x.png","k_1h90dam":"[衰]@2x.png","k_1gigiae":"[睡觉]@2x.png","k_1gijchz":"[太阳]@2x.png","k_1g1sgji":"[跳绳]@2x.png","k_1gjwuri":"[跳跳]@2x.png","k_1g0juhk":"[偷笑]@2x.png","k_1h8nzla":"[吐]@2x.png","k_1g6cv0i":"[委屈]@2x.png","k_1g46l5g":"[握手]@2x.png","k_1g2pgkd":"[西瓜]@2x.png","k_1ging9p":"[下雨]@2x.png","k_1h8nzil":"[吓]@2x.png","k_1g7q7wr":"[献吻]@2x.png","k_1gl6uum":"[香蕉]@2x.png","k_1g23fys":"[象棋]@2x.png","k_0j75rdh":"[心碎了]@2x.png","k_1g6ajj2":"[信封]@2x.png","k_1g21prz":"[熊猫]@2x.png","k_1h8octi":"[嘘]@2x.png","k_1h91zox":"[药]@2x.png","k_1ghttfl":"[疑问]@2x.png","k_1ghk7sz":"[阴险]@2x.png","k_0gl37zz":"[右车头]@2x.png","k_0ifkj1p":"[右哼哼]@2x.png","k_0g1yh2e":"[右太极]@2x.png","k_1g9dkfc":"[雨伞]@2x.png","k_1g8jl88":"[月亮]@2x.png","k_1h8lhqj":"[晕]@2x.png","k_1gi9x2q":"[再见]@2x.png","k_1g6dwwv":"[炸弹]@2x.png","k_1fzmkfi":"[折磨]@2x.png","k_1g6jbiw":"[纸巾]@2x.png","k_1ggjnwu":"[咒骂]@2x.png","k_1g4qlq8":"[猪头]@2x.png","k_1g1lqzz":"[抓狂]@2x.png","k_1g80j3u":"[转圈]@2x.png","k_1g0z55s":"[龇牙]@2x.png","k_1g3ju6v":"[钻戒]@2x.png","k_0gl51l6":"[左车头]@2x.png","k_0iflllk":"[左哼哼]@2x.png","k_0g1y3ir":"[左太极]@2x.png","k_026hiq5":"消息列表加载中","k_003tu8k":"爱你","k_003myvp":"傲慢","k_003kddw":"白眼","k_039yfhv":"棒棒糖","k_003nu3p":"抱抱","k_003nijr":"抱拳","k_003mg88":"爆筋","k_002v17e":"鄙视","k_003qhy4":"闭嘴","k_003l5fq":"鞭炮","k_003uacl":"便便","k_003oq1g":"擦汗","k_003qvey":"彩带","k_003jci7":"彩球","k_003pyu1":"菜刀","k_003q97d":"差劲","k_003po5d":"车厢","k_03eadb2":"打哈欠","k_003pnuf":"大兵","k_003kg57":"蛋糕","k_003mxkt":"得意","k_003onu3":"灯泡","k_002uv8s":"凋谢","k_003kqy0":"调皮","k_003tyum":"多云","k_003pv9u":"发呆","k_036o6mu":"发抖t","k_003nogx":"飞机","k_003q7wg":"飞吻","k_003m0jd":"奋斗","k_002ult9":"风车","k_003r8gt":"尴尬","k_003qy4u":"勾引","k_003mnoa":"鼓掌","k_003lmw8":"害羞","k_003mb30":"憨笑","k_03bj41g":"红灯笼","k_03dxw2f":"红双喜","k_003mk57":"坏笑","k_003nmvf":"挥手","k_003r2i7":"回头","k_002s6f3":"饥饿","k_003qd0t":"激动","k_002vgi4":"街舞","k_003nz33":"惊恐","k_002wh4p":"惊讶","k_003ozpu":"咖啡","k_003qvs4":"磕头","k_003l3wb":"可爱","k_003nuwm":"可怜","k_002rw1q":"抠鼻","k_002tujb":"骷髅","k_00030eq":"酷","k_03i8ath":"快哭了","k_000421h":"困","k_003l5i7":"蜡烛","k_003j72g":"篮球","k_003ofwl":"冷汗","k_02mw65v":"礼品袋","k_003ku40":"礼物","k_003ookz":"流汗","k_003on72":"流泪","k_003rjy0":"麻将","k_003q2f8":"猫咪","k_03et393":"么么哒","k_003j7j2":"玫瑰","k_002sr0b":"米饭","k_003nnza":"面条","k_003jef9":"奶瓶","k_002umn0":"难过","k_002rjib":"闹钟","k_0003zcn":"怒","k_003jzwq":"怄火","k_003j4js":"皮球","k_002r5ir":"啤酒","k_002ubu4":"瓢虫","k_003ppo6":"撇嘴","k_003ty3o":"乒乓","k_002vxwe":"汽车","k_00043hb":"强","k_003nmbo":"敲打","k_002tfhq":"青蛙","k_03i7lrn":"糗大了","k_003r03m":"拳头","k_00043h0":"弱","k_000345z":"色","k_003qmp9":"沙发","k_003it8a":"闪电","k_003pxow":"胜利","k_003kw8e":"示爱","k_003n99g":"手枪","k_00035cl":"衰","k_002vl3h":"睡觉","k_002rgqk":"太阳","k_003m9d1":"跳绳","k_002vobp":"跳跳","k_003mkoz":"偷笑","k_00041px":"吐","k_003rjh5":"委屈","k_003j36u":"西瓜","k_002re92":"下雨","k_00041py":"吓","k_003q06o":"献吻","k_002ubjp":"香蕉","k_003o2tr":"象棋","k_03ie6pa":"心碎了","k_003rao5":"信封","k_003l3us":"熊猫","k_000424d":"嘘","k_00033yi":"药","k_002qtyy":"疑问","k_002qe0o":"阴险","k_03gu7us":"右车头","k_03ere8m":"右哼哼","k_003uqk3":"雨伞","k_003tzdv":"月亮","k_0003z00":"晕","k_002vdrd":"再见","k_003ra1w":"炸弹","k_003lcad":"折磨","k_003q7sz":"纸巾","k_002thn9":"咒骂","k_003qx7f":"猪头","k_003l044":"抓狂","k_003qg4h":"转圈","k_003kb97":"龇牙","k_03gu53l":"左车头","k_03erd1f":"左哼哼","k_003nyvl":"爱情","k_003r85z":"爱心","k_003mk8j":"钞票","k_003pwfj":"大哭","k_00042w5":"刀","k_003nmtr":"握手","k_03c529p":"右太极","k_003n4mk":"钻戒","k_03c5488":"左太极","k_1llp7tu":"该用户不存在","k_0tbyqyb":"加载中…","k_0td1p3f":"保存中…","k_1klqdh1":"仅限汉字、英文、数字和下划线","k_03el5lp":"未填写","k_1ui0gai":"搜索指定内容","k_003nvk2":"消息","k_03agld7":"群提示","k_0elt0kw":"添加群聊","k_0s3sgel":"移出黑名单","k_1qqgjra":"$option3条未读消息","k_0uubyjr":"以下为未读消息","k_16as7eq":"表情回应","k_003s12u":"回复","k_003s38r":"更多","k_002wkr3":"翻译","k_13g4hxv":"翻译完成","k_003molk":"表情","k_165bbw6":"消息历史","k_13sqc0z":"清除消息","k_0glns86":"删除会话","k_13s99rx":"清空消息","k_11vsa3j":"退出群组","k_11vvszp":"解散群组","k_15i9w72":"群管理员","k_0p3espj":"设置备注名","k_118sw9v":"立即搜索","k_0h20hg5":"视频通话","k_0h22snw":"语音通话","k_003lz6t":"对方","k_1xf4yre":"发送给$option1","k_003por5":"截图","k_1rw7s82":" 访问相册中视频权限,以正常使用发送视频等功能。","k_003rcwm":"打开","k_1698c42":"在访达中打开","k_066fxsz":"查看文件夹","k_0k432y2":"无法发送,包含文件夹","k_002wb4y":"会话","k_0od4qyh":"视频文件异常","k_1bfkxg9":"不支持 0KB 文件的传输","k_0vvsw7g":"文件处理异常","k_06e224q":"[消息被管理员撤回]","k_1u1mjcl":"[消息被撤回]","k_1qcqxea":"选择多个会话","k_1qgmc20":"选择一个会话","k_1d8nx6f":"在新窗口中打开","k_1hz05ax":"正在下载原始资源,请稍候...","k_002robo":"清除"} \ No newline at end of file diff --git a/lib/i18n/strings_zh-Hans.i18n.json b/lib/i18n/strings_zh-Hans.i18n.json new file mode 100644 index 0000000..a2937e0 --- /dev/null +++ b/lib/i18n/strings_zh-Hans.i18n.json @@ -0,0 +1 @@ +{"k_1yemzyd":"收到一条消息","k_0ylosxn":"自定义消息","k_13sajrj":"表情消息","k_13sjeb7":"文件消息","k_0yd2ft8":"群提示消息","k_13s7mxn":"图片消息","k_13satlt":"位置消息","k_00bbtsx":"合并转发消息","k_13sqwu4":"语音消息","k_13sqjjp":"视频消息","k_1fdhj9g":"该版本不支持此消息","k_06pujtm":"同意任何用户添加好友","k_0gyhkp5":"需要验证","k_121ruco":"拒绝任何人加好友","k_05nspni":"自定义字段","k_03fchyy":"群头像","k_03i9mfe":"群简介","k_03agq58":"群名称","k_039xqny":"群通知","k_003tr0a":"群主","k_03iqsh4":"$s为 ","k_191t5n4":"$opUserNickName修改","k_1pg6aoj":"$opUserNickName退出群聊","k_1f6zt3v":"邀请$invitedMemberString加入群组","k_0y7zd07":"将$invitedMemberString踢出群组","k_03c49qt":"去授权","k_1d5mshh":"用户$joinedMemberString加入了群聊","k_002wddw":"禁言","k_0got6f7":"解除禁言","k_0yenqf0":"$userName 被","k_0spotql":"将 $adminMember 设置为管理员","k_0pg5zzj":"系统消息 $operationType","k_0ohzb9l":"通话时间:$callTime","k_1uaqed6":"[自定义]","k_0z2z7rx":"[语音]","k_0y39ngu":"[表情]","k_1c7z88n":"[文件] $fileName","k_0y1a2my":"[图片]","k_0z4fib8":"[视频]","k_0y24mcg":"[位置]","k_0pewpd1":"[聊天记录]","k_13s8d9p":"未知消息","k_1c3us5n":"当前群组不支持@全体成员","k_11k579v":"发言中有非法语句","k_003qkx2":"日历","k_003n2pz":"相机","k_03idjo0":"联系人","k_003ltgm":"位置","k_02k3k86":"麦克风","k_003pm7l":"相册","k_15ao57x":"相册写入","k_164m3jd":"本地存储","k_0qba4ns":"想访问您的$yoursItem","k_03r6qyx":"我们需要您的同意才能获取信息","k_02noktt":"不允许","k_00043x4":"好","k_003qzac":"昨天","k_003r39d":"前天","k_03fqp9o":"星期天","k_03ibg5h":"星期一","k_03i7hu1":"星期二","k_03iaiks":"星期三","k_03el9pa":"星期四","k_03i7ok1":"星期五","k_03efxyg":"星期六","k_0oozw9x":"$diffMinutes 分钟前","k_003q7ba":"下午","k_003q7bb":"上午","k_003pu3h":"现在","k_13hzn00":"昨天 $yesterday","k_0n9pyxz":"用户不存在","k_1bjwemh":"搜索用户 ID","k_003kv3v":"搜索","k_02owlq8":"我的用户ID: $userID","k_1wu8h4x":"我是: $showName","k_16758qw":"添加好友","k_1shx4d9":"个性签名: $selfSignature","k_0i553x0":"填写验证信息","k_031ocwx":"请填写备注和分组","k_003ojje":"备注","k_003lsav":"分组","k_167bdvq":"我的好友","k_156b4ut":"好友申请已发送","k_002r305":"发送","k_03gu05e":"聊天室","k_03b4f3p":"会议群","k_03avj1p":"公开群","k_03asq2g":"工作群","k_03b3hbi":"未知群","k_1loix7s":"群类型: $groupType","k_1lqbsib":"该群聊不存在","k_03h153m":"搜索群ID","k_0oxak3r":"群申请已发送","k_002rflt":"删除","k_1don84v":"无法定位到原消息","k_003q5fi":"复制","k_003prq0":"转发","k_002r1h2":"多选","k_003j708":"引用","k_003pqpr":"撤回","k_03ezhho":"已复制","k_11ctfsz":"暂未实现","k_1hbjg5g":"[群系统消息]","k_03tvswb":"[未知消息]","k_155cj23":"您撤回了一条消息,","k_0gapun3":"重新编辑","k_1uh417q":"$displayName撤回了一条消息","k_1aszp2k":"您确定要重发这条消息么?","k_003rzap":"确定","k_003nevv":"取消","k_0003z7x":"您","k_002wfe4":"已读","k_002wjlg":"未读","k_0h1ygf8":"发起通话","k_0h169j0":"取消通话","k_0h13jjk":"接受通话","k_0h19hfx":"拒绝通话","k_0obi9lh":"超时未接听","k_0y9u662":"“$appName”暂不可以打开此类文件,你可以使用其他应用打开并预览","k_001nmhu":"用其他应用打开","k_1ht1b80":"正在接收中","k_105682d":"图片加载失败","k_0pytyeu":"图片保存成功","k_0akceel":"图片保存失败","k_003rk1s":"保存","k_04a0awq":"[语音消息]","k_105c3y3":"视频加载失败","k_176rzr7":"聊天记录","k_0d5z4m5":"选择提醒人","k_003ngex":"完成","k_1665ltg":"发起呼叫","k_003n8b0":"拍摄","k_003kthh":"照片","k_003tnp0":"文件","k_0jhdhtp":"发送失败,视频不能大于100MB","k_119ucng":"图片不能为空","k_0w9x8gw":"选择成功$successPath","k_13dsw4l":"松开取消","k_0am7r68":"手指上滑,取消发送","k_15jl6qw":"说话时间太短!","k_0gx7vl6":"按住说话","k_15dlafd":"逐条转发","k_15dryxy":"合并转发","k_1eyhieh":"确定删除已选消息","k_17fmlyf":"清除聊天","k_0dhesoz":"取消置顶","k_002sk7x":"置顶","k_003ll77":"草稿","k_03icaxo":"自定义","k_1969986":"[语音通话]:$callingLastMsgShow","k_1960dlr":"[视频通话]:$callingLastMsgShow","k_1np495n":"$messageString[有人@我]","k_1m797yi":"$messageString[@所有人]","k_1uaov41":"查找聊天内容","k_003kfai":"未知","k_13dq4an":"自动审批","k_0l13cde":"管理员审批","k_11y8c6a":"禁止加群","k_1kvyskd":"无网络连接,无法修改","k_16payqf":"加群方式","k_0vzvn8r":"修改群名称","k_038lh6u":"群管理","k_0k5wyiy":"设置管理员","k_0goiuwk":"全员禁言","k_1g889xx":"全员禁言开启后,只允许群主和管理员发言。","k_0wlrefq":"添加需要禁言的群成员","k_0goox5g":"设置禁言","k_08daijh":"成功取消管理员身份","k_0bxm97s":"管理员 ($adminNum/10)","k_0k5u935":"添加管理员","k_03enyx5":"群成员","k_0jayw3z":"群成员($groupMemberNum人)","k_0h1svv1":"删除群成员","k_0h1g636":"添加群成员","k_0uj7208":"无网络连接,无法查看群成员","k_01yfa4o":"$memberCount人","k_0hpukyx":"查看更多群成员","k_0qtsar0":"消息免打扰","k_0ef2a12":"修改我的群昵称","k_1aajych":"仅限中文、字母、数字和下划线,2-20个字","k_137pab5":"我的群昵称","k_0ivim6d":"暂无群公告","k_03eq6cn":"群公告","k_002vxya":"编辑","k_17fpl3y":"置顶聊天","k_03es1ox":"群类型","k_003mz1i":"同意","k_003lpre":"拒绝","k_003qk66":"头像","k_003lhvk":"昵称","k_003ps50":"账号","k_15lx52z":"个性签名","k_003qgkp":"性别","k_003m6hr":"生日","k_0003v6a":"男","k_00043x2":"女","k_03bcjkv":"未设置","k_11s0gdz":"修改昵称","k_0p3j4sd":"仅限中字、字母、数字和下划线","k_15lyvdt":"修改签名","k_0vylzjp":"这个人很懒,什么也没写","k_1hs7ese":"等上线再改这个","k_03exjk7":"备注名","k_0s3skfd":"加入黑名单","k_0p3b31s":"修改备注名","k_0003y9x":"无","k_11zgnfs":"个人资料","k_03xd79d":"个性签名: $signature","k_1tez2xl":"暂无个性签名","k_118prbn":"全局搜索","k_1m9dftc":"全部联系人","k_0em4gyz":"全部群聊","k_002twmj":"群聊","k_09kga0d":"更多聊天记录","k_1ui5lzi":"$count条相关聊天记录","k_09khmso":"相关聊天记录","k_1kevf4k":"与$receiver的聊天记录","k_0vjj2kp":"群聊的聊天记录","k_003n2rp":"选择","k_03ignw6":"所有人","k_03erpei":"管理员","k_0qi9tno":"群主、管理员","k_1m9exwh":"最近联系人","k_119nwqr":"输入不能为空","k_0pzwbmg":"视频保存成功","k_0aktupv":"视频保存失败","k_1qbg9xc":"$option8为 ","k_1wq5ubm":"$option7修改","k_0y5pu80":"$option6退出群聊","k_0nl7cmd":"邀请$option5加入群组","k_1ju5iqw":"将$option4踢出群组","k_1ovt677":"用户$option3加入了群聊","k_0k05b8b":"$option2 被","k_0wm4xeb":"系统消息 $option2","k_0nbq9v3":"通话时间:$option2","k_0i1kf53":"[文件] $option2","k_1wh4atg":"$option2 分钟前","k_07sh7g1":"昨天 $option2","k_1pj8xzh":"我的用户ID: $option2","k_0py1evo":"个性签名: $option2","k_1kvj4i2":"$option2撤回了一条消息","k_1v0lbpp":"“$option2”暂不可以打开此类文件,你可以使用其他应用打开并预览","k_0torwfz":"选择成功$option2","k_0i1bjah":"$option1撤回了一条消息","k_1qzxh9q":"通话时间:$option3","k_0wrgmom":"[语音通话]:$option1","k_06ix2f0":"[视频通话]:$option2","k_08o3z5w":"[文件] $option1","k_0ezbepg":"$option2[有人@我]","k_1ccnht1":"$option2[@所有人]","k_1k3arsw":"管理员 ($option2/10)","k_1d4golg":"群成员($option1人)","k_1bg69nt":"$option1人","k_00gjqxj":"个性签名: $option1","k_0c29cxr":"$option1条相关聊天记录","k_1twk5rz":"与$option1的聊天记录","k_1vn4xq1":"将 $adminMember 取消管理员","k_0e35hsw":"为方便您将所拍摄的照片或视频发送给朋友,以及进行视频通话,请允许我们访问摄像头进行拍摄照片和视频。","k_0dj6yr7":"为方便您发送语音消息、拍摄视频以及音视频通话,请允许我们使用麦克风进行录音。","k_003qnsl":"存储","k_0s3rtpw":"为方便您查看和选择相册里的图片视频发送给朋友,以及保存内容到设备,请允许我们访问您设备上的照片、媒体内容。","k_0tezv85":" 申请获取$option2","k_002rety":"权限","k_18o68ro":"需要授予","k_1onpf8u":" 相机权限,以正常使用拍摄图片视频、视频通话等功能。","k_17irga5":" 麦克风权限,以正常使用发送语音消息、拍摄视频、音视频通话等功能。","k_0572kc4":" 访问照片权限,以正常使用发送图片、视频等功能。","k_0slykws":" 访问相册写入权限,以正常使用存储图片、视频等功能。","k_119pkcd":" 文件读写权限,以正常使用在聊天功能中的图片查看、选择能力和发送文件的能力。","k_0gqewd3":"以后再说","k_03eq4s1":"去开启","k_0nt2uyg":"回到最新位置","k_04l16at":"$option1条新消息","k_13p3w93":"有人@我","k_18w5uk6":"@所有人","k_0jmujgh":"其他文件正在接收中","k_12s5ept":"消息详情","k_0mxa4f4":"$option1人已读","k_061tue3":"$option2人未读","k_18qjstb":"转让群主","k_0on1aj2":"有$option2条@我消息","k_09j4izl":"[有人@我] ","k_1oqtjw0":"[@所有人] ","k_1x5a9vb":"我是: $option1","k_14n31e7":"进群请求","k_08nc5j1":"群类型: $option1","k_1josu12":"$option1 条入群请求","k_0n2x5s0":"验证消息: $option2","k_03c1nx0":"已同意","k_03aw9w8":"已拒绝","k_038ryos":"去处理","k_0gw8pum":"进群申请","k_1gcvfrj":"请填写备注","k_002v9zj":"确认","k_10oqrki":"轻触拍照","k_0f8b3ws":"加载失败","k_11cm5lm":"手动聚焦","k_002uzrd":"预览","k_003qkn3":"录像","k_003k6a7":"拍照","k_0bqpqco":"拍照按钮","k_1626ozl":"停止录像","k_003lvmu":"前置","k_003lued":"后置","k_003lwzh":"外置","k_002qzi3":"关闭","k_003pufb":"自动","k_0apm0ze":"拍照时闪光","k_157zog5":"始终闪光","k_0cfyqhy":"$option1 画面预览","k_0phctlz":"闪光模式: $option2","k_02vfqe0":"切换至 $option3 摄像头","k_0f0y9ex":"说话时间太短","k_0ln70tk":"无法打开URL","k_11a3jdv":"轻触拍照,长按摄像","k_1k18miv":"请传入离开群组生命周期函数,提供返回首页或其他页面的导航方法。","k_1fu9ahv":"全员禁言状态","k_0gmwbnd":"全员禁言中","k_0got2zr":"您被禁言","k_0y9jck8":"你必须自定义search bar,并处理点击跳转","k_0yum3tv":"如使用自定义区域,请在profileWidgetBuilder传入对应组件","k_09kalj0":"清空聊天记录","k_14j5iul":"删除并退出","k_125ru1w":"解散该群","k_0jtutmw":"退出后不会接收到此群聊消息","k_0jtzmqa":"解散后不会接收到此群聊消息","k_0r8fi93":"好友添加成功","k_02qw14e":"好友申请已发出","k_0n3md5x":"当前用户在黑名单","k_094phq4":"好友添加失败","k_129scag":"好友删除成功","k_129uzfn":"好友删除失败","k_1666isy":"清除好友","k_1679vrd":"加为好友","k_1ualc52":"看看对方带来的数据是啥","k_0szluvp":"设置对方在线状态","k_0f4rnf8":"该用户已是好友","k_1tdkom4":"您已是群成员","k_1p2lyuz":"对方正在输入中...","k_1g8wfpy":"...共$option1人","k_12rv9vw":"回应详情","k_0havgi0":"[查看详情 >>](${linkMessage.link})","k_0n9p7g8":"群组不存在","k_1tdh5vn":"您不是群成员","k_0h1q57v":"暂无群成员","k_0y5drq1":"[查看详情 >>]($option1)","k_03pjp61":"[表情消息]","k_1jpvzul":"[自定义消息]","k_03u3bh1":"[文件消息]","k_1odsnsw":"[群消息]","k_03sel4t":"[图片消息]","k_03sfw3r":"[位置消息]","k_03xpuwq":"[合并消息]","k_07ycxwo":"[没有元素]","k_03rc9vz":"[文本消息]","k_046uopf":"[视频消息]","k_0ehmsun":"设备存储空间不足,建议清理,以获得更好使用体验","k_003kmos":"图片","k_002s86q":"视频","k_06bk5ei":"视频消息仅限 mp4 格式","k_13opfxf":"Web网页端不支持搜索","k_1i0o0y2":"暂时仅限 Android/iOS 端","k_045dtzl":"$option1的聊天记录","k_0t0131u":"群资料信息","k_18ok8xz":"消息接收方式","k_03ax3ks":"群资料","k_0sqvoqo":"将 $option1 设置为管理员","k_1gbg1v8":"将 $option1 取消管理员","k_17k64g4":"群聊创建成功!","k_05mn217":"暂未安装表情包插件,如需使用表情相关功能,请根据本文档安装:https://cloud.tencent.com/document/product/269/70746","k_14j17nz":"暂无表情包","k_1gnnby6":" 想访问您的$option2","k_0fvjexh":"正在下载中","k_1cdagzz":"已加入待下载队列,其他文件下载中","k_0g4vojc":"开始下载","k_1g32es3":"[调皮]@2x.png","k_1g8qorz":"[爱你]@2x.png","k_1g4hmx6":"[爱情]@2x.png","k_1g6b558":"[爱心]@2x.png","k_1g3m4su":"[傲慢]@2x.png","k_1g2jym7":"[白眼]@2x.png","k_0cgkxuw":"[棒棒糖]@2x.png","k_1g48br2":"[抱抱]@2x.png","k_1g49ol8":"[抱拳]@2x.png","k_1g0ras3":"[爆筋]@2x.png","k_1ghy881":"[鄙视]@2x.png","k_1g86bmv":"[闭嘴]@2x.png","k_1g1xs1p":"[鞭炮]@2x.png","k_1g8i6ri":"[便便]@2x.png","k_1g2u5kf":"[擦汗]@2x.png","k_1g60uwh":"[彩带]@2x.png","k_1g1o0d0":"[彩球]@2x.png","k_1g6a6yq":"[菜刀]@2x.png","k_1g6vqo2":"[差劲]@2x.png","k_1g0kvjc":"[钞票]@2x.png","k_1g65x7e":"[车厢]@2x.png","k_0e1tjol":"[打哈欠]@2x.png","k_1g65n58":"[大兵]@2x.png","k_1g7se7o":"[大哭]@2x.png","k_1g03868":"[蛋糕]@2x.png","k_1h8nm66":"[刀]@2x.png","k_1g3dlpi":"[得意]@2x.png","k_1g3u434":"[灯泡]@2x.png","k_1giuqs7":"[凋谢]@2x.png","k_1g8r0r9":"[多云]@2x.png","k_1g7k6i1":"[发呆]@2x.png","k_1g44zsp":"[发抖]@2x.png","k_1g5l96i":"[飞机]@2x.png","k_1g7wsqj":"[飞吻]@2x.png","k_1g49luq":"[奋斗]@2x.png","k_1gixbsm":"[风车]@2x.png","k_1g6cqbq":"[尴尬]@2x.png","k_1g6jbw5":"[勾引]@2x.png","k_1g3lwo1":"[鼓掌]@2x.png","k_1g13nkj":"[害羞]@2x.png","k_1g0mt47":"[憨笑]@2x.png","k_0bxujkf":"[红灯笼]@2x.png","k_0hhaeh8":"[红双喜]@2x.png","k_1g0jnts":"[坏笑]@2x.png","k_1g46g9c":"[挥手]@2x.png","k_1g4vi9g":"[回头]@2x.png","k_1gf7hes":"[饥饿]@2x.png","k_1g6mvsm":"[激动]@2x.png","k_1gku5mf":"[街舞]@2x.png","k_1g4hidg":"[惊恐]@2x.png","k_1gjbrtu":"[惊讶]@2x.png","k_1g6sand":"[咖啡]@2x.png","k_1g4s8rj":"[磕头]@2x.png","k_1g1wn34":"[可爱]@2x.png","k_1g3l0wd":"[可怜]@2x.png","k_1ggaon9":"[抠鼻]@2x.png","k_1ggvcb0":"[骷髅]@2x.png","k_1h8yqjt":"[酷]@2x.png","k_0jac97i":"[快哭了]@2x.png","k_1h8oiby":"[困]@2x.png","k_1g0s5hg":"[蜡烛]@2x.png","k_1g1iuer":"[篮球]@2x.png","k_1g2xjfi":"[冷汗]@2x.png","k_0s5oyqw":"[礼品袋]@2x.png","k_1g1qqvf":"[礼物]@2x.png","k_1g2slew":"[流汗]@2x.png","k_1g3z9xx":"[流泪]@2x.png","k_1g6pabn":"[麻将]@2x.png","k_0pkaxul":"[麦克风]@2x.png","k_1g7m0zj":"[猫咪]@2x.png","k_0ibvtpo":"[么么哒]@2x.png","k_1g1hoh1":"[玫瑰]@2x.png","k_1gfzeow":"[米饭]@2x.png","k_1g5l15p":"[面条]@2x.png","k_1g2hfa6":"[奶瓶]@2x.png","k_1gix9pj":"[难过]@2x.png","k_1giqn6g":"[闹钟]@2x.png","k_1h8kd64":"[怒]@2x.png","k_1g0vui9":"[怄火]@2x.png","k_1g1jsj7":"[皮球]@2x.png","k_1ghdluw":"[啤酒]@2x.png","k_1gl6ec7":"[瓢虫]@2x.png","k_1g7gg5p":"[撇嘴]@2x.png","k_1g8psin":"[乒乓]@2x.png","k_1gjzu3p":"[汽车]@2x.png","k_1h8mr0k":"[强]@2x.png","k_1g45y2n":"[敲打]@2x.png","k_1gkaxsl":"[青蛙]@2x.png","k_0jcfnoo":"[糗大了]@2x.png","k_1g4njy1":"[拳头]@2x.png","k_1h8mqr3":"[弱]@2x.png","k_1h926fg":"[色]@2x.png","k_1g6rtbq":"[沙发]@2x.png","k_1giirh6":"[删除]@2x.png","k_1g14ny9":"[闪电]@2x.png","k_1g6bmsr":"[胜利]@2x.png","k_1g1rytx":"[示爱]@2x.png","k_1g52fbz":"[手枪]@2x.png","k_1h90dam":"[衰]@2x.png","k_1gigiae":"[睡觉]@2x.png","k_1gijchz":"[太阳]@2x.png","k_1g1sgji":"[跳绳]@2x.png","k_1gjwuri":"[跳跳]@2x.png","k_1g0juhk":"[偷笑]@2x.png","k_1h8nzla":"[吐]@2x.png","k_1g6cv0i":"[委屈]@2x.png","k_1g46l5g":"[握手]@2x.png","k_1g2pgkd":"[西瓜]@2x.png","k_1ging9p":"[下雨]@2x.png","k_1h8nzil":"[吓]@2x.png","k_1g7q7wr":"[献吻]@2x.png","k_1gl6uum":"[香蕉]@2x.png","k_1g23fys":"[象棋]@2x.png","k_0j75rdh":"[心碎了]@2x.png","k_1g6ajj2":"[信封]@2x.png","k_1g21prz":"[熊猫]@2x.png","k_1h8octi":"[嘘]@2x.png","k_1h91zox":"[药]@2x.png","k_1ghttfl":"[疑问]@2x.png","k_1ghk7sz":"[阴险]@2x.png","k_0gl37zz":"[右车头]@2x.png","k_0ifkj1p":"[右哼哼]@2x.png","k_0g1yh2e":"[右太极]@2x.png","k_1g9dkfc":"[雨伞]@2x.png","k_1g8jl88":"[月亮]@2x.png","k_1h8lhqj":"[晕]@2x.png","k_1gi9x2q":"[再见]@2x.png","k_1g6dwwv":"[炸弹]@2x.png","k_1fzmkfi":"[折磨]@2x.png","k_1g6jbiw":"[纸巾]@2x.png","k_1ggjnwu":"[咒骂]@2x.png","k_1g4qlq8":"[猪头]@2x.png","k_1g1lqzz":"[抓狂]@2x.png","k_1g80j3u":"[转圈]@2x.png","k_1g0z55s":"[龇牙]@2x.png","k_1g3ju6v":"[钻戒]@2x.png","k_0gl51l6":"[左车头]@2x.png","k_0iflllk":"[左哼哼]@2x.png","k_0g1y3ir":"[左太极]@2x.png","k_026hiq5":"消息列表加载中","k_003tu8k":"爱你","k_003myvp":"傲慢","k_003kddw":"白眼","k_039yfhv":"棒棒糖","k_003nu3p":"抱抱","k_003nijr":"抱拳","k_003mg88":"爆筋","k_002v17e":"鄙视","k_003qhy4":"闭嘴","k_003l5fq":"鞭炮","k_003uacl":"便便","k_003oq1g":"擦汗","k_003qvey":"彩带","k_003jci7":"彩球","k_003pyu1":"菜刀","k_003q97d":"差劲","k_003po5d":"车厢","k_03eadb2":"打哈欠","k_003pnuf":"大兵","k_003kg57":"蛋糕","k_003mxkt":"得意","k_003onu3":"灯泡","k_002uv8s":"凋谢","k_003kqy0":"调皮","k_003tyum":"多云","k_003pv9u":"发呆","k_036o6mu":"发抖t","k_003nogx":"飞机","k_003q7wg":"飞吻","k_003m0jd":"奋斗","k_002ult9":"风车","k_003r8gt":"尴尬","k_003qy4u":"勾引","k_003mnoa":"鼓掌","k_003lmw8":"害羞","k_003mb30":"憨笑","k_03bj41g":"红灯笼","k_03dxw2f":"红双喜","k_003mk57":"坏笑","k_003nmvf":"挥手","k_003r2i7":"回头","k_002s6f3":"饥饿","k_003qd0t":"激动","k_002vgi4":"街舞","k_003nz33":"惊恐","k_002wh4p":"惊讶","k_003ozpu":"咖啡","k_003qvs4":"磕头","k_003l3wb":"可爱","k_003nuwm":"可怜","k_002rw1q":"抠鼻","k_002tujb":"骷髅","k_00030eq":"酷","k_03i8ath":"快哭了","k_000421h":"困","k_003l5i7":"蜡烛","k_003j72g":"篮球","k_003ofwl":"冷汗","k_02mw65v":"礼品袋","k_003ku40":"礼物","k_003ookz":"流汗","k_003on72":"流泪","k_003rjy0":"麻将","k_003q2f8":"猫咪","k_03et393":"么么哒","k_003j7j2":"玫瑰","k_002sr0b":"米饭","k_003nnza":"面条","k_003jef9":"奶瓶","k_002umn0":"难过","k_002rjib":"闹钟","k_0003zcn":"怒","k_003jzwq":"怄火","k_003j4js":"皮球","k_002r5ir":"啤酒","k_002ubu4":"瓢虫","k_003ppo6":"撇嘴","k_003ty3o":"乒乓","k_002vxwe":"汽车","k_00043hb":"强","k_003nmbo":"敲打","k_002tfhq":"青蛙","k_03i7lrn":"糗大了","k_003r03m":"拳头","k_00043h0":"弱","k_000345z":"色","k_003qmp9":"沙发","k_003it8a":"闪电","k_003pxow":"胜利","k_003kw8e":"示爱","k_003n99g":"手枪","k_00035cl":"衰","k_002vl3h":"睡觉","k_002rgqk":"太阳","k_003m9d1":"跳绳","k_002vobp":"跳跳","k_003mkoz":"偷笑","k_00041px":"吐","k_003rjh5":"委屈","k_003j36u":"西瓜","k_002re92":"下雨","k_00041py":"吓","k_003q06o":"献吻","k_002ubjp":"香蕉","k_003o2tr":"象棋","k_03ie6pa":"心碎了","k_003rao5":"信封","k_003l3us":"熊猫","k_000424d":"嘘","k_00033yi":"药","k_002qtyy":"疑问","k_002qe0o":"阴险","k_03gu7us":"右车头","k_03ere8m":"右哼哼","k_003uqk3":"雨伞","k_003tzdv":"月亮","k_0003z00":"晕","k_002vdrd":"再见","k_003ra1w":"炸弹","k_003lcad":"折磨","k_003q7sz":"纸巾","k_002thn9":"咒骂","k_003qx7f":"猪头","k_003l044":"抓狂","k_003qg4h":"转圈","k_003kb97":"龇牙","k_03gu53l":"左车头","k_03erd1f":"左哼哼","k_003nyvl":"爱情","k_003r85z":"爱心","k_003mk8j":"钞票","k_003pwfj":"大哭","k_00042w5":"刀","k_003nmtr":"握手","k_03c529p":"右太极","k_003n4mk":"钻戒","k_03c5488":"左太极","k_1llp7tu":"该用户不存在","k_0tbyqyb":"加载中…","k_0td1p3f":"保存中…","k_1klqdh1":"仅限汉字、英文、数字和下划线","k_03el5lp":"未填写","k_1ui0gai":"搜索指定内容","k_003nvk2":"消息","k_03agld7":"群提示","k_0elt0kw":"添加群聊","k_0s3sgel":"移出黑名单","k_1qqgjra":"$option3条未读消息","k_0uubyjr":"以下为未读消息","k_16as7eq":"表情回应","k_003s12u":"回复","k_003s38r":"更多","k_002wkr3":"翻译","k_13g4hxv":"翻译完成","k_003molk":"表情","k_165bbw6":"消息历史","k_13sqc0z":"清除消息","k_0glns86":"删除会话","k_13s99rx":"清空消息","k_11vsa3j":"退出群组","k_11vvszp":"解散群组","k_15i9w72":"群管理员","k_0p3espj":"设置备注名","k_118sw9v":"立即搜索","k_0h20hg5":"视频通话","k_0h22snw":"语音通话","k_003lz6t":"对方","k_1xf4yre":"发送给$option1","k_003por5":"截图","k_1rw7s82":" 访问相册中视频权限,以正常使用发送视频等功能。","k_003rcwm":"打开","k_1698c42":"在访达中打开","k_066fxsz":"查看文件夹","k_0k432y2":"无法发送,包含文件夹","k_002wb4y":"会话","k_0od4qyh":"视频文件异常","k_1bfkxg9":"不支持 0KB 文件的传输","k_0vvsw7g":"文件处理异常","k_06e224q":"[消息被管理员撤回]","k_1u1mjcl":"[消息被撤回]","k_1qcqxea":"选择多个会话","k_1qgmc20":"选择一个会话","k_1d8nx6f":"在新窗口中打开","k_1hz05ax":"正在下载原始资源,请稍候...","k_002robo":"清除"} \ No newline at end of file diff --git a/lib/i18n/strings_zh-Hant.i18n.json b/lib/i18n/strings_zh-Hant.i18n.json new file mode 100644 index 0000000..a98f134 --- /dev/null +++ b/lib/i18n/strings_zh-Hant.i18n.json @@ -0,0 +1,702 @@ +{ + "k_1yemzyd": "收到一條訊息", + "k_0ylosxn": "自定義訊息", + "k_13sajrj": "貼圖訊息", + "k_13sjeb7": "檔案訊息", + "k_0yd2ft8": "群提示訊息", + "k_13s7mxn": "圖片訊息", + "k_13satlt": "位置訊息", + "k_00bbtsx": "合並轉發訊息", + "k_13sqwu4": "語音訊息", + "k_13sqjjp": "影片", + "k_1fdhj9g": "該版本不支持此訊息", + "k_06pujtm": "同意任何用戶添加好友", + "k_0gyhkp5": "需要驗證", + "k_121ruco": "拒絕任何人加好友", + "k_05nspni": "自定義字段", + "k_03fchyy": "群頭像", + "k_03i9mfe": "群簡介", + "k_03agq58": "群名稱", + "k_039xqny": "群通知", + "k_003tr0a": "群主", + "k_03iqsh4": "$s為 ", + "k_191t5n4": "$opUserNickName修改", + "k_1pg6aoj": "$opUserNickName退出群組", + "k_1f6zt3v": "邀請$invitedMemberString加入群組", + "k_0y7zd07": "將$invitedMemberString踢出群組", + "k_03c49qt": "去授權", + "k_1d5mshh": "用戶$joinedMemberString加入了群組", + "k_002wddw": "禁言", + "k_0got6f7": "解除禁言", + "k_0yenqf0": "$userName 被", + "k_0spotql": "將 $adminMember 設置為管理員", + "k_0pg5zzj": "系統訊息 $operationType", + "k_0ohzb9l": "通話時間:$callTime", + "k_1uaqed6": "[自定義]", + "k_0z2z7rx": "[語音]", + "k_0y39ngu": "[貼圖]", + "k_1c7z88n": "[檔案] $fileName", + "k_0y1a2my": "[圖片]", + "k_0z4fib8": "[影片]", + "k_0y24mcg": "[位置]", + "k_0pewpd1": "[聊天記錄]", + "k_13s8d9p": "未知訊息", + "k_1c3us5n": "當前群組不支持@全體成員", + "k_11k579v": "發言中有非法語句", + "k_003qkx2": "日歷", + "k_003n2pz": "相機", + "k_03idjo0": "聯絡人", + "k_003ltgm": "位置", + "k_02k3k86": "咪高風", + "k_003pm7l": "相冊", + "k_15ao57x": "相冊寫入", + "k_164m3jd": "本地存儲", + "k_0qba4ns": "想訪問您的$yoursItem", + "k_03r6qyx": "我們需要您的同意才能獲取信息", + "k_02noktt": "不允許", + "k_00043x4": "好", + "k_003qzac": "昨天", + "k_003r39d": "前天", + "k_03fqp9o": "星期天", + "k_03ibg5h": "星期一", + "k_03i7hu1": "星期二", + "k_03iaiks": "星期三", + "k_03el9pa": "星期四", + "k_03i7ok1": "星期五", + "k_03efxyg": "星期六", + "k_0oozw9x": "$diffMinutes 分鐘前", + "k_003q7ba": "下午", + "k_003q7bb": "上午", + "k_003pu3h": "現在", + "k_13hzn00": "昨天 $yesterday", + "k_0n9pyxz": "用戶不存在", + "k_1bjwemh": "搜尋用戶 ID", + "k_003kv3v": "搜尋", + "k_02owlq8": "我的用戶ID: $userID", + "k_1wu8h4x": "我是: $showName", + "k_16758qw": "添加好友", + "k_1shx4d9": "個性簽名: $selfSignature", + "k_0i553x0": "填寫驗證信息", + "k_031ocwx": "請填寫備註和分組", + "k_003ojje": "備註", + "k_003lsav": "分組", + "k_167bdvq": "我的好友", + "k_156b4ut": "好友申請已發送", + "k_002r305": "發送", + "k_03gu05e": "聊天室", + "k_03b4f3p": "會議群", + "k_03avj1p": "公開群", + "k_03asq2g": "工作群", + "k_03b3hbi": "未知群", + "k_1loix7s": "群類型: $groupType", + "k_1lqbsib": "該群組不存在", + "k_03h153m": "搜尋群ID", + "k_0oxak3r": "群申請已發送", + "k_002rflt": "刪除", + "k_1don84v": "無法定位到原訊息", + "k_003q5fi": "復製", + "k_003prq0": "轉發", + "k_002r1h2": "多選", + "k_003j708": "引用", + "k_003pqpr": "回收", + "k_03ezhho": "已復製", + "k_11ctfsz": "暫未實現", + "k_1hbjg5g": "[群系統訊息]", + "k_03tvswb": "[未知訊息]", + "k_155cj23": "您回收了一條訊息,", + "k_0gapun3": "重新編輯", + "k_1uh417q": "$displayName回收了一條訊息", + "k_1aszp2k": "您確定要重發這條訊息麽?", + "k_003rzap": "確定", + "k_003nevv": "取消", + "k_0003z7x": "您", + "k_002wfe4": "已讀", + "k_002wjlg": "未讀", + "k_0h1ygf8": "發起通話", + "k_0h169j0": "取消通話", + "k_0h13jjk": "接受通話", + "k_0h19hfx": "拒絕通話", + "k_0obi9lh": "超時未接聽", + "k_0y9u662": "「$appName」暫不可以開啟此類檔案,你可以使用其他應用開啟並預覽", + "k_001nmhu": "用其他應用開啟", + "k_1ht1b80": "正在接收中", + "k_105682d": "圖片載入失敗", + "k_0pytyeu": "圖片保存成功", + "k_0akceel": "圖片保存失敗", + "k_003rk1s": "保存", + "k_04a0awq": "[語音訊息]", + "k_105c3y3": "影片載入失敗", + "k_176rzr7": "聊天記錄", + "k_0d5z4m5": "選擇提醒人", + "k_003ngex": "完成", + "k_1665ltg": "發起呼叫", + "k_003n8b0": "拍攝", + "k_003kthh": "照片", + "k_003tnp0": "檔案", + "k_0jhdhtp": "發送失敗,影片不能大於100MB", + "k_119ucng": "圖片不能為空", + "k_0w9x8gw": "選擇成功$successPath", + "k_13dsw4l": "松開取消", + "k_0am7r68": "手指上滑,取消發送", + "k_15jl6qw": "說話時間太短!", + "k_0gx7vl6": "按住說話", + "k_15dlafd": "逐條轉發", + "k_15dryxy": "合並轉發", + "k_1eyhieh": "確定刪除已選訊息", + "k_17fmlyf": "清除聊天", + "k_0dhesoz": "取消置頂", + "k_002sk7x": "置頂", + "k_003ll77": "草稿", + "k_03icaxo": "自定義", + "k_1969986": "[語音通話]:$callingLastMsgShow", + "k_1960dlr": "[視訊通話]:$callingLastMsgShow", + "k_1np495n": "$messageString[有人@我]", + "k_1m797yi": "$messageString[@所有人]", + "k_1uaov41": "查找聊天內容", + "k_003kfai": "未知", + "k_13dq4an": "自動審批", + "k_0l13cde": "管理員審批", + "k_11y8c6a": "禁止加群", + "k_1kvyskd": "無網絡連接,無法修改", + "k_16payqf": "加群方式", + "k_0vzvn8r": "修改群名稱", + "k_038lh6u": "群管理", + "k_0k5wyiy": "設置管理員", + "k_0goiuwk": "全員禁言", + "k_1g889xx": "全員禁言開啟後,只允許群主和管理員發言。", + "k_0wlrefq": "添加需要禁言的群成員", + "k_0goox5g": "設置禁言", + "k_08daijh": "成功取消管理員身份", + "k_0bxm97s": "管理員 ($adminNum/10)", + "k_0k5u935": "添加管理員", + "k_03enyx5": "群成員", + "k_0jayw3z": "群成員($groupMemberNum人)", + "k_0h1svv1": "刪除群成員", + "k_0h1g636": "添加群成員", + "k_0uj7208": "無網絡連接,無法查看群成員", + "k_01yfa4o": "$memberCount人", + "k_0hpukyx": "查看更多群成員", + "k_0qtsar0": "訊息免打擾", + "k_0ef2a12": "修改我的群昵稱", + "k_1aajych": "僅限中文、字母、數字和下劃線,2-20個字", + "k_137pab5": "我的群昵稱", + "k_0ivim6d": "暫無群公告", + "k_03eq6cn": "群公告", + "k_002vxya": "編輯", + "k_17fpl3y": "置頂聊天", + "k_03es1ox": "群類型", + "k_003mz1i": "同意", + "k_003lpre": "拒絕", + "k_003qk66": "頭像", + "k_003lhvk": "昵稱", + "k_003ps50": "賬號", + "k_15lx52z": "個性簽名", + "k_003qgkp": "性別", + "k_003m6hr": "生日", + "k_0003v6a": "男", + "k_00043x2": "女", + "k_03bcjkv": "未設置", + "k_11s0gdz": "修改昵稱", + "k_0p3j4sd": "僅限中字、字母、數字和下劃線", + "k_15lyvdt": "修改簽名", + "k_0vylzjp": "這個人很懶,什麽也沒寫", + "k_1hs7ese": "等上線再改這個", + "k_03exjk7": "備註名", + "k_0s3skfd": "加入黑名單", + "k_0p3b31s": "修改備註名", + "k_0003y9x": "無", + "k_11zgnfs": "個人資料", + "k_03xd79d": "個性簽名: $signature", + "k_1tez2xl": "暫無個性簽名", + "k_118prbn": "全局搜尋", + "k_1m9dftc": "全部聯絡人", + "k_0em4gyz": "全部群組", + "k_002twmj": "群組", + "k_09kga0d": "更多聊天記錄", + "k_1ui5lzi": "$count條相關聊天記錄", + "k_09khmso": "相關聊天記錄", + "k_1kevf4k": "與$receiver的聊天記錄", + "k_0vjj2kp": "群組的聊天記錄", + "k_003n2rp": "選擇", + "k_03ignw6": "所有人", + "k_03erpei": "管理員", + "k_0qi9tno": "群主、管理員", + "k_1m9exwh": "最近聯絡人", + "k_119nwqr": "輸入不能為空", + "k_0pzwbmg": "影片保存成功", + "k_0aktupv": "影片保存失敗", + "k_1qbg9xc": "$option8為 ", + "k_1wq5ubm": "$option7修改", + "k_0y5pu80": "$option6退出群組", + "k_0nl7cmd": "邀請$option5加入群組", + "k_1ju5iqw": "將$option4踢出群組", + "k_1ovt677": "用戶$option3加入了群組", + "k_0k05b8b": "$option2 被", + "k_0wm4xeb": "系統訊息 $option2", + "k_0nbq9v3": "通話時間:$option2", + "k_0i1kf53": "[檔案] $option2", + "k_1gnnby6": "想訪問您的$option2", + "k_1wh4atg": "$option2 分鐘前", + "k_07sh7g1": "昨天 $option2", + "k_1pj8xzh": "我的用戶ID: $option2", + "k_0py1evo": "個性簽名: $option2", + "k_1kvj4i2": "$option2回收了一條訊息", + "k_1v0lbpp": "「$option2」暫不可以開啟此類檔案,你可以使用其他應用開啟並預覽", + "k_0torwfz": "選擇成功$option2", + "k_0i1bjah": "$option1回收了一條訊息", + "k_1qzxh9q": "通話時間:$option3", + "k_0wrgmom": "[語音通話]:$option1", + "k_06ix2f0": "[視訊通話]:$option2", + "k_08o3z5w": "[檔案] $option1", + "k_0ezbepg": "$option2[有人@我]", + "k_1ccnht1": "$option2[@所有人]", + "k_1k3arsw": "管理員 ($option2/10)", + "k_1d4golg": "群成員($option1人)", + "k_1bg69nt": "$option1人", + "k_00gjqxj": "個性簽名: $option1", + "k_0c29cxr": "$option1條相關聊天記錄", + "k_1twk5rz": "與$option1的聊天記錄", + "k_1vn4xq1": "將 $adminMember 取消管理員", + "k_0e35hsw": "為方便您將所拍攝的照片或影片發送給朋友,以及進行視訊通話,請允許我們訪問攝像頭進行拍攝照片和影片。", + "k_0dj6yr7": "為方便您發送語音訊息、拍攝影片以及音視訊通話,請允許我們使用咪高風進行錄音。", + "k_003qnsl": "存儲", + "k_0s3rtpw": "為方便您查看和選擇相冊裏的圖片影片發送給朋友,以及保存內容到設備,請允許我們訪問您設備上的照片、媒體內容。", + "k_0tezv85": " 申請獲取$option2", + "k_002rety": "權限", + "k_18o68ro": "需要授予", + "k_1onpf8u": " 相機權限,以正常使用拍攝圖片/影片、視訊通話等功能。", + "k_17irga5": " 咪高風權限,以正常使用發送語音訊息、拍攝影片、音視訊通話等功能。", + "k_0572kc4": " 訪問照片權限,以正常使用發送圖片、影片等功能。", + "k_0slykws": " 訪問相冊寫入權限,以正常使用存儲圖片、影片等功能。", + "k_119pkcd": " 檔案讀寫權限,以正常使用在聊天功能中的圖片查看、選擇能力和發送檔案的能力。", + "k_0gqewd3": "以後再說", + "k_03eq4s1": "去開啟", + "k_0nt2uyg": "回到最新位置", + "k_04l16at": "$option1條新訊息", + "k_13p3w93": "有人@我", + "k_18w5uk6": "@所有人", + "k_0jmujgh": "其他檔案正在接收中", + "k_12s5ept": "訊息詳情", + "k_0mxa4f4": "$option1人已讀", + "k_061tue3": "$option2人未讀", + "k_18qjstb": "轉讓群主", + "k_0on1aj2": "有$option2條@我訊息", + "k_09j4izl": "[有人@我] ", + "k_1oqtjw0": "[@所有人] ", + "k_1x5a9vb": "我是: $option1", + "k_14n31e7": "進群請求", + "k_08nc5j1": "群類型: $option1", + "k_1josu12": "$option1 條入群請求", + "k_0n2x5s0": "驗證消息: $option2", + "k_03c1nx0": "已同意", + "k_03aw9w8": "已拒絕", + "k_038ryos": "去處理", + "k_0gw8pum": "進群申請", + "k_1gcvfrj": "請填寫備註名", + "k_002v9zj": "确认", + "k_10oqrki": "轻触拍照", + "k_0f8b3ws": "加载失败", + "k_11cm5lm": "手动聚焦", + "k_002uzrd": "预览", + "k_003qkn3": "录像", + "k_003k6a7": "拍照", + "k_0bqpqco": "拍照按钮", + "k_1626ozl": "停止录像", + "k_003lvmu": "前置", + "k_003lued": "后置", + "k_003lwzh": "外置", + "k_002qzi3": "关闭", + "k_003pufb": "自动", + "k_0apm0ze": "拍照时闪光", + "k_157zog5": "始终闪光", + "k_0cfyqhy": "$option1 画面预览", + "k_0phctlz": "闪光模式: $option2", + "k_02vfqe0": "切换至 $option3 摄像头", + "k_0f0y9ex": "说话时间太短", + "k_0ln70tk": "无法打开URL", + "k_11a3jdv": "轻触拍照,长按摄像", + "k_1k18miv": "请传入离开群组生命周期函数,提供返回首页或其他页面的导航方法。", + "k_1fu9ahv": "全员禁言状态", + "k_0gmwbnd": "全员禁言中", + "k_0got2zr": "您被禁言", + "k_0y9jck8": "你必须自定义search bar,并处理点击跳转", + "k_0yum3tv": "如使用自定义区域,请在profileWidgetBuilder传入对应组件", + "k_09kalj0": "清空聊天记录", + "k_14j5iul": "删除并退出", + "k_125ru1w": "解散该群", + "k_0jtutmw": "退出后不会接收到此群聊消息", + "k_0jtzmqa": "解散后不会接收到此群聊消息", + "k_0r8fi93": "好友添加成功", + "k_02qw14e": "好友申请已发出", + "k_0n3md5x": "当前用户在黑名单", + "k_094phq4": "好友添加失败", + "k_129scag": "好友删除成功", + "k_129uzfn": "好友删除失败", + "k_1666isy": "清除好友", + "k_1679vrd": "加为好友", + "k_1ualc52": "看看对方带来的数据是啥", + "k_0szluvp": "设置对方在线状态", + "k_0f4rnf8": "该用户已是好友", + "k_1tdkom4": "您已是群成员", + "k_1p2lyuz": "对方正在输入中...", + "k_1g8wfpy": "...共$option1人", + "k_12rv9vw": "回应详情", + "k_0havgi0": "[查看详情 >>](${linkMessage.link})", + "k_0n9p7g8": "群组不存在", + "k_1tdh5vn": "您不是群成员", + "k_0h1q57v": "暂无群成员", + "k_0y5drq1": "[查看详情 >>]($option1)", + "k_03pjp61": "[表情消息]", + "k_1jpvzul": "[自定义消息]", + "k_03u3bh1": "[文件消息]", + "k_1odsnsw": "[群消息]", + "k_03sel4t": "[图片消息]", + "k_03sfw3r": "[位置消息]", + "k_03xpuwq": "[合并消息]", + "k_07ycxwo": "[没有元素]", + "k_03rc9vz": "[文本消息]", + "k_046uopf": "[视频消息]", + "k_0ehmsun": "设备存储空间不足,建议清理,以获得更好使用体验", + "k_003kmos": "图片", + "k_002s86q": "视频", + "k_06bk5ei": "视频消息仅限 mp4 格式", + "k_13opfxf": "Web网页端不支持搜索", + "k_1i0o0y2": "暂时仅限 Android/iOS 端", + "k_045dtzl": "$option1的聊天记录", + "k_0t0131u": "群资料信息", + "k_18ok8xz": "消息接收方式", + "k_03ax3ks": "群资料", + "k_0sqvoqo": "将 $option1 设置为管理员", + "k_1gbg1v8": "将 $option1 取消管理员", + "k_17k64g4": "群聊创建成功!", + "k_05mn217": "暂未安装表情包插件,如需使用表情相关功能,请根据本文档安装:https://cloud.tencent.com/document/product/269/70746", + "k_14j17nz": "暂无表情包", + "k_0fvjexh": "正在下载中", + "k_1cdagzz": "已加入待下载队列,其他文件下载中", + "k_0g4vojc": "开始下载", + "k_1g32es3": "[调皮]@2x.png", + "k_1g8qorz": "[爱你]@2x.png", + "k_1g4hmx6": "[爱情]@2x.png", + "k_1g6b558": "[爱心]@2x.png", + "k_1g3m4su": "[傲慢]@2x.png", + "k_1g2jym7": "[白眼]@2x.png", + "k_0cgkxuw": "[棒棒糖]@2x.png", + "k_1g48br2": "[抱抱]@2x.png", + "k_1g49ol8": "[抱拳]@2x.png", + "k_1g0ras3": "[爆筋]@2x.png", + "k_1ghy881": "[鄙视]@2x.png", + "k_1g86bmv": "[闭嘴]@2x.png", + "k_1g1xs1p": "[鞭炮]@2x.png", + "k_1g8i6ri": "[便便]@2x.png", + "k_1g2u5kf": "[擦汗]@2x.png", + "k_1g60uwh": "[彩带]@2x.png", + "k_1g1o0d0": "[彩球]@2x.png", + "k_1g6a6yq": "[菜刀]@2x.png", + "k_1g6vqo2": "[差劲]@2x.png", + "k_1g0kvjc": "[钞票]@2x.png", + "k_1g65x7e": "[车厢]@2x.png", + "k_0e1tjol": "[打哈欠]@2x.png", + "k_1g65n58": "[大兵]@2x.png", + "k_1g7se7o": "[大哭]@2x.png", + "k_1g03868": "[蛋糕]@2x.png", + "k_1h8nm66": "[刀]@2x.png", + "k_1g3dlpi": "[得意]@2x.png", + "k_1g3u434": "[灯泡]@2x.png", + "k_1giuqs7": "[凋谢]@2x.png", + "k_1g8r0r9": "[多云]@2x.png", + "k_1g7k6i1": "[发呆]@2x.png", + "k_1g44zsp": "[发抖]@2x.png", + "k_1g5l96i": "[飞机]@2x.png", + "k_1g7wsqj": "[飞吻]@2x.png", + "k_1g49luq": "[奋斗]@2x.png", + "k_1gixbsm": "[风车]@2x.png", + "k_1g6cqbq": "[尴尬]@2x.png", + "k_1g6jbw5": "[勾引]@2x.png", + "k_1g3lwo1": "[鼓掌]@2x.png", + "k_1g13nkj": "[害羞]@2x.png", + "k_1g0mt47": "[憨笑]@2x.png", + "k_0bxujkf": "[红灯笼]@2x.png", + "k_0hhaeh8": "[红双喜]@2x.png", + "k_1g0jnts": "[坏笑]@2x.png", + "k_1g46g9c": "[挥手]@2x.png", + "k_1g4vi9g": "[回头]@2x.png", + "k_1gf7hes": "[饥饿]@2x.png", + "k_1g6mvsm": "[激动]@2x.png", + "k_1gku5mf": "[街舞]@2x.png", + "k_1g4hidg": "[惊恐]@2x.png", + "k_1gjbrtu": "[惊讶]@2x.png", + "k_1g6sand": "[咖啡]@2x.png", + "k_1g4s8rj": "[磕头]@2x.png", + "k_1g1wn34": "[可爱]@2x.png", + "k_1g3l0wd": "[可怜]@2x.png", + "k_1ggaon9": "[抠鼻]@2x.png", + "k_1ggvcb0": "[骷髅]@2x.png", + "k_1h8yqjt": "[酷]@2x.png", + "k_0jac97i": "[快哭了]@2x.png", + "k_1h8oiby": "[困]@2x.png", + "k_1g0s5hg": "[蜡烛]@2x.png", + "k_1g1iuer": "[篮球]@2x.png", + "k_1g2xjfi": "[冷汗]@2x.png", + "k_0s5oyqw": "[礼品袋]@2x.png", + "k_1g1qqvf": "[礼物]@2x.png", + "k_1g2slew": "[流汗]@2x.png", + "k_1g3z9xx": "[流泪]@2x.png", + "k_1g6pabn": "[麻将]@2x.png", + "k_0pkaxul": "[麦克风]@2x.png", + "k_1g7m0zj": "[猫咪]@2x.png", + "k_0ibvtpo": "[么么哒]@2x.png", + "k_1g1hoh1": "[玫瑰]@2x.png", + "k_1gfzeow": "[米饭]@2x.png", + "k_1g5l15p": "[面条]@2x.png", + "k_1g2hfa6": "[奶瓶]@2x.png", + "k_1gix9pj": "[难过]@2x.png", + "k_1giqn6g": "[闹钟]@2x.png", + "k_1h8kd64": "[怒]@2x.png", + "k_1g0vui9": "[怄火]@2x.png", + "k_1g1jsj7": "[皮球]@2x.png", + "k_1ghdluw": "[啤酒]@2x.png", + "k_1gl6ec7": "[瓢虫]@2x.png", + "k_1g7gg5p": "[撇嘴]@2x.png", + "k_1g8psin": "[乒乓]@2x.png", + "k_1gjzu3p": "[汽车]@2x.png", + "k_1h8mr0k": "[强]@2x.png", + "k_1g45y2n": "[敲打]@2x.png", + "k_1gkaxsl": "[青蛙]@2x.png", + "k_0jcfnoo": "[糗大了]@2x.png", + "k_1g4njy1": "[拳头]@2x.png", + "k_1h8mqr3": "[弱]@2x.png", + "k_1h926fg": "[色]@2x.png", + "k_1g6rtbq": "[沙发]@2x.png", + "k_1giirh6": "[删除]@2x.png", + "k_1g14ny9": "[闪电]@2x.png", + "k_1g6bmsr": "[胜利]@2x.png", + "k_1g1rytx": "[示爱]@2x.png", + "k_1g52fbz": "[手枪]@2x.png", + "k_1h90dam": "[衰]@2x.png", + "k_1gigiae": "[睡觉]@2x.png", + "k_1gijchz": "[太阳]@2x.png", + "k_1g1sgji": "[跳绳]@2x.png", + "k_1gjwuri": "[跳跳]@2x.png", + "k_1g0juhk": "[偷笑]@2x.png", + "k_1h8nzla": "[吐]@2x.png", + "k_1g6cv0i": "[委屈]@2x.png", + "k_1g46l5g": "[握手]@2x.png", + "k_1g2pgkd": "[西瓜]@2x.png", + "k_1ging9p": "[下雨]@2x.png", + "k_1h8nzil": "[吓]@2x.png", + "k_1g7q7wr": "[献吻]@2x.png", + "k_1gl6uum": "[香蕉]@2x.png", + "k_1g23fys": "[象棋]@2x.png", + "k_0j75rdh": "[心碎了]@2x.png", + "k_1g6ajj2": "[信封]@2x.png", + "k_1g21prz": "[熊猫]@2x.png", + "k_1h8octi": "[嘘]@2x.png", + "k_1h91zox": "[药]@2x.png", + "k_1ghttfl": "[疑问]@2x.png", + "k_1ghk7sz": "[阴险]@2x.png", + "k_0gl37zz": "[右车头]@2x.png", + "k_0ifkj1p": "[右哼哼]@2x.png", + "k_0g1yh2e": "[右太极]@2x.png", + "k_1g9dkfc": "[雨伞]@2x.png", + "k_1g8jl88": "[月亮]@2x.png", + "k_1h8lhqj": "[晕]@2x.png", + "k_1gi9x2q": "[再见]@2x.png", + "k_1g6dwwv": "[炸弹]@2x.png", + "k_1fzmkfi": "[折磨]@2x.png", + "k_1g6jbiw": "[纸巾]@2x.png", + "k_1ggjnwu": "[咒骂]@2x.png", + "k_1g4qlq8": "[猪头]@2x.png", + "k_1g1lqzz": "[抓狂]@2x.png", + "k_1g80j3u": "[转圈]@2x.png", + "k_1g0z55s": "[龇牙]@2x.png", + "k_1g3ju6v": "[钻戒]@2x.png", + "k_0gl51l6": "[左车头]@2x.png", + "k_0iflllk": "[左哼哼]@2x.png", + "k_0g1y3ir": "[左太极]@2x.png", + "k_026hiq5": "消息列表加载中", + "k_003tu8k": "爱你", + "k_003myvp": "傲慢", + "k_003kddw": "白眼", + "k_039yfhv": "棒棒糖", + "k_003nu3p": "抱抱", + "k_003nijr": "抱拳", + "k_003mg88": "爆筋", + "k_002v17e": "鄙视", + "k_003qhy4": "闭嘴", + "k_003l5fq": "鞭炮", + "k_003uacl": "便便", + "k_003oq1g": "擦汗", + "k_003qvey": "彩带", + "k_003jci7": "彩球", + "k_003pyu1": "菜刀", + "k_003q97d": "差劲", + "k_003po5d": "车厢", + "k_03eadb2": "打哈欠", + "k_003pnuf": "大兵", + "k_003kg57": "蛋糕", + "k_003mxkt": "得意", + "k_003onu3": "灯泡", + "k_002uv8s": "凋谢", + "k_003kqy0": "调皮", + "k_003tyum": "多云", + "k_003pv9u": "发呆", + "k_036o6mu": "发抖t", + "k_003nogx": "飞机", + "k_003q7wg": "飞吻", + "k_003m0jd": "奋斗", + "k_002ult9": "风车", + "k_003r8gt": "尴尬", + "k_003qy4u": "勾引", + "k_003mnoa": "鼓掌", + "k_003lmw8": "害羞", + "k_003mb30": "憨笑", + "k_03bj41g": "红灯笼", + "k_03dxw2f": "红双喜", + "k_003mk57": "坏笑", + "k_003nmvf": "挥手", + "k_003r2i7": "回头", + "k_002s6f3": "饥饿", + "k_003qd0t": "激动", + "k_002vgi4": "街舞", + "k_003nz33": "惊恐", + "k_002wh4p": "惊讶", + "k_003ozpu": "咖啡", + "k_003qvs4": "磕头", + "k_003l3wb": "可爱", + "k_003nuwm": "可怜", + "k_002rw1q": "抠鼻", + "k_002tujb": "骷髅", + "k_00030eq": "酷", + "k_03i8ath": "快哭了", + "k_000421h": "困", + "k_003l5i7": "蜡烛", + "k_003j72g": "篮球", + "k_003ofwl": "冷汗", + "k_02mw65v": "礼品袋", + "k_003ku40": "礼物", + "k_003ookz": "流汗", + "k_003on72": "流泪", + "k_003rjy0": "麻将", + "k_003q2f8": "猫咪", + "k_03et393": "么么哒", + "k_003j7j2": "玫瑰", + "k_002sr0b": "米饭", + "k_003nnza": "面条", + "k_003jef9": "奶瓶", + "k_002umn0": "难过", + "k_002rjib": "闹钟", + "k_0003zcn": "怒", + "k_003jzwq": "怄火", + "k_003j4js": "皮球", + "k_002r5ir": "啤酒", + "k_002ubu4": "瓢虫", + "k_003ppo6": "撇嘴", + "k_003ty3o": "乒乓", + "k_002vxwe": "汽车", + "k_00043hb": "强", + "k_003nmbo": "敲打", + "k_002tfhq": "青蛙", + "k_03i7lrn": "糗大了", + "k_003r03m": "拳头", + "k_00043h0": "弱", + "k_000345z": "色", + "k_003qmp9": "沙发", + "k_003it8a": "闪电", + "k_003pxow": "胜利", + "k_003kw8e": "示爱", + "k_003n99g": "手枪", + "k_00035cl": "衰", + "k_002vl3h": "睡觉", + "k_002rgqk": "太阳", + "k_003m9d1": "跳绳", + "k_002vobp": "跳跳", + "k_003mkoz": "偷笑", + "k_00041px": "吐", + "k_003rjh5": "委屈", + "k_003j36u": "西瓜", + "k_002re92": "下雨", + "k_00041py": "吓", + "k_003q06o": "献吻", + "k_002ubjp": "香蕉", + "k_003o2tr": "象棋", + "k_03ie6pa": "心碎了", + "k_003rao5": "信封", + "k_003l3us": "熊猫", + "k_000424d": "嘘", + "k_00033yi": "药", + "k_002qtyy": "疑问", + "k_002qe0o": "阴险", + "k_03gu7us": "右车头", + "k_03ere8m": "右哼哼", + "k_003uqk3": "雨伞", + "k_003tzdv": "月亮", + "k_0003z00": "晕", + "k_002vdrd": "再见", + "k_003ra1w": "炸弹", + "k_003lcad": "折磨", + "k_003q7sz": "纸巾", + "k_002thn9": "咒骂", + "k_003qx7f": "猪头", + "k_003l044": "抓狂", + "k_003qg4h": "转圈", + "k_003kb97": "龇牙", + "k_03gu53l": "左车头", + "k_03erd1f": "左哼哼", + "k_003nyvl": "爱情", + "k_003r85z": "爱心", + "k_003mk8j": "钞票", + "k_003pwfj": "大哭", + "k_00042w5": "刀", + "k_003nmtr": "握手", + "k_03c529p": "右太极", + "k_003n4mk": "钻戒", + "k_03c5488": "左太极", + "k_1llp7tu": "该用户不存在", + "k_0tbyqyb": "加载中…", + "k_0td1p3f": "保存中…", + "k_1klqdh1": "仅限汉字、英文、数字和下划线", + "k_03el5lp": "未填写", + "k_1ui0gai": "搜索指定内容", + "k_003nvk2": "消息", + "k_03agld7": "群提示", + "k_0elt0kw": "添加群聊", + "k_0s3sgel": "移出黑名单", + "k_1qqgjra": "$option3条未读消息", + "k_0uubyjr": "以下为未读消息", + "k_16as7eq": "表情回应", + "k_003s12u": "回复", + "k_003s38r": "更多", + "k_002wkr3": "翻译", + "k_13g4hxv": "翻译完成", + "k_003molk": "表情", + "k_165bbw6": "消息历史", + "k_13sqc0z": "清除消息", + "k_0glns86": "删除会话", + "k_13s99rx": "清空消息", + "k_11vsa3j": "退出群组", + "k_11vvszp": "解散群组", + "k_15i9w72": "群管理员", + "k_0p3espj": "设置备注名", + "k_118sw9v": "立即搜索", + "k_0h20hg5": "视频通话", + "k_0h22snw": "语音通话", + "k_003lz6t": "对方", + "k_1xf4yre": "发送给$option1", + "k_003por5": "截图", + "k_1rw7s82": " 访问相册中视频权限,以正常使用发送视频等功能。", + "k_003rcwm": "打开", + "k_1698c42": "在访达中打开", + "k_066fxsz": "查看文件夹", + "k_0k432y2": "无法发送,包含文件夹", + "k_002wb4y": "会话", + "k_0od4qyh": "视频文件异常", + "k_1bfkxg9": "不支持 0KB 文件的传输", + "k_0vvsw7g": "文件处理异常", + "k_06e224q": "[消息被管理员撤回]", + "k_1u1mjcl": "[消息被撤回]", + "k_1qcqxea": "选择多个会话", + "k_1qgmc20": "选择一个会话", + "k_1d8nx6f": "在新窗口中打开", + "k_1hz05ax": "正在下载原始资源,请稍候...", + "k_002robo": "清除" +} \ No newline at end of file diff --git a/lib/import_proxy/general.dart b/lib/import_proxy/general.dart new file mode 100644 index 0000000..fe8a907 --- /dev/null +++ b/lib/import_proxy/general.dart @@ -0,0 +1,4 @@ +import 'package:tencent_cloud_chat_uikit/import_proxy/import_proxy.dart'; + +ImportProxy getImportProxy() => + throw UnsupportedError('Cannot create a ImportProxy.'); diff --git a/lib/import_proxy/import_proxy.dart b/lib/import_proxy/import_proxy.dart new file mode 100644 index 0000000..7ae5010 --- /dev/null +++ b/lib/import_proxy/import_proxy.dart @@ -0,0 +1,11 @@ +import 'package:tencent_cloud_chat_uikit/import_proxy/general.dart' +// ignore: uri_does_not_exist + if (dart.library.html) 'platform/web_import.dart' +// ignore: uri_does_not_exist + if (dart.library.io) 'platform/native_import.dart'; + +abstract class ImportProxy { + getFlutterPluginRecord(); + + factory ImportProxy() => getImportProxy(); +} diff --git a/lib/import_proxy/platform/native_import.dart b/lib/import_proxy/platform/native_import.dart new file mode 100644 index 0000000..faa0566 --- /dev/null +++ b/lib/import_proxy/platform/native_import.dart @@ -0,0 +1,11 @@ +import 'package:flutter_plugin_record_plus/flutter_plugin_record.dart'; +import 'package:tencent_cloud_chat_uikit/import_proxy/import_proxy.dart'; + +class NativeImport implements ImportProxy { + @override + FlutterPluginRecord? getFlutterPluginRecord() { + return FlutterPluginRecord(); + } +} + +ImportProxy getImportProxy() => NativeImport(); diff --git a/lib/import_proxy/platform/web_import.dart b/lib/import_proxy/platform/web_import.dart new file mode 100644 index 0000000..f639e00 --- /dev/null +++ b/lib/import_proxy/platform/web_import.dart @@ -0,0 +1,10 @@ +import 'package:tencent_cloud_chat_uikit/import_proxy/import_proxy.dart'; + +class WebImport implements ImportProxy { + @override + void getFlutterPluginRecord() { + return; + } +} + +ImportProxy getImportProxy() => WebImport(); diff --git a/lib/tencent_cloud_chat_uikit.dart b/lib/tencent_cloud_chat_uikit.dart new file mode 100644 index 0000000..d06b622 --- /dev/null +++ b/lib/tencent_cloud_chat_uikit.dart @@ -0,0 +1,63 @@ +library tencent_cloud_chat_uikit; + +import 'package:tencent_cloud_chat_sdk/manager/v2_tim_manager.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'data_services/core/core_services_implements.dart'; +export 'data_services/core/core_services_implements.dart'; + +// Sticker +export 'package:tim_ui_kit_sticker_plugin/tim_ui_kit_sticker_plugin.dart'; + +// Widgets +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_chat.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/tim_uikit_profile.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitContact/tim_uikit_contact.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroup/tim_uikit_group.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitBlackList/tim_uikit_black_list.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitNewContact/tim_uikit_new_contact.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitNewContact/tim_uikit_unread_count.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/tim_uikit_group_profile.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart'; +export 'package:tencent_cloud_chat_uikit/ui/widgets/unread_message.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitAddFriend/tim_uikit_add_friend.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitAddGroup/tim_uikit_add_group.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_more_panel.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroup/tim_uikit_group_application_list.dart'; +export 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/models/link_preview_content.dart'; +export 'package:tencent_cloud_chat_uikit/ui/widgets/column_menu.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitConversation/tim_ui_kit_conversation_total_unread.dart'; + +// Enum +export 'package:tencent_cloud_chat_uikit/ui/theme/tim_uikit_message_theme.dart'; + +// Controller +export 'package:tencent_cloud_chat_uikit/ui/controller/tim_uikit_profile_controller.dart'; + +// Config +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart'; +export 'package:permission_handler/permission_handler.dart'; + +// Utils +export 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +export 'package:tencent_cloud_uikit_core/tencent_cloud_uikit_core.dart'; + +class TIMUIKitCore { + static CoreServicesImpl getInstance() { + setupServiceLocator(); + return serviceLocator(); + } + + static V2TIMManager getSDKInstance() { + return TencentImSDKPlugin.v2TIMManager; + } +} diff --git a/lib/theme/color.dart b/lib/theme/color.dart new file mode 100644 index 0000000..20bf4de --- /dev/null +++ b/lib/theme/color.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +Color hexToColor(String hexString) { + return Color(int.parse(hexString, radix: 16)).withAlpha(255); +} + +class CommonColor { + static const weakBackgroundColor = Color(0xFFEDEDED); + static const weakDividerColor = Color(0xFFE5E6E9); + static const primaryColor = Color(0xFF147AFF); + static const lightPrimaryColor = Color(0xFFC0E1FF); + static const secondaryColor = Color(0xFF147AFF); + static const weakTextColor = Color(0xFF999999); + static const infoColor = Color(0xFFFF9C19); + static const cautionColor = Color(0xFFFF584C); + static const ownerColor = Colors.orange; + static const adminColor = Colors.blue; + static const inputFillColor = Color.fromARGB(0, 242, 243, 245); + static const textgrey = Color(0xFFAEA4A3); + + static const defaultTheme = TUITheme( + weakBackgroundColor: Color(0xFFEDEDED), + weakDividerColor: Color(0xFFE5E6E9), + primaryColor: Color(0xFF147AFF), + secondaryColor: Color(0xFF147AFF), + infoColor: Color(0xFFFF9C19), + lightPrimaryColor: Color(0xFFC0E1FF), + weakTextColor: Color(0xFF999999), + darkTextColor: Color(0xFF444444), + cautionColor: Color(0xFFFF584C), + ownerColor: Colors.orange, + adminColor: Colors.blue, + inputFillColor: Color.fromARGB(0, 242, 243, 245), + textgrey: Color(0xFFAEA4A3), + ); +} diff --git a/lib/theme/tui_theme.dart b/lib/theme/tui_theme.dart new file mode 100644 index 0000000..0cb50e4 --- /dev/null +++ b/lib/theme/tui_theme.dart @@ -0,0 +1,597 @@ +import 'package:flutter/material.dart'; + +class TUITheme { + const TUITheme({ + this.primaryColor = const Color(0xFF00449E), + this.secondaryColor = const Color(0xFF147AFF), + this.infoColor = const Color(0xFFFF9C19), + this.weakBackgroundColor = const Color(0xFFEDEDED), + this.wideBackgroundColor = Colors.white, + this.weakDividerColor = const Color(0xFFE5E6E9), + this.weakTextColor = const Color(0xFF999999), + this.darkTextColor = const Color(0xFF444444), + this.lightPrimaryColor = const Color(0xFF3371CD), + this.textColor, + this.cautionColor = const Color(0xFFFF584C), + this.ownerColor = Colors.orange, + this.adminColor = Colors.blue, + this.white = Colors.white, + this.black = Colors.black, + this.inputFillColor = const Color(0xFFEDEDED), + this.textgrey = const Color(0xFFAEA4A3), + + /// 消息列表多选面板背景颜色 + this.selectPanelBgColor = const Color(0xFFF9F9FA), + + /// 消息列表多选面板文字及icon颜色 + this.selectPanelTextIconColor = const Color(0xFF37393F), + + /// Appbar 背景颜色 + this.appbarBgColor = const Color(0xFFF2F3F5), + + /// Appbar 文字颜色 + this.appbarTextColor = const Color(0xFF010000), + + /// 会话列表背景颜色 + this.conversationItemBgColor = Colors.white, // 1 + + /// 会话列表边框颜色 + this.conversationItemBorderColor = const Color(0xFFE5E6E9), // 1 + + /// 会话列表选中背景颜色 + this.conversationItemActiveBgColor = const Color(0xFFEDEDED), // 1 + + /// 会话列表置顶背景颜色 + this.conversationItemPinedBgColor = const Color(0xFFEDEDED), // 1 + + /// 会话列表Title字体颜色 + this.conversationItemTitleTextColor = Colors.black, // 1 + + /// 会话列表LastMessage字体颜色 + this.conversationItemLastMessageTextColor = const Color(0xFF999999), // 1 + + /// 会话列表Time字体颜色 + this.conversationItemTitmeTextColor = const Color(0xFF999999), // 1 + + /// 会话列表用户在线状态背景色 + this.conversationItemOnlineStatusBgColor = Colors.green, // 1 + + /// 会话列表用户离线状态背景色 + this.conversationItemOfflineStatusBgColor = Colors.grey, // 1 + + /// 会话列表未读数背景颜色 + this.conversationItemUnreadCountBgColor = const Color(0xFFFF584C), // 1 + + /// 会话列表未读数字体颜色 + this.conversationItemUnreadCountTextColor = Colors.white, // 1 + + /// 会话列表草稿字体颜色 + this.conversationItemDraftTextColor = const Color(0xFFFF584C), // 1 + + /// 会话列表收到消息不提醒Icon颜色 + this.conversationItemNoNotificationIconColor = const Color(0xFF999999), // 1 + + /// 会话列表侧滑按钮字体颜色 + this.conversationItemSliderTextColor = Colors.white, // 1 + + /// 会话列表侧滑按钮Clear背景颜色 + this.conversationItemSliderClearBgColor = const Color(0xFF00449E), // 1 + + /// 会话列表侧滑按钮Pin背景颜色 + this.conversationItemSliderPinBgColor = const Color(0xFFFF9C19), // 1 + + /// 会话列表侧滑按钮Delete背景颜色 + this.conversationItemSliderDeleteBgColor = Colors.red, // 1 + + /// 会话列表宽屏模式选中时背景颜色 + this.conversationItemChooseBgColor = const Color(0xFFE7F0FF), // 1 + + /// 聊天页背景颜色 + this.chatBgColor, // 1 + + /// 桌面端消息输入框背景颜色 + this.desktopChatMessageInputBgColor, // 1 + + /// 聊天页背景颜色 + this.chatTimeDividerTextColor = const Color(0xFF999999), // 1 + + /// 聊天页导航栏背景颜色 + this.chatHeaderBgColor = const Color(0xFFF2F3F5), + + /// 聊天页导航栏Title字体颜色 + this.chatHeaderTitleTextColor = const Color(0xFF010000), + + /// 聊天页导航栏Back字体颜色 + this.chatHeaderBackTextColor = const Color(0xFF010000), + + /// 聊天页导航栏Action字体颜色 + this.chatHeaderActionTextColor = const Color(0xFF010000), + + /// 聊天页历史消息列表字体颜色 + this.chatMessageItemTextColor = Colors.black, // 1 + + /// 聊天页历史消息列表来自自己时背景颜色 + this.chatMessageItemFromSelfBgColor = const Color(0xFFD1E3FF), + + /// 聊天页历史消息列表来自非自己时背景颜色 + this.chatMessageItemFromOthersBgColor = const Color(0xFFEDEDED), // 1 + + /// 聊天页历史消息列表已读状态字体颜色 + this.chatMessageItemUnreadStatusTextColor = const Color(0xFF999999), // 1 + + /// 聊天页历史消息列表小舌头背景颜色 + this.chatMessageTongueBgColor = const Color(0xFFAEA4A3), + + /// 聊天页历史消息列表小舌头字体颜色 + this.chatMessageTongueTextColor = const Color(0xFFAEA4A3), + }); + + // 应用主色 + // Primary Color For The App + final Color? primaryColor; + + // 应用次色 + // Secondary Color For The App + final Color? secondaryColor; + + // 提示颜色,用于次级操作或提示 + // Info Color, Used For Secondary Action Or Info + final Color? infoColor; + + // 浅背景颜色,比主背景颜色浅,用于填充缝隙或阴影 + // Weak Background Color, Lighter Than Main Background, Used For Marginal Space Or Shadowy Space + final Color? weakBackgroundColor; + + // 宽屏幕:浅白背景颜色,比浅背景颜色浅 + // Weak Background Color, Lighter Than Main Background, Used For Marginal Space Or Shadowy Space + final Color? wideBackgroundColor; + + // 浅分割线颜色,用于分割线或边框 + // Weak Divider Color, Used For Divider Or Border + final Color? weakDividerColor; + + // 浅字色 + // Weak Text Color + final Color? weakTextColor; + + // 深字色 + // Dark Text Color + final Color? darkTextColor; + + // 浅主色,用于AppBar或Panels + // Light Primary Color, Used For AppBar Or Several Panels + final Color? lightPrimaryColor; + + // 字色 + // TextColor + final Color? textColor; + + // 警示色,用于危险操作 + // Caution Color, Used For Warning Actions + final Color? cautionColor; + + // 群主标识色 + // Group Owner Identification Color + final Color? ownerColor; + + // 群管理员标识色 + // Group Admin Identification Color + final Color? adminColor; + + // 白色 + // white + final Color? white; + + // 黑色 + // black + final Color? black; + + // 输入框填充色 + // input fill color + final Color? inputFillColor; + + // 灰色文本 + // grey text color + final Color? textgrey; + + /// 新版本颜色从这里开始 + /// + /// Appbar 背景颜色 + final Color? appbarBgColor; + + /// Appbar 文字颜色 + final Color? appbarTextColor; + + /// 消息列表多选面板背景颜色 + final Color? selectPanelBgColor; + + /// 消息列表多选面板文字及icon颜色 + final Color? selectPanelTextIconColor; + + /// 会话列表背景颜色 + final Color? conversationItemBgColor; + + /// 会话列表边框颜色 + final Color? conversationItemBorderColor; + + /// 会话列表选中背景颜色 + final Color? conversationItemActiveBgColor; + + /// 会话列表置顶背景颜色 + final Color? conversationItemPinedBgColor; + + /// 会话列表Title字体颜色 + final Color? conversationItemTitleTextColor; + + /// 会话列表LastMessage字体颜色 + final Color? conversationItemLastMessageTextColor; + + /// 会话列表Time字体颜色 + final Color? conversationItemTitmeTextColor; + + /// 会话列表用户在线状态背景色 + final Color? conversationItemOnlineStatusBgColor; + + /// 会话列表用户离线状态背景色 + final Color? conversationItemOfflineStatusBgColor; + + /// 会话列表未读数背景颜色 + final Color? conversationItemUnreadCountBgColor; + + /// 会话列表未读数字体颜色 + final Color? conversationItemUnreadCountTextColor; + + /// 会话列表草稿字体颜色 + final Color? conversationItemDraftTextColor; + + /// 会话列表收到消息不提醒Icon颜色 + final Color? conversationItemNoNotificationIconColor; + + /// 会话列表侧滑按钮字体颜色 + final Color? conversationItemSliderTextColor; + + /// 会话列表侧滑按钮Clear背景颜色 + final Color? conversationItemSliderClearBgColor; + + /// 会话列表侧滑按钮Pin背景颜色 + final Color? conversationItemSliderPinBgColor; + + /// 会话列表侧滑按钮Delete背景颜色 + final Color? conversationItemSliderDeleteBgColor; + + /// 会话列表宽屏模式选中时背景颜色 + final Color? conversationItemChooseBgColor; + + /// 聊天页背景颜色 + final Color? chatBgColor; + + /// 桌面端消息输入框背景颜色 + final Color? desktopChatMessageInputBgColor; + + /// 聊天页背景颜色 + final Color? chatTimeDividerTextColor; + + /// 聊天页导航栏背景颜色 + final Color? chatHeaderBgColor; + + /// 聊天页导航栏Title字体颜色 + final Color? chatHeaderTitleTextColor; + + /// 聊天页导航栏Back字体颜色 + final Color? chatHeaderBackTextColor; + + /// 聊天页导航栏Action字体颜色 + final Color? chatHeaderActionTextColor; + + /// 聊天页历史消息列表字体颜色 + final Color? chatMessageItemTextColor; + + /// 聊天页历史消息列表来自自己时背景颜色 + final Color? chatMessageItemFromSelfBgColor; + + /// 聊天页历史消息列表来自非自己时背景颜色 + final Color? chatMessageItemFromOthersBgColor; + + /// 聊天页历史消息列表已读状态字体颜色 + final Color? chatMessageItemUnreadStatusTextColor; + + /// 聊天页历史消息列表小舌头背景颜色 + final Color? chatMessageTongueBgColor; + + /// 聊天页历史消息列表小舌头字体颜色 + final Color? chatMessageTongueTextColor; + + static const TUITheme light = TUITheme(); + static const TUITheme dark = TUITheme(); + + MaterialColor get primaryMaterialColor => createMaterialColor(primaryColor!); + + MaterialColor get lightPrimaryMaterialColor => + createMaterialColor(lightPrimaryColor!); + + TUITheme.fromJson(Map json) + : primaryColor = json['primaryColor'] as Color?, + secondaryColor = json['secondaryColor'] as Color?, + infoColor = json['infoColor'] as Color?, + weakBackgroundColor = json['weakBackgroundColor'] as Color?, + wideBackgroundColor = json['wideBackgroundColor'] as Color?, + weakDividerColor = json['weakDividerColor'] as Color?, + weakTextColor = json['weakTextColor'] as Color?, + darkTextColor = json['darkTextColor'] as Color?, + lightPrimaryColor = json['lightPrimaryColor'] as Color?, + textColor = json['textColor'] as Color?, + cautionColor = json['cautionColor'] as Color?, + ownerColor = json['ownerColor'] as Color?, + white = json['white'] as Color?, + black = json['black'] as Color?, + inputFillColor = json["inputFillColor"] as Color?, + textgrey = json['textgrey'] as Color?, + adminColor = json['adminColor'] as Color?, + selectPanelBgColor = json['selectPanelBgColor'] as Color?, + selectPanelTextIconColor = json['selectPanelTextIconColor'] as Color?, + appbarBgColor = json['appbarBgColor'] as Color?, + appbarTextColor = json['appbarTextColor'] as Color?, + + /// 会话列表背景颜色 + conversationItemBgColor = json['conversationItemBgColor'] as Color?, + + /// 会话列表边框颜色 + conversationItemBorderColor = + json['conversationItemBorderColor'] as Color?, + + /// 会话列表选中背景颜色 + conversationItemActiveBgColor = + json['conversationItemActiveBgColor'] as Color?, + + /// 会话列表置顶背景颜色 + conversationItemPinedBgColor = + json['conversationItemPinedBgColor'] as Color?, + + /// 会话列表Title字体颜色 + conversationItemTitleTextColor = + json['conversationItemTitleTextColor'] as Color?, + + /// 会话列表LastMessage字体颜色 + conversationItemLastMessageTextColor = + json['conversationItemLastMessageTextColor'] as Color?, + + /// 会话列表Time字体颜色 + conversationItemTitmeTextColor = + json['conversationItemTitmeTextColor'] as Color?, + + /// 会话列表用户在线状态背景色 + conversationItemOnlineStatusBgColor = + json['conversationItemOnlineStatusBgColor'] as Color?, + + /// 会话列表用户离线状态背景色 + conversationItemOfflineStatusBgColor = + json['conversationItemOfflineStatusBgColor'] as Color?, + + /// 会话列表未读数背景颜色 + conversationItemUnreadCountBgColor = + json['conversationItemUnreadCountBgColor'] as Color?, + + /// 会话列表未读数字体颜色 + conversationItemUnreadCountTextColor = + json['conversationItemUnreadCountTextColor'] as Color?, + conversationItemChooseBgColor = + json['conversationItemChooseBgColor'] as Color?, + + /// 会话列表草稿字体颜色 + conversationItemDraftTextColor = + json['conversationItemDraftTextColor'] as Color?, + + /// 会话列表收到消息不提醒Icon颜色 + conversationItemNoNotificationIconColor = + json['conversationItemNoNotificationIconColor'] as Color?, + + /// 会话列表侧滑按钮字体颜色 + conversationItemSliderTextColor = + json['conversationItemSliderTextColor'] as Color?, + + /// 会话列表侧滑按钮Clear背景颜色 + conversationItemSliderClearBgColor = + json['conversationItemSliderClearBgColor'] as Color?, + + /// 会话列表侧滑按钮Pin背景颜色 + conversationItemSliderPinBgColor = + json['conversationItemSliderPinBgColor'] as Color?, + + /// 会话列表侧滑按钮Delete背景颜色 + conversationItemSliderDeleteBgColor = + json['conversationItemSliderDeleteBgColor'] as Color?, + + /// 聊天页背景颜色 + chatBgColor = json['chatBgColor'] as Color?, + + /// 桌面端消息输入框背景颜色 + desktopChatMessageInputBgColor = + json['desktopChatMessageInputBgColor'] as Color?, + + /// 聊天页背景颜色 + chatTimeDividerTextColor = json['chatTimeDividerTextColor'] as Color?, + + /// 聊天页导航栏背景颜色 + chatHeaderBgColor = json['chatHeaderBgColor'] as Color?, + + /// 聊天页导航栏Title字体颜色 + chatHeaderTitleTextColor = json['chatHeaderTitleTextColor'] as Color?, + + /// 聊天页导航栏Back字体颜色 + chatHeaderBackTextColor = json['chatHeaderBackTextColor'] as Color?, + + /// 聊天页导航栏Action字体颜色 + chatHeaderActionTextColor = json['chatHeaderActionTextColor'] as Color?, + + /// 聊天页历史消息列表字体颜色 + chatMessageItemTextColor = json['chatMessageItemTextColor'] as Color?, + + /// 聊天页历史消息列表来自自己时背景颜色 + chatMessageItemFromSelfBgColor = + json['chatMessageItemFromSelfBgColor'] as Color?, + + /// 聊天页历史消息列表来自非自己时背景颜色 + chatMessageItemFromOthersBgColor = + json['chatMessageItemFromOthersBgColor'] as Color?, + + /// 聊天页历史消息列表已读状态字体颜色 + chatMessageItemUnreadStatusTextColor = + json['chatMessageItemUnreadStatusTextColor'] as Color?, + + /// 聊天页历史消息列表小舌头背景颜色 + chatMessageTongueBgColor = json['chatMessageTongueBgColor'] as Color?, + + /// 聊天页历史消息列表小舌头字体颜色 + chatMessageTongueTextColor = + json['chatMessageTongueTextColor'] as Color?; + + toJson() => { + 'primaryColor': primaryColor, + 'secondaryColor': secondaryColor, + 'infoColor': infoColor, + 'weakBackgroundColor': weakBackgroundColor, + 'wideBackgroundColor': wideBackgroundColor, + 'weakDividerColor': weakDividerColor, + 'weakTextColor': weakTextColor, + 'darkTextColor': darkTextColor, + 'lightPrimaryColor': lightPrimaryColor, + 'textColor': textColor, + 'cautionColor': cautionColor, + 'ownerColor': ownerColor, + 'adminColor': adminColor, + 'white': white, + 'black': black, + 'inputFillColor': inputFillColor, + 'textgrey': textgrey, + + "selectPanelBgColor": selectPanelBgColor, + + "selectPanelTextIconColor": selectPanelTextIconColor, + + "appbarBgColor": appbarBgColor, + + "appbarTextColor": appbarTextColor, + + /// 会话列表背景颜色 + "conversationItemBgColor": conversationItemBgColor, + + /// 会话列表边框颜色 + "conversationItemBorderColor": conversationItemBorderColor, + + /// 会话列表选中背景颜色 + "conversationItemActiveBgColor": conversationItemActiveBgColor, + + /// 会话列表置顶背景颜色 + "conversationItemPinedBgColor": conversationItemPinedBgColor, + + /// 会话列表Title字体颜色 + "conversationItemTitleTextColor": conversationItemTitleTextColor, + + /// 会话列表LastMessage字体颜色 + "conversationItemLastMessageTextColor": + conversationItemLastMessageTextColor, + + /// 会话列表Time字体颜色 + "conversationItemTitmeTextColor": conversationItemTitmeTextColor, + + /// 会话列表用户在线状态背景色 + "conversationItemOnlineStatusBgColor": + conversationItemOnlineStatusBgColor, + + /// 会话列表用户离线状态背景色 + "conversationItemOfflineStatusBgColor": + conversationItemOfflineStatusBgColor, + + /// 会话列表未读数背景颜色 + "conversationItemUnreadCountBgColor": + conversationItemUnreadCountBgColor, + + /// 会话列表未读数字体颜色 + "conversationItemUnreadCountTextColor": + conversationItemUnreadCountTextColor, + + /// 会话列表草稿字体颜色 + "conversationItemDraftTextColor": conversationItemDraftTextColor, + + /// 会话列表收到消息不提醒Icon颜色 + "conversationItemNoNotificationIconColor": + conversationItemNoNotificationIconColor, + + /// 会话列表侧滑按钮字体颜色 + "conversationItemSliderTextColor": conversationItemSliderTextColor, + + /// 会话列表侧滑按钮Clear背景颜色 + "conversationItemSliderClearBgColor": + conversationItemSliderClearBgColor, + + /// 会话列表侧滑按钮Pin背景颜色 + "conversationItemSliderPinBgColor": conversationItemSliderPinBgColor, + + /// 会话列表侧滑按钮Delete背景颜色 + "conversationItemSliderDeleteBgColor": + conversationItemSliderDeleteBgColor, + + /// 会话列表侧滑按钮Delete背景颜色 + "conversationItemChooseBgColor": conversationItemChooseBgColor, + + /// 聊天页背景颜色 + "chatBgColor": chatBgColor, + + /// 桌面端消息输入框背景颜色 + "desktopChatMessageInputBgColor": desktopChatMessageInputBgColor, + + /// 聊天页背景颜色 + "chatTimeDividerTextColor": chatTimeDividerTextColor, + + /// 聊天页导航栏背景颜色 + "chatHeaderBgColor": chatHeaderBgColor, + + /// 聊天页导航栏Title字体颜色 + "chatHeaderTitleTextColor": chatHeaderTitleTextColor, + + /// 聊天页导航栏Back字体颜色 + "chatHeaderBackTextColor": chatHeaderBackTextColor, + + /// 聊天页导航栏Action字体颜色 + "chatHeaderActionTextColor": chatHeaderActionTextColor, + + /// 聊天页历史消息列表字体颜色 + "chatMessageItemTextColor": chatMessageItemTextColor, + + /// 聊天页历史消息列表来自自己时背景颜色 + "chatMessageItemFromSelfBgColor": chatMessageItemFromSelfBgColor, + + /// 聊天页历史消息列表来自非自己时背景颜色 + "chatMessageItemFromOthersBgColor": chatMessageItemFromOthersBgColor, + + /// 聊天页历史消息列表已读状态字体颜色 + "chatMessageItemUnreadStatusTextColor": + chatMessageItemUnreadStatusTextColor, + + /// 聊天页历史消息列表小舌头背景颜色 + "chatMessageTongueBgColor": chatMessageTongueBgColor, + + /// 聊天页历史消息列表小舌头字体颜色 + "chatMessageTongueTextColor": chatMessageTongueTextColor, + }; + + MaterialColor createMaterialColor(Color color) { + List strengths = [.05]; + Map swatch = {}; + final int r = color.red, g = color.green, b = color.blue; + + for (int i = 1; i < 10; i++) { + strengths.add(0.1 * i); + } + for (var strength in strengths) { + final double ds = 0.5 - strength; + swatch[(strength * 1000).round()] = Color.fromRGBO( + r + ((ds < 0 ? r : (255 - r)) * ds).round(), + g + ((ds < 0 ? g : (255 - g)) * ds).round(), + b + ((ds < 0 ? b : (255 - b)) * ds).round(), + 1, + ); + } + return MaterialColor(color.value, swatch); + } +} diff --git a/lib/theme/tui_theme_view_model.dart b/lib/theme/tui_theme_view_model.dart new file mode 100644 index 0000000..a9543ed --- /dev/null +++ b/lib/theme/tui_theme_view_model.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TUIThemeViewModel extends ChangeNotifier { + TUITheme _theme = CommonColor.defaultTheme; + + TUITheme get theme { + return _theme; + } + + set theme(TUITheme theme) { + _theme = theme; + notifyListeners(); + } +} diff --git a/lib/ui/constants/history_message_constant.dart b/lib/ui/constants/history_message_constant.dart new file mode 100644 index 0000000..1118509 --- /dev/null +++ b/lib/ui/constants/history_message_constant.dart @@ -0,0 +1,32 @@ +enum V2TimImageTypesEnum { + original, + big, + small, +} + +class HistoryMessageDartConstant { + static const getCount = 20; + + // ignore: constant_identifier_names + static const V2_TIM_IMAGE_TYPES = { + 'ORIGINAL': 0, + 'BIG': 1, + 'SMALL': 2, + }; + + static Map> imgPriorMap = { + V2TimImageTypesEnum.original: oriImgPrior, + V2TimImageTypesEnum.big: bigImgPrior, + V2TimImageTypesEnum.small: smallImgPrior, + }; + + // 缩略图优先,大图次之,最后是原图 + static const smallImgPrior = ['ORIGINAL', 'BIG', 'SMALL']; + // 大图优先,原图次之,最后是缩略图 + static const bigImgPrior = ['SMALL', 'ORIGINAL', 'BIG']; + // 原图优先,大图次之,最后是缩略图 + static const oriImgPrior = ['SMALL', 'BIG', 'ORIGINAL']; + + // 视频、音频已读状态 + static const int read = 1; +} diff --git a/lib/ui/constants/time.dart b/lib/ui/constants/time.dart new file mode 100644 index 0000000..98e3646 --- /dev/null +++ b/lib/ui/constants/time.dart @@ -0,0 +1,9 @@ +// ignore_for_file: constant_identifier_names + +class TimeConst { + static const DAY_SEC = 86400; + static const HOUR_SEC = 3600; + static const MIN_SEC = 60; + + static const SEC_SERIES = [HOUR_SEC, MIN_SEC]; +} diff --git a/lib/ui/controller/tim_uikit_chat_controller.dart b/lib/ui/controller/tim_uikit_chat_controller.dart new file mode 100644 index 0000000..df905b6 --- /dev/null +++ b/lib/ui/controller/tim_uikit_chat_controller.dart @@ -0,0 +1,378 @@ +// ignore_for_file: avoid_print, empty_catches + +import 'package:flutter/cupertino.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class TIMUIKitChatController { + late TUIChatSeparateViewModel? model; + late TIMUIKitInputTextFieldController? textFieldController; + late AutoScrollController? scrollController; + final TUIChatGlobalModel globalChatModel = serviceLocator(); + + TIMUIKitChatController({TUIChatSeparateViewModel? viewModel}) { + if (viewModel != null) { + model = viewModel; + } + } + + Future loadHistoryMessageList( + {HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_CLOUD_OLDER_MSG, + String? userID, + String? groupID, + int lastMsgSeq = -1, + required int count, + String? lastMsgID, + LoadDirection direction = LoadDirection.previous}) async { + return await model?.loadChatRecord( + count: count, + getType: getType, + lastMsgID: lastMsgID, + lastMsgSeq: lastMsgSeq, + ) ?? + false; + } + + /// Clear the current conversation; + @Deprecated("No need to dispose after tencent_cloud_chat_uikit 0.1.4") + dispose() {} + + /// Clear the history of current conversation; + /// Please provide `convID`, if you use `TIMUIKitChatController` without specifying to a `TIMUIKitChat`. + clearHistory([String? convID]) { + if (convID != null) { + return globalChatModel.setMessageList(convID, []); + } + return model?.clearHistory(); + } + + /// Refresh the history message list manually; + Future refreshCurrentHistoryList([String? convID, ConvType? convType]) async { + if (model != null) { + try { + scrollController?.animateTo( + scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } catch (e) {} + return model!.loadDataFromController(); + } else { + return false; + } + } + + /// Update single message at UI model + /// Please provide `convID`, if you use `TIMUIKitChatController` without specifying to a `TIMUIKitChat`. + Future updateMessage( + { + /// The ID of the target conversation + String? convID, + + /// The type of the target conversation + ConvType? convType, + + /// message ID + required String msgID}) async { + if (convID != null && convType != null) { + return globalChatModel.updateMessageFromController( + msgID: msgID, conversationID: convID, conversationType: convType); + } else if (model != null) { + return model!.updateMessageFromController(msgID: msgID); + } + return; + } + + /// Sends a message to the specified conversation, or to the current conversation specified on `TIMUIKitChat`. + /// You must provide `convType` and either `userID` or `groupID`, only if you use `TIMUIKitChat` without specifying a `TIMUIKitChatController`, you must provide these parameters. + Future?>? sendMessage({ + required V2TimMessage? messageInfo, + + /// The type of the target conversation: either ConvType.group or ConvType.c2c. Required if using `TIMUIKitChat` without specifying a `TIMUIKitChatController`. + ConvType? convType, + + /// The user ID of the target one-to-one conversation. Required if convType is ConvType.c2c. + String? userID, + + /// The target group ID. Required if convType is ConvType.group. + String? groupID, + + /// A callback function to update the input field when message sending fails. + ValueChanged? setInputField, + + /// Offline push information. + OfflinePushInfo? offlinePushInfo, + + /// Whether automatically scrolling to the bottom of the message list after sending a message. + /// This field solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + bool isNavigateToMessageListBottom = true, + + /// Message priorities. This field is valid only for group chat messages. + /// You are advised to set higher priorities for important messages (such as red packet and gift messages) + /// and set lower priorities for frequent but unimportant messages (such as like messages). + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + + /// Whether the message can be received only by online users. + /// If this field is set to true, the message cannot be pulled in recipient historical message pulling. + /// This field is often used to implement weak notification features such as "The other party is typing" or unimportant notifications in the group. This field is not supported by audio-video groups (AVChatRoom). + bool? onlineUserOnly, + + /// Whether the message is excluded from the conversation unread message count. + bool? isExcludedFromUnreadCount, + + /// Whether a read receipt is required. + bool? needReadReceipt, + + /// Cloud custom data (saved in the cloud, will be sent to the peer end, + /// and can still be pulled after the app is uninstalled and reinstalled) + String? cloudCustomData, + + /// Local custom message data (saved locally, will not be sent to the peer end, + /// and will become invalid after the app is uninstalled and reinstalled). + String? localCustomData, + }) { + if (convType != null) { + /// Sends a message to the specified conversation. + assert((groupID == null) != (userID == null)); + assert(groupID != null || convType != ConvType.group); + assert(userID != null || convType != ConvType.c2c); + if (isNavigateToMessageListBottom && scrollController != null) { + try { + scrollController?.animateTo( + scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } catch (e) {} + } + return globalChatModel.sendMessageFromController( + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + cloudCustomData: cloudCustomData, + localCustomData: localCustomData, + messageInfo: messageInfo, + convType: convType, + convID: (convType == ConvType.group ? groupID : userID) ?? "", + setInputField: setInputField, + offlinePushInfo: offlinePushInfo); + } else if (model != null) { + /// Sends a message to the current conversation specified on `TIMUIKitChat`. 发送到 `TIMUIKitChat` 中指定的当前对话。 + if (isNavigateToMessageListBottom && scrollController != null) { + try { + scrollController?.animateTo( + scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } catch (e) {} + } + return model!.sendMessageFromController( + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + cloudCustomData: cloudCustomData, + localCustomData: localCustomData, + messageInfo: messageInfo, + offlinePushInfo: offlinePushInfo); + } + return null; + } + + /// Sends a message, replying to another message, to the specified conversation, or to the current conversation specified on `TIMUIKitChat`. + /// You must provide `convType` and either `userID` or `groupID`, only if you use `TIMUIKitChat` without specifying a `TIMUIKitChatController`, you must provide these parameters. + Future?>? sendReplyMessage({ + required String messageText, + required V2TimMessage messageBeenReplied, + + /// The type of the target conversation: either ConvType.group or ConvType.c2c. Required if using `TIMUIKitChat` without specifying a `TIMUIKitChatController`. + ConvType? convType, + + /// The user ID of the target one-to-one conversation. Required if convType is ConvType.c2c. + String? userID, + + /// The target group ID. Required if convType is ConvType.group. + String? groupID, + + /// A callback function to update the input field when message sending fails. + ValueChanged? setInputField, + + /// Offline push information. + OfflinePushInfo? offlinePushInfo, + + /// Whether automatically scrolling to the bottom of the message list after sending a message. + /// This field solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + bool isNavigateToMessageListBottom = true, + + /// Message priorities. This field is valid only for group chat messages. + /// You are advised to set higher priorities for important messages (such as red packet and gift messages) + /// and set lower priorities for frequent but unimportant messages (such as like messages). + MessagePriorityEnum priority = MessagePriorityEnum.V2TIM_PRIORITY_NORMAL, + + /// Whether the message can be received only by online users. + /// If this field is set to true, the message cannot be pulled in recipient historical message pulling. + /// This field is often used to implement weak notification features such as "The other party is typing" or unimportant notifications in the group. This field is not supported by audio-video groups (AVChatRoom). + bool? onlineUserOnly, + + /// Whether the message is excluded from the conversation unread message count. + bool? isExcludedFromUnreadCount, + + /// Whether a read receipt is required. + bool? needReadReceipt, + + /// Local custom message data (saved locally, will not be sent to the peer end, + /// and will become invalid after the app is uninstalled and reinstalled). + String? localCustomData, + }) { + if (convType != null) { + /// Sends a message to the specified conversation. + assert((groupID == null) != (userID == null)); + assert(groupID != null || convType != ConvType.group); + assert(userID != null || convType != ConvType.c2c); + if (isNavigateToMessageListBottom && scrollController != null) { + try { + scrollController?.animateTo( + scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } catch (e) {} + } + return globalChatModel.sendReplyMessageFromController( + text: messageText, + messageBeenReplied: messageBeenReplied, + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + localCustomData: localCustomData, + convType: convType, + convID: (convType == ConvType.group ? groupID : userID) ?? "", + setInputField: setInputField, + offlinePushInfo: offlinePushInfo); + } else if (model != null) { + /// Sends a message to the current conversation specified on `TIMUIKitChat`. 发送到 `TIMUIKitChat` 中指定的当前对话。 + if (isNavigateToMessageListBottom && scrollController != null) { + scrollController?.animateTo( + scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } + return globalChatModel.sendReplyMessageFromController( + text: messageText, + messageBeenReplied: messageBeenReplied, + priority: priority, + onlineUserOnly: onlineUserOnly, + isExcludedFromUnreadCount: isExcludedFromUnreadCount, + needReadReceipt: needReadReceipt, + localCustomData: localCustomData, + convType: model!.conversationType ?? ConvType.group, + convID: model!.conversationID, + setInputField: setInputField, + offlinePushInfo: offlinePushInfo); + } + return null; + } + + /// Send forward message; + /// This function solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + sendForwardMessage({ + required List conversationList, + }) async { + model?.sendForwardMessage(conversationList: conversationList); + } + + /// Send merger message; + /// This method needs use with TIMUIKitChat directly or model been initialized. + Future?> sendMergerMessage({ + required List conversationList, + required String title, + required List abstractList, + required BuildContext context, + }) async { + return model?.sendMergerMessage( + conversationList: conversationList, title: title, abstractList: abstractList, context: context); + } + + /// Set local custom data; returns the bool shows if succeed. + /// Please provide `convID`, if you use `TIMUIKitChatController` without specifying to a `TIMUIKitChat`. + Future setLocalCustomData(String msgID, String localCustomData, [String? convID]) async { + final String? conversationID = convID ?? model?.conversationID; + if (conversationID == null) { + return false; + } + return globalChatModel.setLocalCustomData(msgID, localCustomData, conversationID); + } + + /// Set local custom int; returns the bool shows if succeed. + /// Please provide `convID`, if you use `TIMUIKitChatController` without specifying to a `TIMUIKitChat`. + Future setLocalCustomInt(String msgID, int localCustomInt, [String? convID]) async { + final String? conversationID = convID ?? model?.conversationID; + if (conversationID == null) { + return false; + } + return globalChatModel.setLocalCustomInt(msgID, localCustomInt, conversationID); + } + + /// Get current conversation, returns UserID or GroupID if in the chat page, returns "" if not. + String getCurrentConversation() { + return globalChatModel.currentSelectedConv; + } + + /// Hide all bottom panels, including the sticker panel and the additional functions panel, on mobile devices. + /// This function solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + void hideAllBottomPanelOnMobile() { + textFieldController?.hideAllPanel(); + } + + /// Mention or @ other members in a group manually. + /// This function solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + void mentionOtherMemberInGroup({required String showNameInMessage, required String userID}) { + textFieldController?.longPressToAt(showNameInMessage, userID); + } + + /// Set the content within the message input text field. + /// This function solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + void setInputTextField(String text) { + textFieldController?.setTextField(text); + } + + /// Returns the list of group members of current group chat based on the provided keyword. + /// + /// This method filters the group members based on the given keyword. If the keyword is not provided, + /// it returns the entire list of group members. The filtering is performed by checking if the keyword + /// is contained within the userID, nickName, or friendRemark properties of each group member. + /// + /// [keyword] (optional) - The keyword to filter the group members. If not provided, the entire list of group members is returned. + /// This function solely works when `TIMUIKitChatController` is specified for use within a `TIMUIKitChat`. + List getGroupMemberList({String? keyword}) { + final List memberList = + (model?.groupMemberList ?? []).whereType().toList(); + + return TencentUtils.checkString(keyword) == null + ? memberList + : memberList.where((e) { + final userID = e.userID; + final nickName = e.nickName ?? ""; + final friendRemark = e.friendRemark ?? ""; + return userID.contains(keyword!) || nickName.contains(keyword) || friendRemark.contains(keyword); + }).toList(); + } +} diff --git a/lib/ui/controller/tim_uikit_conversation_controller.dart b/lib/ui/controller/tim_uikit_conversation_controller.dart new file mode 100644 index 0000000..4608361 --- /dev/null +++ b/lib/ui/controller/tim_uikit_conversation_controller.dart @@ -0,0 +1,75 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; + +class TIMUIKitConversationController { + late TUIConversationViewModel model; + + /// Get the selected conversation currently + V2TimConversation? get selectedConversation { + return model.selectedConversation; + } + + /// Set the selected conversation currently + set selectedConversation(V2TimConversation? conversation) { + model.selectedConversation = conversation; + } + + /// Get the conversation list + List get conversationList { + return model.conversationList; + } + + /// Set the conversation list + set conversationList(List conversationList) { + model.conversationList = conversationList; + } + + /// Load the conversation list to UI + loadData({int count = 40}) { + model.loadData(count: count); + } + + /// Reload the conversation list to UI + reloadData({int count = 100}) { + model.refresh(count: count); + } + + /// Pin one conversation to the top + Future pinConversation({required String conversationID, required bool isPinned}) { + return model.pinConversation(conversationID: conversationID, isPinned: isPinned); + } + + /// Set the draft for a conversation + Future setConversationDraft({required String conversationID, String? draftText}) { + return model.setConversationDraft(conversationID: conversationID, draftText: draftText); + } + + /// Clear the historical message in a specific conversation + Future? clearHistoryMessage({required V2TimConversation conversation}) { + final convType = conversation.type; + final convID = convType == 1 ? conversation.userID : conversation.groupID; + if (convType != null && convID != null) { + return model.clearHistoryMessage(convID: convID, convType: convType); + } + return null; + } + + /// Delete a conversation + Future deleteConversation({required String conversationID}) { + return model.deleteConversation(conversationID: conversationID); + } + + /// Clear the conversation list from UI + dispose() { + model.clearData(); + } + + /// Scroll to a specific conversation, this conversation must be existed in conversation list. + /// If not exist, invoking `loadData` recursively, until find the target conversation. + scrollToConversation(String conversationID) { + model.scrollToConversation = conversationID; + } +} diff --git a/lib/ui/controller/tim_uikit_profile_controller.dart b/lib/ui/controller/tim_uikit_profile_controller.dart new file mode 100644 index 0000000..f3122d9 --- /dev/null +++ b/lib/ui/controller/tim_uikit_profile_controller.dart @@ -0,0 +1,119 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_profile_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/text_input_bottom_sheet.dart'; + +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitProfileController { + late TUIProfileViewModel model; + + /// Remove a user from friend or contact + Future deleteFriend(String userID) { + return model.deleteFriend(userID); + } + + /// Deprecated: Please use [pinConversation] instead. Pin the conversation to the top + @Deprecated("Please use [pinConversation] instead") + Future pinedConversation(bool isPined, String convID) { + return model.pinedConversation(isPined, convID); + } + + /// pin the conversation to the top + Future pinConversation(bool isPined, String convID) { + return model.pinedConversation(isPined, convID); + } + + /// add a user to block list + Future?> addUserToBlackList(bool shouldAdd, String userID) { + return model.addToBlackList(shouldAdd, userID); + } + + /// Change the friend adding request verification method, + /// 0 represents "Accept all friend request", + /// 1 represents "Require approval for friend requests", + /// 2 represents "reject all friend requests". + Future changeFriendVerificationMethod(int allowType) { + return model.changeFriendVerificationMethod(allowType); + } + + /// update the remarks for other users, + Future updateRemarks(String userID, String remark) { + return model.updateRemarks(userID, remark); + } + + /// set the message from a specific user as not disturb, mute notification + Future setMessageDisturb(String userID, bool isDisturb) { + return model.setMessageDisturb(userID, isDisturb); + } + + /// Show the text input bottom sheet + showTextInputBottomSheet({ + required BuildContext context, + required String title, + required String tips, + required Function(String text) onSubmitted, + required TUITheme theme, + Offset? initOffset, + String? initText, + }) { + TextInputBottomSheet.showTextInputBottomSheet( + context: context, + title: title, + tips: tips, + onSubmitted: onSubmitted, + theme: theme, + initOffset: initOffset, + initText: initText); + } + + /// Load the profile data + loadData(String userID) { + model.loadData(userID: userID); + } + + dispose() { + model.dispose(); + } + + /// Add a user as friend or contact + Future addFriend(String userID) { + return model.addFriend(userID); + } + + Future updateSelfSignature(String selfSignature) { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.selfSignature = selfSignature; + return model.updateSelfInfo(userFullInfo); + } + + Future updateNickName(String nickName) { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.nickName = nickName; + return model.updateSelfInfo(userFullInfo); + } + + /// 1:男 2:女 + Future updateGender(int gender) { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.gender = gender; + return model.updateSelfInfo(userFullInfo); + } + + Future updateBirthday(int birthday) { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.birthday = birthday; + return model.updateSelfInfo(userFullInfo); + } + + Future updateAvatar(String url) { + V2TimUserFullInfo userFullInfo = V2TimUserFullInfo(); + userFullInfo.faceUrl = url; + return model.updateSelfInfo(userFullInfo); + } +} diff --git a/lib/ui/theme/tim_uikit_avatar_theme.dart b/lib/ui/theme/tim_uikit_avatar_theme.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/ui/theme/tim_uikit_avatar_theme.dart @@ -0,0 +1 @@ + diff --git a/lib/ui/theme/tim_uikit_message_theme.dart b/lib/ui/theme/tim_uikit_message_theme.dart new file mode 100644 index 0000000..3408d48 --- /dev/null +++ b/lib/ui/theme/tim_uikit_message_theme.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class MessageThemeData { + /// Text style for message text + final TextStyle? messageTextStyle; + + /// Text style for user nick name + final TextStyle? nickNameTextStyle; + + /// Text style for timeline + final TextStyle? timelineTextStyle; + + /// Color for messageBackgroundColor + final Color? messageBackgroundColor; + + /// border radius for text message + final BorderRadius? messageBorderRadius; + + final BorderRadius? avatarBorderRadius; + + MessageThemeData({ + this.messageTextStyle, + this.messageBackgroundColor, + this.messageBorderRadius, + this.nickNameTextStyle, + this.timelineTextStyle, + this.avatarBorderRadius, + }); +} diff --git a/lib/ui/utils/common_utils.dart b/lib/ui/utils/common_utils.dart new file mode 100644 index 0000000..1da0c53 --- /dev/null +++ b/lib/ui/utils/common_utils.dart @@ -0,0 +1,158 @@ +class TencentUtils{ + static bool isTextNotEmpty(String? text){ + return text != null && text.isNotEmpty; + } + + static String? checkString(String? text){ + return (text != null && text.isEmpty) ? null : text; + } + + static String? checkStringWithoutSpace(String? text){ + if (text == null || text.trim().isEmpty || text.contains(' ')) { + return null; + } + return text; + } + + static String getFileType(String fileType) { + switch (fileType) { + case "3gp": + return "video/3gpp"; + case "torrent": + return "application/x-bittorrent"; + case "kml": + return "application/vnd.google-earth.kml+xml"; + case "gpx": + return "application/gpx+xml"; + case "asf": + return "video/x-ms-asf"; + case "avi": + return "video/x-msvideo"; + case "bin": + case "class": + case "exe": + return "application/octet-stream"; + case "bmp": + return "image/bmp"; + case "c": + return "text/plain"; + case "conf": + return "text/plain"; + case "cpp": + return "text/plain"; + case "doc": + return "application/msword"; + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "xls": + case "csv": + return "application/vnd.ms-excel"; + case "xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + case "gif": + return "image/gif"; + case "gtar": + return "application/x-gtar"; + case "gz": + return "application/x-gzip"; + case "h": + return "text/plain"; + case "htm": + return "text/html"; + case "html": + return "text/html"; + case "jar": + return "application/java-archive"; + case "java": + return "text/plain"; + case "jpeg": + return "image/jpeg"; + case "jpg": + return "image/jpeg"; + case "js": + return "application/x-javascript"; + case "log": + return "text/plain"; + case "m3u": + return "audio/x-mpegurl"; + case "m4a": + return "audio/mp4a-latm"; + case "m4b": + return "audio/mp4a-latm"; + case "m4p": + return "audio/mp4a-latm"; + case "m4u": + return "video/vnd.mpegurl"; + case "m4v": + return "video/x-m4v"; + case "mov": + return "video/quicktime"; + case "mp2": + return "audio/x-mpeg"; + case "mp3": + return "audio/x-mpeg"; + case "mp4": + return "video/mp4"; + case "mpc": + return "application/vnd.mpohun.certificate"; + case "mpe": + return "video/mpeg"; + case "mpeg": + return "video/mpeg"; + case "mpg": + return "video/mpeg"; + case "mpg4": + return "video/mp4"; + case "mpga": + return "audio/mpeg"; + case "msg": + return "application/vnd.ms-outlook"; + case "ogg": + return "audio/ogg"; + case "pdf": + return "application/pdf"; + case "png": + return "image/png"; + case "pps": + return "application/vnd.ms-powerpoint"; + case "ppt": + return "application/vnd.ms-powerpoint"; + case "pptx": + return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + case "prop": + return "text/plain"; + case "rc": + return "text/plain"; + case "rmvb": + return "audio/x-pn-realaudio"; + case "rtf": + return "application/rtf"; + case "sh": + return "text/plain"; + case "tar": + return "application/x-tar"; + case "tgz": + return "application/x-compressed"; + case "txt": + return "text/plain"; + case "wav": + return "audio/x-wav"; + case "wma": + return "audio/x-ms-wma"; + case "wmv": + return "audio/x-ms-wmv"; + case "wps": + return "application/vnd.ms-works"; + case "xml": + return "text/plain"; + case "z": + return "application/x-compress"; + case "zip": + return "application/x-zip-compressed"; + default: + return "*/*"; + } + } + + +} \ No newline at end of file diff --git a/lib/ui/utils/custom_sticker.dart b/lib/ui/utils/custom_sticker.dart new file mode 100644 index 0000000..356f80a --- /dev/null +++ b/lib/ui/utils/custom_sticker.dart @@ -0,0 +1,9 @@ +class CustomSticker { + const CustomSticker( + {required this.name, required this.index, this.url, this.unicode}); + + final int? unicode; + final String name; + final int index; + final String? url; +} diff --git a/lib/ui/utils/error_message_converter.dart b/lib/ui/utils/error_message_converter.dart new file mode 100644 index 0000000..1b8a1c3 --- /dev/null +++ b/lib/ui/utils/error_message_converter.dart @@ -0,0 +1,20 @@ +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; + +class ErrorMessageConverter { + static Map errorCodeMap = { + 10007: TIM_t("操作权限不足"), + 20007: TIM_t("发送单聊消息,被对方拉黑,禁止发送。"), + 30010: TIM_t("您的好友数已达系统上限"), + 30014: TIM_t("对方的好友数已达系统上限"), + 30015: TIM_t("对方已是您的好友"), + 30515: TIM_t("被加好友在自己的黑名单中"), + 30516: TIM_t("对方已禁止加好友"), + 30525: TIM_t("您已被被对方设置为黑名单"), + 30539: TIM_t("等待好友审核同意"), + }; + + static String getErrorMessage(int code) { + return errorCodeMap[code] ?? ""; + } + +} \ No newline at end of file diff --git a/lib/ui/utils/frame.dart b/lib/ui/utils/frame.dart new file mode 100644 index 0000000..d23d1c6 --- /dev/null +++ b/lib/ui/utils/frame.dart @@ -0,0 +1,55 @@ +// ignore_for_file: avoid_print, prefer_typing_uninitialized_variables + +import 'dart:ui'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; + +class Frame { + static var orginalCallback; + + static init() { + orginalCallback = window.onReportTimings; + window.onReportTimings = onReportTimings; + } + + // 仅缓存最近 25 帧绘制耗时 + static const maxframes = 25; + static final List lastFrames = []; + // 基准 VSync 信号周期 + static const frameInterval = + Duration(microseconds: Duration.microsecondsPerSecond ~/ 60); + + static void onReportTimings(List timings) { + lastFrames.addAll(timings); + // 仅保留 25 帧 + if (lastFrames.length > maxframes) { + lastFrames.removeRange(0, lastFrames.length - maxframes); + } + // 如果有原始帧回调函数,则执行 + if (orginalCallback != null) { + orginalCallback(timings); + } + outputLogger.i("fps: $fps"); + } + + static double get fps { + int sum = 0; + for (FrameTiming timing in lastFrames) { + // 计算渲染耗时 + int duration = timing.timestampInMicroseconds(FramePhase.rasterFinish) - + timing.timestampInMicroseconds(FramePhase.buildStart); + // 判断耗时是否在 Vsync 信号周期内 + if (duration < frameInterval.inMicroseconds) { + sum += 1; + } else { + // 有丢帧,向上取整 + int count = (duration / frameInterval.inMicroseconds).ceil(); + sum += count; + } + } + return lastFrames.length / sum * 60; + } + + static destroy() { + window.onReportTimings = null; + } +} diff --git a/lib/ui/utils/logger.dart b/lib/ui/utils/logger.dart new file mode 100644 index 0000000..ec21750 --- /dev/null +++ b/lib/ui/utils/logger.dart @@ -0,0 +1,13 @@ +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; + +final outputLogger = TencentCloudChatLog(); + +class TencentCloudChatLog{ + void i(String text){ + if(!PlatformUtils().isWeb){ + TencentImSDKPlugin.v2TIMManager + .uikitTrace(trace: text); + } + } +} \ No newline at end of file diff --git a/lib/ui/utils/message.dart b/lib/ui/utils/message.dart new file mode 100644 index 0000000..b8f9550 --- /dev/null +++ b/lib/ui/utils/message.dart @@ -0,0 +1,346 @@ +// ignore_for_file: unrelated_type_equality_checks, avoid_print + +import 'dart:convert'; +import 'package:flutter/cupertino.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_change_info_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_tips_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_tips_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_tips_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_image.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_image.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/time.dart'; +import 'package:collection/collection.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class MessageUtils { + // 判断CallingData的方式和Trtc的方法一致 + static isCallingData(String data) { + try { + Map customMap = jsonDecode(data); + + if (customMap.containsKey('businessID') && customMap['businessID'] == 1) { + return true; + } + } catch (e) { + outputLogger.i("isCallingData json parse error"); + return false; + } + return false; + } + + // 是否是群组TRTC信息 + static isGroupCallingMessage(V2TimMessage message) { + final isGroup = message.groupID != null; + final isCustomMessage = message.elemType == MessageElemType.V2TIM_ELEM_TYPE_CUSTOM; + if (isCustomMessage) { + final customElemData = message.customElem?.data ?? ""; + return isCallingData(customElemData) && isGroup; + } + return false; + } + + static String getCustomGroupCreatedOrDismissedString(V2TimMessage message) { + try { + final isGroup = message.groupID != null; + final isCustomMessage = message.elemType == MessageElemType.V2TIM_ELEM_TYPE_CUSTOM; + if (isCustomMessage && isGroup) { + final data = message.customElem?.data ?? ""; + Map customMap = jsonDecode(data); + if (customMap.containsKey('businessID') && customMap['businessID'] == "group_create") { + final content = "${customMap['opUser']}${customMap['content']}"; + return content; + } + return ""; + } + return ""; + } catch (e) { + outputLogger.i("getCustomGroupCreatedOrDismissedString json parse error"); + return ""; + } + } + + static Future _getGroupChangeType( + V2TimGroupChangeInfo info, List groupMemberList) async { + int? type = info.type; + var value = info.value; + String s = TIM_t('群资料信息'); + switch (type) { + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_CUSTOM: + s = TIM_t("自定义字段"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_FACE_URL: + s = TIM_t("群头像"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_INTRODUCTION: + s = TIM_t("群简介"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_NAME: + s = TIM_t("群名称"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_NOTIFICATION: + s = TIM_t("群公告"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_OWNER: + s = TIM_t("群主"); + final V2TimGroupMemberFullInfo? groupMemberInfo = + groupMemberList.firstWhereOrNull((element) => element?.userID == value); + if (groupMemberInfo != null) { + value = TencentUtils.checkString(groupMemberInfo.friendRemark) ?? + TencentUtils.checkString(groupMemberInfo.nameCard) ?? + TencentUtils.checkString(groupMemberInfo.nickName) ?? + TencentUtils.checkString(groupMemberInfo.userID); + } else { + final res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [value ?? ""]); + if (res.code == 0) { + final List data = res.data ?? []; + if (data.isNotEmpty) { + final firstPerson = data[0]; + value = TencentUtils.checkString(firstPerson.nickName) ?? TencentUtils.checkString(firstPerson.userID); + } + } + } + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_SHUT_UP_ALL: + s = TIM_t("全员禁言状态"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_RECEIVE_MESSAGE_OPT: + s = TIM_t("消息接收方式"); + break; + case GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_GROUP_ADD_OPT: + s = TIM_t("加群方式"); + break; + } + + final String option8 = s; + if (value != null && value.isNotEmpty) { + return TIM_t_para("{{option8}}为 ", "$option8为 ")(option8: option8) + value; + } else { + return option8; + } + } + + static String? _getOpUserNick(V2TimGroupMemberInfo? opUser) { + if (opUser == null) { + return ""; + } + return TencentUtils.checkString(opUser.friendRemark) ?? + TencentUtils.checkString(opUser.nickName) ?? + TencentUtils.checkString(opUser.userID); + } + + static String? _getMemberNickName(V2TimGroupMemberInfo e) { + final friendRemark = e.friendRemark; + final nameCard = e.nameCard; + final nickName = e.nickName; + final userID = e.userID; + + if (friendRemark != null && friendRemark != "") { + return friendRemark; + } else if (nameCard != null && nameCard != "") { + return nameCard; + } else if (nickName != null && nickName != "") { + return nickName; + } else { + return userID; + } + } + + static Future groupTipsMessageAbstract( + V2TimGroupTipsElem groupTipsElem, List groupMemberList) async { + String displayMessage; + final operationType = groupTipsElem.type; + final operationMember = groupTipsElem.opMember; + final memberList = groupTipsElem.memberList; + final opUserNickName = _getOpUserNick(operationMember); + switch (operationType) { + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_GROUP_INFO_CHANGE: + final String? option7 = opUserNickName ?? ""; + final groupChangeInfoList = groupTipsElem.groupChangeInfoList ?? []; + String changedInfoString = ""; + bool changedValue = false; + for (V2TimGroupChangeInfo? element in groupChangeInfoList) { + final newText = await _getGroupChangeType(element!, groupMemberList); + changedInfoString += (changedInfoString.isEmpty ? "" : " / ") + newText; + changedValue = element!.boolValue ?? false; + } + if (changedInfoString.isEmpty) { + changedInfoString = TIM_t("群资料"); + } + if (changedInfoString == TIM_t("全员禁言状态")) { + changedInfoString = TIM_t("全员禁言"); + displayMessage = changedValue == false + ? TIM_t_para("{{option7}} 取消", "$option7 取消")(option7: option7) + changedInfoString + : TIM_t_para("{{option7}} 开启", "$option7 开启")(option7: option7) + changedInfoString; + } else { + displayMessage = TIM_t_para("{{option7}}修改", "$option7修改")(option7: option7) + changedInfoString; + } + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_QUIT: + final String? option6 = opUserNickName ?? ""; + displayMessage = TIM_t_para("{{option6}}退出群聊", "$option6退出群聊")(option6: option6); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_INVITE: + final option5 = memberList!.map((e) => _getMemberNickName(e!).toString()).join("、"); + final inviteUser = _getOpUserNick(operationMember); + displayMessage = '$inviteUser' + TIM_t_para("邀请{{option5}}加入群组", "邀请$option5加入群组")(option5: option5); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_KICKED: + final option4 = memberList!.map((e) => _getMemberNickName(e!).toString()).join("、"); + final kickUser = _getOpUserNick(operationMember); + displayMessage = '$kickUser' + TIM_t_para("将{{option4}}踢出群组", "将$option4踢出群组")(option4: option4); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_JOIN: + final option3 = memberList!.map((e) => _getMemberNickName(e!).toString()).join("、"); + displayMessage = TIM_t_para("用户{{option3}}加入了群聊", "用户$option3加入了群聊")(option3: option3); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_MEMBER_INFO_CHANGE: + displayMessage = groupTipsElem.memberList!.map((e) { + final changedMember = + groupTipsElem.memberChangeInfoList!.firstWhere((element) => element!.userID == e!.userID); + final isMute = changedMember!.muteTime != 0; + final option2 = _getMemberNickName(e!); + final displayMessage = isMute ? TIM_t("禁言") : TIM_t("解除禁言"); + return TIM_t_para("{{option2}} 被", "$option2 被")(option2: option2) + displayMessage; + }).join("、"); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_SET_ADMIN: + final adminMember = memberList!.map((e) => _getMemberNickName(e!).toString()).join("、"); + final opMember = _getOpUserNick(operationMember); + final option1 = adminMember; + displayMessage = '$opMember' + TIM_t_para("将 {{option1}} 设置为管理员", "将 $option1 设置为管理员")(option1: option1); + break; + case GroupTipsElemType.V2TIM_GROUP_TIPS_TYPE_CANCEL_ADMIN: + final adminMember = memberList!.map((e) => _getMemberNickName(e!).toString()).join("、"); + final opMember = _getOpUserNick(operationMember); + final option1 = adminMember; + displayMessage = '$opMember' + TIM_t_para("将 {{option1}} 取消管理员", "将 $option1 取消管理员")(option1: option1); + break; + default: + final String option2 = operationType.toString(); + displayMessage = TIM_t_para("系统消息 {{option2}}", "系统消息 $option2")(option2: option2); + break; + } + return displayMessage; + } + + static String formatVideoTime(int time) { + List times = []; + if (time <= 0) return '0:01'; + if (time >= TimeConst.DAY_SEC) return '1d+'; + for (int idx = 0; idx < TimeConst.SEC_SERIES.length; idx++) { + int sec = TimeConst.SEC_SERIES[idx]; + if (time >= sec) { + times.add((time / sec).floor()); + time = time % sec; + } else if (idx > 0) { + times.add(0); + } + } + times.add(time); + String formatTime = times[0].toString(); + for (int idx = 1; idx < times.length; idx++) { + if (times[idx] < 10) { + formatTime += ':0${times[idx].toString()}'; + } else { + formatTime += ':${times[idx].toString()}'; + } + } + return formatTime; + } + + static String handleCustomMessageString(V2TimMessage message) { + return TIM_t("消息"); + } + + static Widget wrapMessageTips(Widget child, TUITheme? theme) { + return Container(margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 30), child: child); + } + + static String getAbstractMessageAsync(V2TimMessage message, List groupMemberList) { + final msgType = message.elemType; + switch (msgType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return handleCustomMessageString(message); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return TIM_t("[语音]"); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return message.textElem!.text as String; + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return TIM_t("[表情]"); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + final String? option2 = message.fileElem!.fileName ?? ""; + return TIM_t_para("[文件] {{option2}}", "[文件] $option2")(option2: option2); + case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: + return TIM_t("群提示"); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return TIM_t("[图片]"); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return TIM_t("[视频]"); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return TIM_t("[位置]"); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return TIM_t("[聊天记录]"); + default: + return TIM_t("未知消息"); + } + } + + static V2TimImage? getImageFromImgList(List? list, List order) { + V2TimImage? img; + try { + for (String type in order) { + img = + list?.firstWhere((e) => e?.type == HistoryMessageDartConstant.V2_TIM_IMAGE_TYPES[type], orElse: () => null); + } + } catch (e) { + outputLogger.i('getImageFromImgList error ${e.toString()}'); + } + return img; + } + + static String getDisplayName(V2TimMessage message) { + final friendRemark = message.friendRemark ?? ""; + final nameCard = message.nameCard ?? ""; + final nickName = message.nickName ?? ""; + final sender = message.sender ?? ""; + final displayName = friendRemark.isNotEmpty + ? friendRemark + : nameCard.isNotEmpty + ? nameCard + : nickName.isNotEmpty + ? nickName + : sender; + return displayName.toString(); + } + + static Future?> handleMessageError( + Future?> fun, BuildContext context) async { + final res = await fun; + return handleMessageErrorCode(res, context); + } + + static V2TimValueCallback? handleMessageErrorCode( + V2TimValueCallback? sendMsgRes, BuildContext context) { + if (sendMsgRes == null) return null; + + return sendMsgRes; + } +} diff --git a/lib/ui/utils/optimize_utils.dart b/lib/ui/utils/optimize_utils.dart new file mode 100644 index 0000000..7cc4351 --- /dev/null +++ b/lib/ui/utils/optimize_utils.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +class OptimizeUtils { + static throttle(Function func, int milliseconds) { + bool enable = true; + return (val) { + if (enable == true) { + enable = false; + Future.delayed(Duration(milliseconds: milliseconds), () { + enable = true; + func(val); + }); + } + }; + } + + static multiThrottle(Function func, int milliseconds) { + bool enable = true; + return (val1, val2) { + if (enable == true) { + enable = false; + Future.delayed(Duration(milliseconds: milliseconds), () { + enable = true; + func(val1, val2); + }); + } + }; + } + + static debounce( + Function func, [ + Duration delay = const Duration(milliseconds: 100), + ]) { + Timer? timer; + return (val) { + if (timer != null) { + timer?.cancel(); + } + + timer = Timer(delay, () { + func(val); + }); + }; + } +} diff --git a/lib/ui/utils/permission.dart b/lib/ui/utils/permission.dart new file mode 100644 index 0000000..6ca7d3f --- /dev/null +++ b/lib/ui/utils/permission.dart @@ -0,0 +1,366 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class PermissionRequestInfo extends StatefulWidget { + final Function removeOverLay; + final int permissionType; + final String appName; + + const PermissionRequestInfo( + {Key? key, + required this.removeOverLay, + required this.permissionType, + required this.appName}) + : super(key: key); + + @override + State createState() => _PermissionRequestInfo(); +} + +class _PermissionRequestInfo extends TIMUIKitState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + widget.removeOverLay(); + super.dispose(); + WidgetsBinding.instance.removeObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + widget.removeOverLay(); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + + final permission = { + 1: { + "name": TIM_t("相机"), + "icon": "images/chat_permission_icon_camera.png", + "text": TIM_t("为方便您将所拍摄的照片或视频发送给朋友,以及进行视频通话,请允许我们访问摄像头进行拍摄照片和视频。") + }, + 7: { + "name": TIM_t("麦克风"), + "icon": "images/chat_permission_icon_mic.png", + "text": TIM_t("为方便您发送语音消息、拍摄视频以及音视频通话,请允许我们使用麦克风进行录音。") + }, + 9: { + "name": TIM_t("相册"), + "icon": "images/chat_permission_icon_file.png", + "text": TIM_t("为方便您查看和选择相册里的图片视频发送给朋友,以及保存内容到设备,请允许我们访问您设备上的照片、媒体内容。") + }, + 15: { + "name": TIM_t("存储"), + "icon": "images/chat_permission_icon_file.png", + "text": TIM_t("为方便您查看和选择相册里的图片视频发送给朋友,以及保存内容到设备,请允许我们访问您设备上的照片、媒体内容。") + }, + 32: { + "name": TIM_t("相册"), + "icon": "images/chat_permission_icon_file.png", + "text": TIM_t("为方便您查看和选择相册里的图片视频发送给朋友,以及保存内容到设备,请允许我们访问您设备上的照片、媒体内容。") + }, + }[widget.permissionType]; + final option2 = permission?["name"] ?? ""; + return Stack( + children: [ + Positioned( + child: SafeArea( + child: Opacity( + opacity: 0.7, + child: Container( + color: theme.black, + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + height: 50, + ), + SizedBox( + height: 50, + width: 50, + child: Image.asset( + permission?["icon"] ?? "", + package: "tencent_cloud_chat_uikit", + ), + ), + const SizedBox( + height: 10, + ), + Text( + TIM_t(widget.appName) + + TIM_t_para(" 申请获取{{option2}}", " 申请获取$option2")( + option2: option2) + + TIM_t("权限"), + style: TextStyle(color: theme.white, fontSize: 18), + ), + const SizedBox( + height: 20, + ), + Text( + permission?["text"] ?? "", + style: TextStyle(color: theme.white, fontSize: 16), + ) + ], + ), + ), + ), + ), + left: 0, + right: 0, + bottom: 0, + top: 0, + ) + ], + ); + } +} + +class Permissions { + + static OverlayEntry? _entry; + + static List _names(BuildContext context) { + return [ + TIM_t("日历"), + TIM_t("相机"), + TIM_t("联系人"), + TIM_t("位置"), + 'locationAlways', + 'locationWhenInUse', + 'mediaLibrary', + TIM_t("麦克风"), + 'phone', + TIM_t("照片"), + TIM_t("相册写入"), + 'reminders', + 'sensors', + 'sms', + 'speech', + TIM_t("文件"), + 'ignoreBatteryOptimizations', + 'notification', + 'access_media_location', + 'activity_recognition', + 'unknown', + 'bluetooth', + 'manageExternalStorage', + 'systemAlertWindow', + 'requestInstallPackages', + 'appTrackingTransparency', + 'criticalAlerts', + 'accessNotificationPolicy', + 'bluetoothScan', + 'bluetoothAdvertise', + 'bluetoothConnect', + 'nearbyWifiDevices', + TIM_t("视频"), + 'audio', + 'scheduleExactAlarm' + ]; + } + + static String _permissionText( + BuildContext context, String appName, int value) { + final _prefix = TIM_t("需要授予"); + final _postfixList = [ + TIM_t("日历"), + TIM_t(" 相机权限,以正常使用拍摄图片视频、视频通话等功能。"), + TIM_t("联系人"), + TIM_t("位置"), + 'locationAlways', + 'locationWhenInUse', + 'mediaLibrary', + TIM_t(" 麦克风权限,以正常使用发送语音消息、拍摄视频、音视频通话等功能。"), + 'phone', + TIM_t(" 访问照片权限,以正常使用发送图片、视频等功能。"), + TIM_t(" 访问相册写入权限,以正常使用存储图片、视频等功能。"), + 'reminders', + 'sensors', + 'sms', + 'speech', + TIM_t(" 文件读写权限,以正常使用在聊天功能中的图片查看、选择能力和发送文件的能力。"), + 'ignoreBatteryOptimizations', + 'notification', + 'access_media_location', + 'activity_recognition', + 'unknown', + 'bluetooth', + 'manageExternalStorage', + 'systemAlertWindow', + 'requestInstallPackages', + 'appTrackingTransparency', + 'criticalAlerts', + 'accessNotificationPolicy', + 'bluetoothScan', + 'bluetoothAdvertise', + 'bluetoothConnect', + 'nearbyWifiDevices', + TIM_t(" 访问相册中视频权限,以正常使用发送视频等功能。"), + 'audio', + 'scheduleExactAlarm' + ]; + return _prefix + appName + _postfixList[value]; + } + + static Future checkPermission( + BuildContext context, + int value, [ + TUITheme? theme, + bool isShowPermissionPage = true, + ]) async { + final status = await Permission.byValue(value).status; + if (status.isGranted || status.isLimited) { + return true; + } + final bool? shouldRequestPermission = await showPermissionConfirmDialog( + context, + value, + theme, + isShowPermissionPage, + ); + if (shouldRequestPermission != null && shouldRequestPermission) { + final isGranted = await Permission.byValue(value).request().isGranted; + _entry?.remove(); + _entry = null; + return isGranted; + } + return shouldRequestPermission ?? false; + } + + static Future checkPermissionSetBefore(int value) async { + Future _prefs = SharedPreferences.getInstance(); + SharedPreferences prefs = await _prefs; + final _hasPermissionSetBefore = prefs.getBool("permission$value"); + return _hasPermissionSetBefore ?? false; + } + + static Future setLocalPermission(int value) async { + Future _prefs = SharedPreferences.getInstance(); + SharedPreferences prefs = await _prefs; + return await prefs.setBool("permission$value", true); + } + + static showPermissionRequestInfoDialog(BuildContext context, value) async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + String appName = packageInfo.appName; + final entry = OverlayEntry(builder: (context) { + return PermissionRequestInfo( + appName: appName, + removeOverLay: () { + _entry?.remove(); + _entry = null; + }, + permissionType: value, + ); + }); + _entry = entry; + Overlay.of(context).insert(entry); + } + + static Future showPermissionConfirmDialog(BuildContext context, value, + [TUITheme? theme, bool isShowPermissionPage = true]) async { + final platformUtils = PlatformUtils(); + // 第一次直接走系统文案 + if (!await checkPermissionSetBefore(value)) { + await setLocalPermission(value); + if (platformUtils.isAndroid && isShowPermissionPage) { + showPermissionRequestInfoDialog(context, value); + } + return true; + } + + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + String appName = packageInfo.appName; + final option2 = _names(context)[value]; + final permissionText = _permissionText(context, appName, value); + + void closeDialog() { + Navigator.of(context).pop(false); + } + + void getPermission() async { + Navigator.of(context).pop(false); + openAppSettings(); + } + + return showDialog( + context: context, + builder: (context) { + return platformUtils.isIOS + ? CupertinoAlertDialog( + title: Text("“$appName”" + + TIM_t_para(" 想访问您的{{option2}}", " 想访问您的$option2")( + option2: option2)), + content: Text(permissionText), + actions: [ + CupertinoDialogAction( + child: Text(TIM_t("以后再说")), + onPressed: closeDialog, // 关闭对话框 + ), + CupertinoDialogAction( + child: Text(TIM_t("去开启")), + onPressed: getPermission, + ), + ], + ) + : AlertDialog( + content: Text(permissionText), + actions: [ + const Divider(), + SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: TextButton( + child: Text(TIM_t("以后再说"), + style: TextStyle( + color: theme?.black ?? Colors.black, + )), + onPressed: closeDialog, // 关闭对话框 + ), + ), + const VerticalDivider(), + Expanded( + child: TextButton( + child: Text(TIM_t("去开启"), + style: TextStyle( + color: theme?.black ?? Colors.black, + )), + onPressed: getPermission, + ), + ) + ], + ), + ) + ], + ); + }, + ); + } +} diff --git a/lib/ui/utils/platform.dart b/lib/ui/utils/platform.dart new file mode 100644 index 0000000..4272f2d --- /dev/null +++ b/lib/ui/utils/platform.dart @@ -0,0 +1,61 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; + +class PlatformUtils { + PlatformUtils._internal(); + static late bool _isAndroid; + static late bool _isIos; + static late bool _isMobile; + static late bool _isWeb; + static late bool _isWindows; + static late bool _isMacOS; + static late bool _isLinux; + static late bool _isDesktop; + static bool _isInstantiation = false; + + factory PlatformUtils() { + if (!_isInstantiation) { + _isAndroid = !kIsWeb && Platform.isAndroid; + _isIos = !kIsWeb && Platform.isIOS; + _isMobile = _isAndroid || _isIos; + _isWindows = !kIsWeb && Platform.isWindows; + _isMacOS = !kIsWeb && Platform.isMacOS; + _isLinux = !kIsWeb && Platform.isLinux; + _isDesktop = _isMacOS || _isWindows || _isLinux; + _isWeb = kIsWeb; + _isInstantiation = true; + } + + return _instance; + } + + static late final PlatformUtils _instance = PlatformUtils._internal(); + + get isAndroid { + return _isAndroid; + } + + get isWeb { + return _isWeb; + } + + get isIOS { + return _isIos; + } + + get isWindows { + return _isWindows; + } + + get isMacOS { + return _isMacOS; + } + + get isMobile { + return _isMobile; + } + + bool get isDesktop => _isDesktop; + + bool get isLinux => _isLinux; +} diff --git a/lib/ui/utils/route.dart b/lib/ui/utils/route.dart new file mode 100644 index 0000000..0323d6d --- /dev/null +++ b/lib/ui/utils/route.dart @@ -0,0 +1,24 @@ +import 'package:flutter/cupertino.dart'; + +class FadeRoute extends PageRouteBuilder { + final Widget page; + FadeRoute({required this.page}) + : super( + pageBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => + page, + transitionsBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => + FadeTransition( + opacity: animation, + child: child, + ), + ); +} diff --git a/lib/ui/utils/screen_shot.dart b/lib/ui/utils/screen_shot.dart new file mode 100644 index 0000000..a6d2d83 --- /dev/null +++ b/lib/ui/utils/screen_shot.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:uuid/uuid.dart'; + +class ScreenshotHelper { + static Future captureScreen() async { + await requestScreenRecordingPermission(); + String directory; + + if(PlatformUtils().isWindows){ + final String documentsDirectoryPath = + "${Platform.environment['USERPROFILE']}"; + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + String pkgName = packageInfo.packageName; + directory = p.join(documentsDirectoryPath, "Documents", ".TencentCloudChat", + pkgName, "screenshots"); + }else{ + final dic = await getApplicationSupportDirectory(); + directory = dic.path; + } + + const uuid = Uuid(); + final fileName = 'screenshot_${uuid.v4()}.png'; + final filePath = '$directory/$fileName'; + if (Platform.isMacOS) { + // 在macOS平台上使用screencapture工具 + final result = await Process.run( + 'screencapture', + ['-i', '-s', '-o', filePath], + ); + if (result.exitCode == 0) { + return filePath; + } else { + return null; + } + } else if (Platform.isWindows) { + // 在Windows平台上使用snippingtool工具 + final result = await Process.run( + 'snippingtool', + ['/clip', filePath], + ); + if (result.exitCode == 0) { + return filePath; + } else { + return null; + } + } else { + // 不支持的平台 + return null; + } + } + + static Future requestScreenRecordingPermission() async { + if (Platform.isMacOS) { + final result = await Process.run( + 'sh', ['-c', 'echo ${Platform.environment['USER']}']); + final username = result.stdout.toString().trim(); + const script = + 'tell application "System Events" to return exists (processes where name is "ControlCenter")'; + final process = await Process.run('osascript', ['-e', script]); + final isControlCenterRunning = process.stdout.toString().trim() == 'true'; + + if (!isControlCenterRunning) { + await Process.run('open', ['-a', 'ControlCenter']); + await Future.delayed(const Duration(seconds: 1)); + } + + final script2 = 'tell application "ControlCenter" to activate\n' + 'tell application "System Events"\n' + ' tell process "ControlCenter"\n' + ' click menu item "Screen Recording" of menu "File" of menu bar 1\n' + ' delay 0.5\n' + ' keystroke "$username" & return\n' + ' end tell\n' + 'end tell\n'; + await Process.run('osascript', ['-e', script2]); + + await Future.delayed(const Duration(seconds: 1)); + final isScreenRecordingEnabled = await SystemChannels.platform + .invokeMethod('isScreenRecordingEnabled'); + return isScreenRecordingEnabled ?? false; + } else { + return true; + } + } + + static Future getImageSize(String imagePath) async { + final bytes = await File(imagePath).readAsBytes(); + final completer = Completer(); + final imageStream = + Image.memory(bytes).image.resolve(ImageConfiguration.empty); + imageStream.addListener(ImageStreamListener((imageInfo, _) { + completer.complete(Size( + imageInfo.image.width.toDouble(), imageInfo.image.height.toDouble())); + })); + return completer.future; + } + +} diff --git a/lib/ui/utils/screen_utils.dart b/lib/ui/utils/screen_utils.dart new file mode 100644 index 0000000..a61e51f --- /dev/null +++ b/lib/ui/utils/screen_utils.dart @@ -0,0 +1,63 @@ +// ignore_for_file: constant_identifier_names + +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; + +enum DeviceType { Desktop, Mobile } + +class FormFactor { + static double desktop = 900; + static double handset = 300; +} + +class TUIKitScreenUtils { + static DeviceType? deviceType; + + /// Although specifying the `BuildContext` is optional, providing it can prevent layout issues when this widget renders immediately after the app launch. + /// If this widget needs to be used at the moment the app launches, it's recommended to provide the `BuildContext` here. + static DeviceType getFormFactor([BuildContext? context]) { + if (deviceType != null) return deviceType!; + + if (PlatformUtils().isWeb) { + final win = WidgetsBinding.instance.platformDispatcher.views.first; + final size = win.physicalSize; + final screenWidth = size.width / win.devicePixelRatio; + final screenHeight = size.height / win.devicePixelRatio; + + final diagonalInInches = + sqrt(pow(screenWidth, 2) + pow(screenHeight, 2)) / 96.0; + + deviceType = diagonalInInches < 11.0 ? DeviceType.Mobile : DeviceType.Desktop; + return deviceType ?? DeviceType.Mobile; + }else{ + if(context != null){ + double deviceWidth = MediaQuery.of(context).size.width; + double deviceHeight = MediaQuery.of(context).size.height; + + if (deviceWidth > FormFactor.desktop || deviceWidth > deviceHeight * 1.1) { + deviceType = DeviceType.Desktop; + } else if (deviceWidth > FormFactor.handset) { + deviceType = DeviceType.Mobile; + } + return deviceType ?? DeviceType.Mobile; + }else{ + return DeviceType.Mobile; + } + } + } + + static Widget getDeviceWidget({ + /// Although specifying the `BuildContext` is optional, providing it can prevent layout issues when this widget renders immediately after the app launch. + /// If this widget needs to be used at the moment the app launches, it's recommended to provide the `BuildContext` here. + BuildContext? context, + required Widget defaultWidget, + Widget? desktopWidget, + Widget? mobileWidget, + }) { + deviceType ??= getFormFactor(context); + if (deviceType == DeviceType.Desktop) return desktopWidget ?? defaultWidget; + return mobileWidget ?? defaultWidget; + } +} diff --git a/lib/ui/utils/sound_record.dart b/lib/ui/utils/sound_record.dart new file mode 100644 index 0000000..c6ffff3 --- /dev/null +++ b/lib/ui/utils/sound_record.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:flutter_plugin_record_plus/const/play_state.dart'; +import 'package:flutter_plugin_record_plus/const/response.dart'; +import 'package:flutter_plugin_record_plus/index.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:tencent_cloud_chat_uikit/import_proxy/import_proxy.dart'; + +typedef PlayStateListener = void Function(PlayState playState); +typedef SoundInterruptListener = void Function(); +typedef ResponseListener = void Function(RecordResponse recordResponse); + +class SoundPlayer { + final ImportProxy importProxy = ImportProxy(); + static final FlutterPluginRecord _recorder = FlutterPluginRecord(); + static SoundInterruptListener? _soundInterruptListener; + static bool isInit = false; + static final AudioPlayer _audioPlayer = AudioPlayer(); + + static initSoundPlayer() { + if (!isInit) { + _recorder.init(); + // AudioPlayer.global.setGlobalAudioContext(const AudioContext()); + isInit = true; + } + } + + static Future play({required String url}) async { + _audioPlayer.stop(); + if (_soundInterruptListener != null) { + _soundInterruptListener!(); + } + await _audioPlayer.setUrl(url); + + await _audioPlayer.play(); + } + + static stop() { + _audioPlayer.stop(); + + } + + static dispose() { + _audioPlayer.dispose(); + _recorder.dispose(); + } + + static StreamSubscription playStateListener({required void Function(PlayerState)? listener}) => _audioPlayer.playerStateStream.listen(listener); + + static setSoundInterruptListener(SoundInterruptListener listener) { + _soundInterruptListener = listener; + } + + static removeSoundInterruptListener() { + _soundInterruptListener = null; + } + + static StreamSubscription responseListener(ResponseListener listener) => _recorder.response.listen(listener); + + static StreamSubscription responseFromAmplitudeListener(ResponseListener listener) => _recorder.responseFromAmplitude.listen(listener); + + static startRecord() { + _recorder.start(); + } + + static stopRecord() { + _recorder.stop(); + } +} diff --git a/lib/ui/utils/time_ago.dart b/lib/ui/utils/time_ago.dart new file mode 100644 index 0000000..da6db4a --- /dev/null +++ b/lib/ui/utils/time_ago.dart @@ -0,0 +1,111 @@ +// ignore_for_file: prefer_typing_uninitialized_variables + +import 'package:intl/intl.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; + +class TimeAgo { + List dayMap() { + return [ + TIM_t("昨天"), + TIM_t("前天"), + ]; + } + + List weekdayMap() { + return ['', TIM_t("星期一"), TIM_t("星期二"), TIM_t("星期三"), TIM_t("星期四"), TIM_t("星期五"), TIM_t("星期六"), TIM_t("星期天")]; + } + + String getYearMonthDate(DateTime dateTime) { + String month = dateTime.month.toString(); + String date = dateTime.day.toString(); + return dateTime.year.toString() + '/' + (month.length == 1 ? '0' : '') + month + '/' + (date.length == 1 ? '0' : '') + date; + } + + String getMonthDate(DateTime dateTime) { + String month = dateTime.month.toString(); + String date = dateTime.day.toString(); + return (month.length == 1 ? '0' : '') + month + '/' + (date.length == 1 ? '0' : '') + date; + } + + static String getMonth(DateTime dateTime) { + String month = dateTime.month.toString(); + return (month.length == 1 ? '0' : '') + month; + } + + static String getDay(DateTime dateTime) { + String day = dateTime.day.toString(); + return (day.length == 1 ? '0' : '') + day; + } + + String? getTimeStringForChat(int timeStamp) { + final DateTime date = DateTime.fromMillisecondsSinceEpoch(timeStamp * 1000); + final DateTime epochLimit = DateTime.utc(1971); + + if (date.isBefore(epochLimit)) { + return null; + } + + final Duration duration = DateTime.now().difference(date); + final int diffDays = duration.inDays + (duration.inMinutes > DateTime.now().difference(DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day)).inMinutes ? 1 : 0); + final int diffMinutes = duration.inMinutes; + + var res; + + // 一个礼拜之内 + if (diffDays > 0 && diffDays < 7) { + if (diffDays <= 2) { + res = dayMap()[diffDays - 1]; + } else { + res = weekdayMap()[date.weekday]; + } + } else if (diffDays >= 7) { + //当年内 + if (date.year == DateTime.now().year) { + res = getMonthDate(date); + } else { + res = getYearMonthDate(date); + } + } else { + if (diffMinutes > 1) { + if (diffMinutes < 60) { + final String option2 = diffMinutes.toString(); + res = TIM_t_para("{{option2}} 分钟前", "$option2 分钟前")(option2: option2); + } else { + res = "${date.hour}:${date.minute < 10 ? "0" + date.minute.toString() : date.minute}"; + // res = "$prefix $timeStr"; + } + } else { + res = TIM_t("现在"); + } + } + + return res; + } + + String getTimeForMessage(int timeStamp) { + var nowTime = DateTime.now(); + nowTime = DateTime(nowTime.year, nowTime.month, nowTime.day); + var ftime = DateTime.fromMillisecondsSinceEpoch(timeStamp * 1000); + // var preFix = ftime.hour >= 12 ? TIM_t("下午") : TIM_t("上午"); + final timeStr = DateFormat('HH:mm').format(ftime); // Use 'HH:mm' for 24-hour format + // 一年外 年月日 + 时间 (24小时制) + if (nowTime.year != ftime.year) { + return '${DateFormat('yyyy-MM-dd').format(ftime)} $timeStr'; + } + // 一年内一周外 月日 + 时间 (24小时制) + if (ftime.isBefore(nowTime.subtract(const Duration(days: 6)))) { + return '${DateFormat('MM-dd').format(ftime)} $timeStr'; + } + // 一周内一天外 星期 + 时间 (24小时制) + if (ftime.isBefore(nowTime.subtract(const Duration(days: 1)))) { + return '${weekdayMap()[ftime.weekday]} $timeStr'; + } + // 昨日 昨天 + 时间 (24小时制) + if (nowTime.day != ftime.day) { + String option2 = timeStr; + return TIM_t_para("昨天 {{option2}}", "昨天 $option2")(option2: option2); + } + // 同年月日 时间 (24小时制) + return timeStr; + } +} diff --git a/lib/ui/views/TIMUIKitAddFriend/tim_uikit_add_friend.dart b/lib/ui/views/TIMUIKitAddFriend/tim_uikit_add_friend.dart new file mode 100644 index 0000000..432d2d7 --- /dev/null +++ b/lib/ui/views/TIMUIKitAddFriend/tim_uikit_add_friend.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/add_friend_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitAddFriend/tim_uikit_send_application.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitAddFriend extends StatefulWidget { + final bool? isShowDefaultGroup; + + /// You may navigate to user profile page, if friendship relationship exists. + final Function(String userID) onTapAlreadyFriendsItem; + + /// The life cycle hooks for adding friends and contact business logic + final AddFriendLifeCycle? lifeCycle; + + /// The callback function to close the widget upon completion by the parent component. + final VoidCallback? closeFunc; + + const TIMUIKitAddFriend( + {Key? key, + this.isShowDefaultGroup = false, + this.lifeCycle, + required this.onTapAlreadyFriendsItem, + this.closeFunc}) + : super(key: key); + + @override + State createState() => _TIMUIKitAddFriendState(); +} + +class _TIMUIKitAddFriendState extends TIMUIKitState { + final TextEditingController _controller = TextEditingController(); + final CoreServicesImpl _coreServicesImpl = serviceLocator(); + final FriendshipServices _friendshipServices = serviceLocator(); + final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator(); + final FocusNode _focusNode = FocusNode(); + bool isFocused = false; + bool showResult = false; + List? searchResult; + + Widget _searchResultItemBuilder(V2TimUserFullInfo friendInfo, TUITheme theme) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + final faceUrl = friendInfo.faceUrl ?? ""; + final userID = friendInfo.userID ?? ""; + final String showName = + ((friendInfo.nickName != null && friendInfo.nickName!.isNotEmpty) ? friendInfo.nickName : userID) ?? ""; + return InkWell( + onTap: () async { + final checkFriend = await _friendshipServices + .checkFriend(userIDList: [userID], checkType: FriendTypeEnum.V2TIM_FRIEND_TYPE_SINGLE); + if (checkFriend != null) { + final res = checkFriend.first; + if (res.resultCode == 0 && res.resultType != 0) { + widget.onTapAlreadyFriendsItem(userID); + return; + } + } + + if (userID == _selfInfoViewModel.loginInfo?.userID) { + widget.onTapAlreadyFriendsItem(userID); + return; + } + + if (isDesktopScreen) { + if (widget.closeFunc != null) { + widget.closeFunc!(); + } + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.addFriend, + context: context, + width: MediaQuery.of(context).size.width * 0.3, + height: MediaQuery.of(context).size.width * 0.4, + title: TIM_t("添加好友"), + child: (closeFuncSendApplication) => SendApplication( + lifeCycle: widget.lifeCycle, + isShowDefaultGroup: widget.isShowDefaultGroup ?? false, + friendInfo: friendInfo, + model: _selfInfoViewModel), + ); + } else { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => SendApplication( + lifeCycle: widget.lifeCycle, + isShowDefaultGroup: widget.isShowDefaultGroup ?? false, + friendInfo: friendInfo, + model: _selfInfoViewModel))); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + // crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: isDesktopScreen ? 38 : 48, + height: isDesktopScreen ? 38 : 48, + margin: const EdgeInsets.only(right: 16), + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName, + style: TextStyle(color: theme.darkTextColor, fontSize: isDesktopScreen ? 16 : 18), + ), + const SizedBox( + height: 4, + ), + Text( + "ID: $userID", + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ) + ], + ), + ], + ), + ), + ); + } + + List _searchResultBuilder(List? searchResult, TUITheme theme) { + final noResult = searchResult == null || searchResult.isEmpty; + if (noResult) { + return [ + Container( + margin: const EdgeInsets.only(top: 20), + child: Center( + child: Text(TIM_t("该用户不存在"), style: TextStyle(color: theme.weakTextColor, fontSize: 14)), + ), + ) + ]; + } + return searchResult.map((e) => _searchResultItemBuilder(e, theme)).toList(); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(() { + final _isFocused = _focusNode.hasFocus; + isFocused = _isFocused; + setState(() {}); + }); + } + + @override + void dispose() { + super.dispose(); + } + + searchFriend(String params) async { + final response = await _coreServicesImpl.getUsersInfo(userIDList: [params]); + if (response.code == 0) { + setState(() { + searchResult = response.data; + }); + } else { + setState(() { + searchResult = null; + }); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _selfInfoViewModel), + ], + builder: (BuildContext context, Widget? w) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _focusNode, + controller: _controller, + onChanged: (value) { + if (value.trim().isEmpty) { + setState(() { + showResult = false; + }); + } + }, + textInputAction: TextInputAction.search, + onSubmitted: (_) { + final searchParams = _controller.text; + if (searchParams.trim().isNotEmpty) { + searchFriend(searchParams); + showResult = true; + _focusNode.requestFocus(); + setState(() {}); + } + }, + decoration: InputDecoration( + prefixIcon: Icon( + Icons.search_outlined, + color: theme.weakTextColor, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + contentPadding: EdgeInsets.zero, + hintStyle: TextStyle( + color: theme.weakTextColor, + ), + fillColor: theme.inputFillColor, + filled: true, + hintText: TIM_t("搜索用户 ID")), + )), + ], + ), + ), + if (showResult) + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SingleChildScrollView( + child: Column( + children: _searchResultBuilder(searchResult, theme), + ), + ), + ), + ) + ], + ); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitAddFriend/tim_uikit_send_application.dart b/lib/ui/views/TIMUIKitAddFriend/tim_uikit_send_application.dart new file mode 100644 index 0000000..3949a9e --- /dev/null +++ b/lib/ui/views/TIMUIKitAddFriend/tim_uikit_send_application.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/add_friend_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class SendApplication extends StatefulWidget { + final V2TimUserFullInfo friendInfo; + final TUISelfInfoViewModel model; + final bool? isShowDefaultGroup; + final AddFriendLifeCycle? lifeCycle; + + const SendApplication( + {Key? key, this.lifeCycle, required this.friendInfo, required this.model, this.isShowDefaultGroup = false}) + : super(key: key); + + @override + State createState() => _SendApplicationState(); +} + +class _SendApplicationState extends TIMUIKitState { + final TextEditingController _verficationController = TextEditingController(); + final TextEditingController _nickNameController = TextEditingController(); + + @override + void initState() { + super.initState(); + final showName = widget.model.loginInfo?.nickName ?? widget.model.loginInfo?.userID; + _verficationController.text = "我是: $showName"; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final FriendshipServices _friendshipServices = serviceLocator(); + + final faceUrl = widget.friendInfo.faceUrl ?? ""; + final userID = widget.friendInfo.userID ?? ""; + final String showName = ((widget.friendInfo.nickName != null && widget.friendInfo.nickName!.isNotEmpty) + ? widget.friendInfo.nickName + : userID) ?? + ""; + final option2 = widget.friendInfo.selfSignature ?? ""; + + Widget sendApplicationBody() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: theme.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + margin: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + margin: const EdgeInsets.only(right: 12), + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName, + style: TextStyle(color: theme.darkTextColor, fontSize: 18), + ), + const SizedBox( + height: 4, + ), + Text( + "ID: $userID", + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + ), + const SizedBox( + height: 4, + ), + if (TencentUtils.checkString(option2) != null) + Text( + TIM_t_para("个性签名: {{option2}}", "个性签名: $option2")(option2: option2), + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + ), + ], + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + TIM_t("填写验证信息"), + style: TextStyle(fontSize: 16, color: theme.weakTextColor), + ), + ), + Container( + margin: const EdgeInsets.only(top: 6, bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: theme.white, + child: TextField( + // minLines: 1, + maxLines: 4, + controller: _verficationController, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + border: InputBorder.none, + hintStyle: TextStyle(color: theme.textgrey), + hintText: '', + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + TIM_t("请填写备注"), + style: TextStyle(fontSize: 16, color: theme.weakTextColor), + ), + ), + Container( + color: theme.white, + padding: const EdgeInsets.symmetric(horizontal: 16), + margin: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("备注"), + style: TextStyle(color: theme.darkTextColor, fontSize: 16), + ), + SizedBox( + width: 50, + child: TextField( + controller: _nickNameController, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintStyle: TextStyle( + color: theme.textgrey, + ), + hintText: '', + ), + ), + ) + ], + ), + ), + const Divider( + height: 1, + ), + if (widget.isShowDefaultGroup == true) + Container( + color: theme.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("分组"), + style: TextStyle(color: theme.darkTextColor, fontSize: 16), + ), + Text( + TIM_t("我的好友"), + style: TextStyle(color: theme.darkTextColor, fontSize: 16), + ) + ], + ), + ), + Container( + color: theme.white, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + child: TextButton( + onPressed: () async { + final remark = _nickNameController.text; + final addWording = _verficationController.text; + final friendGroup = TIM_t("我的好友"); + + if (widget.lifeCycle?.shouldAddFriend != null && + await widget.lifeCycle!.shouldAddFriend(userID, remark, friendGroup, addWording, context) == + false) { + return; + } + + _friendshipServices.addFriend( + userID: userID, + addType: FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH, + remark: remark, + addWording: addWording, + friendGroup: friendGroup); + }, + child: Text(TIM_t("发送"))), + ) + ], + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + padding: const EdgeInsets.only(top: 10), + color: theme.weakBackgroundColor, + child: sendApplicationBody(), + ), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("添加好友"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.white, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + ), + body: sendApplicationBody(), + )); + } +} diff --git a/lib/ui/views/TIMUIKitAddGroup/tim_uikit_add_group.dart b/lib/ui/views/TIMUIKitAddGroup/tim_uikit_add_group.dart new file mode 100644 index 0000000..03200b1 --- /dev/null +++ b/lib/ui/views/TIMUIKitAddGroup/tim_uikit_add_group.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/add_group_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitAddGroup/tim_uikit_send_application.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; + +class TIMUIKitAddGroup extends StatefulWidget { + /// The life cycle hooks for adding group business logic + final AddGroupLifeCycle? lifeCycle; + + /// Navigate to group chat, if user is already a member of the current group. + final Function(String groupID, V2TimConversation conversation) onTapExistGroup; + + final VoidCallback? closeFunc; + + const TIMUIKitAddGroup({Key? key, this.lifeCycle, required this.onTapExistGroup, this.closeFunc}) : super(key: key); + + @override + State createState() => _TIMUIKitAddGroupState(); +} + +class _TIMUIKitAddGroupState extends TIMUIKitState { + final TextEditingController _controller = TextEditingController(); + final GroupServices _groupServices = serviceLocator(); + final ConversationService _conversationService = serviceLocator(); + final TUIFriendShipViewModel friendShipViewModel = serviceLocator(); + List? _addedGroupList; + List? groupResult = []; + final FocusNode _focusNode = FocusNode(); + bool isFocused = false; + bool showResult = false; + + String _getGroupType(String type) { + String groupType; + switch (type) { + case GroupType.AVChatRoom: + groupType = TIM_t("聊天室"); + break; + case GroupType.Meeting: + groupType = TIM_t("会议群"); + break; + case GroupType.Public: + groupType = TIM_t("公开群"); + break; + case GroupType.Work: + groupType = TIM_t("工作群"); + break; + case GroupType.Community: + groupType = TIM_t("社群"); + break; + default: + groupType = TIM_t("未知群"); + break; + } + return groupType; + } + + Widget _searchResultItemBuilder(V2TimGroupInfo groupInfo, TUITheme theme) { + final faceUrl = groupInfo.faceUrl ?? ""; + final groupID = groupInfo.groupID; + final showName = groupInfo.groupName ?? groupID; + final groupType = _getGroupType(groupInfo.groupType); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return InkWell( + onTap: () async { + final V2TimConversation? groupConversation = await getGroupConversation(groupID); + if (groupConversation != null) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("您已是群成员"), infoCode: 6660202)); + if (widget.closeFunc != null) { + widget.closeFunc!(); + } + return; + } + + if (isDesktopScreen) { + if (widget.closeFunc != null) { + widget.closeFunc!(); + } + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.addGroup, + context: context, + width: MediaQuery.of(context).size.width * 0.3, + height: MediaQuery.of(context).size.width * 0.4, + title: TIM_t("添加群聊"), + child: (closeFuncSendApplication) => SendJoinGroupApplication( + lifeCycle: widget.lifeCycle, + groupInfo: groupInfo, + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SendJoinGroupApplication( + lifeCycle: widget.lifeCycle, + groupInfo: groupInfo, + ))); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + // crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: isDesktopScreen ? 38 : 48, + height: isDesktopScreen ? 38 : 48, + margin: const EdgeInsets.only(right: 16), + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName, + style: TextStyle(fontSize: isDesktopScreen ? 16 : 18), + ), + Text( + "ID: $groupID", + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ), + Text( + "群类型: $groupType", + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ) + ], + ) + ], + ), + ), + ); + } + + List _searchResultBuilder(List? searchResult, TUITheme theme) { + final noResult = searchResult != null && searchResult.isEmpty; + if (noResult) { + return [ + Container( + margin: const EdgeInsets.only(top: 20), + child: Center( + child: Text(TIM_t("该群聊不存在"), style: TextStyle(color: theme.weakTextColor, fontSize: 14)), + ), + ) + ]; + } + return searchResult?.map((e) => _searchResultItemBuilder(e, theme)).toList() ?? []; + } + + Future getGroupConversation(String groupID) async { + if (_addedGroupList == null || _addedGroupList!.isEmpty) { + _addedGroupList = await _groupServices.getJoinedGroupList(); + } + try { + if ((_addedGroupList?.firstWhere((groupItem) { + return groupItem.groupID == groupID; + })) != + null) { + V2TimConversation? conversation; + conversation = await _conversationService.getConversationListByConversationId(convID: "group_$groupID"); + if (conversation == null) { + await friendShipViewModel.loadGroupListData(); + if (friendShipViewModel.groupList.indexWhere((element) => element.groupID == groupID) > -1) { + final V2TimGroupInfo groupInfo = + friendShipViewModel.groupList.firstWhere((element) => element.groupID == groupID); + conversation = V2TimConversation( + conversationID: "group_$groupID", + type: 2, + groupID: groupID, + showName: groupInfo.groupName, + groupType: groupInfo.groupType, + ); + } + } + return conversation; + } + } catch (e) { + return null; + } + return null; + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(() { + final _isFocused = _focusNode.hasFocus; + isFocused = _isFocused; + setState(() {}); + }); + initGroupList(); + } + + void initGroupList() async { + // Get the joined group list in previous + _addedGroupList = await _groupServices.getJoinedGroupList(); + } + + @override + void dispose() { + super.dispose(); + } + + searchGroup(String params) async { + final res = await _groupServices.getGroupsInfo(groupIDList: [params]); + if (res != null) { + setState(() { + groupResult = res.where((e) => e.resultCode == 0).map((e) => e.groupInfo!).toList(); + }); + } else { + setState(() { + groupResult = []; + }); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _focusNode, + controller: _controller, + onChanged: (value) { + if (value.trim().isEmpty) { + setState(() { + showResult = false; + }); + } + }, + textInputAction: TextInputAction.search, + onSubmitted: (_) { + final searchParams = _controller.text; + if (searchParams.trim().isNotEmpty) { + searchGroup(searchParams); + showResult = true; + _focusNode.requestFocus(); + setState(() {}); + } + }, + decoration: InputDecoration( + prefixIcon: Icon( + Icons.search_outlined, + color: theme.weakTextColor, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + contentPadding: EdgeInsets.zero, + hintStyle: TextStyle( + color: theme.weakTextColor, + ), + fillColor: theme.inputFillColor, + filled: true, + hintText: TIM_t("搜索群ID")), + )), + ], + ), + ), + if (showResult) + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SingleChildScrollView( + child: Column( + children: _searchResultBuilder(groupResult, theme), + ), + ), + ), + ) + ], + ); + } +} diff --git a/lib/ui/views/TIMUIKitAddGroup/tim_uikit_send_application.dart b/lib/ui/views/TIMUIKitAddGroup/tim_uikit_send_application.dart new file mode 100644 index 0000000..08f048a --- /dev/null +++ b/lib/ui/views/TIMUIKitAddGroup/tim_uikit_send_application.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/add_group_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class SendJoinGroupApplication extends StatefulWidget { + final V2TimGroupInfo groupInfo; + final AddGroupLifeCycle? lifeCycle; + + const SendJoinGroupApplication({Key? key, required this.groupInfo, this.lifeCycle}) : super(key: key); + + @override + State createState() => _SendJoinGroupApplicationState(); +} + +class _SendJoinGroupApplicationState extends TIMUIKitState { + final TextEditingController _verficationController = TextEditingController(); + final GroupServices _groupServices = serviceLocator(); + final CoreServicesImpl _coreServicesImpl = serviceLocator(); + + @override + void initState() { + super.initState(); + final loginUserInfo = _coreServicesImpl.loginUserInfo; + final option1 = loginUserInfo?.nickName ?? loginUserInfo?.userID; + _verficationController.text = TIM_t_para("我是: {{option1}}", "我是: $option1")(option1: option1); + } + + Future addGroup(String groupID, String message) async { + if (widget.lifeCycle?.shouldAddGroup != null && + await widget.lifeCycle!.shouldAddGroup(groupID, message, context) == false) { + return null; + } + return _groupServices.joinGroup(groupID: groupID, message: message); + } + + String _getGroupType(String type) { + String groupType; + switch (type) { + case GroupType.AVChatRoom: + groupType = TIM_t("聊天室"); + break; + case GroupType.Meeting: + groupType = TIM_t("会议群"); + break; + case GroupType.Public: + groupType = TIM_t("公开群"); + break; + case GroupType.Work: + groupType = TIM_t("工作群"); + break; + case GroupType.Community: + groupType = TIM_t("社群"); + break; + default: + groupType = TIM_t("未知群"); + break; + } + return groupType; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final faceUrl = widget.groupInfo.faceUrl ?? ""; + final groupID = widget.groupInfo.groupID; + final showName = widget.groupInfo.groupName ?? groupID; + final option1 = _getGroupType(widget.groupInfo.groupType); + + Widget sendGroupApplicationBody() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: theme.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + margin: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + margin: const EdgeInsets.only(right: 12), + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName, + style: TextStyle(color: theme.darkTextColor, fontSize: 18), + ), + const SizedBox( + height: 4, + ), + Text( + "ID: $groupID", + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + ), + const SizedBox( + height: 4, + ), + Text( + TIM_t_para("群类型: {{option1}}", "群类型: $option1")(option1: option1), + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ), + ], + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + TIM_t("填写验证信息"), + style: TextStyle(fontSize: 16, color: theme.weakTextColor), + ), + ), + Container( + margin: const EdgeInsets.only(top: 6, bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: theme.white, + child: TextField( + maxLines: 4, + controller: _verficationController, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + border: InputBorder.none, + hintStyle: TextStyle( + color: theme.textgrey, + ), + hintText: '')), + ), + Container( + color: theme.white, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + child: TextButton( + onPressed: () async { + final addWording = _verficationController.text; + final res = await addGroup(groupID, addWording); + if (res?.code == 0) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, infoRecommendText: TIM_t("群申请已发送"), infoCode: 6660201)); + } + }, + child: Text(TIM_t("发送"))), + ) + ], + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: sendGroupApplicationBody(), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("进群申请"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.white, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + ), + body: sendGroupApplicationBody(), + )); + } +} diff --git a/lib/ui/views/TIMUIKitBlackList/tim_uikit_black_list.dart b/lib/ui/views/TIMUIKitBlackList/tim_uikit_black_list.dart new file mode 100644 index 0000000..b68a9ff --- /dev/null +++ b/lib/ui/views/TIMUIKitBlackList/tim_uikit_black_list.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_slidable_plus_plus/flutter_slidable_plus_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/block_list_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef BlackListItemBuilder = Widget Function(BuildContext context, V2TimFriendInfo friendInfo); + +class TIMUIKitBlackList extends StatefulWidget { + final void Function(V2TimFriendInfo friendInfo)? onTapItem; + final Widget Function(BuildContext context)? emptyBuilder; + final BlackListItemBuilder? itemBuilder; + + /// The life cycle hooks for block list business logic + final BlockListLifeCycle? lifeCycle; + + const TIMUIKitBlackList({Key? key, this.onTapItem, this.emptyBuilder, this.itemBuilder, this.lifeCycle}) + : super(key: key); + + @override + State createState() => _TIMUIKitBlackListState(); +} + +class _TIMUIKitBlackListState extends TIMUIKitState { + final TUIFriendShipViewModel _friendshipViewModel = serviceLocator(); + + _getShowName(V2TimFriendInfo item) { + final friendRemark = item.friendRemark ?? ""; + final nickName = item.userProfile?.nickName ?? ""; + final userID = item.userID; + final showName = nickName != "" ? nickName : userID; + return friendRemark != "" ? friendRemark : showName; + } + + Widget _itemBuilder(BuildContext context, V2TimFriendInfo friendInfo) { + final theme = Provider.of(context).theme; + final showName = _getShowName(friendInfo); + final faceUrl = friendInfo.userProfile?.faceUrl ?? ""; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget itemWidget() { + return Material( + color: theme.wideBackgroundColor, + child: InkWell( + onTap: () { + if (widget.onTapItem != null) { + widget.onTapItem!(friendInfo); + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10, left: 16, right: 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + children: [ + Container( + padding: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 10, bottom: 20), + child: Text( + showName, + style: TextStyle(color: theme.black, fontSize: isDesktopScreen ? 14 : 18), + ), + )), + if (isDesktopScreen) + OutlinedButton( + onPressed: () { + _friendshipViewModel.deleteFromBlockList([friendInfo.userID]); + }, + child: Text( + TIM_t("移出黑名单"), + style: TextStyle(color: theme.primaryColor), + )) + ], + ), + ), + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: itemWidget(), + defaultWidget: Slidable( + endActionPane: ActionPane(motion: const DrawerMotion(), children: [ + SlidableAction( + onPressed: (context) async { + await _friendshipViewModel.deleteFromBlockList([friendInfo.userID]); + }, + backgroundColor: theme.cautionColor ?? CommonColor.cautionColor, + foregroundColor: theme.white, + label: TIM_t("删除"), + autoClose: true, + ) + ]), + child: itemWidget(), + )); + } + + BlackListItemBuilder _getItemBuilder() { + return widget.itemBuilder ?? _itemBuilder; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _friendshipViewModel), + ], + builder: (BuildContext context, Widget? w) { + final model = Provider.of(context); + model.blockListLifeCycle = widget.lifeCycle; + final blockList = model.blockList; + if (blockList.isNotEmpty) { + return ListView.builder( + shrinkWrap: true, + itemCount: blockList.length, + itemBuilder: (context, index) { + final friendInfo = blockList[index]; + final itemBuilder = _getItemBuilder(); + return itemBuilder(context, friendInfo); + }, + ); + } + + if (widget.emptyBuilder != null) { + return widget.emptyBuilder!(context); + } + + return Container(); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart new file mode 100644 index 0000000..6b876bc --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart @@ -0,0 +1,63 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_tongue_item.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +enum MessageListTongueType { + none, + toLatest, + showUnread, + atMe, + showPrevious, + atAll, +} + +typedef TongueItemBuilder = Widget Function( + VoidCallback onClick, MessageListTongueType valueType, int unreadCount); + +class TIMUIKitHistoryMessageListTongue extends TIMUIKitStatelessWidget { + /// the value type currently + final MessageListTongueType valueType; + + /// the callback after clicking + final VoidCallback onClick; + + /// unread amount currently + final int unreadCount; + + /// the builder for tongue item + final TongueItemBuilder? tongueItemBuilder; + + /// total amount of messages at me + final String atNum; + + final int previousCount; + + TIMUIKitHistoryMessageListTongue({ + Key? key, + required this.valueType, + required this.onClick, + required this.previousCount, + required this.unreadCount, + this.tongueItemBuilder, + this.atNum = "", + }) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + late Widget tongueItem; + if (tongueItemBuilder != null) { + tongueItem = tongueItemBuilder!(onClick, valueType, unreadCount); + } else { + tongueItem = TIMUIKitTongueItem( + onClick: onClick, + unreadCount: unreadCount, + valueType: valueType, + atNum: atNum, + previousCount: previousCount, + ); + } + return valueType != MessageListTongueType.none ? tongueItem : Container(); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue_container.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue_container.dart new file mode 100644 index 0000000..8fc1f59 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue_container.dart @@ -0,0 +1,182 @@ +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart'; +import 'package:tuple/tuple.dart'; + +class TIMUIKitHistoryMessageListTongueContainer extends StatefulWidget { + final Widget Function(void Function(), MessageListTongueType, int)? tongueItemBuilder; + final List? groupAtInfoList; + final Function(String targetSeq) scrollToIndexBySeq; + final AutoScrollController scrollController; + final TUIChatSeparateViewModel model; + final V2TimConversation conversation; + + const TIMUIKitHistoryMessageListTongueContainer({ + Key? key, + this.tongueItemBuilder, + this.groupAtInfoList, + required this.conversation, + required this.scrollToIndexBySeq, + required this.scrollController, + required this.model, + }) : super(key: key); + + @override + State createState() => _TIMUIKitHistoryMessageListTongueContainerState(); +} + +class _TIMUIKitHistoryMessageListTongueContainerState extends TIMUIKitState { + bool isFinishJumpToAt = false; + List? groupAtInfoList = []; + final TUIChatGlobalModel globalModel = serviceLocator(); + bool isClickShowPrevious = false; + + @override + void initState() { + super.initState(); + initScrollListener(); + groupAtInfoList = widget.groupAtInfoList?.reversed.toList(); + } + + void changePositionState(HistoryMessagePosition newPosition) { + if (globalModel.getMessageListPosition(widget.model.conversationID) != newPosition) { + globalModel.setMessageListPosition(widget.model.conversationID, newPosition); + } + } + + scrollHandler() { + final screenHeight = MediaQuery.of(context).size.height; + final offset = widget.scrollController.offset; + final conversationUnreadCount = widget.model.getConversationUnreadCount(); + if (offset <= 0.0 && conversationUnreadCount != 0) { + widget.model.showLatestUnread(); + } + if (widget.scrollController.offset <= widget.scrollController.position.minScrollExtent && + !widget.scrollController.position.outOfRange && + !widget.model.haveMoreLatestData) { + changePositionState(HistoryMessagePosition.bottom); + } else if (widget.scrollController.offset <= screenHeight * 1.6 && + widget.scrollController.offset > 0 && + !widget.scrollController.position.outOfRange && + !widget.model.haveMoreLatestData) { + changePositionState(HistoryMessagePosition.inTwoScreen); + } else if (widget.scrollController.offset > screenHeight * 1.6 && + !widget.scrollController.position.outOfRange && + !widget.model.haveMoreLatestData) { + changePositionState(HistoryMessagePosition.awayTwoScreen); + } + } + + void initScrollListener() { + widget.scrollController.addListener(scrollHandler); + } + + MessageListTongueType _getTongueValueType(List? groupAtInfoList) { + if (globalModel.getMessageListPosition(widget.model.conversationID) == HistoryMessagePosition.notShowLatest) { + return MessageListTongueType.none; + } + if (groupAtInfoList != null && groupAtInfoList.isNotEmpty && !isFinishJumpToAt) { + if (groupAtInfoList[0]!.atType == 1) { + return MessageListTongueType.atMe; + } else { + return MessageListTongueType.atAll; + } + } + + // if ((widget.conversation.unreadCount ?? 0) > 20 && !isClickShowPrevious) { + // return MessageListTongueType.showPrevious; + // } + + if (globalModel.unreadCountForTongue > 0) { + return MessageListTongueType.showUnread; + } + + if (globalModel.getMessageListPosition(widget.model.conversationID) == HistoryMessagePosition.awayTwoScreen) { + return MessageListTongueType.toLatest; + } + + return MessageListTongueType.none; + } + + @override + void dispose() { + super.dispose(); + widget.scrollController.removeListener(scrollHandler); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Selector>( + builder: (context, value, child) { + return Positioned( + bottom: _getTongueValueType(groupAtInfoList) != MessageListTongueType.showPrevious ? 16 : null, + top: _getTongueValueType(groupAtInfoList) == MessageListTongueType.showPrevious ? 16 : null, + right: 16, + child: TIMUIKitHistoryMessageListTongue( + previousCount: widget.conversation.unreadCount ?? 0, + tongueItemBuilder: widget.tongueItemBuilder, + unreadCount: value.item2, + onClick: () async { + if (groupAtInfoList != null && groupAtInfoList!.isNotEmpty) { + if (groupAtInfoList?.length == 1) { + widget.scrollToIndexBySeq(groupAtInfoList![0]!.seq); + + setState(() { + groupAtInfoList = []; + isFinishJumpToAt = true; + }); + } else { + widget.scrollToIndexBySeq(groupAtInfoList!.removeAt(0)!.seq); + } + } else if ((widget.conversation.unreadCount ?? 0) > 20 && !isClickShowPrevious) { + try { + isClickShowPrevious = true; + final String? lastSeqString = widget.conversation.lastMessage?.seq; + final int? lastSeq = + TencentUtils.checkString(lastSeqString) != null ? int.parse(lastSeqString!) : null; + final int? previousCount = widget.conversation.unreadCount; + if (lastSeq != null && previousCount != null) { + final targetSeq = lastSeq - previousCount; + await widget.model.loadListForSpecificMessage(seq: targetSeq); + // Future.delayed(const Duration(milliseconds: 100), () { + // widget.scrollToIndexBySeq((targetSeq).toString()); + // }); + } + } catch (e) { + // TODO: 这里后续加个弹窗提示客户,找消息失败了 + } + // widget.model.loadListForSpecificMessage(seq: count); + } else if (value.item1 == HistoryMessagePosition.awayTwoScreen || value.item2 > 0) { + widget.model.showLatestUnread(); + widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + return; + } + }, + atNum: groupAtInfoList?.length.toString() ?? "", + valueType: _getTongueValueType(groupAtInfoList), + ), + ); + }, + selector: (c, model) { + final messageListPosition = model.getMessageListPosition(widget.model.conversationID); + final unreadCountForConversation = model.unreadCountForTongue; + return Tuple2(messageListPosition, unreadCountForConversation); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_tongue_item.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_tongue_item.dart new file mode 100644 index 0000000..189f093 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_tongue_item.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitTongueItem extends TIMUIKitStatelessWidget { + /// the callback after clicking + final VoidCallback onClick; + + /// the value type currently + final MessageListTongueType valueType; + + /// unread amount currently + final int unreadCount; + + /// total amount of messages at me + final String atNum; + + final int previousCount; + + TIMUIKitTongueItem({ + Key? key, + required this.onClick, + required this.valueType, + required this.previousCount, + required this.unreadCount, + required this.atNum, + }) : super(key: key); + + Map textType(BuildContext context) { + final option1 = unreadCount.toString(); + final option2 = atNum.toString(); + // final option3 = previousCount.toString(); + final String atMeString = option2 != "" + ? TIM_t_para("有{{option2}}条@我消息", "有$option2条@我消息")(option2: option2) + : TIM_t("有人@我"); + + return { + // MessageListTongueType.showPrevious: + // TIM_t_para("{{option3}}条未读消息", "$option3条未读消息")(option3: option3), + MessageListTongueType.toLatest: TIM_t("回到最新位置"), + MessageListTongueType.showUnread: + TIM_t_para("{{option1}}条新消息", "$option1条新消息")(option1: option1), + MessageListTongueType.atMe: atMeString, + MessageListTongueType.atAll: TIM_t("@所有人"), + }; + } + + final Map iconType = { + MessageListTongueType.toLatest: Icons.arrow_downward_outlined, + MessageListTongueType.showUnread: Icons.arrow_downward_outlined, + MessageListTongueType.atMe: Icons.arrow_upward_outlined, + MessageListTongueType.atAll: Icons.arrow_upward_outlined, + MessageListTongueType.showPrevious: Icons.arrow_upward_outlined, + }; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + return GestureDetector( + onTap: onClick, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: hexToColor("E5E5E5"), width: 1), + boxShadow: [ + BoxShadow( + color: theme.weakDividerColor ?? hexToColor("E6E9EB"), + offset: const Offset(0.0, 0.0), + blurRadius: 10, + spreadRadius: 2), + ], + ), + padding: const EdgeInsets.all(10), + // width: 112, + // height: 37, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 6), + child: Icon( + iconType[valueType], + color: theme.primaryColor, + size: 12, + ), + ), + Text( + textType(context)[valueType] ?? "", + style: TextStyle( + color: theme.primaryColor, + fontSize: 12.0, + fontWeight: FontWeight.w400), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart new file mode 100644 index 0000000..4fd1b55 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart @@ -0,0 +1,531 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +// ignore: unused_import +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/keepalive_wrapper.dart'; + +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart'; +import 'TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue_container.dart'; + +enum LoadingPlace { + none, + top, + bottom, +} + +enum ScrollType { toIndex, toIndexBegin } + +class TIMUIKitHistoryMessageListController extends ChangeNotifier { + AutoScrollController? scrollController = AutoScrollController(); + late ScrollType scrollType; + late V2TimMessage targetMessage; + + TIMUIKitHistoryMessageListController({ + AutoScrollController? scrollController, + }) { + if (scrollController != null) { + this.scrollController = scrollController; + } + } + + scrollToIndex(V2TimMessage message) { + scrollType = ScrollType.toIndex; + targetMessage = message; + notifyListeners(); + } + + scrollToIndexBegin(V2TimMessage message) { + scrollType = ScrollType.toIndexBegin; + targetMessage = message; + notifyListeners(); + } +} + +class TIMUIKitHistoryMessageList extends StatefulWidget { + /// message list + final List messageList; + + /// tongue item builder + final TongueItemBuilder? tongueItemBuilder; + + /// group at info, it can get from conversation info + final List? groupAtInfoList; + + /// use for build message item + final Widget Function(BuildContext, V2TimMessage?)? itemBuilder; + + /// can controll message list scroll + final TIMUIKitHistoryMessageListController? controller; + + /// use for message jump, if passed will jump to target message. + final V2TimMessage? initFindingMsg; + + /// use for load more message + final Future Function(String?, LoadDirection direction, [int?, int?]) onLoadMore; + + /// configuration for list view + final TIMUIKitHistoryMessageListConfig? mainHistoryListConfig; + + final TUIChatSeparateViewModel model; + + final bool isAllowScroll; + + final V2TimConversation conversation; + + const TIMUIKitHistoryMessageList( + {Key? key, + required this.model, + required this.messageList, + this.itemBuilder, + this.controller, + required this.onLoadMore, + this.tongueItemBuilder, + this.groupAtInfoList, + this.initFindingMsg, + this.isAllowScroll = true, + this.mainHistoryListConfig, + required this.conversation}) + : super(key: key); + + @override + State createState() => _TIMUIKitHistoryMessageListState(); +} + +class _TIMUIKitHistoryMessageListState extends TIMUIKitState { + V2TimMessage? findingMsg; + String findingSeq = ""; + late TIMUIKitHistoryMessageListController _controller; + late AutoScrollController _autoScrollController; + LoadingPlace loadingPlace = LoadingPlace.none; + bool maybeHaveMoreMessageForFind = true; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? TIMUIKitHistoryMessageListController(); + _autoScrollController = _controller.scrollController ?? AutoScrollController(); + _controller.addListener(_controllerListener); + initFinding(); + } + + initFinding() async { + if (widget.initFindingMsg != null) { + await widget.onLoadMore(null, LoadDirection.previous); + setState(() { + findingMsg = widget.initFindingMsg!; + }); + } + } + + _controllerListener() { + final scrollType = _controller.scrollType; + final targetMessage = _controller.targetMessage; + switch (scrollType) { + case ScrollType.toIndex: + _onScrollToIndex(targetMessage); + break; + case ScrollType.toIndexBegin: + _onScrollToIndexBegin(targetMessage); + break; + default: + } + } + + Widget _getMessageItemBuilder(V2TimMessage? messageItem) { + if (widget.itemBuilder != null) { + return widget.itemBuilder!(context, messageItem); + } + return Container(); + } + + _getMessageId(int index) { + if (widget.messageList[index]!.elemType == 11) { + return _getMessageId(index - 1); + } + return widget.messageList[index]!.msgID; + } + + void showCantFindMsg() { + findingMsg = null; + findingSeq = ""; + loadingPlace = LoadingPlace.none; + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("无法定位到原消息"), infoCode: 6660401)); + } + + _onScrollToIndex(V2TimMessage targetMsg) async { + // This method called by @ messages or messages been searched, aims to jump to target message + loadingPlace = LoadingPlace.top; + const int singleLoadAmount = kIsWeb ? 15 : 40; + final lastTimestamp = widget.messageList[widget.messageList.length - 1]?.timestamp; + final msgList = widget.messageList; + final targetTimeStamp = targetMsg.timestamp!; + + if (targetTimeStamp >= lastTimestamp!) { + // 当前列表里应该有这个消息,试试能不能直接定位到那去 + bool isFound = false; + int targetIndex = 1; + for (int i = msgList.length - 1; i >= 0; i--) { + final currentMsg = msgList[i]; + if (currentMsg?.timestamp == targetTimeStamp && + currentMsg?.elemType != 11 && + currentMsg!.msgID == targetMsg.msgID) { + // find the target index by timestamp and msgID + isFound = true; + targetIndex = -i; + break; + } + } + + if (isFound && targetIndex != 1) { + findingMsg = null; + _autoScrollController.scrollToIndex( + targetIndex, + preferPosition: AutoScrollPosition.middle, + ); + + // execute twice for accurate position, as the position located firstly can be wrong + _autoScrollController.scrollToIndex(targetIndex, preferPosition: AutoScrollPosition.middle); + _autoScrollController.scrollToIndex(targetIndex, preferPosition: AutoScrollPosition.middle); + + widget.model.jumpMsgID = targetMsg.msgID!; + loadingPlace = LoadingPlace.none; + } else { + showCantFindMsg(); + } + } else { + if (maybeHaveMoreMessageForFind) { + // if the target message not in current message list, load more + findingMsg = targetMsg; + final lastMsgId = _getMessageId(widget.messageList.length - 1); + maybeHaveMoreMessageForFind = await widget.onLoadMore(lastMsgId, LoadDirection.previous, singleLoadAmount); + } else { + showCantFindMsg(); + } + } + } + + _onScrollToIndexBySeq(String targetSeq) async { + // This method called by tongue request jumping to target @ message + loadingPlace = LoadingPlace.top; + // const int singleLoadAmount = 40; + final msgList = widget.messageList; + String lastSeq = ""; + for (int i = msgList.length - 1; i >= 0; i--) { + final currentMsg = msgList[i]; + if (currentMsg!.seq != null && currentMsg.seq != "") { + lastSeq = currentMsg.seq!; + break; + } + } + + if (int.parse(lastSeq) <= int.parse(targetSeq)) { + bool isFound = false; + int targetIndex = 1; + String? targetMsgID = ""; + for (int i = msgList.length - 1; i >= 0; i--) { + final currentMsg = msgList[i]; + if (currentMsg?.seq == targetSeq) { + isFound = true; + targetMsgID = currentMsg?.msgID; + targetIndex = -i; + break; + } + } + + if (isFound && targetIndex != 1) { + findingSeq = ""; + _autoScrollController.scrollToIndex( + targetIndex, + preferPosition: AutoScrollPosition.middle, + ); + _autoScrollController.scrollToIndex(targetIndex, preferPosition: AutoScrollPosition.middle); + if (targetMsgID != null && targetMsgID != "") { + widget.model.jumpMsgID = targetMsgID; + } + loadingPlace = LoadingPlace.none; + } else { + showCantFindMsg(); + } + } else { + if (maybeHaveMoreMessageForFind) { + findingSeq = targetSeq; + int requestCount = int.parse(lastSeq) - int.parse(targetSeq); + maybeHaveMoreMessageForFind = await widget.onLoadMore( + _getMessageId(widget.messageList.length - 1), LoadDirection.previous, requestCount, int.parse(lastSeq)); + } else { + showCantFindMsg(); + } + } + } + + _onScrollToIndexBegin(V2TimMessage targetMsg) { + final lastTimestamp = widget.messageList[widget.messageList.length - 1]?.timestamp; + final msgList = widget.messageList; + final int targetTimeStamp = targetMsg.timestamp!; + + if (targetTimeStamp >= lastTimestamp!) { + bool isFound = false; + int targetIndex = 1; + for (int i = msgList.length - 1; i >= 0; i--) { + final currentMsg = msgList[i]; + if (currentMsg?.timestamp == targetTimeStamp && + currentMsg?.elemType != 11 && + currentMsg!.msgID == targetMsg.msgID) { + isFound = true; + targetIndex = -i; + break; + } + } + if (isFound && targetIndex != 1) { + _autoScrollController.scrollToIndex( + targetIndex, + preferPosition: AutoScrollPosition.end, + ); + } + } + } + + List _getReceivedMessageList(int receivedMessageListCount) { + if (receivedMessageListCount == 0) { + return []; + } + final haveTimeStampMessage = widget.messageList[receivedMessageListCount]?.elemType == 11; + final endPoint = haveTimeStampMessage ? receivedMessageListCount + 1 : receivedMessageListCount; + return widget.messageList.sublist(0, endPoint).reversed.toList(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + // center key should generate everytime when build method called. + final GlobalKey centerKey = GlobalKey(); + + final theme = value.theme; + if (widget.messageList.isEmpty) { + return Container(); + } + + final messageList = widget.messageList; + final globalModel = context.read(); + final receivedNewMessageCount = globalModel.receivedNewMessageCount; + final shouldShowUnreadMessage = receivedNewMessageCount > 0; + final unreadMessageList = _getReceivedMessageList(receivedNewMessageCount); + final readMessageList = messageList.sublist(unreadMessageList.length, messageList.length).toList(); + + final throttleFunction = OptimizeUtils.multiThrottle((index, LoadDirection direction) async { + final msgID = TIMUIKitChatUtils.getMessageIDWithinIndex(readMessageList, index); + await widget.onLoadMore(msgID, direction); + }, 20); + + final throttleFunctionWithMsgID = OptimizeUtils.multiThrottle((msgID, LoadDirection direction) async { + await widget.onLoadMore(msgID, direction); + }, 200); + + if (findingMsg != null) { + _onScrollToIndex(findingMsg!); + } else if (findingSeq != "") { + _onScrollToIndexBySeq(findingSeq); + } + + String getMessageIdentifier(V2TimMessage? message, int index) { + return "${message?.msgID} - ${message?.timestamp} - ${message?.seq} -${message?.id ?? ""}"; + } + + return Stack( + alignment: Alignment.topCenter, + children: [ + Scrollbar( + controller: _autoScrollController, + child: CustomScrollView( + center: shouldShowUnreadMessage ? centerKey : null, + key: widget.mainHistoryListConfig?.key, + primary: widget.mainHistoryListConfig?.primary, + physics: (widget.isAllowScroll == false) + ? const NeverScrollableScrollPhysics() + : widget.mainHistoryListConfig?.physics, + // padding: widget.mainHistoryListConfig?.padding ?? EdgeInsets.zero, + // itemExtent: widget.mainHistoryListConfig?.itemExtent, + // prototypeItem: widget.mainHistoryListConfig?.prototypeItem, + cacheExtent: widget.mainHistoryListConfig?.cacheExtent ?? 1500, + semanticChildCount: widget.mainHistoryListConfig?.semanticChildCount, + dragStartBehavior: widget.mainHistoryListConfig?.dragStartBehavior ?? DragStartBehavior.start, + keyboardDismissBehavior: + widget.mainHistoryListConfig?.keyboardDismissBehavior ?? ScrollViewKeyboardDismissBehavior.manual, + restorationId: widget.mainHistoryListConfig?.restorationId, + clipBehavior: widget.mainHistoryListConfig?.clipBehavior ?? Clip.hardEdge, + reverse: true, + shrinkWrap: !shouldShowUnreadMessage, + controller: _autoScrollController, + slivers: [ + SliverPadding( + padding: widget.mainHistoryListConfig?.padding ?? EdgeInsets.zero, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final messageItem = unreadMessageList[index]; + if (index == unreadMessageList.length - 1 && widget.model.haveMoreLatestData == true) { + throttleFunctionWithMsgID(messageItem?.msgID ?? "", LoadDirection.latest); + } + outputLogger.i( + "Rendering a unread message: ${getMessageIdentifier(messageItem, 0)}, message Type: ${messageItem?.elemType}"); + return AutoScrollTag( + controller: _autoScrollController, + index: -index, + key: ValueKey(getMessageIdentifier(messageItem, index)), + highlightColor: Colors.black.withOpacity(0.1), + child: KeepAliveWrapper( + keepAlive: messageItem?.elemType == MessageElemType.V2TIM_ELEM_TYPE_SOUND, + child: Container(child: _getMessageItemBuilder(messageItem))), + ); + }, + childCount: unreadMessageList.length, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final String data = valueKey.value; + final int index = + unreadMessageList.indexWhere((element) => getMessageIdentifier(element, 0) == data); + return index != -1 ? index : null; + })), + ), + SliverPadding( + padding: EdgeInsets.zero, + key: centerKey, + ), + SliverPadding( + padding: widget.mainHistoryListConfig?.padding ?? EdgeInsets.zero, + sliver: Selector( + selector: (context, model) { + return model.haveMoreData; + }, + shouldRebuild: (previous, next) { + return previous != next; + }, + builder: (context, haveMoreData, child) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final messageItem = readMessageList[index]; + if (index == readMessageList.length - 1) { + if (haveMoreData) { + final lastMessage = globalModel + .messageListMap[TencentUtils.checkString(widget.conversation.groupID) ?? + widget.conversation.userID ?? + widget.conversation.conversationID] + ?.last; + if (lastMessage != null) { + throttleFunctionWithMsgID(lastMessage.msgID ?? "", LoadDirection.previous); + } else { + throttleFunction(index, messageList); + } + return Column( + children: [ + LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 28, + ), + AutoScrollTag( + controller: _autoScrollController, + index: -index, + key: ValueKey(getMessageIdentifier(messageItem, index)), + highlightColor: Colors.black.withOpacity(0.1), + child: KeepAliveWrapper( + keepAlive: messageItem?.elemType == MessageElemType.V2TIM_ELEM_TYPE_SOUND, + child: Container(child: _getMessageItemBuilder(messageItem))), + ), + ], + ); + } + } + if (index == 0 && + widget.model.haveMoreLatestData == true && + globalModel.receivedNewMessageCount < 10) { + throttleFunction(index, LoadDirection.latest); + } + outputLogger.i( + "Rendering a read message: ${getMessageIdentifier(messageItem, 0)}, message Type: ${messageItem?.elemType}"); + return AutoScrollTag( + controller: _autoScrollController, + index: -index, + key: ValueKey(getMessageIdentifier(messageItem, index)), + highlightColor: Colors.black.withOpacity(0.1), + child: KeepAliveWrapper( + keepAlive: messageItem?.elemType == MessageElemType.V2TIM_ELEM_TYPE_SOUND, + child: Container(child: _getMessageItemBuilder(messageItem))), + ); + }, + childCount: readMessageList.length, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final String data = valueKey.value; + final int index = + readMessageList.indexWhere((element) => getMessageIdentifier(element, 0) == data); + return index > -1 ? index : null; + })); + }, + ), + ), + ], + )), + TIMUIKitHistoryMessageListTongueContainer( + conversation: widget.conversation, + model: widget.model, + scrollController: _autoScrollController, + scrollToIndexBySeq: _onScrollToIndexBySeq, + groupAtInfoList: widget.groupAtInfoList, + tongueItemBuilder: widget.tongueItemBuilder, + ), + if (loadingPlace == LoadingPlace.top) + Positioned( + top: 8, + child: LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 28, + ), + ), + ], + ); + } +} + +class TIMUIKitHistoryMessageListSelector extends TIMUIKitStatelessWidget { + final Widget Function(BuildContext, List, Widget?) builder; + final String conversationID; + + TIMUIKitHistoryMessageListSelector({Key? key, required this.builder, required this.conversationID}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Selector>( + builder: builder, + shouldRebuild: (previous, next) { + final isEquals = const DeepCollectionEquality.unordered().equals(previous, next); + return !isEquals; + }, + selector: (context, model) { + final messageList = model.getMessageList(conversationID) ?? []; + return messageList; + }); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart new file mode 100644 index 0000000..be0f4ba --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart @@ -0,0 +1,171 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// The definition of the following parameters is the same with [ListView.builder] +class TIMUIKitHistoryMessageListConfig { + Key? key; + + /// {@template flutter.widgets.scroll_view.scrollDirection} + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + /// {@endtemplate} + Axis? scrollDirection; + + /// {@template flutter.widgets.scroll_view.primary} + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Also when true, the scroll view is used for default [ScrollAction]s. If a + /// ScrollAction is not handled by an otherwise focused part of the application, + /// the ScrollAction will be evaluated using this scroll view, for example, + /// when executing [Shortcuts] key events like page up and down. + /// + /// On iOS, this also identifies the scroll view that will scroll to top in + /// response to a tap in the status bar. + /// {@endtemplate} + /// + /// Defaults to true when [scrollDirection] is [Axis.vertical] and + /// [controller] is null. + bool? primary; + + /// {@template flutter.widgets.scroll_view.physics} + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + /// + /// To force the scroll view to always be scrollable even if there is + /// insufficient content, as if [primary] was true but without necessarily + /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics + /// object, as in: + /// + /// ```dart + /// physics: const AlwaysScrollableScrollPhysics(), + /// ``` + /// + /// To force the scroll view to use the default platform conventions and not + /// be scrollable if there is insufficient content, regardless of the value of + /// [primary], provide an explicit [ScrollPhysics] object, as in: + /// + /// ```dart + /// physics: const ScrollPhysics(), + /// ``` + /// + /// The physics can be changed dynamically (by providing a new object in a + /// subsequent build), but new physics will only take effect if the _class_ of + /// the provided object changes. Merely constructing a new instance with a + /// different configuration is insufficient to cause the physics to be + /// reapplied. (This is because the final object used is generated + /// dynamically, which can be relatively expensive, and it would be + /// inefficient to speculatively create this object each frame to see if the + /// physics should be updated.) + /// {@endtemplate} + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [physics]. + ScrollPhysics? physics; + + /// The amount of space by which to inset the children. + EdgeInsetsGeometry? padding; + + /// {@template flutter.widgets.list_view.itemExtent} + /// If non-null, forces the children to have the given extent in the scroll + /// direction. + /// + /// Specifying an [itemExtent] is more efficient than letting the children + /// determine their own extent because the scrolling machinery can make use of + /// the foreknowledge of the children's extent to save work, for example when + /// the scroll position changes drastically. + /// + /// See also: + /// + /// * [SliverFixedExtentList], the sliver used internally when this property + /// is provided. It constrains its box children to have a specific given + /// extent along the main axis. + /// * The [prototypeItem] property, which allows forcing the children's + /// extent to be the same as the given widget. + /// {@endtemplate} + double? itemExtent; + + /// {@template flutter.widgets.list_view.prototypeItem} + /// If non-null, forces the children to have the same extent as the given + /// widget in the scroll direction. + /// + /// Specifying an [prototypeItem] is more efficient than letting the children + /// determine their own extent because the scrolling machinery can make use of + /// the foreknowledge of the children's extent to save work, for example when + /// the scroll position changes drastically. + /// + /// See also: + /// + /// * [SliverPrototypeExtentList], the sliver used internally when this + /// property is provided. It constrains its box children to have the same + /// extent as a prototype item along the main axis. + /// * The [itemExtent] property, which allows forcing the children's extent + /// to a given value. + /// {@endtemplate} + Widget? prototypeItem; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + double? cacheExtent; + + /// The number of children that will contribute semantic information. + /// + /// Some subtypes of [ScrollView] can infer this value automatically. For + /// example [ListView] will use the number of widgets in the child list, + /// while the [ListView.separated] constructor will use half that amount. + /// + /// For [CustomScrollView] and other types which do not receive a builder + /// or list of widgets, the child count must be explicitly provided. If the + /// number is unknown or unbounded this should be left unset or set to null. + /// + /// See also: + /// + /// * [SemanticsConfiguration.scrollChildCount], the corresponding semantics property. + int? semanticChildCount; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + DragStartBehavior? dragStartBehavior; + + /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + /// {@endtemplate} + ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + String? restorationId; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + Clip? clipBehavior; + + bool? shrinkWrap; + + TIMUIKitHistoryMessageListConfig( + {this.key, + this.scrollDirection, + this.primary, + this.physics, + this.padding, + this.itemExtent, + this.prototypeItem, + this.cacheExtent, + this.semanticChildCount, + this.dragStartBehavior, + this.keyboardDismissBehavior, + this.restorationId, + this.shrinkWrap, + this.clipBehavior}); +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart new file mode 100644 index 0000000..6d59062 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart @@ -0,0 +1,1460 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/time_ago.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_message_tooltip.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_message_read_receipt.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_custom_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_translate_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/forward_message_screen.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_super_tooltip/tencent_super_tooltip.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; +import '../TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart'; + +typedef MessageRowBuilder = Widget? Function( + /// current message + V2TimMessage message, + + /// the message widget for current message, build by your custom builder or our default builder + Widget messageWidget, + + /// scroll to the specific message, it will shows in the screen center, and call isNeedShowJumpStatus if necessary + Function onScrollToIndex, + + /// if current message been called to jumped by other message + bool isNeedShowJumpStatus, + + /// clear the been jumped status, recommend to execute after get 'isNeedShowJumpStatus' + VoidCallback clearJumpStatus, + + /// scroll to specific message, it will shows on the screen top, without the call isNeedShowJumpStatus + Function onScrollToIndexBegin, +); + +typedef MessageNickNameBuilder = Widget Function( + BuildContext context, V2TimMessage message, TUIChatSeparateViewModel model); + +typedef MessageItemContent = Widget? Function( + V2TimMessage message, + bool isShowJump, + VoidCallback clearJump, +); + +class MessageHoverControlItem { + String name; + Widget icon; + ValueChanged onClick; + + MessageHoverControlItem({required this.name, required this.icon, required this.onClick}); +} + +class MessageItemBuilder { + /// text message builder, returns null means using default widget. + final MessageItemContent? textMessageItemBuilder; + + /// text message builder for reply message, returns null means using default widget. + final MessageItemContent? textReplyMessageItemBuilder; + + /// custom message builder, returns null means using default widget. + final MessageItemContent? customMessageItemBuilder; + + /// image message builder, returns null means using default widget. + final MessageItemContent? imageMessageItemBuilder; + + /// sound message builder, returns null means using default widget. + final MessageItemContent? soundMessageItemBuilder; + + /// video message builder, returns null means using default widget. + final MessageItemContent? videoMessageItemBuilder; + + /// file message builder, returns null means using default widget. + final MessageItemContent? fileMessageItemBuilder; + + /// location message (LBS) item builder; + /// recommend to use our LBS plug-in: https://pub.dev/packages/tim_ui_kit_lbs_plugin + final MessageItemContent? locationMessageItemBuilder; + + /// face message, like emoji, message builder, returns null means using default widget. + final MessageItemContent? faceMessageItemBuilder; + + /// group tips message builder, returns null means using default widget. + final MessageItemContent? groupTipsMessageItemBuilder; + + /// merger message builder, returns null means using default widget. + final MessageItemContent? mergerMessageItemBuilder; + + /// The builder for the whole message line, expect for those message type without avatar and nickname. + /// [Update] You can only re-define the message types you need, returns null means using default row layout. + final MessageRowBuilder? messageRowBuilder; + + /// message nick name builder + final MessageNickNameBuilder? messageNickNameBuilder; + + MessageItemBuilder({ + this.locationMessageItemBuilder, + this.textMessageItemBuilder, + this.textReplyMessageItemBuilder, + this.customMessageItemBuilder, + this.imageMessageItemBuilder, + this.soundMessageItemBuilder, + this.videoMessageItemBuilder, + this.fileMessageItemBuilder, + this.faceMessageItemBuilder, + this.groupTipsMessageItemBuilder, + this.mergerMessageItemBuilder, + this.messageRowBuilder, + this.messageNickNameBuilder, + }); +} + +class MessageToolTipItem { + final String label; + final String id; + final String iconImageAsset; + final VoidCallback onClick; + + MessageToolTipItem({required this.label, required this.id, required this.iconImageAsset, required this.onClick}); +} + +class ToolTipsConfig { + /// Whether to show the reply to a message option. + bool showReplyMessage; + + /// Whether to show the multiple-choice option for messages. + bool showMultipleChoiceMessage; + + /// Whether to show the option to delete a message. + bool showDeleteMessage; + + /// Whether to show the option to recall a message. + bool showRecallMessage; + + /// Whether to show the option to copy a message. + bool showCopyMessage; + + /// Whether to show the option to forward a message. + bool showForwardMessage; + + /// Whether to show the option to translate a text message. This module is not available by default. Please contact your Tencent Cloud sales representative or customer service team to enable this feature. + bool showTranslation; + + /// A builder for additional custom items. We recommend using `additionalMessageToolTips` instead of this field since version 2.0, as you only need to provide the data rather than the whole widget. This makes usage easier and you don't need to worry about the UI display. + final Widget? Function(V2TimMessage message, Function() closeTooltip, [Key? key, BuildContext? context])? + additionalItemBuilder; + + /// A list of additional message tooltip menu items, provided with the data only. We recommend using this field instead of the previous `additionalItemBuilder`. + List Function(V2TimMessage message, Function() closeTooltip)? additionalMessageToolTips; + + ToolTipsConfig( + {this.showDeleteMessage = true, + this.showMultipleChoiceMessage = true, + this.showRecallMessage = true, + this.showReplyMessage = true, + this.showTranslation = true, + this.showCopyMessage = true, + this.showForwardMessage = true, + this.additionalMessageToolTips, + @Deprecated( + "Please use `additionalMessageToolTips` instead. You are now only expected to specify the data, rather than providing a whole widget. This makes usage easier, as you no longer need to worry about the UI display.") + this.additionalItemBuilder}); +} + +class TIMUIKitHistoryMessageListItem extends StatefulWidget { + /// message instance + final V2TimMessage message; + + /// tap remote user avatar callback function + final void Function(String userID, TapDownDetails tapDetails)? onTapForOthersPortrait; + + /// secondary tap remote user avatar callback function + final void Function(String userID, TapDownDetails tapDetails)? onSecondaryTapForOthersPortrait; + + /// the function use for reply message, when click replied message can scroll to it. + final Function? onScrollToIndex; + + /// message is too long should scroll this message to begin so that the tool tips panel can show correctly. + final Function? onScrollToIndexBegin; + + /// the callback for long press event, except myself avatar + final Function(String? userId, String? nickName)? onLongPressForOthersHeadPortrait; + + /// message item builder, works for customize all message types and row layout. + final MessageItemBuilder? messageItemBuilder; + + /// Control avatar hide or show + final bool showAvatar; + + /// message is read status + final bool showMessageReadReceipt; + + /// allow message can long press + final bool allowLongPress; + + /// allow avatar can tap + final bool allowAvatarTap; + + /// Auto mention user when send reply message + final bool allowAtUserWhenReply; + + @Deprecated( + "Nickname will not show in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + + /// allow show user nick name + final bool showNickName; + + /// on message long press callback + final Function(BuildContext context, V2TimMessage message)? onLongPress; + + /// tool tips panel configuration, long press message will show tool tips panel + final ToolTipsConfig? toolTipsConfig; + + /// padding for each message item + final EdgeInsetsGeometry? padding; + + /// The controller for text field. + final TIMUIKitInputTextFieldController? textFieldController; + + /// padding for text message、sound message、reply message + final EdgeInsetsGeometry? textPadding; + + /// avatar builder + final Widget Function(BuildContext context, V2TimMessage message)? userAvatarBuilder; + + /// theme info for message and avatar + final MessageThemeData? themeData; + + /// builder for nick name row + final Widget Function(BuildContext context, V2TimMessage message)? topRowBuilder; + + /// builder for bottom raw which under message content + final Widget Function(BuildContext context, V2TimMessage message)? bottomRowBuilder; + + // open MessageReaction + final bool? isUseMessageReaction; + + final List customEmojiStickerList; + + final V2TimGroupMemberFullInfo? groupMemberInfo; + + /// This parameter accepts a custom widget to be displayed when the mouse hovers over a message, + /// replacing the default message hover action bar. + /// Applicable only on desktop platforms. + /// If provided, the default message action functionality will appear in the right-click context menu instead. + final Widget? Function(V2TimMessage message)? customMessageHoverBarOnDesktop; + + const TIMUIKitHistoryMessageListItem( + {Key? key, + required this.message, + @Deprecated( + "Nickname will not show in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + this.showNickName = false, + this.onScrollToIndex, + this.onScrollToIndexBegin, + this.onTapForOthersPortrait, + this.messageItemBuilder, + this.onLongPressForOthersHeadPortrait, + this.showAvatar = true, + this.showMessageReadReceipt = true, + this.allowLongPress = true, + this.toolTipsConfig, + this.onLongPress, + this.allowAtUserWhenReply = true, + this.allowAvatarTap = true, + this.userAvatarBuilder, + this.themeData, + this.padding, + this.textPadding, + this.topRowBuilder, + this.isUseMessageReaction, + this.bottomRowBuilder, + this.customEmojiStickerList = const [], + this.textFieldController, + this.onSecondaryTapForOthersPortrait, + this.groupMemberInfo, + this.customMessageHoverBarOnDesktop}) + : super(key: key); + + @override + State createState() => _TIMUIKItHistoryMessageListItemState(); +} + +class TipsActionItem extends TIMUIKitStatelessWidget { + final String label; + final String icon; + final String? package; + + TipsActionItem({Key? key, required this.label, required this.icon, this.package}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Column( + children: [ + Image.asset( + icon, + package: package, + width: 20, + height: 20, + ), + const SizedBox( + height: 4, + ), + Text( + label, + style: const TextStyle( + decoration: TextDecoration.none, + color: Color(0xFF444444), + fontSize: 10, + ), + ) + ], + ); + } +} + +class _TIMUIKItHistoryMessageListItemState extends TIMUIKitState + with SingleTickerProviderStateMixin { + SuperTooltip? tooltip; + late AnimationController _animationController; + + // ignore: unused_field + final MessageService _messageService = serviceLocator(); + final TUISelfInfoViewModel selfInfoModel = serviceLocator(); + final TUIThemeViewModel themeModel = serviceLocator(); + + // bool isChecked = false; + final GlobalKey _key = GlobalKey(); + bool isShowWideToolTip = false; + TapDownDetails? _tapDetails; + + @override + void initState() { + super.initState(); + _animationController = AnimationController(duration: const Duration(seconds: 1), vsync: this)..repeat(); + } + + closeTooltip() { + tooltip?.close(); + } + + bool isReplyMessage(V2TimMessage message) { + final hasCustomData = message.cloudCustomData != null && message.cloudCustomData != ""; + if (hasCustomData) { + try { + final CloudCustomData messageCloudCustomData = CloudCustomData.fromJson( + json.decode(TencentUtils.checkString(message.cloudCustomData) != null ? message.cloudCustomData! : "{}")); + if (messageCloudCustomData.messageReply != null) { + MessageRepliedData.fromJson(messageCloudCustomData.messageReply!); + return true; + } + return false; + } catch (error) { + return false; + } + } + return false; + } + + (bool isRevoke, bool isRevokeByAdmin) isRevokeMessage(V2TimMessage message, TUIChatSeparateViewModel model) { + if (message.status == 6) { + return (true, false); + } else if (model.chatConfig.isGroupAdminRecallEnabled) { + try { + final customData = jsonDecode(message.cloudCustomData ?? "{}"); + final isRevoke = customData["isRevoke"] ?? false; + final revokeByAdmin = customData["revokeByAdmin"] ?? false; + return (isRevoke, revokeByAdmin); + } catch (e) { + return (false, false); + } + } + return (false, false); + } + + Widget _messageItemBuilder(V2TimMessage messageItem, TUIChatSeparateViewModel model) { + final msgType = messageItem.elemType; + final isShowJump = (model.jumpMsgID == messageItem.msgID) && (messageItem.msgID?.isNotEmpty ?? false); + final MessageItemBuilder? messageItemBuilder = widget.messageItemBuilder; + final isFromSelf = messageItem.isSelf ?? true; + void clearJump() { + Future.delayed(const Duration(milliseconds: 100), () { + model.jumpMsgID = ""; + }); + } + + switch (msgType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + final customWidget = messageItemBuilder?.customMessageItemBuilder != null + ? messageItemBuilder!.customMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitCustomElem( + message: messageItem, + customElem: messageItem.customElem, + isFromSelf: isFromSelf, + messageBackgroundColor: widget.themeData?.messageBackgroundColor, + messageBorderRadius: widget.themeData?.messageBorderRadius, + messageFontStyle: widget.themeData?.messageTextStyle, + textPadding: widget.textPadding, + isShowMessageReaction: widget.isUseMessageReaction, + ); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + final customWidget = messageItemBuilder?.soundMessageItemBuilder != null + ? messageItemBuilder!.soundMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitSoundElem( + chatModel: model, + message: messageItem, + soundElem: messageItem.soundElem!, + msgID: messageItem.msgID ?? "", + isFromSelf: messageItem.isSelf ?? true, + clearJump: clearJump, + isShowJump: isShowJump, + localCustomInt: messageItem.localCustomInt, + borderRadius: widget.themeData?.messageBorderRadius, + fontStyle: widget.themeData?.messageTextStyle, + backgroundColor: widget.themeData?.messageBackgroundColor, + textPadding: widget.textPadding, + isShowMessageReaction: widget.isUseMessageReaction, + ); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + if (isReplyMessage(messageItem)) { + final customWidget = messageItemBuilder?.textReplyMessageItemBuilder != null + ? messageItemBuilder!.textReplyMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitReplyElem( + message: messageItem, + clearJump: clearJump, + isShowJump: isShowJump, + scrollToIndex: widget.onScrollToIndex ?? () {}, + borderRadius: widget.themeData?.messageBorderRadius, + fontStyle: widget.themeData?.messageTextStyle, + backgroundColor: widget.themeData?.messageBackgroundColor, + textPadding: widget.textPadding, + customEmojiStickerList: widget.customEmojiStickerList, + chatModel: model, + isShowMessageReaction: widget.isUseMessageReaction, + ); + } + final customWidget = messageItemBuilder?.textMessageItemBuilder != null + ? messageItemBuilder!.textMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitTextElem( + chatModel: model, + message: messageItem, + isFromSelf: messageItem.isSelf ?? true, + clearJump: clearJump, + isShowJump: isShowJump, + borderRadius: widget.themeData?.messageBorderRadius, + fontStyle: widget.themeData?.messageTextStyle, + backgroundColor: widget.themeData?.messageBackgroundColor, + textPadding: widget.textPadding, + isShowMessageReaction: widget.isUseMessageReaction, + customEmojiStickerList: widget.customEmojiStickerList, + ); + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + final customWidget = messageItemBuilder?.faceMessageItemBuilder != null + ? messageItemBuilder!.faceMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitFaceElem( + model: model, + path: messageItem.faceElem!.data ?? "", + clearJump: clearJump, + isShowJump: isShowJump, + message: messageItem, + isShowMessageReaction: widget.isUseMessageReaction, + ); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + final customWidget = messageItemBuilder?.fileMessageItemBuilder != null + ? messageItemBuilder!.fileMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitFileElem( + chatModel: model, + message: messageItem, + messageID: messageItem.msgID, + fileElem: messageItem.fileElem, + isSelf: messageItem.isSelf ?? true, + clearJump: clearJump, + isShowJump: isShowJump, + isShowMessageReaction: widget.isUseMessageReaction, + ); + case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: + final customWidget = messageItemBuilder?.groupTipsMessageItemBuilder != null + ? messageItemBuilder!.groupTipsMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? Text(TIM_t("[群系统消息]")); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + final customWidget = messageItemBuilder?.imageMessageItemBuilder != null + ? messageItemBuilder!.imageMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitImageElem( + clearJump: clearJump, + isShowJump: isShowJump, + chatModel: model, + message: messageItem, + isShowMessageReaction: widget.isUseMessageReaction, + key: Key("${messageItem.seq}_${messageItem.timestamp}"), + ); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + final customWidget = messageItemBuilder?.videoMessageItemBuilder != null + ? messageItemBuilder!.videoMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitVideoElem( + messageItem, + isShowJump: isShowJump, + chatModel: model, + clearJump: clearJump, + isShowMessageReaction: widget.isUseMessageReaction, + ); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + final customWidget = messageItemBuilder?.locationMessageItemBuilder != null + ? messageItemBuilder!.locationMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? Text(TIM_t("[位置]")); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + final customWidget = messageItemBuilder?.mergerMessageItemBuilder != null + ? messageItemBuilder!.mergerMessageItemBuilder!( + messageItem, + isShowJump, + () => model.jumpMsgID = "", + ) + : null; + return customWidget ?? + TIMUIKitMergerElem( + messageItemBuilder: messageItemBuilder, + model: model, + isShowJump: isShowJump, + clearJump: clearJump, + message: messageItem, + isShowMessageReaction: widget.isUseMessageReaction, + mergerElem: messageItem.mergerElem!, + messageID: messageItem.msgID ?? "", + isSelf: messageItem.isSelf ?? true); + default: + return Text(TIM_t("[未知消息]")); + } + } + + Widget _groupTipsMessageBuilder(TUIChatSeparateViewModel model) { + final messageItem = widget.message; + return Container( + padding: const EdgeInsets.only(bottom: 20), + child: TIMUIKitGroupTipsElem( + groupTipsElem: messageItem.groupTipsElem!, groupMemberList: model.groupMemberList ?? [])); + } + + Widget _selfRevokeEditMessageBuilder(theme, TUIChatSeparateViewModel model) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 20), + alignment: Alignment.center, + child: Text.rich(TextSpan(children: [ + TextSpan( + text: TIM_t("您撤回了一条消息,"), + style: TextStyle(color: theme.weakTextColor), + ), + TextSpan( + text: TIM_t("重新编辑"), + recognizer: TapGestureRecognizer() + ..onTap = () { + widget.textFieldController?.setTextField(widget.message.textElem?.text ?? ""); + }, + style: TextStyle(color: theme.primaryColor), + ) + ], style: const TextStyle(fontSize: 12)))); + } + + Widget _revokedMessageBuilder(theme, String option2) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 20), + alignment: Alignment.center, + child: Text( + TIM_t_para("{{option2}}撤回了一条消息", "$option2撤回了一条消息")(option2: option2), + style: TextStyle(color: theme.weakTextColor, fontSize: 12), + )); + } + + Widget _timeDividerBuilder(theme, int timeStamp, TUIChatSeparateViewModel model) { + return Container( + alignment: Alignment.center, + margin: const EdgeInsets.symmetric(vertical: 20), + child: Text( + model.chatConfig.timeDividerConfig?.timestampParser != null + ? (model.chatConfig.timeDividerConfig?.timestampParser!(timeStamp))! + : TimeAgo().getTimeForMessage(timeStamp), + style: widget.themeData?.timelineTextStyle ?? + TextStyle( + fontSize: 12, + color: theme.chatTimeDividerTextColor, + ), + ), + ); + } + + Widget _latestDividerBuilder(TUITheme theme) { + return Container( + alignment: Alignment.center, + margin: const EdgeInsets.symmetric(vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 20), + child: SizedBox( + height: 1, + width: 100, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [const Color(0x00C0E1FF), theme.primaryColor ?? CommonColor.lightPrimaryColor]), + )), + ), + ), + Text( + TIM_t("以下为未读消息"), + style: widget.themeData?.timelineTextStyle ?? + TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: theme.primaryColor, + ), + ), + Container( + margin: const EdgeInsets.only(left: 20), + child: SizedBox( + height: 1, + width: 100, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [ + theme.primaryColor ?? CommonColor.primaryColor, + const Color(0x00C0E1FF), + ]), + )), + ), + ), + ], + ), + ); + } + + bool isRevocable(int timestamp) => (DateTime.now().millisecondsSinceEpoch / 1000).ceil() - timestamp < 120; + + // TODO : 继续看这里 + + _onOpenToolTip( + c, + V2TimMessage message, + TUIChatSeparateViewModel model, + TUITheme theme, + TapDownDetails? details, + bool? isFromWideTooltip, + bool? isShowMoreSticker, + ) { + if (tooltip != null && tooltip!.isOpen) { + tooltip!.close(); + return; + } + tooltip = null; + + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.width; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final isLongMessage = context.size!.height + 350 > screenHeight && !(isDesktopScreen); + final tapDetails = (isDesktopScreen || isLongMessage) ? (details ?? _tapDetails) : details; + final isSelf = message.isSelf ?? true; + + final targetWidth = min(MediaQuery.of(context).size.width * 0.84, 350).toDouble(); + final double dx = !isSelf + ? min(tapDetails?.globalPosition.dx ?? targetWidth, screenWidth - targetWidth) + : max(tapDetails?.globalPosition.dx ?? targetWidth, targetWidth).toDouble(); + final double dy = min(tapDetails?.globalPosition.dy ?? MediaQuery.of(context).size.height, + MediaQuery.of(context).size.height - 320) + .toDouble(); + final finalTapDetail = tapDetails != null + ? TapDownDetails( + globalPosition: Offset(dx, dy), + ) + : null; + + initTools( + context: c, + model: model, + isShowMoreSticker: isShowMoreSticker, + details: finalTapDetail, + theme: theme, + isFromWideToolTip: isFromWideTooltip); + tooltip!.show(c, targetCenter: finalTapDetail?.globalPosition); + } + + _clickOnCurrentSticker(int sticker) async { + for (int i = 0; i < 5; i++) { + final res = await _modifySticker(sticker); + if (res.code == 0) { + break; + } + } + } + + Future> _modifySticker(int sticker) async { + return await Future.delayed(const Duration(milliseconds: 50), () async { + return await MessageReactionUtils.clickOnSticker(widget.message, sticker); + }); + } + + initTools( + {BuildContext? context, + bool isLongMessage = false, + required TUIChatSeparateViewModel model, + TUITheme? theme, + bool? isShowMoreSticker, + TapDownDetails? details, + bool? isFromWideToolTip}) { + final isUseMessageReaction = widget.message.elemType == 2 ? false : model.chatConfig.isUseMessageReaction; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final isSelf = widget.message.isSelf ?? true; + double arrowTipDistance = 30; + double arrowBaseWidth = 10; + double arrowLength = 10; + bool hasArrow = true; + TooltipDirection popupDirection = TooltipDirection.up; + double? left; + double? right; + SelectEmojiPanelPosition selectEmojiPanelPosition = SelectEmojiPanelPosition.down; + if (context != null) { + RenderBox? box = _key.currentContext?.findRenderObject() as RenderBox?; + if (details != null && box != null) { + double screenWidth = MediaQuery.of(context).size.width; + final mousePosition = details.globalPosition; + hasArrow = isDesktopScreen ? false : true; + arrowTipDistance = 0; + arrowBaseWidth = 0; + arrowLength = 0; + popupDirection = TooltipDirection.down; + if (isSelf || (isFromWideToolTip ?? false)) { + right = screenWidth - mousePosition.dx; + } else { + left = mousePosition.dx; + } + } else { + if (box != null) { + double screenWidth = MediaQuery.of(context).size.width; + double viewInsetsBottom = MediaQuery.of(context).viewInsets.bottom; + Offset offset = box.localToGlobal(Offset.zero); + double boxWidth = box.size.width; + if (isSelf) { + right = screenWidth - offset.dx - ((isUseMessageReaction) ? boxWidth : (boxWidth / 1.3)); + } else { + left = offset.dx; + } + if (offset.dy < 300 && !isLongMessage && viewInsetsBottom == 0) { + selectEmojiPanelPosition = SelectEmojiPanelPosition.up; + popupDirection = TooltipDirection.down; + } else if (viewInsetsBottom != 0 && offset.dy < 220) { + selectEmojiPanelPosition = SelectEmojiPanelPosition.up; + popupDirection = TooltipDirection.down; + } + } + arrowTipDistance = (context.size!.height / 2).roundToDouble() + (isLongMessage ? -120 : 10); + } + } + + // 如果配置了显示回复消息,则需要根据消息状态来决定是否可以回复;如果配置了不显示回复消息,则不需要判断消息状态。 + if ((widget.toolTipsConfig?.showReplyMessage ?? true)) { + if (widget.message.status != MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL) { + widget.toolTipsConfig?.showReplyMessage = true; + } else { + widget.toolTipsConfig?.showReplyMessage = false; + } + } + + // 如果配置了显示转发消息,则需要根据消息状态来决定是否可以转发;如果配置了不显示转发消息,则不需要判断消息状态。 + if ((widget.toolTipsConfig?.showForwardMessage ?? true)) { + if (widget.message.status != MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL && + !(widget.message.hasRiskContent ?? false)) { + widget.toolTipsConfig?.showForwardMessage = true; + } else { + widget.toolTipsConfig?.showForwardMessage = false; + } + } + + tooltip = SuperTooltip( + popupDirection: popupDirection, + minimumOutSidePadding: 0, + arrowTipDistance: arrowTipDistance, + arrowBaseWidth: arrowBaseWidth, + arrowLength: arrowLength, + right: right, + left: left, + hasArrow: hasArrow, + borderColor: theme?.white ?? Colors.white, + backgroundColor: theme?.white ?? Colors.white, + shadowColor: Colors.black26, + hasShadow: isDesktopScreen ? false : true, + borderWidth: 1.0, + showCloseButton: ShowCloseButton.none, + touchThroughAreaShape: ClipAreaShape.rectangle, + content: TIMUIKitMessageTooltip( + iSUseDefaultHoverBar: + model.chatConfig.isUseMessageHoverBarOnDesktop && widget.customMessageHoverBarOnDesktop == null, + model: model, + groupMemberInfo: widget.groupMemberInfo, + isShowMoreSticker: isShowMoreSticker ?? false, + toolTipsConfig: widget.toolTipsConfig, + isUseMessageReaction: isUseMessageReaction, + message: widget.message, + allowAtUserWhenReply: widget.allowAtUserWhenReply, + onLongPressForOthersHeadPortrait: widget.onLongPressForOthersHeadPortrait, + selectEmojiPanelPosition: selectEmojiPanelPosition, + onCloseTooltip: () => tooltip?.close(), + onSelectSticker: (int value) { + tooltip?.close(); + _clickOnCurrentSticker(value); + }, + ), + ); + } + + Widget _getMessageItemBuilder(V2TimMessage message, int? messageStatues, TUIChatSeparateViewModel model) { + final messageBuilder = _messageItemBuilder; + + return messageBuilder(widget.message, model); + } + + // 弹出对话框 + Future showResendMsgFailDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: Text(TIM_t("您确定要重发这条消息么?")), + actions: [ + CupertinoDialogAction( + child: Text(TIM_t("确定")), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + CupertinoDialogAction( + child: Text(TIM_t("取消")), + isDestructiveAction: true, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + if (tooltip?.isOpen ?? false) { + tooltip?.close(); + } + } + + List getWideMessageHoverControlBar(TUIChatSeparateViewModel model, TUITheme theme) { + return [ + if (widget.isUseMessageReaction ?? false) + MessageHoverControlItem( + name: TIM_t("表情回应"), + icon: Icon( + Icons.emoji_emotions, + size: 13, + color: hexToColor("8f959e"), + ), + onClick: (details) { + _onOpenToolTip(context, widget.message, model, theme, details, true, true); + }, + ), + if (widget.toolTipsConfig?.showReplyMessage ?? true) + MessageHoverControlItem( + name: TIM_t("回复"), + icon: Icon( + Icons.message, + size: 13, + color: hexToColor("8f959e"), + ), + onClick: (_) { + model.repliedMessage = widget.message; + final isSelf = widget.message.isSelf ?? true; + final isGroup = TencentUtils.checkString(widget.message.groupID) != null; + final isAtWhenReply = + !isSelf && isGroup && widget.allowAtUserWhenReply && widget.onLongPressForOthersHeadPortrait != null; + + /// If replying to a self message, do not add a at tag, only requestFocus. + widget.onLongPressForOthersHeadPortrait!( + !isAtWhenReply ? null : widget.message.sender, !isAtWhenReply ? null : widget.message.nickName); + }, + ), + if ((widget.toolTipsConfig?.showForwardMessage ?? true) && !model.isVoteMessage(widget.message)) + MessageHoverControlItem( + name: TIM_t("转发"), + icon: Icon( + Icons.send, + size: 13, + color: hexToColor("8f959e"), + ), + onClick: (_) { + model.setMessageItemChecked(widget.message, true); + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.forward, + context: context, + title: TIM_t("转发"), + submitWidget: Text(TIM_t("发送")), + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + onSubmit: () { + forwardMessageScreenKey.currentState?.handleForwardMessage(); + }, + child: (onClose) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: ForwardMessageScreen( + conversationType: ConvType.c2c, + key: forwardMessageScreenKey, + onClose: onClose, + model: model, + ), + ), + theme: theme); + }, + ), + MessageHoverControlItem( + name: TIM_t("更多"), + icon: Icon( + Icons.more_horiz, + size: 13, + color: hexToColor("8f959e"), + ), + onClick: (details) { + _onOpenToolTip(context, widget.message, model, theme, details, true, false); + }, + ), + ...?model.chatConfig.additionalDesktopMessageHoverBarItem + ]; + } + + _onMsgSendFailIconTap(V2TimMessage message, TUIChatSeparateViewModel model) { + final convID = model.conversationID; + final convType = model.conversationType; + MessageUtils.handleMessageError( + model.reSendFailMessage(message: message, convType: convType ?? ConvType.c2c, convID: convID), context); + } + + Widget renderHoverTipAndReadStatus(TUIChatSeparateViewModel model, bool isSelf, V2TimMessage message, bool isPeerRead, + TUITheme theme, bool isDownloadWaiting) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final customHoverBar = + widget.customMessageHoverBarOnDesktop != null ? widget.customMessageHoverBarOnDesktop!(message) : null; + final wideHoverTipList = (model.chatConfig.isUseMessageHoverBarOnDesktop && customHoverBar == null) + ? getWideMessageHoverControlBar(model, theme) + : []; + final lastItemName = wideHoverTipList.isNotEmpty ? wideHoverTipList.last.name : ""; + + // 满足条件延迟 1 秒渲染,否则立即渲染 + Future _conditionalDelay() async { + if (!(model.hasDelayedRenderSendingStatus(message.id ?? message.msgID!) ?? true)) { + model.setDelayedRenderSendingStatus(message.id ?? message.msgID!); + await Future.delayed(const Duration(seconds: 1)); + } + } + + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (isDesktopScreen && + isShowWideToolTip && + customHoverBar == null && + !((widget.message.elemType == 6 && isDownloadWaiting))) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), border: Border.all(color: hexToColor("d9dde0"), width: 1)), + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: wideHoverTipList + .map((e) => Tooltip( + message: e.name, + preferBelow: false, + textStyle: TextStyle(fontSize: 12, color: theme.white), + child: Row( + children: [ + InkWell( + onTapDown: e.onClick, + child: SizedBox( + width: 22, + height: 22, + child: e.icon, + ), + ), + if (lastItemName != e.name) + SizedBox( + width: 1, + height: 22, + child: Container( + color: theme.weakDividerColor, + ), + ) + ], + ), + )) + .toList(), + ), + ), + if (isDesktopScreen && isShowWideToolTip && customHoverBar != null) customHoverBar, + if (!isDesktopScreen || + (model.chatConfig.isUseMessageHoverBarOnDesktop && customHoverBar == null && !isShowWideToolTip)) + const SizedBox( + height: 20, + ), + if (isSelf && message.status == MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL) + Container( + padding: const EdgeInsets.only(bottom: 3), + margin: const EdgeInsets.only(right: 6), + child: GestureDetector( + onTap: () async { + final reSend = await showResendMsgFailDialog(context); + if (reSend != null) { + _onMsgSendFailIconTap(message, model); + } + }, + child: Icon(Icons.error, color: theme.cautionColor, size: 18), + )), + if (isSelf && message.status == MessageStatus.V2TIM_MSG_STATUS_SENDING) + FutureBuilder( + future: _conditionalDelay(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return Container( + padding: const EdgeInsets.only(bottom: 3), + margin: const EdgeInsets.only(right: 6), + width: 12.0, + height: 15.0, + child: CircularProgressIndicator(strokeWidth: 1.0), + ); + } else { + return Container(); + } + }, + ), + if (model.chatConfig.isShowReadingStatus && + isSelf && + message.status == MessageStatus.V2TIM_MSG_STATUS_SEND_SUCC && + (message.needReadReceipt ?? false) && + !model.isVoteMessage(widget.message)) + TIMUIKitMessageReadReceipt( + messageItem: widget.message, + onTapAvatar: widget.onTapForOthersPortrait, + ), + ], + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUIChatSeparateViewModel model = Provider.of(context); + final isDownloadWaiting = + context.select((value) => value.isWaiting(widget.message.msgID ?? "")); + final TUITheme theme = value.theme; + final message = widget.message; + final msgType = message.elemType; + final isSelf = message.isSelf ?? true; + final isGroupTipsMsg = msgType == MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS; + + final revokeStatus = isRevokeMessage(message, model); + final isRevokedMsg = revokeStatus.$1; + final isAdminRevoke = revokeStatus.$2; + + final isTimeDivider = msgType == 11; + final isLatestDivider = msgType == 101; + final isPeerRead = message.isPeerRead ?? false; + final isGroupMessage = model.conversationType == ConvType.group; + final bool isRevokeEditable = widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_TEXT; + final isShowNickNameForSelf = isGroupMessage && model.chatConfig.isShowSelfNameInGroup; + final isShowNickNameForOthers = isGroupMessage && model.chatConfig.isShowOthersNameInGroup; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (isTimeDivider) { + return _timeDividerBuilder(theme, message.timestamp ?? 0, model); + } + if (isLatestDivider) { + return _latestDividerBuilder(theme); + } + void clearJump() { + Future.delayed(const Duration(milliseconds: 100), () { + model.jumpMsgID = ""; + }); + } + + if (isGroupTipsMsg) { + if (widget.messageItemBuilder?.groupTipsMessageItemBuilder != null) { + final groupTipsMessage = widget.messageItemBuilder!.groupTipsMessageItemBuilder!( + message, + (model.jumpMsgID == message.msgID), + clearJump, + ); + return groupTipsMessage ?? _groupTipsMessageBuilder(model); + } + return _groupTipsMessageBuilder(model); + } + + if (isRevokedMsg) { + final displayName = isAdminRevoke + ? TIM_t("管理员") + : (isSelf + ? TIM_t("您") + : TencentUtils.checkString(message.nickName) ?? + TencentUtils.checkString(message.sender) ?? + message.userID); + return isSelf && isRevokeEditable && isRevocable(message.timestamp!) + ? _selfRevokeEditMessageBuilder(theme, model) + : _revokedMessageBuilder(theme, displayName ?? ""); + } + + // 使用自定义行 + if (widget.messageItemBuilder?.messageRowBuilder != null) { + final customRow = widget.messageItemBuilder!.messageRowBuilder!( + message, + _getMessageItemBuilder(message, message.status, model), + widget.onScrollToIndex ?? () {}, + message.msgID == model.jumpMsgID, + clearJump, + widget.onScrollToIndexBegin ?? () {}, + ); + if (customRow != null) { + return customRow; + } + } + + return VisibilityDetector( + key: Key(message.id ?? message.msgID!), + // 判断消息是否可见 + onVisibilityChanged: (visibilityInfo) { + var visiblePercentage = visibilityInfo.visibleFraction * 100; + if (visiblePercentage > 50) { + model.addToMessageReadReceiptList(message); + } + }, + child: LayoutBuilder( + builder: (context, constraints) => Container( + padding: EdgeInsets.only(left: isSelf ? 0 : 16, right: isSelf ? 16 : 0), + margin: widget.padding ?? const EdgeInsets.only(bottom: 20), + child: Row( + key: _key, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (model.isMultiSelect) + Container( + margin: EdgeInsets.only(right: 12, top: 10, left: isSelf ? 16 : 0), + child: CheckBoxButton( + isChecked: model.getSelectedMessageList().contains(message), + onChanged: (value) { + model.setMessageItemChecked(message, value); + }, + ), + ), + Expanded( + child: MouseRegion( + onEnter: (_) { + if (isDesktopScreen && model.chatConfig.isUseMessageHoverBarOnDesktop) { + setState(() { + isShowWideToolTip = true; + }); + } + }, + onExit: (_) { + if (isDesktopScreen && model.chatConfig.isUseMessageHoverBarOnDesktop) { + Tooltip.dismissAllToolTips(); + Future.delayed(const Duration(milliseconds: 100), () { + setState(() { + isShowWideToolTip = false; + }); + }); + } + }, + child: GestureDetector( + behavior: model.isMultiSelect ? HitTestBehavior.translucent : null, + onTap: () { + if (model.isMultiSelect) { + final checked = model.getSelectedMessageList().contains(message); + model.setMessageItemChecked(message, !checked); + } else { + return; + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: isSelf ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!isSelf && widget.showAvatar) + GestureDetector( + onLongPress: () { + if (widget.onLongPressForOthersHeadPortrait != null) {} + if (model.chatConfig.isAllowLongPressAvatarToAt) { + widget.onLongPressForOthersHeadPortrait!(message.sender, message.nickName); + } + }, + onTapDown: isDesktopScreen + ? (details) { + if (widget.onTapForOthersPortrait != null && widget.allowAvatarTap) { + widget.onTapForOthersPortrait!(message.sender ?? "", details); + } + } + : null, + onTap: isDesktopScreen + ? null + : () { + if (widget.onTapForOthersPortrait != null && widget.allowAvatarTap) { + widget.onTapForOthersPortrait!(message.sender ?? "", TapDownDetails()); + } + }, + onSecondaryTap: isDesktopScreen + ? null + : () { + if (widget.onSecondaryTapForOthersPortrait != null && widget.allowAvatarTap) { + widget.onSecondaryTapForOthersPortrait!(message.sender ?? "", TapDownDetails()); + } + }, + onSecondaryTapDown: isDesktopScreen + ? (details) { + if (widget.onSecondaryTapForOthersPortrait != null && widget.allowAvatarTap) { + widget.onSecondaryTapForOthersPortrait!(message.sender ?? "", details); + } + } + : null, + child: widget.userAvatarBuilder != null + ? widget.userAvatarBuilder!(context, message) + : Container( + margin: (isSelf && isShowNickNameForSelf) || (!isSelf && isShowNickNameForOthers) + ? const EdgeInsets.only(top: 2) + : null, + child: SizedBox( + width: 40, + height: 40, + child: Avatar( + faceUrl: message.faceUrl ?? "", + showName: MessageUtils.getDisplayName(message), + ), + ), + ), + ), + if (isSelf && widget.message.elemType == 6 && isDownloadWaiting) + Container( + margin: const EdgeInsets.only(top: 46, right: 10), + child: LoadingAnimationWidget.threeArchedCircle( + color: theme.weakTextColor ?? Colors.grey, + size: 20, + ), + ), + Container( + margin: widget.showAvatar + ? (isSelf ? const EdgeInsets.only(right: 13) : const EdgeInsets.only(left: 13)) + : null, + child: Column( + crossAxisAlignment: isSelf ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + if ((isSelf && isShowNickNameForSelf) || (!isSelf && isShowNickNameForOthers)) + widget.topRowBuilder != null + ? widget.topRowBuilder!(context, message) + : Container( + // margin: const EdgeInsets.only(bottom: 4), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width / 1.7), + child: Text( + MessageUtils.getDisplayName(message), + overflow: TextOverflow.ellipsis, + style: widget.themeData?.nickNameTextStyle ?? + TextStyle(fontSize: 12, color: theme.weakTextColor), + ), + )), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (isSelf) + renderHoverTipAndReadStatus( + model, isSelf, message, isPeerRead, theme, isDownloadWaiting), + Container( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * 0.77, + ), + child: Builder(builder: (context) { + return GestureDetector( + child: IgnorePointer( + ignoring: model.isMultiSelect, + child: _getMessageItemBuilder(message, message.status, model)), + onSecondaryTapDown: (details) { + if (widget.onLongPress != null) { + widget.onLongPress!(context, message); + return; + } + if (!PlatformUtils().isMobile) { + if (widget.allowLongPress) { + _onOpenToolTip(context, message, model, theme, details, false, false); + } + } + }, + onLongPress: () { + if (widget.onLongPress != null) { + widget.onLongPress!(context, message); + return; + } + if (widget.allowLongPress && !isDesktopScreen) { + _onOpenToolTip(context, message, model, theme, null, false, false); + } + }, + onTapDown: (details) { + _tapDetails = details; + }, + ); + }), + ), + if (!isSelf && + message.elemType == MessageElemType.V2TIM_ELEM_TYPE_SOUND && + message.localCustomInt != null && + message.localCustomInt != HistoryMessageDartConstant.read) + Padding( + padding: const EdgeInsets.only(left: 5, bottom: 12), + child: Icon(Icons.circle, color: theme.cautionColor, size: 10)), + if (!isSelf) + renderHoverTipAndReadStatus( + model, isSelf, message, isPeerRead, theme, isDownloadWaiting), + ], + ), + TIMUIKitTextTranslationElem( + message: message, + customEmojiStickerList: widget.customEmojiStickerList, + isFromSelf: message.isSelf ?? true, + isShowJump: false, + clearJump: () {}, + chatModel: model), + if (widget.bottomRowBuilder != null) widget.bottomRowBuilder!(context, message) + ], + ), + ), + if (!isSelf && widget.message.elemType == 6 && isDownloadWaiting) + Container( + margin: const EdgeInsets.only(top: 46, left: 10), + child: LoadingAnimationWidget.threeArchedCircle( + color: theme.weakTextColor ?? Colors.grey, + size: 20, + ), + ), + if (isSelf && widget.showAvatar) + widget.userAvatarBuilder != null + ? widget.userAvatarBuilder!(context, message) + : SizedBox( + width: 40, + height: 40, + child: InkWell( + onTapDown: (details) { + if (widget.onTapForOthersPortrait != null && widget.allowAvatarTap) { + widget.onTapForOthersPortrait!(message.sender ?? "", details); + } + }, + child: Avatar( + faceUrl: message.faceUrl ?? "", showName: MessageUtils.getDisplayName(message)), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_message_tooltip.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_message_tooltip.dart new file mode 100644 index 0000000..7396798 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_message_tooltip.dart @@ -0,0 +1,620 @@ +// ignore_for_file: non_constant_identifier_names, avoid_print + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:open_file/open_file.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/forward_message_screen.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:path/path.dart' as path; + +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitMessageTooltip extends StatefulWidget { + /// tool tips panel configuration, long press message will show tool tips panel + final ToolTipsConfig? toolTipsConfig; + + /// current message + final V2TimMessage message; + + /// allow notifi user when send reply message + final bool allowAtUserWhenReply; + + /// the callback for long press event, except myself avatar + final Function(String? userId, String? nickName)? onLongPressForOthersHeadPortrait; + + final bool isUseMessageReaction; + + /// direction + final SelectEmojiPanelPosition selectEmojiPanelPosition; + + /// on add sticker reaction to a message + final ValueChanged onSelectSticker; + + /// on close tooltip area + final VoidCallback onCloseTooltip; + + final TUIChatSeparateViewModel model; + + final bool isShowMoreSticker; + + final V2TimGroupMemberFullInfo? groupMemberInfo; + + final bool iSUseDefaultHoverBar; + + const TIMUIKitMessageTooltip( + {Key? key, + this.toolTipsConfig, + this.isUseMessageReaction = true, + required this.model, + required this.message, + required this.allowAtUserWhenReply, + this.onLongPressForOthersHeadPortrait, + required this.selectEmojiPanelPosition, + required this.onCloseTooltip, + required this.onSelectSticker, + this.isShowMoreSticker = false, + this.groupMemberInfo, + required this.iSUseDefaultHoverBar}) + : super(key: key); + + @override + State createState() => TIMUIKitMessageTooltipState(); +} + +class TIMUIKitMessageTooltipState extends TIMUIKitState { + final TUIChatGlobalModel globalModal = serviceLocator(); + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + bool isShowMoreSticker = false; + bool fileBeenDownloaded = false; + String filePath = ""; + + @override + void initState() { + super.initState(); + hasFile(); + isShowMoreSticker = widget.isShowMoreSticker; + } + + hasFile() { + if (PlatformUtils().isMobile || + (widget.message.fileElem == null && widget.message.imageElem == null && widget.message.videoElem == null)) { + fileBeenDownloaded = false; + return; + } + if (PlatformUtils().isWeb) { + fileBeenDownloaded = true; + return; + } + if (PlatformUtils().isDesktop) { + if (widget.message.fileElem != null) { + String savePath = TencentUtils.checkString(globalModal.getFileMessageLocation(widget.message.msgID)) ?? + TencentUtils.checkString(widget.message.fileElem!.localUrl) ?? + widget.message.fileElem?.path ?? + ""; + File f = File(savePath); + if (f.existsSync() && widget.message.msgID != null) { + filePath = savePath; + fileBeenDownloaded = true; + return; + } + } else if (widget.message.imageElem != null) { + if (TencentUtils.checkString(widget.message.imageElem!.imageList![0]!.localUrl) != null && + File(widget.message.imageElem!.imageList![0]!.localUrl!).existsSync()) { + fileBeenDownloaded = true; + return; + } + } else if (widget.message.videoElem != null) { + if (TencentUtils.checkString(widget.message.videoElem!.localVideoUrl) != null && + File(widget.message.videoElem!.localVideoUrl!).existsSync()) { + fileBeenDownloaded = true; + return; + } + } + } + fileBeenDownloaded = false; + } + + bool isRevocable(int timestamp, int upperTimeLimit) => + ((DateTime.now().millisecondsSinceEpoch / 1000).ceil() - timestamp < upperTimeLimit) && + (widget.message.isSelf ?? true); + + Widget ItemInkWell({ + Widget? child, + GestureTapCallback? onTap, + }) { + return SizedBox( + width: 44, + child: InkWell( + onTap: onTap, + splashColor: Colors.white, + child: Container( + padding: const EdgeInsets.only(bottom: 6, top: 6), + child: child, + ), + ), + ); + } + + bool isAdminCanRecall() { + if (widget.model.chatConfig.isGroupAdminRecallEnabled) { + final selfMemberInfo = widget.groupMemberInfo ?? widget.model.selfMemberInfo; + final selfRole = selfMemberInfo?.role; + return selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN || + selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER; + } else { + return false; + } + } + + _buildLongPressTipItem(TUITheme theme, TUIChatSeparateViewModel model, V2TimMessage message) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final isCanRevokeSelf = isRevocable(widget.message.timestamp!, model.chatConfig.upperRecallTime); + final shouldShowRevokeAction = + (isCanRevokeSelf || isAdminCanRecall()) && widget.message.status != MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL; + final shouldShowReplyAction = + !(widget.message.customElem?.data != null && MessageUtils.isCallingData(widget.message.customElem!.data!)); + final shouldShowForwardAction = + !(widget.message.customElem?.data != null && MessageUtils.isCallingData(widget.message.customElem!.data!)); + final tooltipsConfig = widget.toolTipsConfig; + final messageCanCopy = widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_TEXT || + (isDesktopScreen && widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_IMAGE && fileBeenDownloaded); + bool showTranslation = true; + if (widget.message.localCustomData != null) { + final LocalCustomDataModel localCustomData = + LocalCustomDataModel.fromMap(json.decode(TencentUtils.checkString(widget.message.localCustomData) ?? "{}")); + if (localCustomData.translatedText != null && localCustomData.translatedText != "") { + showTranslation = false; + } + } + + final dynamicQuote = model.chatConfig.isAtWhenReplyDynamic?.call(widget.message); + + final List defaultTipsList = [ + if (fileBeenDownloaded) + MessageToolTipItem( + label: TIM_t("打开"), + id: "open", + iconImageAsset: "images/open_in_new.png", + onClick: () => _onTap("open", model)), + if (fileBeenDownloaded && PlatformUtils().isDesktop) + MessageToolTipItem( + label: PlatformUtils().isMacOS ? TIM_t("在访达中打开") : TIM_t("查看文件夹"), + id: "finder", + iconImageAsset: "images/folder_open.png", + onClick: () => _onTap("finder", model)), + if (messageCanCopy) + MessageToolTipItem( + label: TIM_t("复制"), + id: "copyMessage", + iconImageAsset: "images/copy_message.png", + onClick: () => _onTap("copyMessage", model)), + if (shouldShowForwardAction && !model.isVoteMessage(widget.message)) + MessageToolTipItem( + label: TIM_t("转发"), + id: "forwardMessage", + iconImageAsset: "images/forward_message.png", + onClick: () => _onTap("forwardMessage", model)), + if (shouldShowReplyAction) + MessageToolTipItem( + label: TIM_t((dynamicQuote ?? model.chatConfig.isAtWhenReply) ? "回复" : "引用"), + id: "replyMessage", + iconImageAsset: "images/reply_message.png", + onClick: () => _onTap("replyMessage", model)), + MessageToolTipItem( + label: TIM_t("多选"), + id: "multiSelect", + iconImageAsset: "images/multi_message.png", + onClick: () => _onTap("multiSelect", model)), + MessageToolTipItem( + label: TIM_t("删除"), + id: "delete", + iconImageAsset: "images/delete_message.png", + onClick: () => _onTap("delete", model)), + if (showTranslation) + MessageToolTipItem( + label: TIM_t("翻译"), + id: "translate", + iconImageAsset: "images/translate.png", + onClick: () => _onTap("translate", model)), + if (shouldShowRevokeAction) + MessageToolTipItem( + label: TIM_t("撤回"), + id: "revoke", + iconImageAsset: "images/revoke_message.png", + onClick: () => _onTap("revoke", model)), + ]; + final defaultTipsIds = defaultTipsList.map((e) => e.id); + List defaultFormattedTipsList = defaultTipsList; + if (tooltipsConfig != null) { + defaultFormattedTipsList = defaultTipsList.where((element) { + final type = element.id; + if (type == "copyMessage") { + return tooltipsConfig.showCopyMessage; + } + if (type == "forwardMessage") { + return tooltipsConfig.showForwardMessage && !(isDesktopScreen && widget.iSUseDefaultHoverBar); + } + if (type == "replyMessage") { + return tooltipsConfig.showReplyMessage && !(isDesktopScreen && widget.iSUseDefaultHoverBar); + } + if (type == "delete") { + return (!PlatformUtils().isWeb) && tooltipsConfig.showDeleteMessage; + } + if (type == "multiSelect") { + return tooltipsConfig.showMultipleChoiceMessage; + } + + if (type == "revoke") { + return tooltipsConfig.showRecallMessage; + } + if (type == "translate") { + return tooltipsConfig.showTranslation && widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_TEXT; + } + return true; + }).toList(); + } + + final List? customList = widget.toolTipsConfig?.additionalMessageToolTips != null + ? (widget.toolTipsConfig?.additionalMessageToolTips!(message, widget.onCloseTooltip)) + : []; + + List formattedTipsList = [ + ...defaultFormattedTipsList, + ...?customList, + ]; + + List widgetList = []; + if (isDesktopScreen) { + widgetList = formattedTipsList + .map( + (item) => Material( + color: Colors.white, + child: InkWell( + onTap: () { + item.onClick(); + }, + child: Container( + padding: const EdgeInsets.all(6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + item.iconImageAsset, + package: defaultTipsIds.contains(item.id) ? 'tencent_cloud_chat_uikit' : null, + width: 20, + height: 20, + ), + const SizedBox( + height: 4, + width: 8, + ), + Text( + item.label, + style: TextStyle( + decoration: TextDecoration.none, + color: theme.darkTextColor, + fontSize: 12, + ), + ) + ], + ), + ), + ), + ), + ) + .toList(); + } else { + widgetList = formattedTipsList + .map( + (item) => Material( + color: Colors.white, + child: ItemInkWell( + onTap: () { + item.onClick(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + item.iconImageAsset, + package: defaultTipsIds.contains(item.id) ? 'tencent_cloud_chat_uikit' : null, + width: 20, + height: 20, + ), + const SizedBox( + height: 4, + width: 60, + ), + Text( + item.label, + style: TextStyle( + decoration: TextDecoration.none, + color: theme.darkTextColor, + fontSize: 10, + ), + ) + ], + ), + ), + ), + ) + .toList(); + } + if (widgetList.isEmpty && widget.isUseMessageReaction == false) { + widget.onCloseTooltip(); + } + + return widgetList; + } + + _onOpenDesktop(String path) { + try { + if (PlatformUtils().isDesktop && !PlatformUtils().isWindows) { + launchUrl(Uri.file(path)); + } else { + OpenFile.open(path); + } + // ignore: empty_catches + } catch (e) {} + } + + _onTap(String operation, TUIChatSeparateViewModel model) async { + final messageItem = widget.message; + final msgID = messageItem.msgID as String; + switch (operation) { + case "open": + if (widget.message.fileElem != null) { + _onOpenDesktop(TencentUtils.checkString(globalModal.getFileMessageLocation(widget.message.msgID)) ?? + TencentUtils.checkString(widget.message.fileElem!.localUrl) ?? + widget.message.fileElem?.path ?? + ""); + } else if (widget.message.imageElem != null) { + _onOpenDesktop(TencentUtils.checkString(widget.message.imageElem!.imageList?[0]?.localUrl) ?? + TencentUtils.checkString(widget.message.imageElem?.path) ?? + ""); + } else if (widget.message.videoElem != null) { + _onOpenDesktop(TencentUtils.checkString(widget.message.videoElem!.localVideoUrl) ?? + TencentUtils.checkString(widget.message.videoElem?.videoPath) ?? + ""); + } + break; + case "finder": + String savePath = ""; + if (widget.message.fileElem != null) { + savePath = (TencentUtils.checkString(globalModal.getFileMessageLocation(widget.message.msgID)) ?? + TencentUtils.checkString(widget.message.fileElem!.localUrl) ?? + widget.message.fileElem?.path ?? + ""); + } else if (widget.message.imageElem != null) { + savePath = (TencentUtils.checkString(widget.message.imageElem!.imageList?[0]?.localUrl) ?? + TencentUtils.checkString(widget.message.imageElem?.path) ?? + ""); + } else if (widget.message.videoElem != null) { + savePath = (TencentUtils.checkString(widget.message.videoElem!.localVideoUrl) ?? + TencentUtils.checkString(widget.message.videoElem?.videoPath) ?? + ""); + } + final String fileDir = path.dirname(savePath); + _onOpenDesktop(fileDir); + break; + case "delete": + model.deleteMsg(msgID, webMessageInstance: messageItem.messageFromWeb); + break; + case "revoke": + model.revokeMsg(msgID, !isRevocable(widget.message.timestamp!, model.chatConfig.upperRecallTime), + messageItem.messageFromWeb); + break; + case 'translate': + model.translateText(widget.message); + break; + case "multiSelect": + model.updateMultiSelectStatus(true); + model.setMessageItemChecked(widget.message, true); + break; + case "forwardMessage": + model.setMessageItemChecked(widget.message, true); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ForwardMessageScreen( + conversationType: ConvType.c2c, + model: model, + ))); + break; + case "copyMessage": + if (widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_TEXT) { + try { + await Clipboard.setData(ClipboardData(text: widget.message.textElem?.text ?? "")); + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("已复制"), infoCode: 6660408)); + // ignore: empty_catches + } catch (e) {} + } + break; + case "replyMessage": + model.repliedMessage = widget.message; + final dynamicQuote = model.chatConfig.isAtWhenReplyDynamic?.call(widget.message); + final isSelf = widget.message.isSelf ?? true; + final isGroup = TencentUtils.checkString(widget.message.groupID) != null; + final isAtWhenReply = !isSelf && + isGroup && + (dynamicQuote ?? widget.allowAtUserWhenReply) && + widget.onLongPressForOthersHeadPortrait != null; + + /// If replying to a self message, do not add a at tag, only requestFocus. + widget.onLongPressForOthersHeadPortrait!( + !isAtWhenReply ? null : widget.message.sender, !isAtWhenReply ? null : widget.message.nickName); + break; + default: + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("暂未实现"), infoCode: 6660409)); + } + widget.onCloseTooltip(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: widget.model), + ], + builder: (BuildContext context, Widget? w) { + final TUIChatSeparateViewModel model = Provider.of(context); + final bool haveExtraTipsConfig = + widget.toolTipsConfig != null && widget.toolTipsConfig?.additionalItemBuilder != null; + Widget? extraTipsActionItem = haveExtraTipsConfig + ? widget.toolTipsConfig!.additionalItemBuilder!(widget.message, widget.onCloseTooltip, null, context) + : null; + final message = widget.message; + return Container( + decoration: isDesktopScreen + ? BoxDecoration( + boxShadow: const [ + BoxShadow( + color: Color(0xCCbebebe), + offset: Offset(2, 2), + blurRadius: 10, + spreadRadius: 1, + ), + ], + border: Border.all( + width: 1, + color: hexToColor("dee0e3"), + ), + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ) + : null, + color: isDesktopScreen ? null : Colors.white, + padding: EdgeInsets.symmetric(horizontal: 8, vertical: isDesktopScreen ? 8 : 4), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.of(context).size.width * 0.75, 350), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if ((!isDesktopScreen || widget.isShowMoreSticker) && + widget.isUseMessageReaction && + widget.selectEmojiPanelPosition == SelectEmojiPanelPosition.up) + TIMUIKitMessageReactionEmojiSelectPanel( + isShowMoreSticker: isShowMoreSticker, + onSelect: (int value) => widget.onSelectSticker(value), + onClickShowMore: (bool value) { + setState(() { + isShowMoreSticker = value; + }); + }, + ), + if (!isDesktopScreen && + widget.isUseMessageReaction && + widget.selectEmojiPanelPosition == SelectEmojiPanelPosition.up && + isShowMoreSticker == false) + Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: const Divider( + thickness: 1, + indent: 0, + // endIndent: 10, + color: Colors.black12)), + if (isShowMoreSticker == false) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isDesktopScreen && widget.isUseMessageReaction) + Expanded( + child: Wrap( + direction: Axis.horizontal, + alignment: TUIKitScreenUtils.getFormFactor(context) == DeviceType.Mobile + ? WrapAlignment.start + : WrapAlignment.start, + spacing: 12, + runSpacing: 8, + children: [ + ..._buildLongPressTipItem(theme, model, message), + if (extraTipsActionItem != null) extraTipsActionItem + ], + )), + if (!isDesktopScreen && !widget.isUseMessageReaction) + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.of(context).size.width * 0.75, 350), + ), + child: Wrap( + direction: Axis.horizontal, + alignment: TUIKitScreenUtils.getFormFactor(context) == DeviceType.Mobile + ? WrapAlignment.spaceBetween + : WrapAlignment.start, + spacing: 4, + runSpacing: 8, + children: [ + ..._buildLongPressTipItem(theme, model, message), + if (extraTipsActionItem != null) extraTipsActionItem + ], + ), + ), + if (isDesktopScreen) + Table(columnWidths: const { + 0: IntrinsicColumnWidth(), + }, children: [ + ..._buildLongPressTipItem(theme, model, message).map((e) => TableRow(children: [e])) + ]) + ], + ), + if (!isDesktopScreen && + widget.isUseMessageReaction && + widget.selectEmojiPanelPosition == SelectEmojiPanelPosition.down && + isShowMoreSticker == false) + Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: const Divider( + thickness: 1, + indent: 0, + // endIndent: 10, + color: Colors.black12)), + if ((!isDesktopScreen || widget.isShowMoreSticker) && + widget.isUseMessageReaction && + widget.selectEmojiPanelPosition == SelectEmojiPanelPosition.down) + TIMUIKitMessageReactionEmojiSelectPanel( + isShowMoreSticker: isShowMoreSticker, + onSelect: (int value) => widget.onSelectSticker(value), + onClickShowMore: (bool value) { + setState(() { + isShowMoreSticker = value; + }); + }, + ), + ], + ), + )); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_history_message_list_container.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_history_message_list_container.dart new file mode 100644 index 0000000..7ec0905 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_history_message_list_container.dart @@ -0,0 +1,203 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +enum LoadingPlace { + none, + top, + bottom, +} + +class TIMUIKitHistoryMessageListContainer extends StatefulWidget { + final Widget Function(BuildContext, V2TimMessage?)? itemBuilder; + final AutoScrollController? scrollController; + final String conversationID; + final Function(String? userId, String? nickName)? onLongPressForOthersHeadPortrait; + final List? groupAtInfoList; + final V2TimMessage? initFindingMsg; + + /// message item builder, works for customize all message types and row layout. + final MessageItemBuilder? messageItemBuilder; + + /// The controller for text field. + final TIMUIKitInputTextFieldController? textFieldController; + + /// the builder for avatar + final Widget Function(BuildContext context, V2TimMessage message)? userAvatarBuilder; + + /// the builder for tongue + final TongueItemBuilder? tongueItemBuilder; + + final Widget? Function(V2TimMessage message, Function() closeTooltip, [Key? key, BuildContext? context])? + extraTipsActionItemBuilder; + + /// conversation type + final ConvType conversationType; + + /// Avatar and name in message reaction tap callback. + final void Function(String userID, TapDownDetails tapDetails)? onTapAvatar; + + /// Avatar and name in message reaction secondary tap callback. + final void Function(String userID, TapDownDetails tapDetails)? onSecondaryTapAvatar; + + @Deprecated( + "Nickname will not show in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + final bool showNickName; + + final TIMUIKitHistoryMessageListConfig? mainHistoryListConfig; + + /// tool tips panel configuration, long press message will show tool tips panel + final ToolTipsConfig? toolTipsConfig; + + final List customEmojiStickerList; + + final bool isAllowScroll; + + final V2TimConversation conversation; + + final V2TimGroupMemberFullInfo? groupMemberInfo; + + /// This parameter accepts a custom widget to be displayed when the mouse hovers over a message, + /// replacing the default message hover action bar. + /// Applicable only on desktop platforms. + /// If provided, the default message action functionality will appear in the right-click context menu instead. + final Widget? Function(V2TimMessage message)? customMessageHoverBarOnDesktop; + + const TIMUIKitHistoryMessageListContainer({ + Key? key, + this.itemBuilder, + this.scrollController, + required this.conversationID, + required this.conversationType, + this.userAvatarBuilder, + this.onLongPressForOthersHeadPortrait, + this.groupAtInfoList, + this.messageItemBuilder, + this.tongueItemBuilder, + this.extraTipsActionItemBuilder, + this.isAllowScroll = true, + this.onTapAvatar, + @Deprecated( + "Nickname will not show in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + this.showNickName = true, + this.initFindingMsg, + this.mainHistoryListConfig, + this.toolTipsConfig, + this.customEmojiStickerList = const [], + this.textFieldController, + required this.conversation, + this.onSecondaryTapAvatar, + this.groupMemberInfo, + this.customMessageHoverBarOnDesktop, + }) : super(key: key); + + @override + State createState() => _TIMUIKitHistoryMessageListContainerState(); +} + +class _TIMUIKitHistoryMessageListContainerState extends TIMUIKitState { + late TIMUIKitHistoryMessageListController _historyMessageListController; + + List historyMessageList = []; + + Future requestForData(String? lastMsgID, LoadDirection direction, TUIChatSeparateViewModel model, + [int? count, int? lastSeq]) async { + if ((direction == LoadDirection.previous) || (direction == LoadDirection.latest && model.haveMoreLatestData)) { + return await model.loadChatRecord( + direction: direction, + count: count ?? (kIsWeb ? 15 : HistoryMessageDartConstant.getCount), + lastMsgID: lastMsgID, + lastMsgSeq: lastSeq ?? -1, + ); + } else { + return false; + } + } + + Widget Function(BuildContext, V2TimMessage)? _getTopRowBuilder(TUIChatSeparateViewModel model) { + if (widget.messageItemBuilder?.messageNickNameBuilder != null) { + return (BuildContext context, V2TimMessage message) { + return widget.messageItemBuilder!.messageNickNameBuilder!(context, message, model); + }; + } + return null; + } + + @override + void initState() { + super.initState(); + _historyMessageListController = TIMUIKitHistoryMessageListController(scrollController: widget.scrollController); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final chatConfig = Provider.of(context); + final TUIChatSeparateViewModel model = Provider.of(context, listen: false); + + return TIMUIKitHistoryMessageListSelector( + conversationID: model.conversationID, + builder: (context, messageList, child) { + return TIMUIKitHistoryMessageList( + conversation: widget.conversation, + model: model, + isAllowScroll: widget.isAllowScroll, + controller: _historyMessageListController, + groupAtInfoList: widget.groupAtInfoList, + mainHistoryListConfig: widget.mainHistoryListConfig, + itemBuilder: (context, message) { + return TIMUIKitHistoryMessageListItem( + customMessageHoverBarOnDesktop: widget.customMessageHoverBarOnDesktop, + groupMemberInfo: widget.groupMemberInfo, + textFieldController: widget.textFieldController, + userAvatarBuilder: widget.userAvatarBuilder, + customEmojiStickerList: widget.customEmojiStickerList, + topRowBuilder: _getTopRowBuilder(model), + onScrollToIndex: _historyMessageListController.scrollToIndex, + onScrollToIndexBegin: _historyMessageListController.scrollToIndexBegin, + toolTipsConfig: + widget.toolTipsConfig ?? ToolTipsConfig(additionalItemBuilder: widget.extraTipsActionItemBuilder), + message: message!, + showAvatar: chatConfig.isShowAvatar, + onSecondaryTapForOthersPortrait: widget.onSecondaryTapAvatar, + onTapForOthersPortrait: widget.onTapAvatar, + messageItemBuilder: widget.messageItemBuilder, + onLongPressForOthersHeadPortrait: widget.onLongPressForOthersHeadPortrait, + allowAtUserWhenReply: chatConfig.isAtWhenReply, + allowAvatarTap: chatConfig.isAllowClickAvatar, + allowLongPress: chatConfig.isAllowLongPressMessage, + isUseMessageReaction: chatConfig.isUseMessageReaction); + }, + tongueItemBuilder: widget.tongueItemBuilder, + initFindingMsg: widget.initFindingMsg, + messageList: messageList, + onLoadMore: (String? a, LoadDirection direction, [int? b, int? lastSeq]) async { + return await requestForData(a, direction, model, b, lastSeq); + }, + ); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_message_read_receipt.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_message_read_receipt.dart new file mode 100644 index 0000000..fe89b5a --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_message_read_receipt.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_receipt.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/message_read_receipt.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; + +class TIMUIKitMessageReadReceipt extends TIMUIKitStatelessWidget { + final V2TimMessage messageItem; + final void Function(String, TapDownDetails tapDetails)? onTapAvatar; + + TIMUIKitMessageReadReceipt({Key? key, this.onTapAvatar, required this.messageItem}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final TUIChatSeparateViewModel model = Provider.of(context, listen: false); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return Selector( + builder: (context, value, child) { + if (model.conversationType == ConvType.c2c) { + bool isPeerRead = false; + if (value != null) { + isPeerRead = value.isPeerRead ?? false; + } + return Container( + padding: const EdgeInsets.only(bottom: 3), + margin: const EdgeInsets.only(right: 6), + child: Text( + isPeerRead ? TIM_t("已读") : TIM_t("未读"), + style: TextStyle(color: theme.chatMessageItemUnreadStatusTextColor, fontSize: 12), + ), + ); + } else { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if ((value?.readCount ?? 0) > 0) { + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.messageReadDetails, + context: context, + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + title: TIM_t("消息详情"), + child: (onClose) => MessageReadReceipt( + model: model, + onTapAvatar: onTapAvatar, + messageItem: messageItem, + unreadCount: value?.unreadCount ?? 0, + readCount: value?.readCount ?? 0)); + } else { + if (value?.unreadCount == 0) { + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MessageReadReceipt( + model: model, + onTapAvatar: onTapAvatar, + messageItem: messageItem, + unreadCount: value?.unreadCount ?? 0, + readCount: value?.readCount ?? 0))); + } + } + }, + child: Container( + padding: EdgeInsets.only(bottom: 3, right: 6, left: 6, top: isDesktopScreen ? 2 : 6), + child: ((value?.unreadCount ?? 0) == 0 && (value?.readCount ?? 0) > 0) + ? Icon( + Icons.check_circle_outline, + size: 18, + color: theme.weakTextColor, + ) + : Container( + width: 14, + height: 14, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 1.3, + color: (value?.readCount ?? 0) > 0 ? theme.primaryColor! : theme.weakTextColor!)), + child: (value?.readCount ?? 0) > 0 + ? Text( + '${value?.readCount ?? 0}', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 8, color: theme.primaryColor), + ) + : null, + ), + ), + ); + } + }, + selector: (c, model) { + return model.getMessageReadReceipt(messageItem.msgID ?? ""); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/utils.dart b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/utils.dart new file mode 100644 index 0000000..582dbc3 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKItMessageList/utils.dart @@ -0,0 +1,13 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; + +class TIMUIKitChatUtils { + static String? getMessageIDWithinIndex(List messageList, int index) { + if (messageList[index]!.elemType == 11) { + if (index > 0) { + return getMessageIDWithinIndex(messageList, index - 1); + } + } + return messageList[index]!.msgID; + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar.dart new file mode 100644 index 0000000..1da606f --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar.dart @@ -0,0 +1,272 @@ +// ignore_for_file: deprecated_member_use + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimGroupListener.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_change_info_type.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/friendShip/friendship_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar_title.dart'; +import 'package:tuple/tuple.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitAppBar extends StatefulWidget implements PreferredSizeWidget { + /// Appbar config + final AppBar? config; + + /// Allow show conversation total unread count + final bool showTotalUnReadCount; + + /// Conversation id + final String conversationID; + + /// conversation name + final String conversationShowName; + + /// If allow update the conversation show name automatically. + final bool isConversationShowNameFixed; + + final bool showC2cMessageEditStatus; + + final GestureTapDownCallback? onClickTitle; + + const TIMUIKitAppBar({ + Key? key, + this.config, + this.isConversationShowNameFixed = false, + this.showTotalUnReadCount = true, + this.conversationID = "", + this.conversationShowName = "", + this.showC2cMessageEditStatus = true, + this.onClickTitle, + }) : super(key: key); + + @override + Size get preferredSize => + config?.preferredSize ?? const Size.fromHeight(56.0); + + @override + State createState() => _TIMUIKitAppBarState(); +} + +class _TIMUIKitAppBarState extends TIMUIKitState { + final FriendshipServices _friendshipServices = + serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + + V2TimFriendshipListener? _friendshipListener; + V2TimGroupListener? _groupListener; + + String _conversationShowName = ""; + + _addConversationShowNameListener() { + _friendshipListener = V2TimFriendshipListener( + onFriendInfoChanged: (infoList) { + try { + final changedInfo = infoList.firstWhere( + (element) => element.userID == widget.conversationID, + ); + if (changedInfo.friendRemark != null && + changedInfo.friendRemark!.isNotEmpty) { + _conversationShowName = changedInfo.friendRemark!; + setState(() {}); + } else { + _conversationShowName = (changedInfo.userProfile?.nickName ?? + changedInfo.userProfile?.userID) ?? + ""; + } + // ignore: empty_catches + } catch (e) {} + }, + ); + if (_friendshipListener != null) { + _friendshipServices.addFriendListener(listener: _friendshipListener!); + } + } + + _addGroupListener() { + _groupListener = V2TimGroupListener( + onGroupInfoChanged: (groupID, changeInfos) { + try { + if (groupID == widget.conversationID) { + final groupNameChangeInfo = changeInfos.firstWhere((element) => + element.type == + GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_NAME); + if (groupNameChangeInfo.value != null) { + _conversationShowName = groupNameChangeInfo.value!; + setState(() {}); + } + } + // ignore: empty_catches + } catch (e) {} + }, + ); + if (_groupListener != null) { + _groupServices.addGroupListener(listener: _groupListener!); + } + } + + String _getTotalUnReadCount(int unreadCount) { + return unreadCount < 99 ? unreadCount.toString() : "99"; + } + + @override + void initState() { + super.initState(); + _conversationShowName = widget.conversationShowName; + if (!widget.isConversationShowNameFixed) { + _addConversationShowNameListener(); + _addGroupListener(); + } + } + + @override + void dispose() { + super.dispose(); + if (!widget.isConversationShowNameFixed) { + _groupServices.removeGroupListener(listener: _groupListener); + _friendshipServices.removeFriendListener(listener: _friendshipListener); + } + } + + @override + void didUpdateWidget(TIMUIKitAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.conversationShowName != widget.conversationShowName) { + if (widget.conversationShowName.isNotEmpty) { + setState(() { + _conversationShowName = widget.conversationShowName; + }); + } else { + updateTitleFuture(); + } + } + } + + void updateTitleFuture() async { + try { + final res = await _friendshipServices + .getFriendsInfo(userIDList: [widget.conversationID]); + if (res != null && res.isNotEmpty && res.first.resultCode == 0) { + setState(() { + _conversationShowName = res.first.friendInfo?.userProfile?.nickName ?? + res.first.friendInfo?.userProfile?.userID ?? + ""; + }); + } + // ignore: empty_catches + } catch (e) {} + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final setAppbar = widget.config; + final TUIChatSeparateViewModel chatVM = + Provider.of(context); + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return AppBar( + backgroundColor: setAppbar?.backgroundColor ?? + theme.chatHeaderBgColor ?? + theme.appbarBgColor ?? + theme.primaryColor, + actionsIconTheme: setAppbar?.actionsIconTheme, + foregroundColor: setAppbar?.foregroundColor, + elevation: setAppbar?.elevation ?? (isDesktopScreen ? 0 : 1), + bottom: setAppbar?.bottom, + bottomOpacity: setAppbar?.bottomOpacity ?? 1.0, + titleSpacing: setAppbar?.titleSpacing, + automaticallyImplyLeading: setAppbar?.automaticallyImplyLeading ?? false, + shadowColor: setAppbar?.shadowColor ?? theme.weakDividerColor, + excludeHeaderSemantics: setAppbar?.excludeHeaderSemantics ?? false, + toolbarHeight: setAppbar?.toolbarHeight, + titleTextStyle: setAppbar?.titleTextStyle, + toolbarOpacity: setAppbar?.toolbarOpacity ?? 1.0, + toolbarTextStyle: setAppbar?.toolbarTextStyle, + + // textTheme: setAppbar?.textTheme, + iconTheme: setAppbar?.iconTheme ?? + const IconThemeData( + color: Colors.white, + ), + title: TIMUIKitAppBarTitle( + title: setAppbar?.title, + onClick: widget.onClickTitle, + textStyle: TextStyle( + color: theme.appbarTextColor ?? hexToColor("010000"), fontSize: 16), + conversationShowName: _conversationShowName, + showC2cMessageEditStatus: widget.showC2cMessageEditStatus, + fromUser: widget.conversationID, + ), + centerTitle: setAppbar?.centerTitle ?? (isDesktopScreen ? false : true), + leadingWidth: setAppbar?.leadingWidth ?? (isDesktopScreen ? 8 : 70), + leading: Selector>( + builder: (context, data, _) { + final isMultiSelect = data.item1; + final unReadCount = data.item2; + return (!isDesktopScreen && isMultiSelect) + ? TextButton( + onPressed: () { + chatVM.updateMultiSelectStatus(false); + }, + child: Text( + TIM_t('取消'), + style: TextStyle( + color: theme.appbarTextColor ?? hexToColor("010000"), + fontSize: 16, + ), + ), + ) + : setAppbar?.leading ?? + (isDesktopScreen + ? Container() + : Row( + children: [ + IconButton( + padding: const EdgeInsets.only(left: 16), + constraints: const BoxConstraints(), + icon: Icon( + Icons.arrow_back_ios, + color: hexToColor("010000"), + size: 17, + ), + onPressed: () async { + chatVM.repliedMessage = null; + Navigator.pop(context); + }, + ), + if (widget.showTotalUnReadCount && + unReadCount > 0) + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.cautionColor, + ), + child: + Text(_getTotalUnReadCount(unReadCount)), + ), + ], + )); + }, + shouldRebuild: (prev, next) => + prev.item1 != next.item1 || prev.item2 != next.item2, + selector: (_, model) => + Tuple2(chatVM.isMultiSelect, model.totalUnReadCount)), + actions: setAppbar?.actions, + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar_title.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar_title.dart new file mode 100644 index 0000000..cb586ff --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitAppBar/tim_uikit_appbar_title.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; + +class TIMUIKitAppBarTitle extends StatelessWidget { + final Widget? title; + final String conversationShowName; + final bool showC2cMessageEditStatus; + final String fromUser; + final GestureTapDownCallback? onClick; + final TextStyle? textStyle; + + const TIMUIKitAppBarTitle( + {Key? key, + this.title, + this.textStyle, + required this.conversationShowName, + required this.showC2cMessageEditStatus, + required this.fromUser, this.onClick}) + : super(key: key); + + Widget titleText(String text){ + return InkWell( + onTapDown: onClick, + child: Text( + text, + style: textStyle ?? + const TextStyle( + color: Colors.white, + fontSize: 17, + ), + ), + ); + } + + // String conversationShowName; + @override + Widget build(BuildContext context) { + int status = Provider.of(context, listen: true) + .getC2cMessageEditStatus(fromUser); + if (status == 0) { + if (title != null) { + return title!; + } + return titleText(conversationShowName,); + } else { + if (showC2cMessageEditStatus) { + return titleText( + TIM_t("对方正在输入中..."),); + + } else { + if (title != null) { + return title!; + } + return titleText( + conversationShowName,); + + } + } + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/message_reaction_emoji.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/message_reaction_emoji.dart new file mode 100644 index 0000000..cba1731 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/message_reaction_emoji.dart @@ -0,0 +1,36 @@ +List> messageReactionEmojiData = [ + {"name": "red heart", "unicode": 10084}, + {"name": "OK", "unicode": 128076}, + {"name": "thumbs up", "unicode": 128077}, + {"name": "flexed biceps", "unicode": 128170}, + {"name": "waving hand", "unicode": 128079}, + {"name": "handshake", "unicode": 129309}, + {"name": "folded hands", "unicode": 128591}, + {"name": "raised fist", "unicode": 9994}, + {"name": "thumbs down", "unicode": 128078}, + {"name": "smiling face with sunglasses", "unicode": 128526}, + {"name": "GRINNING FACE WITH SMILING EYES", "unicode": 128513}, + {"name": "SMILING FACE WITH SMILING EYES", "unicode": 128522}, + {"name": "smiling face with hearts", "unicode": 129392}, + {"name": "SMILING FACE WITH HEART-SHAPED EYES", "unicode": 128525}, + {"name": "FACE WITH TEARS OF JOY", "unicode": 128514}, + {"name": "flushed face", "unicode": 128563}, + {"name": "SMILING FACE WITH OPEN MOUTH AND COLD SWEAT", "unicode": 128517}, + {"name": "FACE WITH COLD SWEAT", "unicode": 128531}, + {"name": "loudly crying face", "unicode": 128557}, + {"name": "CONFOUNDED FACE", "unicode": 128534}, + {"name": "KISSING FACE WITH CLOSED EYES", "unicode": 128538}, + { + "name": "FACE WITH STUCK-OUT TONGUE AND TIGHTLY-CLOSED EYES", + "unicode": 128541 + }, + {"name": "DISAPPOINTED FACE", "unicode": 128542}, + {"name": "ANGRY FACE", "unicode": 128544}, + {"name": "POUTING FACE", "unicode": 128545}, + {"name": "CRYING FACE", "unicode": 128546}, + {"name": "FACE WITH LOOK OF TRIUMPH", "unicode": 128548}, + {"name": "FEARFUL FACE", "unicode": 128552}, + {"name": "SLEEPY FACE", "unicode": 128554}, + {"name": "FACE WITH NO GOOD GESTURE", "unicode": 128581}, + {"name": "FACE WITH OK GESTURE", "unicode": 128582}, +]; diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_detail.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_detail.dart new file mode 100644 index 0000000..1250c0d --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_detail.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitMessageReactionDetail extends StatefulWidget { + /// the index of the current emoji sticker + final int currentStickerIndex; + + /// the list of member + final List? memberList; + + /// message reaction map + final Map messageReaction; + + /// the sticker list from message reaction + final List stickerList; + + final Function(String userID, TapDownDetails tapDetails)? onTapAvatar; + + const TIMUIKitMessageReactionDetail( + {required this.currentStickerIndex, + this.memberList, + required this.messageReaction, + Key? key, + required this.stickerList, + this.onTapAvatar}) + : super(key: key); + + @override + State createState() => TIMUIKitMessageReactionDetailState(); +} + +class TIMUIKitMessageReactionDetailState extends TIMUIKitState + with TickerProviderStateMixin { + final TUISelfInfoViewModel selfInfoModel = serviceLocator(); + + Widget getUserItem(String userID, TUITheme theme, Function(String userID, TapDownDetails tapDetails)? onTapAvatar) { + V2TimGroupMemberFullInfo? memberInfo; + String showName = userID; + try { + memberInfo = widget.memberList?.firstWhere((element) => element?.userID == userID); + if (memberInfo != null) { + if (memberInfo.friendRemark != null && memberInfo.friendRemark!.isNotEmpty) { + showName = memberInfo.friendRemark!; + } else if (memberInfo.nameCard != null && memberInfo.nameCard!.isNotEmpty) { + showName = memberInfo.nameCard!; + } else if (memberInfo.nickName != null && memberInfo.nickName!.isNotEmpty) { + showName = memberInfo.nickName!; + } else { + showName = memberInfo.userID; + } + } + } catch (e) { + // e + } + + return InkWell( + onTapDown: (tapDetails) { + if (onTapAvatar != null) { + if (userID != selfInfoModel.loginInfo?.userID) { + onTapAvatar(userID, tapDetails); + } + } + }, + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: 40, + width: 40, + child: (memberInfo?.faceUrl != null) + ? Avatar(faceUrl: memberInfo!.faceUrl!, showName: userID) + : Container(), + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 20, bottom: 20, right: 28), + child: Text( + showName, + style: TextStyle(color: theme.black, fontSize: 18), + ), + )), + ], + ), + ), + ); + } + + Widget stickerItem(int sticker, int length) { + return Container( + margin: const EdgeInsets.only(top: 12, bottom: 12), + padding: const EdgeInsets.only(left: 10, right: 10, top: 2, bottom: 1), + decoration: const BoxDecoration( + color: Color(0x198a8a8a), + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + String.fromCharCode(sticker), + style: const TextStyle( + fontSize: 16, + ), + ), + ), + Container( + margin: const EdgeInsets.only(left: 6), + child: Text(length.toString()), + ), + ], + ), + ); + } + + Widget getStickerNameList( + int sticker, TUITheme theme, Function(String userID, TapDownDetails tapDetails)? onTapAvatar) { + final nameList = widget.messageReaction[sticker.toString()]; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [...nameList.map((e) => getUserItem(e, theme, onTapAvatar))], + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + return DefaultTabController( + initialIndex: widget.currentStickerIndex, + length: widget.stickerList.length, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(bottom: 4), + child: TabBar( + isScrollable: true, + labelColor: theme.primaryColor, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + unselectedLabelColor: hexToColor("62626b"), + unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.normal), + indicatorSize: TabBarIndicatorSize.label, + indicatorColor: theme.primaryColor ?? hexToColor("62626b"), + tabs: [ + ...widget.stickerList.map((element) { + return stickerItem(element, widget.messageReaction[element.toString()].length); + }) + ], + ), + ), + Expanded( + child: TabBarView( + children: widget.stickerList + .map((int sticker) => getStickerNameList(sticker, theme, widget.onTapAvatar)) + .toList())) + ], + ), + )); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart new file mode 100644 index 0000000..9e09c6f --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_select_emoji.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/message_reaction_emoji.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_emoji_panel.dart' + as emoji; +import 'package:tencent_cloud_chat_uikit/ui/widgets/emoji.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/extended_wrap/extended_wrap.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +enum SelectEmojiPanelPosition { up, down } + +class TIMUIKitMessageReactionEmojiSelectPanel extends StatefulWidget { + final ValueChanged onSelect; + final bool isShowMoreSticker; + final ValueChanged onClickShowMore; + + const TIMUIKitMessageReactionEmojiSelectPanel( + {Key? key, + required this.onSelect, + required this.isShowMoreSticker, + required this.onClickShowMore}) + : super(key: key); + + @override + State createState() => + TIMUIKitMessageReactionEmojiSelectPanelState(); +} + +class TIMUIKitMessageReactionEmojiSelectPanelState + extends TIMUIKitState { + bool isShowMore = false; + + _buildSimplePanel(TUITheme theme) { + final List> emojiData = messageReactionEmojiData; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + return Material( + color: Colors.white, + child: ExtendedWrap( + maxLines: widget.isShowMoreSticker ? 5 : 1, + spacing: 18, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 24, + children: [ + if(!isDesktopScreen) + GestureDetector( + onTap: () { + widget.onClickShowMore(!widget.isShowMoreSticker); + }, + child: SizedBox( + height: 34, + child: Icon( + widget.isShowMoreSticker + ? Icons.cancel_outlined + : Icons.add_circle_outline_outlined, + color: hexToColor("444444"), + size: 26), + ), + ), + ...emojiData.map( + (e) { + var item = Emoji.fromJson(e); + return SizedBox( + // width: 50, + child: InkWell( + splashColor: Colors.white, + onTap: () { + widget.onSelect(item.unicode); + }, + child: emoji.EmojiItem( + name: item.name, + unicode: item.unicode, + ), + ), + ); + }, + ).toList() + ], + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Container( + child: _buildSimplePanel(theme), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_item.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_item.dart new file mode 100644 index 0000000..5d60a98 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_item.dart @@ -0,0 +1,198 @@ +// ignore_for_file: unused_field + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/extended_wrap/extended_wrap.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitMessageReactionShowItem extends TIMUIKitStatelessWidget { + /// the unicode of the emoji + final int sticker; + + /// the list contains the name who choose the current emoji + final List nameList; + + /// current message + final V2TimMessage message; + + /// show the details of message reaction + final Function(int sticker) onShowDetail; + + /// the member in current chat + final List memberList; + + TIMUIKitMessageReactionShowItem( + {required this.message, + required this.sticker, + required this.memberList, + required this.onShowDetail, + required this.nameList, + Key? key}) + : super(key: key); + + final TUISelfInfoViewModel selfInfoModel = serviceLocator(); + final MessageService _messageService = serviceLocator(); + + clickOnCurrentSticker() async { + for (int i = 0; i < 5; i++) { + final res = await modifySticker(); + if (res.code == 0) { + break; + } + } + } + + Future> modifySticker() async { + return await Future.delayed(const Duration(milliseconds: 50), () async { + return await MessageReactionUtils.clickOnSticker(message, sticker); + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final option1 = nameList.length; + final TUIChatSeparateViewModel model = Provider.of(context); + + final List userIDs = []; + for (final user in nameList) { + final V2TimGroupMemberFullInfo? memberInfo = + memberList.firstWhereOrNull((element) => element?.userID == user && TencentUtils.checkString(user) != null); + if ((memberInfo == null || TencentUtils.checkString(memberInfo.userID) == null) && + TencentUtils.checkString(user.toString()) != null) { + userIDs.add(user.toString()); + } + } + if (userIDs.isNotEmpty) { + model.getUserShowName(userIDs); + } + + return LayoutBuilder(builder: (context, constraints) { + return Container( + padding: const EdgeInsets.only( + left: 10, + right: 10, + ), + decoration: const BoxDecoration( + color: Color(0x19727271), + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + InkWell( + onTap: clickOnCurrentSticker, + child: Container( + margin: + EdgeInsets.only(bottom: (!PlatformUtils().isIOS) ? 4 : 2, top: (!PlatformUtils().isIOS) ? 4 : 0), + child: Text( + String.fromCharCode(sticker), + style: TextStyle(fontSize: (!PlatformUtils().isIOS) ? 12 : 16, color: hexToColor("f9453d")), + ), + ), + ), + Container( + margin: const EdgeInsets.only(left: 6, right: 6), + child: SizedBox( + width: 1, + height: 14, + child: DecoratedBox( + decoration: BoxDecoration(color: theme.weakTextColor), + ), + ), + ), + Container( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * 0.8, + ), + child: ExtendedWrap( + maxLines: 1, + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + overflowWidget: GestureDetector( + onTap: () { + onShowDetail(sticker); + }, + child: Text( + TIM_t_para("...共{{option1}}人", "...共$option1人")(option1: option1), + style: TextStyle(fontSize: 12, color: hexToColor("616669")), + ), + ), + children: [ + ...nameList.map((e) { + String showName = e; + if (memberList.isNotEmpty) { + try { + final V2TimGroupMemberFullInfo? memberInfo = + memberList.firstWhere((element) => element?.userID == e); + if (memberInfo != null) { + if (memberInfo.friendRemark != null && memberInfo.friendRemark!.isNotEmpty) { + showName = memberInfo.friendRemark!; + } else if (memberInfo.nameCard != null && memberInfo.nameCard!.isNotEmpty) { + showName = memberInfo.nameCard!; + } else if (memberInfo.nickName != null && memberInfo.nickName!.isNotEmpty) { + showName = memberInfo.nickName!; + } else { + showName = memberInfo.userID; + } + } else { + final String? data = model.groupUserShowName[e]; + if (TencentUtils.checkString(data) != null) { + showName = data ?? e; + } + } + } catch (error) { + final String? data = model.groupUserShowName[e]; + if (TencentUtils.checkString(data) != null) { + showName = data ?? e; + } + } + } else { + final String? data = model.groupUserShowName[e]; + if (TencentUtils.checkString(data) != null) { + showName = data ?? e; + } + } + return InkWell( + onTapDown: (tapDetails) { + if (model.onTapAvatar != null) { + if (e != selfInfoModel.loginInfo?.userID) { + model.onTapAvatar!(e, tapDetails); + } + } + }, + child: Text( + showName, + style: TextStyle(fontSize: 12, color: hexToColor("616669")), + ), + ); + }) + ], + ), + ) + ], + ), + ); + }); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart new file mode 100644 index 0000000..e7154f2 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_detail.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; + +class TIMUIKitMessageReactionShowPanel extends TIMUIKitStatelessWidget { + /// current message + final V2TimMessage message; + + TIMUIKitMessageReactionShowPanel({required this.message, Key? key}) : super(key: key); + + final TUISelfInfoViewModel selfInfoModel = serviceLocator(); + + void showMore(BuildContext context, List? memberList, Map messageReaction, + int currentSticker, List stickerList, TUIChatSeparateViewModel model) async { + _showCustomModalBottomSheet(context, memberList, messageReaction, currentSticker, stickerList, model); + } + + Future> _showCustomModalBottomSheet( + context, + List? memberList, + Map messageReaction, + int currentSticker, + List stickerList, + TUIChatSeparateViewModel model) async { + return showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20.0), + topRight: Radius.circular(20.0), + ), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.66, + minHeight: MediaQuery.of(context).size.height * 0.2, + ), + child: Column(children: [ + SizedBox( + height: 50, + child: Stack( + textDirection: TextDirection.rtl, + children: [ + Center( + child: Text( + TIM_t("回应详情"), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }), + ], + ), + ), + const Divider(height: 1.0), + Expanded( + child: TIMUIKitMessageReactionDetail( + onTapAvatar: model.onTapAvatar, + stickerList: stickerList, + currentStickerIndex: stickerList.indexWhere((element) => element == currentSticker), + memberList: memberList, + messageReaction: messageReaction, + )), + ]), + ); + }, + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + Map messageReaction = {}; + CloudCustomData messageCloudCustomData = MessageReactionUtils.getCloudCustomData(message); + final TUIChatSeparateViewModel model = Provider.of(context); + if (messageCloudCustomData.messageReaction != null && messageCloudCustomData.messageReaction!.isNotEmpty) { + messageReaction = messageCloudCustomData.messageReaction!; + } else { + return const SizedBox(width: 0, height: 0); + } + + final List messageReactionStickerList = []; + + messageReaction.forEach((key, value) { + messageReactionStickerList.add(int.parse(key)); + }); + + final filteredMessageReactionStickerList = messageReactionStickerList.where((sticker) { + if (messageReaction[sticker.toString()] == null || + messageReaction[sticker.toString()] is! List || + messageReaction[sticker.toString()].length == 0) { + return false; + } + return true; + }).toList(); + + final ConvType convType = model.conversationType ?? ConvType.c2c; + List memberList = []; + if (convType == ConvType.group) { + memberList = model.groupMemberList ?? []; + } else { + final V2TimGroupMemberFullInfo selfInfo = V2TimGroupMemberFullInfo( + userID: selfInfoModel.loginInfo?.userID ?? "", + nickName: selfInfoModel.loginInfo?.nickName, + faceUrl: selfInfoModel.loginInfo?.faceUrl, + ); + + final V2TimGroupMemberFullInfo targetInfo = V2TimGroupMemberFullInfo( + userID: model.conversationID, + ); + memberList = [selfInfo, model.currentChatUserInfo ?? targetInfo]; + } + + return filteredMessageReactionStickerList.isNotEmpty + ? Container( + margin: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: 8, + runSpacing: (!PlatformUtils().isIOS) ? 12 : 8, + children: [ + ...filteredMessageReactionStickerList.map((sticker) { + return TIMUIKitMessageReactionShowItem( + memberList: memberList, + message: message, + nameList: messageReaction[sticker.toString()], + sticker: sticker, + onShowDetail: (int sticker) { + showMore( + context, memberList, messageReaction, sticker, filteredMessageReactionStickerList, model); + }); + }).toList(), + ], + ), + ) + : const SizedBox(width: 0, height: 0); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart new file mode 100644 index 0000000..efa69c6 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_utils.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_change_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; + +class MessageReactionUtils { + static final TUISelfInfoViewModel selfInfoModel = serviceLocator(); + static final MessageService _messageService = serviceLocator(); + + static CloudCustomData getCloudCustomData(V2TimMessage message) { + CloudCustomData messageCloudCustomData; + try { + messageCloudCustomData = CloudCustomData.fromJson( + json.decode(TencentUtils.checkString(message.cloudCustomData) != null ? message.cloudCustomData! : "{}")); + } catch (e) { + messageCloudCustomData = CloudCustomData(); + } + + return messageCloudCustomData; + } + + static Map getMessageReaction(V2TimMessage message) { + return getCloudCustomData(message).messageReaction ?? {}; + } + + static Future> clickOnSticker(V2TimMessage message, int sticker) async { + final CloudCustomData messageCloudCustomData = getCloudCustomData(message); + final Map messageReaction = messageCloudCustomData.messageReaction ?? {}; + List targetList = messageReaction["$sticker"] ?? []; + if (targetList.contains(selfInfoModel.loginInfo!.userID!)) { + targetList.remove(selfInfoModel.loginInfo!.userID!); + } else { + targetList = [selfInfoModel.loginInfo!.userID!, ...targetList]; + } + messageReaction["$sticker"] = targetList; + + if (PlatformUtils().isWeb) { + final decodedMessage = jsonDecode(message.messageFromWeb!); + decodedMessage["cloudCustomData"] = jsonEncode(messageCloudCustomData.toMap()); + message.messageFromWeb = jsonEncode(decodedMessage); + } else { + message.cloudCustomData = json.encode(messageCloudCustomData.toMap()); + } + return await _messageService.modifyMessage(message: message); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart new file mode 100644 index 0000000..c0efb06 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; + +class TIMUIKitMessageReactionWrapper extends StatefulWidget { + final Widget child; + final V2TimMessage message; + final Color? backgroundColor; + final bool isFromSelf; + final BorderRadius? borderRadius; + final bool isShowJump; + final VoidCallback? clearJump; + final bool isShowMessageReaction; + final TUIChatSeparateViewModel chatModel; + + const TIMUIKitMessageReactionWrapper( + {Key? key, + required this.isShowJump, + this.clearJump, + required this.isFromSelf, + this.backgroundColor, + required this.chatModel, + required this.message, + this.borderRadius, + required this.child, + this.isShowMessageReaction = true}) + : super(key: key); + + @override + State createState() => _TIMUIKitMessageReactionWrapperState(); +} + +class _TIMUIKitMessageReactionWrapperState extends TIMUIKitState { + bool isShowJumpState = false; + bool isShining = false; + bool isShowBorder = false; + + _showJumpColor() { + if ((widget.chatModel.jumpMsgID != widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? true)) { + return; + } + isShining = true; + int shineAmount = 6; + setState(() { + isShowJumpState = true; + isShowBorder = true; + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + isShowBorder = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + isShining = false; + timer.cancel(); + } + shineAmount--; + }); + if (widget.clearJump != null) { + widget.clearJump!(); + } + } + + bool isHaveMessageReaction() { + Map messageReaction = {}; + CloudCustomData messageCloudCustomData; + try { + messageCloudCustomData = CloudCustomData.fromJson(json.decode( + TencentUtils.checkString(widget.message.cloudCustomData) != null ? widget.message.cloudCustomData! : "{}")); + } catch (e) { + messageCloudCustomData = CloudCustomData(); + } + + if (messageCloudCustomData.messageReaction != null && messageCloudCustomData.messageReaction!.isNotEmpty) { + messageReaction = messageCloudCustomData.messageReaction!; + } else { + return false; + } + + final List messageReactionStickerList = []; + + messageReaction.forEach((key, value) { + messageReactionStickerList.add(int.parse(key)); + }); + + final filteredMessageReactionStickerList = messageReactionStickerList.where((sticker) { + if (messageReaction[sticker.toString()] == null || + messageReaction[sticker.toString()] is! List || + messageReaction[sticker.toString()].length == 0) { + return false; + } + return true; + }); + + if (filteredMessageReactionStickerList.isEmpty) { + return false; + } + return true; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final borderRadius = widget.isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + + if (widget.isShowJump) { + if (!isShining) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } else { + if ((widget.chatModel.jumpMsgID == widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? false)) { + if (widget.clearJump != null) { + widget.clearJump!(); + } + } + } + } + + final defaultStyle = widget.isFromSelf ? theme.lightPrimaryMaterialColor.shade50 : theme.weakBackgroundColor; + final backgroundColor = + isShowJumpState ? const Color.fromRGBO(245, 166, 35, 1) : (widget.backgroundColor ?? defaultStyle); + + if (!widget.isShowMessageReaction || !isHaveMessageReaction()) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + border: Border.all(color: Color.fromRGBO(245, 166, 35, (isShowBorder ? 1 : 0)), width: 2)), + child: widget.child, + ); + } + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: widget.borderRadius ?? borderRadius, + ), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + border: Border.all(color: Color.fromRGBO(245, 166, 35, (isShowBorder ? 1 : 0)), width: 2)), + child: widget.child, + ), + if (widget.isShowMessageReaction) TIMUIKitMessageReactionShowPanel(message: widget.message) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart new file mode 100644 index 0000000..f19041e --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart @@ -0,0 +1,8 @@ +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_group_tips_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart'; +export 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_reply_elem.dart'; diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_custom_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_custom_elem.dart new file mode 100644 index 0000000..97bd4d7 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_custom_elem.dart @@ -0,0 +1,60 @@ +// ignore_for_file: unrelated_type_equality_checks + +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_custom_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_custom_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; + +class TIMUIKitCustomElem extends TIMUIKitStatelessWidget { + final V2TimCustomElem? customElem; + final bool isFromSelf; + final TextStyle? messageFontStyle; + final BorderRadius? messageBorderRadius; + final Color? messageBackgroundColor; + final EdgeInsetsGeometry? textPadding; + final V2TimMessage message; + final bool? isShowMessageReaction; + + TIMUIKitCustomElem({ + Key? key, + required this.message, + this.isShowMessageReaction, + this.customElem, + this.isFromSelf = false, + this.messageFontStyle, + this.messageBorderRadius, + this.messageBackgroundColor, + this.textPadding, + }) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final borderRadius = isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + final backgroundColor = isFromSelf ? theme.lightPrimaryMaterialColor.shade50 : theme.weakBackgroundColor; + return Container( + padding: textPadding ?? const EdgeInsets.all(10), + decoration: BoxDecoration( + color: messageBackgroundColor ?? backgroundColor, + borderRadius: messageBorderRadius ?? borderRadius, + ), + constraints: const BoxConstraints(maxWidth: 240), + child: Column( + children: [Text(TIM_t("自定义消息"))], + )); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart new file mode 100644 index 0000000..1965eb4 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart'; + +class TIMUIKitFaceElem extends StatefulWidget { + final String path; + final bool isShowJump; + final VoidCallback? clearJump; + final V2TimMessage message; + final bool? isShowMessageReaction; + final TUIChatSeparateViewModel model; + + const TIMUIKitFaceElem( + {Key? key, + required this.path, + required this.isShowJump, + this.clearJump, + required this.message, + this.isShowMessageReaction, + required this.model}) + : super(key: key); + + @override + State createState() => _TIMUIKitTextElemState(); +} + +class _TIMUIKitTextElemState extends TIMUIKitState { + bool isFromNetwork() { + return widget.path.startsWith('http'); + } + + createPathFromNative(String path) { + String prefix = ""; + String suffix = ""; + if (widget.model.chatConfig.faceURIPrefix != null) { + prefix = widget.model.chatConfig.faceURIPrefix!(path); + } + if (widget.model.chatConfig.faceURISuffix != null) { + suffix = widget.model.chatConfig.faceURISuffix!(path); + } + return "$prefix$path$suffix"; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return TIMUIKitMessageReactionWrapper( + chatModel: widget.model, + isShowJump: widget.isShowJump, + isFromSelf: widget.message.isSelf ?? true, + clearJump: widget.clearJump, + message: widget.message, + isShowMessageReaction: widget.isShowMessageReaction ?? true, + child: Container( + padding: const EdgeInsets.all(10), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * (isDesktopScreen ? 0.1 : 0.3)), + child: isFromNetwork() ? Image.network(widget.path) : Image.asset(createPathFromNative(widget.path)), + )); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart new file mode 100644 index 0000000..cf3a154 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart @@ -0,0 +1,472 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; +import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:open_file/open_file.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_file_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_file_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_download_progress.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_download_progress.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/textSize.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:url_launcher/url_launcher.dart'; + +class TIMUIKitFileElem extends StatefulWidget { + final String? messageID; + final V2TimFileElem? fileElem; + final bool isSelf; + final bool isShowJump; + final VoidCallback? clearJump; + final V2TimMessage message; + final bool? isShowMessageReaction; + final TUIChatSeparateViewModel chatModel; + + const TIMUIKitFileElem( + {Key? key, + required this.chatModel, + required this.messageID, + required this.fileElem, + required this.isSelf, + required this.isShowJump, + this.clearJump, + required this.message, + this.isShowMessageReaction}) + : super(key: key); + + @override + State createState() => _TIMUIKitFileElemState(); +} + +class _TIMUIKitFileElemState extends TIMUIKitState { + String filePath = ""; + bool isWebDownloading = false; + final TUIChatGlobalModel model = serviceLocator(); + int downloadProgress = 0; + V2TimAdvancedMsgListener? advancedMsgListener; + final GlobalKey containerKey = GlobalKey(); + double? containerHeight; + bool? _downloadFailed = false; + + @override + void dispose() { + if (advancedMsgListener != null) { + TencentImSDKPlugin.v2TIMManager.getMessageManager().removeAdvancedMsgListener(listener: advancedMsgListener); + advancedMsgListener = null; + } + super.dispose(); + } + + @override + void initState() { + super.initState(); + if (!PlatformUtils().isWeb) { + Future.delayed(const Duration(microseconds: 10), () { + hasFile(); + }); + } + } + + Future addAdvancedMsgListenerForDownload() async { + if (advancedMsgListener != null) { + return false; + } + advancedMsgListener = V2TimAdvancedMsgListener( + onMessageDownloadProgressCallback: (V2TimMessageDownloadProgress messageProgress) async { + if (messageProgress.msgID == widget.message.msgID) { + if (messageProgress.isError || messageProgress.errorCode != 0) { + setState(() { + _downloadFailed = true; + }); + return; + } + + if (messageProgress.isFinish) { + if (mounted) { + setState(() { + downloadProgress = 100; + }); + + if (advancedMsgListener != null) { + TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .removeAdvancedMsgListener(listener: advancedMsgListener); + advancedMsgListener = null; + } + } + } else { + final currentProgress = (messageProgress.currentSize / messageProgress.totalSize * 100).floor(); + if (mounted && currentProgress > downloadProgress) { + setState(() { + downloadProgress = currentProgress; + }); + } + } + } + }, + ); + await TencentImSDKPlugin.v2TIMManager.getMessageManager().addAdvancedMsgListener(listener: advancedMsgListener!); + return true; + } + + Future getSavePath() async { + String savePathWithAppPath = '/storage/emulated/0/Android/data/com.tencent.flutter.tuikit/cache/' + + (widget.message.msgID ?? "") + + widget.fileElem!.fileName!; + return savePathWithAppPath; + } + + Future hasFile() async { + if (PlatformUtils().isWeb) { + return true; + } + String savePath = TencentUtils.checkString(model.getFileMessageLocation(widget.messageID)) ?? + TencentUtils.checkString(widget.message.fileElem!.localUrl) ?? + widget.message.fileElem?.path ?? + ''; + + File f = File(savePath); + if (widget.messageID != null) { + if (f.existsSync()) { + filePath = savePath; + if (downloadProgress != 100) { + setState(() { + downloadProgress = 100; + }); + } + if (model.getMessageProgress(widget.messageID) != 100) { + model.setMessageProgress(widget.messageID!, 100); + } + if (advancedMsgListener != null) { + TencentImSDKPlugin.v2TIMManager.getMessageManager().removeAdvancedMsgListener(listener: advancedMsgListener); + advancedMsgListener = null; + } + return true; + } else { + model.setMessageProgress(widget.messageID!, 0); + } + } + + return false; + } + + String showFileSize(int fileSize) { + if (fileSize < 1024) { + return fileSize.toString() + "B"; + } else if (fileSize < 1024 * 1024) { + return (fileSize / 1024).toStringAsFixed(2) + "KB"; + } else if (fileSize < 1024 * 1024 * 1024) { + return (fileSize / 1024 / 1024).toStringAsFixed(2) + "MB"; + } else { + return (fileSize / 1024 / 1024 / 1024).toStringAsFixed(2) + "GB"; + } + } + + addUrlToWaitingPath(TUITheme theme) async { + if (widget.messageID != null) { + model.addWaitingList(widget.messageID!); + } + if (model.getWaitingListLength() == 1) { + await downloadFile(theme); + } + } + + checkIsWaiting() { + bool res = false; + try { + if (widget.messageID!.isNotEmpty) { + res = model.isWaiting(widget.messageID!); + } + } catch (err) { + // err + } + return res; + } + + downloadFile(TUITheme theme) async { + if (PlatformUtils().isMobile) { + if (PlatformUtils().isIOS) { + if (!await Permissions.checkPermission(context, Permission.photosAddOnly.value, theme, false)) { + return; + } + } else { + final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + if ((androidInfo.version.sdkInt) >= 33) { + } else { + var storage = await Permissions.checkPermission( + context, + Permission.storage.value, + ); + if (!storage) { + return; + } + } + } + } + await model.downloadFile(); + } + + Future hasZeroSize(String filePath) async { + try { + final file = File(filePath); + final fileSize = await file.length(); + return fileSize == 0; + } catch (e) { + return false; + } + } + + tryOpenFile(context, theme) async { + if (!PlatformUtils().isWeb && (await hasZeroSize(filePath) || widget.message.status == 3)) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: "不支持 0KB 文件的传输", infoCode: 6660417)); + return; + } + if (PlatformUtils().isMobile) { + if (PlatformUtils().isIOS) { + if (!await Permissions.checkPermission(context, Permission.photosAddOnly.value, theme!, false)) { + return; + } + } else { + final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + if ((androidInfo.version.sdkInt) >= 33) { + } else { + var storage = await Permissions.checkPermission( + context, + Permission.storage.value, + ); + if (!storage) { + return; + } + } + } + } + + try { + if (PlatformUtils().isDesktop && !PlatformUtils().isWindows) { + launchUrl(Uri.file(filePath)); + } else { + OpenFile.open(filePath); + } + // ignore: empty_catches + } catch (e) { + OpenFile.open(filePath); + } + } + + void downloadWebFile(String fileUrl) async { + if (mounted) { + setState(() { + isWebDownloading = true; + }); + } + String fileName = Uri.parse(fileUrl).pathSegments.last; + try { + http.Response response = await http.get( + Uri.parse(fileUrl), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + ); + + final html.AnchorElement downloadAnchor = html.document.createElement('a') as html.AnchorElement; + + final html.Blob blob = html.Blob([response.bodyBytes]); + + downloadAnchor.href = html.Url.createObjectUrlFromBlob(blob); + downloadAnchor.download = widget.message.fileElem?.fileName ?? fileName; + + downloadAnchor.click(); + } catch (e) { + html.AnchorElement( + href: widget.fileElem?.path ?? "", + ) + ..setAttribute("download", widget.message.fileElem?.fileName ?? fileName) + ..setAttribute("target", '_blank') + ..style.display = "none" + ..click(); + } + if (mounted) { + setState(() { + isWebDownloading = false; + }); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final received = downloadProgress; + final fileName = widget.fileElem!.fileName ?? ""; + final fileSize = widget.fileElem!.fileSize; + final borderRadius = widget.isSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + String? fileFormat; + if (widget.fileElem?.fileName != null && widget.fileElem!.fileName!.isNotEmpty) { + final String fileName = widget.fileElem!.fileName!; + fileFormat = fileName.split(".")[max(fileName.split(".").length - 1, 0)]; + } + final RenderBox? containerRenderBox = containerKey.currentContext?.findRenderObject() as RenderBox?; + if (containerRenderBox != null) { + containerHeight = containerRenderBox.size.height; + } + + return Row( + key: containerKey, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isSelf && isWebDownloading) + Container( + margin: const EdgeInsets.only(top: 2), + child: LoadingAnimationWidget.threeArchedCircle( + color: theme.weakTextColor ?? Colors.grey, + size: 20, + ), + ), + TIMUIKitMessageReactionWrapper( + chatModel: widget.chatModel, + isShowJump: widget.isShowJump, + clearJump: widget.clearJump, + isFromSelf: widget.message.isSelf ?? true, + isShowMessageReaction: widget.isShowMessageReaction ?? true, + message: widget.message, + child: GestureDetector( + onTap: () async { + try { + if (PlatformUtils().isWeb) { + if (!isWebDownloading) { + downloadWebFile(widget.fileElem?.path ?? ""); + } + return; + } + + await addAdvancedMsgListenerForDownload(); + if (await hasFile()) { + if (received == 100) { + tryOpenFile(context, theme); + } else { + onTIMCallback( + TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("正在下载中"), + infoCode: 6660411, + ), + ); + } + return; + } + if (checkIsWaiting()) { + onTIMCallback( + TIMCallback( + type: TIMCallbackType.INFO, infoRecommendText: TIM_t("已加入待下载队列,其他文件下载中"), infoCode: 6660413), + ); + return; + } else { + await addUrlToWaitingPath(theme); + } + } catch (e) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: "文件处理异常", infoCode: 6660416)); + } + }, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 72), + child: Container( + width: 170, + decoration: BoxDecoration( + border: Border.all( + color: theme.weakDividerColor ?? CommonColor.weakDividerColor, + ), + borderRadius: borderRadius), + child: Stack(children: [ + ClipRRect( + borderRadius: borderRadius, + child: LinearProgressIndicator( + minHeight: ((containerHeight) ?? 72) - 6, + value: (received == 100 ? 0 : received) / 100, + backgroundColor: received == 100 ? theme.weakBackgroundColor : Colors.white, + valueColor: AlwaysStoppedAnimation(theme.lightPrimaryMaterialColor.shade50), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Row( + mainAxisAlignment: widget.isSelf ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 160), + child: LayoutBuilder( + builder: (buildContext, boxConstraints) { + return CustomText( + fileName, + width: boxConstraints.maxWidth, + maxLines: 1, + style: TextStyle( + color: theme.darkTextColor, + fontSize: 16, + ), + ); + }, + ), + ), + if (fileSize != null) + Text( + showFileSize(fileSize), + style: TextStyle(fontSize: 14, color: theme.weakTextColor), + ) + ], + )), + TIMUIKitFileIcon( + fileFormat: fileFormat, + ), + ])), + ]), + ), + ), + )), + if (!widget.isSelf && isWebDownloading) + Container( + margin: const EdgeInsets.only(top: 2), + child: LoadingAnimationWidget.threeArchedCircle( + color: theme.weakTextColor ?? Colors.grey, + size: 20, + ), + ), + ], + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart new file mode 100644 index 0000000..e2853cb --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart @@ -0,0 +1,60 @@ +// ignore_for_file: must_be_immutable + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; + +class TIMUIKitFileIcon extends TIMUIKitStatelessWidget { + final String? fileFormat; + final double? size; + + TIMUIKitFileIcon( {this.size, this.fileFormat, Key? key}) : super(key: key); + + Map fileMap = { + "doc": "images/word.png", + "docx": "images/word.png", + "ppt": "images/ppt.png", + "pptx": "images/ppt.png", + "xls": "images/excel.png", + "xlsx": "images/excel.png", + "pdf": "images/pdf.png", + "zip": "images/zip.png", + "rar": "images/zip.png", + "7z": "images/zip.png", + "tar": "images/zip.png", + "gz": "images/zip.png", + "xz": "images/zip.png", + "bz2": "images/zip.png", + "txt": "images/txt.png", + "jpg": "images/image_icon.png", + "bmp": "images/image_icon.png", + "gif": "images/image_icon.png", + "png": "images/image_icon.png", + "jpeg": "images/image_icon.png", + "tif": "images/image_icon.png", + "wmf": "images/image_icon.png", + "dib": "images/image_icon.png", + "mp4": "images/video_icon.png", + "avi": "images/video_icon.png", + "mov": "images/video_icon.png", + "wmv": "images/video_icon.png", + "flv": "images/video_icon.png", + }; + + Widget _getFileIcon() { + return Image.asset( + fileMap[fileFormat?.toLowerCase()] ?? "images/unknown.png", + package: 'tencent_cloud_chat_uikit', + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return SizedBox( + height: size ?? 50, + width: size ?? 50, + child: Container(padding: const EdgeInsets.all(4), child: _getFileIcon()), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_group_tips_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_group_tips_elem.dart new file mode 100644 index 0000000..472942f --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_group_tips_elem.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_tips_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_tips_elem.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitGroupTipsElem extends StatefulWidget { + final V2TimGroupTipsElem groupTipsElem; + final List groupMemberList; + + const TIMUIKitGroupTipsElem({Key? key, required this.groupMemberList, required this.groupTipsElem}) : super(key: key); + + @override + State createState() => _TIMUIKitGroupTipsElemState(); +} + +class _TIMUIKitGroupTipsElemState extends TIMUIKitState { + String groupTipsAbstractText = ""; + + @override + void initState() { + super.initState(); + getText(); + } + + void getText() async { + final newText = await MessageUtils.groupTipsMessageAbstract(widget.groupTipsElem, widget.groupMemberList); + setState(() { + groupTipsAbstractText = newText; + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return MessageUtils.wrapMessageTips( + Text( + groupTipsAbstractText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: hexToColor("888888")), + ), + theme); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart new file mode 100644 index 0000000..9daa055 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart @@ -0,0 +1,638 @@ +// ignore_for_file: prefer_typing_uninitialized_variables, unused_import + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_image.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_image.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/image_screen.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:transparent_image/transparent_image.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:url_launcher/url_launcher.dart'; + +class TIMUIKitImageElem extends StatefulWidget { + final V2TimMessage message; + final bool isShowJump; + final VoidCallback? clearJump; + final String? isFrom; + final bool? isShowMessageReaction; + final TUIChatSeparateViewModel chatModel; + + const TIMUIKitImageElem( + {required this.message, + this.isShowJump = false, + required this.chatModel, + this.clearJump, + this.isFrom, + Key? key, + this.isShowMessageReaction}) + : super(key: key); + + @override + State createState() => _TIMUIKitImageElem(); +} + +class _TIMUIKitImageElem extends TIMUIKitState { + final TUIChatGlobalModel globalModel = serviceLocator(); + final TUIChatGlobalModel model = serviceLocator(); + final MessageService _messageService = serviceLocator(); + Widget? imageItem; + + @override + didUpdateWidget(oldWidget) { + super.didUpdateWidget(oldWidget); + } + + String getOriginImgURL() { + // 实际拿的是原图 + V2TimImage? img = + MessageUtils.getImageFromImgList(widget.message.imageElem!.imageList, HistoryMessageDartConstant.oriImgPrior); + return img == null ? widget.message.imageElem!.path! : img.url!; + } + + Widget errorDisplay(BuildContext context, TUITheme? theme) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + border: Border.all( + width: 2, + color: theme?.weakDividerColor ?? Colors.grey, + )), + height: 170, + width: 170, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimationWidget.staggeredDotsWave( + color: theme?.weakTextColor ?? Colors.grey, + size: 28, + ) + ], + ), + ), + ); + } + + Widget getImage(image, {imageElem}) { + Widget res = ClipRRect( + clipper: ImageClipper(), + child: image, + ); + + return res; + } + + //保存网络图片到本地 + Future _saveImageToLocal( + context, + String imageUrl, { + bool isLocalResource = true, + TUITheme? theme, + }) async { + if (PlatformUtils().isWeb) { + download(imageUrl) async { + final http.Response r = await http.get(Uri.parse(imageUrl)); + final data = r.bodyBytes; + final base64data = base64Encode(data); + final a = html.AnchorElement(href: 'data:image/jpeg;base64,$base64data'); + a.download = md5.convert(utf8.encode(imageUrl)).toString(); + a.click(); + a.remove(); + } + + download(imageUrl); + return; + } + + if (PlatformUtils().isIOS) { + if (!await Permissions.checkPermission(context, Permission.photosAddOnly.value, theme!, false)) { + return; + } + } else { + final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + if (PlatformUtils().isMobile) { + if ((androidInfo.version.sdkInt) >= 33) { + final photos = await Permissions.checkPermission( + context, + Permission.photos.value, + theme, + ); + if (!photos) { + return; + } + } else { + final storage = await Permissions.checkPermission( + context, + Permission.storage.value, + ); + if (!storage) { + return; + } + } + } + } + + if (!isLocalResource) { + if (widget.message.msgID == null || widget.message.msgID!.isEmpty) { + return; + } + + if (model.getMessageProgress(widget.message.msgID) == 100) { + String savePath; + if (widget.message.imageElem!.path != null && + widget.message.imageElem!.path != '' && + File(widget.message.imageElem!.path!).existsSync()) { + savePath = widget.message.imageElem!.path!; + } else { + savePath = model.getFileMessageLocation(widget.message.msgID); + } + File f = File(savePath); + if (f.existsSync()) { + var result = await ImageGallerySaverPlus.saveFile(savePath); + + if (PlatformUtils().isIOS) { + if (result['isSuccess']) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存成功"), infoCode: 6660406)); + } else { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存失败"), infoCode: 6660407)); + } + } else { + if (result != null) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存成功"), infoCode: 6660406)); + } else { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存失败"), infoCode: 6660407)); + } + } + return; + } + } else { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, infoRecommendText: TIM_t("the message is downloading"), infoCode: -1)); + } + return; + } + + var result = await ImageGallerySaverPlus.saveFile(imageUrl); + + if (PlatformUtils().isIOS) { + if (result['isSuccess']) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存成功"), infoCode: 6660406)); + } else { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存失败"), infoCode: 6660407)); + } + } else { + if (result != null) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存成功"), infoCode: 6660406)); + } else { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("图片保存失败"), infoCode: 6660407)); + } + } + return; + } + + Future _saveImg(TUITheme theme) async { + try { + String? imageUrl; + bool isAssetBool = false; + final imageElem = widget.message.imageElem; + + if (imageElem != null) { + final originUrl = getOriginImgURL(); + final localUrl = imageElem.imageList?.firstOrNull?.localUrl; + final filePath = imageElem.path; + final isWeb = PlatformUtils().isWeb; + + if (!isWeb && filePath != null && File(filePath).existsSync()) { + imageUrl = filePath; + isAssetBool = true; + } else if (localUrl != null && (!isWeb && File(localUrl).existsSync())) { + imageUrl = localUrl; + isAssetBool = true; + } else { + imageUrl = originUrl; + isAssetBool = false; + } + } + + if (imageUrl != null) { + return await _saveImageToLocal( + context, + imageUrl, + isLocalResource: isAssetBool, + theme: theme, + ); + } + } catch (e) { + onTIMCallback(TIMCallback(infoCode: 6660414, infoRecommendText: TIM_t("正在下载中"), type: TIMCallbackType.INFO)); + return; + } + } + + V2TimImage? getImageFromList(V2TimImageTypesEnum imgType) { + V2TimImage? img = MessageUtils.getImageFromImgList(widget.message.imageElem!.imageList, + HistoryMessageDartConstant.imgPriorMap[imgType] ?? HistoryMessageDartConstant.oriImgPrior); + + return img; + } + + void launchDesktopFile(String path) { + if (PlatformUtils().isWindows) { + OpenFile.open(path); + } else { + launchUrl(Uri.file(path)); + } + } + + Widget errorPage(theme) => Container( + height: MediaQuery.of(context).size.height, + color: theme.black, + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: errorDisplay(context, theme), + )); + + bool checkIfDownloadSuccess() { + final localUrl = TencentUtils.checkString(model.getFileMessageLocation(widget.message.msgID)) ?? + widget.message.imageElem!.imageList![0]!.localUrl; + return TencentUtils.checkString(localUrl) != null && File(localUrl!).existsSync(); + } + + _onClickOpenImageInNewWindow() { + final localUrl = TencentUtils.checkString(model.getFileMessageLocation(widget.message.msgID)) ?? + widget.message.imageElem!.imageList![0]!.localUrl; + Future.delayed(const Duration(milliseconds: 0), () async { + final isDownloaded = checkIfDownloadSuccess(); + if (isDownloaded) { + launchDesktopFile(localUrl ?? ""); + } else { + onTIMCallback( + TIMCallback(infoCode: 6660414, infoRecommendText: TIM_t("正在下载原始资源,请稍候..."), type: TIMCallbackType.INFO)); + } + }); + } + + _handleOnTapPreviewImageOnDesktop({ + double? positionRadio, + String? originImgUrl, + }) { + final localUrl = TencentUtils.checkString(model.getFileMessageLocation(widget.message.msgID)) ?? + widget.message.imageElem!.imageList![0]!.localUrl; + if (checkIfDownloadSuccess()) { + TUIKitWidePopup.showMedia( + aspectRatio: positionRadio, + context: context, + mediaLocalPath: localUrl ?? "", + onClickOrigin: () => _onClickOpenImageInNewWindow()); + } else { + if (TencentUtils.checkString(originImgUrl) != null) { + TUIKitWidePopup.showMedia( + aspectRatio: positionRadio, + context: context, + mediaURL: originImgUrl, + onClickOrigin: () => _onClickOpenImageInNewWindow()); + } else { + onTIMCallback(TIMCallback(infoCode: 6660414, infoRecommendText: TIM_t("正在下载中"), type: TIMCallbackType.INFO)); + } + } + } + + Future calculateAspectRatio(ImageProvider imageProvider) async { + Completer completer = Completer(); + + final imageStream = imageProvider.resolve(const ImageConfiguration()); + imageStream.addListener( + ImageStreamListener((imageInfo, synchronousCall) { + if (imageInfo.image.width != 0 && imageInfo.image.height != 0) { + double aspectRatio = imageInfo.image.width / imageInfo.image.height; + completer.complete(aspectRatio); + } else { + // If unable to calculate aspect ratio, return default value of 0.5 + completer.complete(0.5); + } + }, onError: (Object exception, StackTrace? stackTrace) { + // If there's an error, return default value of 0.5 + completer.complete(1); + }), + ); + + return await completer.future; + } + + void onClickImage({ + required bool isNetworkImage, + dynamic heroTag, + required TUITheme theme, + String? imgUrl, + String? imgPath, + }) { + if (isNetworkImage) { + if (PlatformUtils().isWeb) { + TUIKitWidePopup.showMedia( + context: context, + mediaURL: widget.message.imageElem?.path ?? "", + onClickOrigin: () => launchUrl( + Uri.parse(widget.message.imageElem?.path ?? ""), + mode: LaunchMode.externalApplication, + )); + return; + } + if (PlatformUtils().isDesktop) { + _handleOnTapPreviewImageOnDesktop( + originImgUrl: imgUrl, + ); + } else { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (_, __, ___) => ImageScreen( + imageProvider: CachedNetworkImageProvider( + imgUrl ?? "", + cacheKey: widget.message.msgID, + ), + heroTag: heroTag, + messageID: widget.message.msgID, + downloadFn: () async { + return await _saveImg(theme); + })), + ); + } + } else { + if (PlatformUtils().isDesktop) { + TUIKitWidePopup.showMedia( + mediaLocalPath: imgPath, context: context, onClickOrigin: () => launchDesktopFile(imgPath ?? "")); + } else { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, // set to false + pageBuilder: (_, __, ___) => ImageScreen( + imageProvider: FileImage(File(imgPath ?? "")), + heroTag: heroTag, + messageID: widget.message.msgID, + downloadFn: () async { + return await _saveImg(theme); + }), + ), + ); + } + } + } + + Widget _renderAllImage( + {dynamic heroTag, + double? positionRadio, + required TUITheme theme, + bool isNetworkImage = false, + String? webPath, + V2TimImage? originalImg, + V2TimImage? smallImg, + String? smallLocalPath, + String? originLocalPath}) { + Widget getImageWidget() { + if (isNetworkImage) { + return Hero( + tag: heroTag, + child: PlatformUtils().isWeb + ? Image.network(webPath ?? smallImg?.url ?? originalImg!.url!, fit: BoxFit.contain) + : CachedNetworkImage( + alignment: Alignment.topCenter, + imageUrl: webPath ?? smallImg?.url ?? originalImg!.url!, + errorWidget: (context, error, stackTrace) => errorPage(theme), + fit: BoxFit.contain, + cacheKey: smallImg?.uuid ?? originalImg!.uuid, + placeholder: (context, url) => Image(image: MemoryImage(kTransparentImage)), + fadeInDuration: const Duration(milliseconds: 0), + )); + } else { + final imgPath = (TencentUtils.checkString(smallLocalPath) != null ? smallLocalPath : originLocalPath)!; + return Hero(tag: heroTag, child: Image.file(File(imgPath), fit: BoxFit.contain)); + } + } + + double? currentPositionRadio; + // File imgF = File((TencentUtils.checkString(originLocalPath) != null + // ? originLocalPath + // : smallLocalPath) ?? + // ""); + // bool isExist = imgF.existsSync(); + // + // if (!isExist) { + // return errorDisplay(context, theme); + // } + // Image image = Image.file(imgF); + // + // image.image + // .resolve(const ImageConfiguration()) + // .addListener(ImageStreamListener((image, synchronousCall) { + // if (image.image.width != 0 && image.image.height != 0) { + // currentPositionRadio = image.image.width / image.image.height; + // } + // })); + + return GestureDetector( + onTap: () => onClickImage( + theme: theme, + heroTag: heroTag, + isNetworkImage: isNetworkImage, + imgUrl: webPath ?? smallImg?.url ?? originalImg?.url ?? "", + imgPath: (TencentUtils.checkString(originLocalPath) != null ? originLocalPath : smallLocalPath) ?? ""), + child: Stack( + children: [ + if (positionRadio != null) + AspectRatio( + aspectRatio: (currentPositionRadio ?? positionRadio)!, + child: Container( + decoration: const BoxDecoration(color: Colors.transparent), + ), + ), + getImageWidget(), + ], + ), + ); + } + + void initImages() async { + final zeroImageLocal = TencentUtils.checkString( + widget.message.imageElem?.imageList?.firstWhereOrNull((element) => element?.type == 0)?.localUrl); + final oneImageLocal = TencentUtils.checkString( + widget.message.imageElem?.imageList?.firstWhereOrNull((element) => element?.type == 1)?.localUrl); + final twoImageLocal = TencentUtils.checkString( + widget.message.imageElem?.imageList?.firstWhereOrNull((element) => element?.type == 2)?.localUrl); + + if (!PlatformUtils().isWeb && TencentUtils.checkString(widget.message.msgID) != null) { + if ((widget.message.imageElem?.imageList) == null || widget.message.imageElem!.imageList!.isEmpty) { + final response = await _messageService.getMessageOnlineUrl(msgID: widget.message.msgID!); + final elem = response.data; + if (elem != null && elem.imageElem != null) { + widget.message.imageElem = elem.imageElem; + } + } + if (oneImageLocal == null || !File(oneImageLocal).existsSync()) { + _messageService.downloadMessage(msgID: widget.message.msgID!, messageType: 3, imageType: 1, isSnapshot: false); + } + if (twoImageLocal == null || !File(twoImageLocal).existsSync()) { + _messageService.downloadMessage(msgID: widget.message.msgID!, messageType: 3, imageType: 2, isSnapshot: false); + } + if (zeroImageLocal == null || !File(zeroImageLocal).existsSync()) { + _messageService.downloadMessage(msgID: widget.message.msgID!, messageType: 3, imageType: 0, isSnapshot: false); + } + } + } + + @override + void initState() { + super.initState(); + initImages(); + } + + Widget? _renderImage(dynamic heroTag, TUITheme theme, {V2TimImage? originalImg, V2TimImage? smallImg}) { + double positionRadio = 1.0; + if (smallImg?.width != null && smallImg?.height != null && smallImg?.width != 0 && smallImg?.height != 0) { + positionRadio = (smallImg!.width! / smallImg.height!); + } + + if (PlatformUtils().isWeb && widget.message.imageElem!.path != null) { + // Displaying on Web only + return _renderAllImage( + heroTag: heroTag, + theme: theme, + isNetworkImage: true, + smallImg: smallImg, + originalImg: originalImg, + positionRadio: positionRadio, + webPath: widget.message.imageElem!.path); + } + + try { + if ((widget.message.isSelf! && + widget.message.imageElem!.path != null && + widget.message.imageElem!.path!.isNotEmpty && + File(widget.message.imageElem!.path!).existsSync())) { + return _renderAllImage( + smallLocalPath: widget.message.imageElem!.path!, + heroTag: heroTag, + theme: theme, + positionRadio: positionRadio, + originLocalPath: widget.message.imageElem!.path!); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i(e.toString()); + } + + try { + if ((TencentUtils.checkString(smallImg?.localUrl) != null && File((smallImg?.localUrl!)!).existsSync()) || + (TencentUtils.checkString(originalImg?.localUrl) != null && File((originalImg?.localUrl!)!).existsSync())) { + return _renderAllImage( + smallLocalPath: smallImg?.localUrl ?? "", + heroTag: heroTag, + theme: theme, + positionRadio: positionRadio, + originLocalPath: originalImg?.localUrl); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i(e.toString()); + return _renderAllImage( + heroTag: heroTag, + theme: theme, + isNetworkImage: true, + smallImg: smallImg, + positionRadio: positionRadio, + originalImg: originalImg); + } + + if ((smallImg?.url ?? originalImg?.url) != null && (smallImg?.url ?? originalImg?.url)!.isNotEmpty) { + return _renderAllImage( + heroTag: heroTag, + theme: theme, + isNetworkImage: true, + positionRadio: positionRadio, + smallImg: smallImg, + originalImg: originalImg); + } + + return errorDisplay(context, theme); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final heroTag = + "${widget.message.msgID ?? widget.message.id ?? widget.message.timestamp ?? DateTime.now().millisecondsSinceEpoch}${widget.isFrom}"; + + V2TimImage? originalImg = getImageFromList(V2TimImageTypesEnum.original); + V2TimImage? smallImg = getImageFromList(V2TimImageTypesEnum.small); + return TIMUIKitMessageReactionWrapper( + chatModel: widget.chatModel, + isShowJump: widget.isShowJump, + clearJump: widget.clearJump, + isFromSelf: widget.message.isSelf ?? true, + isShowMessageReaction: widget.isShowMessageReaction ?? true, + message: widget.message, + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * (isDesktopScreen ? 0.4 : 0.5), + minWidth: 64, + maxHeight: 256, + ), + child: _renderImage(heroTag, theme, originalImg: originalImg, smallImg: smallImg), + ); + })); + } +} + +class ImageClipper extends CustomClipper { + @override + RRect getClip(Size size) { + return RRect.fromRectAndRadius(Rect.fromLTWH(0, 0, size.width, min(size.height, 256)), const Radius.circular(5)); + } + + @override + bool shouldReclip(CustomClipper oldClipper) { + return oldClipper != this; + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_reply_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_reply_elem.dart new file mode 100644 index 0000000..d3eb40a --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_reply_elem.dart @@ -0,0 +1,422 @@ +// ignore_for_file: unused_import + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_model_tools.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:extended_text/extended_text.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/models/link_preview_content.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_preview.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +class TIMUIKitReplyElem extends StatefulWidget { + final V2TimMessage message; + final Function scrollToIndex; + final bool isShowJump; + final VoidCallback clearJump; + final TextStyle? fontStyle; + final BorderRadius? borderRadius; + final Color? backgroundColor; + final EdgeInsetsGeometry? textPadding; + final TUIChatSeparateViewModel chatModel; + final bool? isShowMessageReaction; + final List customEmojiStickerList; + + const TIMUIKitReplyElem({ + Key? key, + required this.message, + required this.scrollToIndex, + this.isShowJump = false, + required this.clearJump, + this.fontStyle, + this.borderRadius, + this.isShowMessageReaction, + this.backgroundColor, + this.textPadding, + this.customEmojiStickerList = const [], + required this.chatModel, + }) : super(key: key); + + @override + State createState() => _TIMUIKitReplyElemState(); +} + +class _TIMUIKitReplyElemState extends TIMUIKitState { + MessageRepliedData? repliedMessage; + V2TimMessage? rawMessage; + bool isShowJumpState = false; + bool isShining = false; + + MessageRepliedData? _getRepliedMessage() { + try { + final CloudCustomData messageCloudCustomData = CloudCustomData.fromJson(json.decode( + TencentUtils.checkString(widget.message.cloudCustomData) != null ? widget.message.cloudCustomData! : "{}")); + if (messageCloudCustomData.messageReply != null) { + final MessageRepliedData repliedMessage = MessageRepliedData.fromJson(messageCloudCustomData.messageReply!); + return repliedMessage; + } + return null; + } catch (error) { + return null; + } + } + + _getMessageByMessageID() async { + final MessageRepliedData? cloudCustomData = _getRepliedMessage(); + if (cloudCustomData != null) { + if (mounted) { + setState(() { + repliedMessage = cloudCustomData; + }); + } + + final messageID = cloudCustomData.messageID; + if (PlatformUtils().isWeb) { + return; + } + V2TimMessage? message = await widget.chatModel.findMessage(messageID); + if (message == null) { + try { + final RepliedMessageAbstract repliedMessageAbstract = + RepliedMessageAbstract.fromJson(jsonDecode(cloudCustomData.messageAbstract)); + if (repliedMessageAbstract.isNotEmpty) { + message = V2TimMessage( + elemType: 0, + seq: repliedMessageAbstract.seq, + timestamp: repliedMessageAbstract.timestamp, + msgID: repliedMessageAbstract.msgID); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i(e.toString()); + } + } + if (message != null) { + if (mounted) { + setState(() { + rawMessage = message; + }); + } + } + } + } + + Widget _defaultRawMessageText(String text, TUITheme? theme) { + return Text(text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: theme?.weakTextColor, fontWeight: FontWeight.w400)); + } + + _renderMessageSummary(TUITheme? theme) { + try { + final RepliedMessageAbstract repliedMessageAbstract = + RepliedMessageAbstract.fromJson(jsonDecode(repliedMessage?.messageAbstract ?? "")); + if (TencentUtils.checkString(repliedMessageAbstract.summary) != null) { + return _defaultRawMessageText(repliedMessageAbstract.summary!, theme); + } + return _defaultRawMessageText(repliedMessage?.messageAbstract ?? TIM_t("[未知消息]"), theme); + } catch (e) { + return _defaultRawMessageText(repliedMessage?.messageAbstract ?? TIM_t("[未知消息]"), theme); + } + } + + (bool isRevoke, bool isRevokeByAdmin) isRevokeMessage(V2TimMessage message) { + if (message.status == 6) { + return (true, false); + } else { + try { + final customData = jsonDecode(message.cloudCustomData ?? "{}"); + final isRevoke = customData["isRevoke"] ?? false; + final revokeByAdmin = customData["revokeByAdmin"] ?? false; + return (isRevoke, revokeByAdmin); + } catch (e) { + return (false, false); + } + } + } + + _rawMessageBuilder(V2TimMessage? message, TUITheme? theme) { + if (repliedMessage == null) { + return const SizedBox(width: 0, height: 12); + } + if (message == null) { + if (repliedMessage?.messageAbstract != null) { + return _renderMessageSummary(theme); + } + return const SizedBox(width: 0, height: 12); + } + + final revokeStatus = isRevokeMessage(message); + final isRevokedMsg = revokeStatus.$1; + final isAdminRevoke = revokeStatus.$2; + + if (isRevokedMsg) { + return _defaultRawMessageText(isAdminRevoke ? TIM_t("[消息被管理员撤回]") : TIM_t("[消息被撤回]"), theme); + } + + final messageType = message.elemType; + final isSelf = message.isSelf ?? true; + final customAbstractMessage = + widget.chatModel.abstractMessageBuilder != null ? widget.chatModel.abstractMessageBuilder!(message) : null; + if (customAbstractMessage != null) { + return _defaultRawMessageText( + customAbstractMessage, + theme, + ); + } + switch (messageType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return _defaultRawMessageText(TIM_t("[自定义]"), theme); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return _defaultRawMessageText(TIM_t("[语音消息]"), theme); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return _defaultRawMessageText(message.textElem?.text ?? "", theme); + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return TIMUIKitFaceElem( + model: widget.chatModel, + isShowJump: false, + isShowMessageReaction: false, + path: message.faceElem!.data ?? "", + message: message, + ); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + return TIMUIKitFileElem( + chatModel: widget.chatModel, + isShowMessageReaction: false, + message: message, + messageID: message.msgID, + fileElem: message.fileElem, + isSelf: isSelf, + isShowJump: false); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return TIMUIKitImageElem( + chatModel: widget.chatModel, message: message, isFrom: "reply", isShowMessageReaction: false); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return TIMUIKitVideoElem(message, chatModel: widget.chatModel, isFrom: "reply", isShowMessageReaction: false); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return _defaultRawMessageText(TIM_t("[位置]"), theme); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return TIMUIKitMergerElem( + model: widget.chatModel, + isShowJump: false, + isShowMessageReaction: false, + message: message, + mergerElem: message.mergerElem!, + messageID: message.msgID ?? "", + isSelf: isSelf); + default: + return _renderMessageSummary(theme); + } + } + + @override + void initState() { + _getMessageByMessageID(); + super.initState(); + } + + @override + void didUpdateWidget(covariant TIMUIKitReplyElem oldWidget) { + WidgetsBinding.instance.addPostFrameCallback((mag) { + super.didUpdateWidget(oldWidget); + _getMessageByMessageID(); + }); + } + + _showJumpColor() { + if ((widget.chatModel.jumpMsgID != widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? true)) { + return; + } + isShining = true; + int shineAmount = 6; + setState(() { + isShowJumpState = true; + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + isShining = false; + timer.cancel(); + } + shineAmount--; + }); + widget.clearJump(); + } + + void _jumpToRawMsg() { + if (rawMessage?.status != MessageStatus.V2TIM_MSG_STATUS_LOCAL_REVOKED && rawMessage?.timestamp != null) { + widget.scrollToIndex(rawMessage); + } else { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("无法定位到原消息"))); + } + } + + Widget? _renderPreviewWidget() { + // If the link preview info from [localCustomData] is available, use it to render the preview card. + // Otherwise, it will returns null. + if (widget.message.localCustomData != null && widget.message.localCustomData!.isNotEmpty) { + try { + final String localJSON = widget.message.localCustomData!; + final LocalCustomDataModel? localPreviewInfo = LocalCustomDataModel.fromMap(json.decode(localJSON)); + if (localPreviewInfo != null && !localPreviewInfo.isLinkPreviewEmpty()) { + return Container( + margin: const EdgeInsets.only(top: 8), + child: + // You can use this default widget [LinkPreviewWidget] to render preview card, or you can use custom widget. + LinkPreviewWidget(linkPreview: localPreviewInfo), + ); + } else { + return null; + } + } catch (e) { + return null; + } + } else { + return null; + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (widget.isShowJump) { + if (!isShining) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } else { + if ((widget.chatModel.jumpMsgID == widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? false)) { + widget.clearJump(); + } + } + } + + final isFromSelf = widget.message.isSelf ?? true; + + final defaultStyle = isFromSelf + ? (theme.chatMessageItemFromSelfBgColor ?? theme.lightPrimaryMaterialColor.shade50) + : (theme.chatMessageItemFromOthersBgColor); + + final backgroundColor = + isShowJumpState ? const Color.fromRGBO(245, 166, 35, 1) : (defaultStyle ?? widget.backgroundColor); + + final borderRadius = isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + final textWithLink = LinkPreviewEntry.getHyperlinksText( + widget.message.textElem?.text ?? "", widget.chatModel.chatConfig.isSupportMarkdownForTextMessage, + onLinkTap: widget.chatModel.chatConfig.onTapLink, + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? false, + customEmojiStickerList: widget.customEmojiStickerList, + isEnableTextSelection: widget.chatModel.chatConfig.isEnableTextSelection ?? false); + return Container( + padding: widget.textPadding ?? EdgeInsets.all(isDesktopScreen ? 12 : 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: widget.borderRadius ?? borderRadius, + ), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.6), + child: GestureDetector( + onTap: _jumpToRawMsg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + // 这里是引用的部分 + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 6), + constraints: const BoxConstraints(minWidth: 120), + decoration: const BoxDecoration( + color: Color.fromRGBO(68, 68, 68, 0.05), + border: Border(left: BorderSide(color: Color.fromRGBO(68, 68, 68, 0.1), width: 2))), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + repliedMessage != null ? "${repliedMessage!.messageSender}:" : "", + style: TextStyle(fontSize: 12, color: theme.weakTextColor, fontWeight: FontWeight.w500), + ), + const SizedBox( + height: 4, + ), + _rawMessageBuilder(rawMessage, theme) + ], + ), + ), + const SizedBox( + height: 12, + ), + // If the [elemType] is text message, it will not be null here. + // You can render the widget from extension directly, with a [TextStyle] optionally. + widget.chatModel.chatConfig.urlPreviewType != UrlPreviewType.none + ? textWithLink!( + style: widget.fontStyle ?? + TextStyle( + fontSize: isDesktopScreen ? 14 : 16, + textBaseline: TextBaseline.ideographic, + height: widget.chatModel.chatConfig.textHeight)) + : ExtendedText(widget.message.textElem?.text ?? "", + softWrap: true, + style: widget.fontStyle ?? + TextStyle(fontSize: isDesktopScreen ? 14 : 16, height: widget.chatModel.chatConfig.textHeight), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? + false, + customEmojiStickerList: widget.customEmojiStickerList, + showAtBackground: true, + )), + // If the link preview info is available, render the preview card. + if (_renderPreviewWidget() != null && + widget.chatModel.chatConfig.urlPreviewType == UrlPreviewType.previewCardAndHyperlink) + _renderPreviewWidget()!, + if (widget.isShowMessageReaction ?? true) TIMUIKitMessageReactionShowPanel(message: widget.message) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart new file mode 100644 index 0000000..3ab4dc9 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart @@ -0,0 +1,268 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_sound_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_sound_elem.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/sound_record.dart'; +import 'TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart'; + +class TIMUIKitSoundElem extends StatefulWidget { + final V2TimMessage message; + final V2TimSoundElem soundElem; + final String msgID; + final bool isFromSelf; + final int? localCustomInt; + final bool isShowJump; + final VoidCallback? clearJump; + final TextStyle? fontStyle; + final BorderRadius? borderRadius; + final Color? backgroundColor; + final EdgeInsetsGeometry? textPadding; + final bool? isShowMessageReaction; + final TUIChatSeparateViewModel chatModel; + + const TIMUIKitSoundElem( + {Key? key, + required this.soundElem, + required this.msgID, + required this.isFromSelf, + this.isShowJump = false, + this.clearJump, + this.localCustomInt, + this.fontStyle, + this.borderRadius, + this.backgroundColor, + this.textPadding, + required this.message, + this.isShowMessageReaction, + required this.chatModel}) + : super(key: key); + + @override + State createState() => _TIMUIKitSoundElemState(); +} + +class _TIMUIKitSoundElemState extends TIMUIKitState { + final int charLen = 8; + bool isPlaying = false; + StreamSubscription? subscription; + bool isShowJumpState = false; + bool isShining = false; + final TUIChatGlobalModel globalModel = serviceLocator(); + final MessageService _messageService = serviceLocator(); + late V2TimSoundElem stateElement = widget.message.soundElem!; + + _playSound() async { + if (!SoundPlayer.isInit) { + SoundPlayer.initSoundPlayer(); + } + if (widget.localCustomInt == null || widget.localCustomInt != HistoryMessageDartConstant.read) { + globalModel.setLocalCustomInt(widget.msgID, HistoryMessageDartConstant.read, widget.chatModel.conversationID); + } + if (isPlaying) { + SoundPlayer.stop(); + widget.chatModel.currentPlayedMsgId = ""; + } else { + SoundPlayer.play(url: stateElement.url!); + widget.chatModel.currentPlayedMsgId = widget.msgID; + + setState(() { + isPlaying = widget.chatModel.currentPlayedMsgId != '' && widget.chatModel.currentPlayedMsgId == widget.msgID; + }); + } + } + + downloadMessageDetailAndSave() async { + if (widget.message.msgID != null && widget.message.msgID != '') { + if (widget.message.soundElem!.url == null || widget.message.soundElem!.url == '') { + final response = await _messageService.getMessageOnlineUrl(msgID: widget.message.msgID!); + if (response.data != null) { + widget.message.soundElem = response.data!.soundElem; + Future.delayed(const Duration(microseconds: 10), () { + setState(() => stateElement = response.data!.soundElem!); + }); + } + } + if (!PlatformUtils().isWeb) { + if (widget.message.soundElem!.localUrl == null || widget.message.soundElem!.localUrl == '') { + _messageService.downloadMessage( + msgID: widget.message.msgID!, messageType: 4, imageType: 0, isSnapshot: false); + } + } + } + } + + @override + void initState() { + super.initState(); + + subscription = SoundPlayer.playStateListener(listener: (PlayerState state) { + if (state.processingState == ProcessingState.completed) { + widget.chatModel.currentPlayedMsgId = ""; + setState(() { + isPlaying = false; + }); + } + }); + + downloadMessageDetailAndSave(); + } + + @override + void dispose() { + if (isPlaying) { + SoundPlayer.stop(); + widget.chatModel.currentPlayedMsgId = ""; + } + subscription?.cancel(); + super.dispose(); + } + + double _getSoundLen() { + double soundLen = 32; + if (stateElement.duration != null) { + final realSoundLen = stateElement.duration!; + int sdLen = 32; + if (realSoundLen > 10) { + sdLen = 12 * charLen + ((realSoundLen - 10) * charLen / 0.5).floor(); + } else if (realSoundLen > 2) { + sdLen = 2 * charLen + realSoundLen * charLen; + } + sdLen = min(sdLen, 20 * charLen); + soundLen = sdLen.toDouble(); + } + + return soundLen; + } + + _showJumpColor() { + if ((widget.chatModel.jumpMsgID != widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? true)) { + return; + } + isShining = true; + int shineAmount = 6; + setState(() { + isShowJumpState = true; + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + isShining = false; + timer.cancel(); + } + shineAmount--; + }); + widget.clearJump!(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + + final backgroundColor = widget.isFromSelf + ? (theme.chatMessageItemFromSelfBgColor ?? theme.lightPrimaryMaterialColor.shade50) + : (theme.chatMessageItemFromOthersBgColor); + + final borderRadius = widget.isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + if (widget.isShowJump) { + if (!isShining) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } else { + if ((widget.chatModel.jumpMsgID == widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? false)) { + widget.clearJump!(); + } + } + } + return GestureDetector( + onTap: () => _playSound(), + child: Container( + padding: widget.textPadding ?? const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isShowJumpState ? const Color.fromRGBO(245, 166, 35, 1) : (widget.backgroundColor ?? backgroundColor), + borderRadius: widget.borderRadius ?? borderRadius, + ), + constraints: const BoxConstraints(maxWidth: 240), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: widget.isFromSelf + ? [ + Container(width: _getSoundLen()), + Text( + "''${stateElement.duration} ", + style: widget.fontStyle, + ), + isPlaying + ? Image.asset( + 'images/play_voice_send.gif', + package: 'tencent_cloud_chat_uikit', + width: 16, + height: 16, + ) + : Image.asset( + 'images/voice_send.png', + package: 'tencent_cloud_chat_uikit', + width: 16, + height: 16, + ), + ] + : [ + isPlaying + ? Image.asset( + 'images/play_voice_receive.gif', + package: 'tencent_cloud_chat_uikit', + width: 16, + height: 16, + ) + : Image.asset( + 'images/voice_receive.png', + width: 16, + height: 16, + package: 'tencent_cloud_chat_uikit', + ), + Text( + " ${stateElement.duration}''", + style: widget.fontStyle, + ), + Container(width: _getSoundLen()), + ], + ), + if (widget.isShowMessageReaction ?? true) + TIMUIKitMessageReactionShowPanel( + message: widget.message, + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_elem.dart new file mode 100644 index 0000000..60e3479 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_elem.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_preview.dart'; +import 'TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart'; + +class TIMUIKitTextElem extends StatefulWidget { + final V2TimMessage message; + final bool isFromSelf; + final bool isShowJump; + final VoidCallback clearJump; + final TextStyle? fontStyle; + final BorderRadius? borderRadius; + final Color? backgroundColor; + final EdgeInsetsGeometry? textPadding; + final TUIChatSeparateViewModel chatModel; + final bool? isShowMessageReaction; + final List customEmojiStickerList; + + const TIMUIKitTextElem( + {Key? key, + required this.message, + required this.isFromSelf, + required this.isShowJump, + required this.clearJump, + this.fontStyle, + this.borderRadius, + this.isShowMessageReaction, + this.backgroundColor, + this.textPadding, + required this.chatModel, + this.customEmojiStickerList = const []}) + : super(key: key); + + @override + State createState() => _TIMUIKitTextElemState(); +} + +class _TIMUIKitTextElemState extends TIMUIKitState { + bool isShowJumpState = false; + bool isShining = false; + + @override + void initState() { + super.initState(); + // get the link preview info + _getLinkPreview(); + } + + @override + void didUpdateWidget(TIMUIKitTextElem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.msgID == null && widget.message.msgID != null) { + _getLinkPreview(); + } + } + + _showJumpColor() { + if ((widget.chatModel.jumpMsgID != widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? true)) { + return; + } + isShining = true; + int shineAmount = 6; + setState(() { + isShowJumpState = true; + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + isShining = false; + timer.cancel(); + } + shineAmount--; + }); + Future.delayed(const Duration(milliseconds: 100), () { + widget.clearJump(); + }); + } + + // get the link preview info + _getLinkPreview() { + if (widget.chatModel.chatConfig.urlPreviewType != UrlPreviewType.previewCardAndHyperlink) { + return; + } + try { + if (widget.message.localCustomData != null && widget.message.localCustomData!.isNotEmpty) { + final String localJSON = widget.message.localCustomData!; + final LocalCustomDataModel? localPreviewInfo = LocalCustomDataModel.fromMap(json.decode(localJSON)); + // If [localCustomData] is not empty, check if the link preview info exists + if (localPreviewInfo == null || localPreviewInfo.isLinkPreviewEmpty()) { + // If not exists, get it + _initLinkPreview(); + } + } else { + // It [localCustomData] is empty, get the link info + _initLinkPreview(); + } + } catch (e) { + return null; + } + } + + _initLinkPreview() async { + // Get the link preview info from extension, let it update the message UI automatically by providing a [onUpdateMessage]. + // The `onUpdateMessage` can use the `updateMessage()` from the [TIMUIKitChatController] directly. + LinkPreviewEntry.getFirstLinkPreviewContent( + message: widget.message, + onUpdateMessage: (message) { + widget.chatModel.updateMessageFromController(msgID: widget.message.msgID!, message: message); + }); + } + + Widget? _renderPreviewWidget() { + // If the link preview info from [localCustomData] is available, use it to render the preview card. + // Otherwise, it will returns null. + if (widget.message.localCustomData != null && widget.message.localCustomData!.isNotEmpty) { + try { + final String localJSON = widget.message.localCustomData!; + final LocalCustomDataModel? localPreviewInfo = LocalCustomDataModel.fromMap(json.decode(localJSON)); + if (localPreviewInfo != null && !localPreviewInfo.isLinkPreviewEmpty()) { + return Container( + margin: const EdgeInsets.only(top: 8), + child: + // You can use this default widget [LinkPreviewWidget] to render preview card, or you can use custom widget. + LinkPreviewWidget(linkPreview: localPreviewInfo), + ); + } else { + return null; + } + } catch (e) { + return null; + } + } else { + return null; + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final textWithLink = LinkPreviewEntry.getHyperlinksText( + widget.message.textElem?.text ?? "", widget.chatModel.chatConfig.isSupportMarkdownForTextMessage, + onLinkTap: widget.chatModel.chatConfig.onTapLink, + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? false, + customEmojiStickerList: widget.customEmojiStickerList, + isEnableTextSelection: widget.chatModel.chatConfig.isEnableTextSelection ?? false); + final borderRadius = widget.isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + if ((widget.chatModel.jumpMsgID == widget.message.msgID)) {} + if (widget.isShowJump) { + if (!isShining) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } else { + if ((widget.chatModel.jumpMsgID == widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? false)) { + widget.clearJump(); + } + } + } + + final defaultStyle = widget.isFromSelf + ? (theme.chatMessageItemFromSelfBgColor ?? theme.lightPrimaryMaterialColor.shade50) + : (theme.chatMessageItemFromOthersBgColor); + + final backgroundColor = + isShowJumpState ? const Color.fromRGBO(245, 166, 35, 1) : (defaultStyle ?? widget.backgroundColor); + + return Container( + padding: widget.textPadding ?? EdgeInsets.all(isDesktopScreen ? 12 : 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: widget.borderRadius ?? borderRadius, + ), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // If the [elemType] is text message, it will not be null here. + // You can render the widget from extension directly, with a [TextStyle] optionally. + widget.chatModel.chatConfig.urlPreviewType != UrlPreviewType.none + ? textWithLink!( + style: widget.fontStyle ?? + TextStyle( + fontSize: isDesktopScreen ? 14 : 16, + textBaseline: TextBaseline.ideographic, + height: widget.chatModel.chatConfig.textHeight)) + : ExtendedText(widget.message.textElem?.text ?? "", + softWrap: true, + style: widget.fontStyle ?? + TextStyle(fontSize: isDesktopScreen ? 14 : 16, height: widget.chatModel.chatConfig.textHeight), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? + false, + customEmojiStickerList: widget.customEmojiStickerList, + showAtBackground: true, + checkHttpLink: true, + )), + // If the link preview info is available, render the preview card. + if (_renderPreviewWidget() != null && + widget.chatModel.chatConfig.urlPreviewType == UrlPreviewType.previewCardAndHyperlink) + _renderPreviewWidget()!, + if (widget.isShowMessageReaction ?? true) TIMUIKitMessageReactionShowPanel(message: widget.message) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_translate_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_translate_elem.dart new file mode 100644 index 0000000..c1401c0 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_text_translate_elem.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart'; + +class TIMUIKitTextTranslationElem extends StatefulWidget { + final V2TimMessage message; + final bool isFromSelf; + final bool isShowJump; + final VoidCallback clearJump; + final TextStyle? fontStyle; + final BorderRadius? borderRadius; + final Color? backgroundColor; + final EdgeInsetsGeometry? textPadding; + final TUIChatSeparateViewModel chatModel; + final bool? isShowMessageReaction; + final List customEmojiStickerList; + + const TIMUIKitTextTranslationElem( + {Key? key, + required this.message, + required this.isFromSelf, + required this.isShowJump, + required this.clearJump, + this.fontStyle, + this.borderRadius, + this.isShowMessageReaction, + this.backgroundColor, + this.textPadding, + required this.chatModel, + this.customEmojiStickerList = const []}) + : super(key: key); + + @override + State createState() => _TIMUIKitTextTranslationElemState(); +} + +class _TIMUIKitTextTranslationElemState extends TIMUIKitState { + bool isShowJumpState = false; + bool isShining = false; + + _showJumpColor() { + if ((widget.chatModel.jumpMsgID != widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? true)) { + return; + } + isShining = true; + int shineAmount = 6; + setState(() { + isShowJumpState = true; + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + isShining = false; + timer.cancel(); + } + shineAmount--; + }); + Future.delayed(const Duration(milliseconds: 100), () { + widget.clearJump(); + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final borderRadius = widget.isFromSelf + ? const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)) + : const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10)); + if ((widget.chatModel.jumpMsgID == widget.message.msgID)) {} + if (widget.isShowJump) { + if (!isShining) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } else { + if ((widget.chatModel.jumpMsgID == widget.message.msgID) && (widget.message.msgID?.isNotEmpty ?? false)) { + widget.clearJump(); + } + } + } + + final defaultStyle = widget.isFromSelf + ? (theme.chatMessageItemFromSelfBgColor ?? theme.lightPrimaryMaterialColor.shade50) + : (theme.chatMessageItemFromOthersBgColor); + + final backgroundColor = + isShowJumpState ? const Color.fromRGBO(245, 166, 35, 1) : (defaultStyle ?? widget.backgroundColor); + + final LocalCustomDataModel localCustomData = + LocalCustomDataModel.fromMap(json.decode(TencentUtils.checkString(widget.message.localCustomData) ?? "{}")); + final String? translateText = localCustomData.translatedText; + + final textWithLink = LinkPreviewEntry.getHyperlinksText( + translateText ?? "", widget.chatModel.chatConfig.isSupportMarkdownForTextMessage, + onLinkTap: widget.chatModel.chatConfig.onTapLink, + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? false, + customEmojiStickerList: widget.customEmojiStickerList, + isEnableTextSelection: widget.chatModel.chatConfig.isEnableTextSelection ?? false); + + return TencentUtils.checkString(translateText) != null + ? Container( + margin: const EdgeInsets.only(top: 6), + padding: widget.textPadding ?? EdgeInsets.all(isDesktopScreen ? 12 : 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: widget.borderRadius ?? borderRadius, + ), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // If the [elemType] is text message, it will not be null here. + // You can render the widget from extension directly, with a [TextStyle] optionally. + widget.chatModel.chatConfig.urlPreviewType != UrlPreviewType.none + ? textWithLink!( + style: widget.fontStyle ?? + TextStyle( + fontSize: isDesktopScreen ? 14 : 16, + textBaseline: TextBaseline.ideographic, + height: widget.chatModel.chatConfig.textHeight)) + : ExtendedText(translateText!, + softWrap: true, + style: widget.fontStyle ?? + TextStyle( + fontSize: isDesktopScreen ? 14 : 16, height: widget.chatModel.chatConfig.textHeight), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: widget.chatModel.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: widget + .chatModel.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? + false, + customEmojiStickerList: widget.customEmojiStickerList, + showAtBackground: true, + )), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Icon( + Icons.check_circle, + color: Color(0x72282c34), + size: 12, + ), + const SizedBox( + width: 4, + ), + Text( + TIM_t("翻译完成"), + style: const TextStyle(color: Color(0x72282c34), fontSize: 10), + ) + ], + ) + ], + ), + ) + : const SizedBox(width: 0, height: 0); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart new file mode 100644 index 0000000..75b81ad --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart @@ -0,0 +1,285 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:open_file/open_file.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_video_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_video_elem.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/TIMUIKitMessageReaction/tim_uikit_message_reaction_wrapper.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/video_screen.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class TIMUIKitVideoElem extends StatefulWidget { + final V2TimMessage message; + final bool isShowJump; + final VoidCallback? clearJump; + final String? isFrom; + final TUIChatSeparateViewModel chatModel; + final bool? isShowMessageReaction; + + const TIMUIKitVideoElem(this.message, + {Key? key, + this.isShowJump = false, + this.clearJump, + this.isFrom, + this.isShowMessageReaction, + required this.chatModel}) + : super(key: key); + + @override + State createState() => _TIMUIKitVideoElemState(); +} + +class _TIMUIKitVideoElemState extends TIMUIKitState { + final MessageService _messageService = serviceLocator(); + late V2TimVideoElem stateElement = widget.message.videoElem!; + + Widget errorDisplay(TUITheme? theme) { + return Container( + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.black12, + )), + height: 100, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.warning_amber_outlined, + color: theme?.cautionColor, + size: 16, + ), + Text( + TIM_t("视频加载失败"), + style: TextStyle(color: theme?.cautionColor), + ), + ], + ), + ), + ); + } + + Widget generateSnapshot(TUITheme theme, int height) { + if (!PlatformUtils().isWeb) { + final current = (DateTime.now().millisecondsSinceEpoch / 1000).ceil(); + final timeStamp = widget.message.timestamp ?? current; + if (current - timeStamp < 300) { + if (stateElement.snapshotPath != null && stateElement.snapshotPath != '') { + File imgF = File(stateElement.snapshotPath!); + bool isExist = imgF.existsSync(); + if (isExist) { + return Image.file(File(stateElement.snapshotPath!), fit: BoxFit.fitWidth); + } + } + } + } + + if ((stateElement.snapshotUrl == null || stateElement.snapshotUrl == '') && + (stateElement.snapshotPath == null || stateElement.snapshotPath == '')) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + border: Border.all( + width: 1, + color: Colors.black12, + )), + height: double.parse(height.toString()), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 28, + ) + ], + ), + ), + ); + } + return (!PlatformUtils().isWeb && stateElement.snapshotUrl == null || + widget.message.status == MessageStatus.V2TIM_MSG_STATUS_SENDING) + ? (stateElement.snapshotPath!.isNotEmpty + ? Image.file(File(stateElement.snapshotPath!), fit: BoxFit.fitWidth) + : Image.file(File(stateElement.localSnapshotUrl!), fit: BoxFit.fitWidth)) + : (PlatformUtils().isWeb || stateElement.localSnapshotUrl == null || stateElement.localSnapshotUrl == "") + ? Image.network(stateElement.snapshotUrl!, fit: BoxFit.fitWidth) + : Image.file(File(stateElement.localSnapshotUrl!), fit: BoxFit.fitWidth); + } + + downloadMessageDetailAndSave() async { + if (TencentUtils.checkString(widget.message.msgID) != null) { + if (TencentUtils.checkString(widget.message.videoElem!.videoUrl) == null) { + final response = await _messageService.getMessageOnlineUrl(msgID: widget.message.msgID!); + if (response.data != null) { + widget.message.videoElem = response.data!.videoElem; + Future.delayed(const Duration(microseconds: 10), () { + setState(() => stateElement = response.data!.videoElem!); + }); + } + } + if (!PlatformUtils().isWeb) { + if (TencentUtils.checkString(widget.message.videoElem!.localVideoUrl) == null || + !File(widget.message.videoElem!.localVideoUrl!).existsSync()) { + _messageService.downloadMessage( + msgID: widget.message.msgID!, messageType: 5, imageType: 0, isSnapshot: false); + } + if (TencentUtils.checkString(widget.message.videoElem!.localSnapshotUrl) == null || + !File(widget.message.videoElem!.localSnapshotUrl!).existsSync()) { + _messageService.downloadMessage(msgID: widget.message.msgID!, messageType: 5, imageType: 0, isSnapshot: true); + } + } + } + } + + @override + void initState() { + super.initState(); + downloadMessageDetailAndSave(); + } + + void launchDesktopFile(String path) { + if (PlatformUtils().isWindows) { + OpenFile.open(path); + } else { + launchUrl(Uri.file(path)); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final heroTag = + "${widget.message.msgID ?? widget.message.id ?? widget.message.timestamp ?? DateTime.now().millisecondsSinceEpoch}${widget.isFrom}"; + + return GestureDetector( + onTap: () { + if (PlatformUtils().isWeb) { + final url = widget.message.videoElem?.videoUrl ?? widget.message.videoElem?.videoPath ?? ""; + TUIKitWidePopup.showMedia( + context: context, + mediaURL: url, + onClickOrigin: () => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + )); + return; + } + if (PlatformUtils().isDesktop) { + final videoElem = widget.message.videoElem; + if (videoElem != null) { + final localVideoUrl = TencentUtils.checkString(videoElem.localVideoUrl); + final videoPath = TencentUtils.checkString(videoElem.videoPath); + final videoUrl = videoElem.videoUrl; + if (localVideoUrl != null) { + launchDesktopFile(localVideoUrl); + // todo + // TUIKitWidePopup.showMedia( + // context: context, + // mediaPath: localVideoUrl, + // onClickOrigin: () => launchDesktopFile(localVideoUrl)); + } else if (videoPath != null && File(videoPath).existsSync()) { + launchDesktopFile(videoPath); + // todo + // TUIKitWidePopup.showMedia( + // context: context, + // mediaPath: videoPath, + // onClickOrigin: () => launchDesktopFile(videoPath)); + } else if (TencentUtils.isTextNotEmpty(videoUrl)) { + onTIMCallback( + TIMCallback(infoCode: 6660414, infoRecommendText: TIM_t("正在下载中"), type: TIMCallbackType.INFO)); + } + } + } else { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, // set to false + pageBuilder: (_, __, ___) => VideoScreen( + message: widget.message, + heroTag: heroTag, + videoElement: stateElement, + ), + ), + ); + } + }, + child: Hero( + tag: heroTag, + child: TIMUIKitMessageReactionWrapper( + chatModel: widget.chatModel, + message: widget.message, + isShowJump: widget.isShowJump, + isShowMessageReaction: widget.isShowMessageReaction ?? true, + clearJump: widget.clearJump, + isFromSelf: widget.message.isSelf ?? true, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(5)), + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + double? positionRadio; + if ((stateElement.snapshotWidth) != null && + stateElement.snapshotHeight != null && + stateElement.snapshotWidth != 0 && + stateElement.snapshotHeight != 0) { + positionRadio = (stateElement.snapshotWidth! / stateElement.snapshotHeight!); + } + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: PlatformUtils().isWeb ? 300 : constraints.maxWidth * 0.5, + maxHeight: min(constraints.maxHeight * 0.8, 300), + minHeight: 20, + minWidth: 20), + child: Stack( + children: [ + if (positionRadio != null && + (stateElement.snapshotUrl != null || stateElement.snapshotUrl != null)) + AspectRatio( + aspectRatio: positionRadio, + child: Container( + decoration: const BoxDecoration(color: Colors.transparent), + ), + ), + Row( + children: [Expanded(child: generateSnapshot(theme, stateElement.snapshotHeight ?? 100))], + ), + if (widget.message.status != MessageStatus.V2TIM_MSG_STATUS_SENDING && + (stateElement.snapshotUrl != null || stateElement.snapshotPath != null) && + stateElement.videoPath != null || + stateElement.videoUrl != null) + Positioned.fill( + // alignment: Alignment.center, + child: Center( + child: + Image.asset('images/play.png', package: 'tencent_cloud_chat_uikit', height: 64)), + ), + if (widget.message.videoElem?.duration != null && widget.message.videoElem!.duration! > 0) + Positioned( + right: 10, + bottom: 10, + child: Text( + MessageUtils.formatVideoTime(widget.message.videoElem!.duration!).toString(), + style: const TextStyle(color: Colors.white, fontSize: 12))), + ], + )); + }), + ))), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_videoplayer.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_videoplayer.dart new file mode 100644 index 0000000..0378da5 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_videoplayer.dart @@ -0,0 +1,209 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:better_player_plus/better_player_plus.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_online_url.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_online_url.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class TIMUIKitVideoPlayer extends StatefulWidget { + final V2TimMessage message; + final bool controller; + final bool isSending; + + const TIMUIKitVideoPlayer({ + super.key, + required this.message, + required this.controller, + required this.isSending, + }); + + @override + State createState() => TIMUIKitVideoPlayerState(); +} + +enum CurrentVideoType { + online, + local, +} + +class CurrentVideoInfo { + final String path; + final CurrentVideoType type; + final double aspectRatio; + + CurrentVideoInfo({ + required this.path, + required this.type, + required this.aspectRatio, + }); +} + +class TIMUIKitVideoPlayerState extends State { + final String _tag = "TencentCloudChatMessageVideoPlayer"; + + BetterPlayerController? _betterPlayerController; + + @override + void initState() { + super.initState(); + _initializePlayer(); + } + + Future _initializePlayer() async { + try { + final info = await getMessageInfo(); + if (info != null && mounted) { + BetterPlayerDataSource dataSource; + if (info.type == CurrentVideoType.online) { + dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.network, + info.path, + ); + } else { + dataSource = BetterPlayerDataSource( + BetterPlayerDataSourceType.file, + info.path, + ); + } + + final betterPlayerConfiguration = BetterPlayerConfiguration( + aspectRatio: info.aspectRatio, + fit: BoxFit.contain, + autoPlay: true, + allowedScreenSleep: false, + fullScreenByDefault: false, + controlsConfiguration: const BetterPlayerControlsConfiguration( + enableFullscreen: false, + enablePlayPause: true, + enableProgressBar: true, + enableProgressText: true, + showControlsOnInitialize: false, + enableMute: false, + enableOverflowMenu: false, + enableSkips: false, + ), + ); + + _betterPlayerController = BetterPlayerController( + betterPlayerConfiguration, + betterPlayerDataSource: dataSource, + ); + + if (mounted) { + setState(() {}); + } + } + } catch (e) { + debugPrint("Video initialization error: $e"); + } + } + + @override + void dispose() { + _betterPlayerController?.dispose(); + super.dispose(); + } + + Future getMessageInfo() async { + if (widget.message.elemType == MessageElemType.V2TIM_ELEM_TYPE_VIDEO) { + double aspectRatio = (9 / 16); + + if (widget.isSending) { + var lp = widget.message.videoElem!.videoPath ?? ""; + if (lp.isNotEmpty) { + console("view sending message video path"); + if (File(lp).existsSync() && !kIsWeb) { + return CurrentVideoInfo(path: lp, type: CurrentVideoType.local, aspectRatio: aspectRatio); + } + } + } + + if (widget.message.videoElem!.snapshotWidth != null && widget.message.videoElem!.snapshotHeight != null) { + if (widget.message.videoElem!.snapshotHeight != 0) { + aspectRatio = (widget.message.videoElem!.snapshotWidth!) / (widget.message.videoElem!.snapshotHeight!); + } + } + + if (TencentUtils.checkString(widget.message.videoElem!.videoPath) != null) { + // 先查本地发送的视频地址 + if (File(widget.message.videoElem!.videoPath!).existsSync()) { + console("video: local video path exists"); + return CurrentVideoInfo( + path: widget.message.videoElem!.videoPath!, type: CurrentVideoType.local, aspectRatio: aspectRatio); + } + } else if (TencentUtils.checkString(widget.message.videoElem!.localVideoUrl) != null) { + // 再查本地下载的视频地址 + if (File(widget.message.videoElem!.localVideoUrl!).existsSync()) { + console("video: local url exists"); + return CurrentVideoInfo( + path: widget.message.videoElem!.localVideoUrl!, type: CurrentVideoType.local, aspectRatio: aspectRatio); + } + } else { + // 最后再查在线地址(todo 使用 getMessageOnlineUrl 查询) + if (widget.message.videoElem != null) { + if (widget.message.videoElem!.snapshotUrl != null) { + console("video: online url ${widget.message.videoElem!.videoUrl}"); + return CurrentVideoInfo( + path: widget.message.videoElem!.videoUrl!, + type: CurrentVideoType.online, + aspectRatio: aspectRatio, + ); + } + } + if (!kIsWeb) { + V2TimValueCallback urlres = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .getMessageOnlineUrl(msgID: widget.message.msgID ?? ""); + if (urlres.data != null) { + if (urlres.data?.videoElem != null) { + if (TencentUtils.checkString(urlres.data?.videoElem?.videoUrl) != null) { + console("view video online url ${urlres.data?.videoElem?.videoUrl}"); + return CurrentVideoInfo( + path: urlres.data!.videoElem!.videoUrl!, type: CurrentVideoType.online, aspectRatio: aspectRatio); + } + } + } + } + } + } else { + console("The component received a non-video message parameter. please check"); + } + console("has no view video source. please check"); + return null; + } + + console(String log) { + print("$_tag, $log"); + } + + @override + Widget build(BuildContext context) { + if (widget.message.hasRiskContent == true) { + return const Center( + child: Text( + "Risk Video", + style: TextStyle(color: Colors.white), + ), + ); + } + + if (_betterPlayerController == null) { + return Container(); + } + + return AspectRatio( + aspectRatio: _betterPlayerController!.videoPlayerController?.value.aspectRatio ?? 9 / 16, + child: BetterPlayer( + controller: _betterPlayerController!, + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart new file mode 100644 index 0000000..9142e00 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_merger_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_merger_elem.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/merger_message_screen.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'TIMUIKitMessageReaction/tim_uikit_message_reaction_show_panel.dart'; + +class TIMUIKitMergerElem extends StatefulWidget { + final V2TimMergerElem mergerElem; + final String messageID; + final bool isSelf; + final bool isShowJump; + final VoidCallback? clearJump; + final V2TimMessage message; + final bool? isShowMessageReaction; + final TUIChatSeparateViewModel model; + final MessageItemBuilder? messageItemBuilder; + + const TIMUIKitMergerElem( + {Key? key, + required this.message, + required this.model, + required this.mergerElem, + required this.isSelf, + this.isShowMessageReaction, + required this.messageID, + required this.isShowJump, + this.clearJump, + this.messageItemBuilder}) + : super(key: key); + + @override + State createState() => TIMUIKitMergerElemState(); +} + +class TIMUIKitMergerElemState extends TIMUIKitState { + bool isShowJumpState = false; + late ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + _showJumpColor() { + int shineAmount = 6; + setState(() { + isShowJumpState = true; + }); + Future.delayed(const Duration(milliseconds: 100), () { + if (widget.clearJump != null) { + widget.clearJump!(); + } + }); + Timer.periodic(const Duration(milliseconds: 300), (timer) { + if (mounted) { + setState(() { + isShowJumpState = shineAmount.isOdd ? true : false; + }); + } + if (shineAmount == 0 || !mounted) { + timer.cancel(); + } + shineAmount--; + }); + } + + _handleTap(BuildContext context, TUIChatSeparateViewModel model) async { + try { + if (widget.messageID != "") { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.mergerMessageList, + context: context, + width: MediaQuery.of(context).size.width * 0.7, + title: TIM_t("聊天记录"), + height: MediaQuery.of(context).size.height * 0.7, + child: (onClose) => Scrollbar( + controller: _scrollController, + child: MergerMessageScreen( + messageItemBuilder: widget.messageItemBuilder, model: model, msgID: widget.messageID), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MergerMessageScreen( + messageItemBuilder: widget.messageItemBuilder, model: model, msgID: widget.messageID), + )); + } + } + } catch (e) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("无法定位到原消息"), infoCode: 6660401)); + } + } + + List? _getAbstractList() { + final length = widget.mergerElem.abstractList!.length; + if (length <= 4) { + return widget.mergerElem.abstractList; + } + return widget.mergerElem.abstractList!.getRange(0, 4).toList(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + if (widget.isShowJump) { + Future.delayed(Duration.zero, () { + _showJumpColor(); + }); + } + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return Container( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * (isDesktopScreen ? 0.3 : 0.6)), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: widget.isSelf ? const Radius.circular(10) : Radius.zero, + bottomLeft: const Radius.circular(10), + topRight: widget.isSelf ? Radius.zero : const Radius.circular(10), + bottomRight: const Radius.circular(10), + ), + border: Border.all( + color: isShowJumpState + ? const Color.fromRGBO(245, 166, 35, 1) + : (theme.weakDividerColor ?? CommonColor.weakDividerColor), + width: 1, + ), + ), + child: GestureDetector( + onTap: () { + _handleTap(context, widget.model); + }, + child: Container( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.mergerElem.title!, + softWrap: true, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + maxLines: 1, + style: const TextStyle( + fontSize: 16, + color: Colors.black, + ), + ), + ) + ], + ), + const SizedBox( + height: 4, + ), + // const Divider(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: _getAbstractList()! + .map( + (e) => Row( + children: [ + Expanded( + child: Text( + e, + textAlign: TextAlign.left, + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle( + color: theme.weakTextColor, + fontSize: 12, + ), + ), + ), + ], + ), + ) + .toList(), + ), + const SizedBox( + height: 4, + ), + const Divider(), + Text( + TIM_t("聊天记录"), + style: TextStyle( + color: theme.weakTextColor, + fontSize: 10, + ), + ), + if (widget.isShowMessageReaction ?? true) TIMUIKitMessageReactionShowPanel(message: widget.message) + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/at_member_panel.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/at_member_panel.dart new file mode 100644 index 0000000..c0f7d72 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/at_member_panel.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; + +class AtMemberPanel extends StatefulWidget { + /// messageList widget scroll controller + final AutoScrollController atMemberPanelScroll; + + final ValueChanged onSelectMember; + + // final TextFieldWebController textFieldWebController; + const AtMemberPanel( + // this.textFieldWebController, + {Key? key, + required this.atMemberPanelScroll, + required this.onSelectMember}) + : super(key: key); + + @override + State createState() { + return _AtMemberPanelState(); + } +} + +_getShowName(V2TimGroupMemberFullInfo? item) { + return TencentUtils.checkStringWithoutSpace(item?.nameCard) ?? + TencentUtils.checkStringWithoutSpace(item?.nickName) ?? + TencentUtils.checkStringWithoutSpace(item?.userID); +} + +class _AtMemberPanelState extends TIMUIKitState { + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final chatModal = Provider.of(context); + final List groupMemberList = chatModal.showAtMemberList; + final double positionX = chatModal.atPositionX; + final double positionY = chatModal.atPositionY; + final int activeIndex = chatModal.activeAtIndex; + + if (groupMemberList.isEmpty) { + return Container(); + } + return Positioned( + left: positionX, + bottom: positionY, + child: Container( + constraints: const BoxConstraints(maxHeight: 170, maxWidth: 170), + padding: const EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all(color: const Color(0xFFE5E6E9))), + child: Scrollbar( + controller: widget.atMemberPanelScroll, + child: ListView.builder( + shrinkWrap: true, + itemCount: groupMemberList.length, + controller: widget.atMemberPanelScroll, + itemBuilder: ((context, index) { + final memberItem = groupMemberList[index]; + if (memberItem == null) { + return AutoScrollTag(key: ValueKey(index), controller: widget.atMemberPanelScroll, index: index); + } + final showName = _getShowName(memberItem); + final isAtAll = memberItem.userID == "__kImSDK_MesssageAtALL__"; + return AutoScrollTag( + key: ValueKey(index), + controller: widget.atMemberPanelScroll, + index: index, + child: Material( + color: theme.wideBackgroundColor, + child: InkWell( + onTap: () { + chatModal.activeAtIndex = index; + widget.onSelectMember(memberItem); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: activeIndex == index ? theme.weakBackgroundColor : theme.wideBackgroundColor, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: 24, + width: 24, + child: Avatar(faceUrl: memberItem.faceUrl ?? "", type: 1, showName: showName), + ), + const SizedBox( + width: 8, + ), + Expanded( + child: Text( + isAtAll ? "$showName(${groupMemberList.length - 1})" : showName, + softWrap: false, + style: TextStyle( + fontSize: 14, + overflow: TextOverflow.ellipsis, + fontWeight: memberItem.role == 400 || memberItem.role == 300 + ? FontWeight.w500 + : FontWeight.normal, + color: memberItem.role == 400 || memberItem.role == 300 + ? theme.primaryColor + : theme.darkTextColor), + )) + ], + ), + ), + ), + ), + ); + })), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart new file mode 100644 index 0000000..8b8ccab --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart @@ -0,0 +1,53 @@ +// ignore_for_file: file_names + +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +import 'emoji_text.dart'; + +class DefaultSpecialTextSpanBuilder extends SpecialTextSpanBuilder { + DefaultSpecialTextSpanBuilder({ + this.isUseQQPackage = false, + this.isUseTencentCloudChatPackage = false, + this.isUseTencentCloudChatPackageOldKeys = false, + this.customEmojiStickerList = const [], + this.showAtBackground = false, + this.checkHttpLink = true, + }); + + /// whether show background for @somebody + final bool showAtBackground; + + final bool isUseQQPackage; + + final bool isUseTencentCloudChatPackage; + + final bool isUseTencentCloudChatPackageOldKeys; + + final bool checkHttpLink; + + final List customEmojiStickerList; + + @override + SpecialText? createSpecialText(String flag, + {TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, int? index}) { + if (flag == '') { + return null; + } + + ///index is end index of start flag, so text start index should be index-(flag.length-1) + if (isStart(flag, EmojiText.flag)) { + return EmojiText(textStyle, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + isUseQQPackage: isUseQQPackage, + start: index! - (EmojiText.flag.length - 1), + customEmojiStickerList: customEmojiStickerList); + } else if (isStart(flag, HttpText.flag) && checkHttpLink) { + return HttpText(textStyle, onTap, start: index! - (HttpText.flag.length - 1)); + } + return null; + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart new file mode 100644 index 0000000..6ac259f --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:extended_text/extended_text.dart'; +import 'package:tim_ui_kit_sticker_plugin/constant/emoji.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +///emoji/image text +class EmojiText extends SpecialText { + EmojiText(TextStyle? textStyle, + {this.start, + this.isUseQQPackage = false, + this.isUseTencentCloudChatPackage = false, + this.isUseTencentCloudChatPackageOldKeys = false, + this.customEmojiStickerList = const []}) + : super(EmojiText.flag, ']', textStyle); + static const String flag = '['; + final int? start; + final bool isUseQQPackage; + final bool isUseTencentCloudChatPackage; + final bool isUseTencentCloudChatPackageOldKeys; + final List customEmojiStickerList; + + @override + InlineSpan finishText() { + final String key = toString(); + final EmojiUtil emojiUtil = EmojiUtil( + isUseQQPackage: isUseQQPackage, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + customEmojiStickerList: customEmojiStickerList); + + if (emojiUtil.emojiMap.containsKey(key)) { + double size = 16; + + final TextStyle ts = textStyle!; + if (ts.fontSize != null) { + size = ts.fontSize! * 1.44; + } + + if (isUseQQPackage == true && (emojiUtil.emojiKeyCategoryMap["4349"]?.contains(key) ?? false)) { + return ImageSpan(AssetImage(emojiUtil.emojiMap[key]!, package: "tim_ui_kit_sticker_plugin"), + actualText: key, + imageWidth: size, + imageHeight: size, + start: start!, + // fit: BoxFit.cover, + margin: const EdgeInsets.all(0)); + } else if (isUseTencentCloudChatPackage == true && + (emojiUtil.emojiKeyCategoryMap["tcc1"]?.contains(key) ?? false)) { + return ImageSpan(AssetImage(emojiUtil.emojiMap[key]!, package: "tim_ui_kit_sticker_plugin"), + actualText: key, + imageWidth: size, + imageHeight: size, + start: start!, + // fit: BoxFit.cover, + margin: const EdgeInsets.all(0)); + } else { + return ImageSpan(AssetImage(emojiUtil.emojiMap[key]!), + actualText: key, + imageWidth: size, + imageHeight: size, + start: start!, + // fit: BoxFit.cover, + margin: const EdgeInsets.all(0)); + } + } + + return TextSpan(text: toString(), style: textStyle); + } +} + +class EmojiUtil { + // Private constructor initializing the emoji data + EmojiUtil._internal( + {required this.isUseQQPackage, + required this.isUseTencentCloudChatPackage, + required this.isUseTencentCloudChatPackageOldKeys, + required this.customEmojiStickerList}) { + _emojiMap.addAll(loadDefaultEmojis()); + + final customEmojis = loadCustomEmojis(); + _emojiMap.addAll(customEmojis.$1); + _emojiKeyCategoryMap["custom"] = customEmojis.$2; + } + + final bool isUseQQPackage; + final bool isUseTencentCloudChatPackage; + final bool isUseTencentCloudChatPackageOldKeys; + final List customEmojiStickerList; + + // Load the default emojis into a Map + Map loadDefaultEmojis() { + Map defaultEmojiMap = {}; + for (final emojiGroup in TUIKitStickerConstData.emojiList) { + final groupName = emojiGroup.name; + final keyList = []; + if (isUseQQPackage && groupName == "4349") { + for (final emoji in emojiGroup.list) { + String emojiName = emoji.split('.png')[0]; + defaultEmojiMap['[$emojiName]'] = '$_emojiFilePath/$groupName/$emojiName.png'; + keyList.add('[$emojiName]'); + + final zhKey = TUIKitStickerConstData.emoji4349ZhMapList[emojiName]; + defaultEmojiMap['[$zhKey]'] = '$_emojiFilePath/$groupName/$emojiName.png'; + keyList.add('[$zhKey]'); + } + _emojiKeyCategoryMap[groupName] = keyList; + } + + if (isUseTencentCloudChatPackage && groupName == "tcc1") { + for (final emoji in emojiGroup.list) { + String emojiName = emoji.split('.png')[0]; + String compatibleEmojiName = emojiName; + if (isUseTencentCloudChatPackageOldKeys) { + // use old emoji keys in 3.x version + compatibleEmojiName = getCompatibleEmojiName(emojiName); + } + + defaultEmojiMap['[$compatibleEmojiName]'] = '$_emojiFilePath/$groupName/$emojiName.png'; + keyList.add('[$compatibleEmojiName]'); + } + _emojiKeyCategoryMap[groupName] = keyList; + } + } + return defaultEmojiMap; + } + + // Load the custom emojis into a Map + (Map, List) loadCustomEmojis() { + Map customEmojiMap = {}; + List keyList = []; + for (final customEmojiGroup in customEmojiStickerList) { + for (final customEmoji in customEmojiGroup.list) { + String customEmojiName = customEmoji.split('.png')[0]; + customEmojiMap['[$customEmojiName]'] = '$_emojiFilePath/${customEmojiGroup.name}/$customEmojiName.png'; + keyList.add('[$customEmojiName]'); + } + } + return (customEmojiMap, keyList); + } + + // A Map instance variable to store the emojis and their paths + final Map _emojiMap = {}; + + // A getter method for _emojiMap + Map get emojiMap => _emojiMap; + + // A Map instance variable to store the emojis and their paths + final Map _emojiKeyCategoryMap = {}; + + // A getter method for _emojiMap + Map get emojiKeyCategoryMap => _emojiKeyCategoryMap; + + // An instance variable to store the emoji file path + final String _emojiFilePath = 'assets/custom_face_resource'; + + // Singleton pattern to avoid creating multiple instances of EmojiUtil + static EmojiUtil? _instance; + + // Factory constructor to return the singleton instance of EmojiUtil with custom parameters + factory EmojiUtil( + {bool isUseQQPackage = false, + bool isUseTencentCloudChatPackage = false, + bool isUseTencentCloudChatPackageOldKeys = false, + List customEmojiStickerList = const []}) { + return _instance ??= EmojiUtil._internal( + isUseQQPackage: isUseQQPackage, + customEmojiStickerList: customEmojiStickerList, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys); + } + + static String getCompatibleEmojiName(String emojiName) { + String compatibleEmojiName = emojiName; + try { + compatibleEmojiName = emojiName.split('_')[1]; + // 对特殊字符串 Ok 进行处理 + if (compatibleEmojiName == 'Ok') { + compatibleEmojiName = 'OK'; + } + } catch (e) { + print(e); + } + + return compatibleEmojiName; + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart new file mode 100644 index 0000000..6c469a9 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart @@ -0,0 +1,32 @@ + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart'; +import 'package:extended_text/extended_text.dart'; + +class HttpText extends SpecialText { + HttpText(TextStyle? textStyle, SpecialTextGestureTapCallback? onTap, + {this.start}) + : super(flag, flag, textStyle, onTap: onTap); + static const String flag = '!@TURL#*&\$'; + final int? start; + @override + InlineSpan finishText() { + final String text = getContent(); + + return SpecialTextSpan( + text: text, + actualText: toString(), + start: start!, + + ///caret can move into special text + deleteAll: true, + style: TextStyle(color: LinkUtils.hexToColor("015fff")), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (onTap != null) { + onTap!(toString()); + } + }); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_at_text.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_at_text.dart new file mode 100644 index 0000000..f58ec66 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_at_text.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class AtText extends StatefulWidget { + final String? groupID; + final V2TimGroupInfo? groupInfo; + final List? groupMemberList; + final VoidCallback? closeFunc; + final Function(List memberInfo)? onChooseMember; + final bool canAtAll; + + // some Group type cant @all + final String? groupType; + + const AtText({ + this.groupID, + this.groupType, + Key? key, + this.groupInfo, + this.groupMemberList, + this.closeFunc, + this.onChooseMember, + this.canAtAll = false, + }) : super(key: key); + + @override + State createState() => _AtTextState(); +} + +class _AtTextState extends TIMUIKitState { + final GroupServices _groupServices = serviceLocator(); + final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator(); + + List? groupMemberList; + List? searchMemberList; + + List selectedGroupMemberList = []; + + @override + void initState() { + groupMemberList = widget.groupMemberList; + searchMemberList = groupMemberList; + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _submitAtMemberList() { + if (widget.closeFunc != null) { + widget.closeFunc!(); + } + + if (widget.onChooseMember != null) { + widget.onChooseMember!(selectedGroupMemberList); + } else { + Navigator.pop(context, selectedGroupMemberList); + } + } + + Future> searchGroupMember( + V2TimGroupMemberSearchParam searchParam) async { + final res = await _groupServices.searchGroupMembers(searchParam: searchParam); + + if (res.code == 0) {} + return res; + } + + handleSearchGroupMembers(String searchText, context) async { + final res = await searchGroupMember(V2TimGroupMemberSearchParam( + keywordList: [searchText], + groupIDList: [widget.groupID!], + )); + + if (res.code == 0) { + List list = []; + final searchResult = res.data!.groupMemberSearchResultItems!; + searchResult.forEach((key, value) { + if (value is List) { + for (V2TimGroupMemberFullInfo item in value) { + list.add(item); + } + } + }); + searchMemberList = list; + } + + setState(() { + searchMemberList = isSearchTextExist(searchText) ? searchMemberList : groupMemberList; + }); + } + + bool isSearchTextExist(String? searchText) { + return searchText != null && searchText != ""; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + V2TimUserFullInfo? loginUserInfo = _selfInfoViewModel.loginInfo; + if (loginUserInfo != null) { + searchMemberList?.removeWhere((memberInfo) { + return memberInfo?.userID == loginUserInfo.userID; + }); + } + + Widget mentionedMembersBody() { + return GroupProfileMemberList( + groupType: widget.groupType ?? "", + memberList: searchMemberList ?? [], + canAtAll: widget.canAtAll, + canSelectMember: true, + canSlideDelete: false, + onSelectedMemberChange: (selectedMemberList) { + selectedGroupMemberList = selectedMemberList; + bool isAtAllSelected = selectedGroupMemberList.where((element) { + return element.userID == GroupProfileMemberList.AT_ALL_USER_ID; + }).isNotEmpty; + + if (isAtAllSelected) { + _submitAtMemberList(); + } + }, + touchBottomCallBack: () { + // Get all by once, unnecessary to load more + }, + customTopArea: PlatformUtils().isWeb + ? null + : GroupMemberSearchTextField( + onTextChange: (text) => handleSearchGroupMembers(text, context), + )); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: mentionedMembersBody(), + defaultWidget: Scaffold( + appBar: AppBar( + shadowColor: theme.weakBackgroundColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + leading: Row( + children: [ + IconButton( + padding: const EdgeInsets.only(left: 16), + constraints: const BoxConstraints(), + icon: Image.asset( + 'images/arrow_back.png', + package: 'tencent_cloud_chat_uikit', + height: 34, + width: 34, + color: theme.appbarTextColor, + ), + onPressed: () async { + Navigator.pop(context); + }, + ), + ], + ), + centerTitle: true, + leadingWidth: 100, + title: Text( + TIM_t("选择提醒人"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 17, + ), + ), + actions: [ + TextButton( + onPressed: () { + _submitAtMemberList(); + }, + child: Text( + TIM_t("确定"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ) + ], + ), + body: mentionedMembersBody())); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_call_invite_list.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_call_invite_list.dart new file mode 100644 index 0000000..78b46fe --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_call_invite_list.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_filter_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_value_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class SelectCallInviter extends StatefulWidget { + final String? groupID; + const SelectCallInviter({ + this.groupID, + Key? key, + }) : super(key: key); + + @override + State createState() => _SelectCallInviterState(); +} + +class _SelectCallInviterState extends TIMUIKitState { + final CoreServicesImpl _coreServicesImpl = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + List selectedMember = []; + List? _groupMemberList = []; + String _groupMemberListSeq = "0"; + List? searchMemberList; + String? searchText; + bool loading = true; + + @override + void initState() { + super.initState(); + if (widget.groupID != null) { + _loadGroupMemberList(groupID: widget.groupID!); + } + } + + @override + void dispose() { + super.dispose(); + } + + bool isSearchTextExist(String? searchText) { + return searchText != null && searchText != ""; + } + + Future _loadGroupMemberList({required String groupID, int count = 100, String? seq}) async { + if (seq == null || seq == "" || seq == "0") { + _groupMemberList = []; + } + final String? nextSeq = await _loadGroupMemberListFunction(groupID: groupID, seq: seq, count: count); + if (nextSeq != null && nextSeq != "0" && nextSeq != "") { + return await _loadGroupMemberList(groupID: groupID, count: count, seq: nextSeq); + } else { + setState(() { + _groupMemberList = _groupMemberList; + searchMemberList = _groupMemberList; + loading = true; + }); + } + } + + Future _loadGroupMemberListFunction({required String groupID, int count = 100, String? seq}) async { + if (seq == "0") { + _groupMemberList?.clear(); + } + final res = await _groupServices.getGroupMemberList( + groupID: widget.groupID!, + filter: GroupMemberFilterTypeEnum.V2TIM_GROUP_MEMBER_FILTER_ALL, + count: count, + nextSeq: seq ?? _groupMemberListSeq); + final groupMemberListRes = res.data; + if (res.code == 0 && groupMemberListRes != null) { + final groupMemberListTemp = groupMemberListRes.memberInfoList ?? []; + _groupMemberList = [...?_groupMemberList, ...groupMemberListTemp]; + _groupMemberListSeq = groupMemberListRes.nextSeq ?? "0"; + } + return groupMemberListRes?.nextSeq; + } + + Future> searchGroupMember( + V2TimGroupMemberSearchParam searchParam) async { + final res = await _groupServices.searchGroupMembers(searchParam: searchParam); + + if (res.code == 0) {} + return res; + } + + handleSearchGroupMembers(String searchText, context) async { + loading = true; + if (widget.groupID == null || widget.groupID!.isEmpty) { + return; + } + List currentGroupMember = []; + final res = await searchGroupMember(V2TimGroupMemberSearchParam( + keywordList: [searchText], + groupIDList: [widget.groupID!], + )); + + if (res.code == 0) { + List list = []; + final searchResult = res.data!.groupMemberSearchResultItems!; + searchResult.forEach((key, value) { + if (value is List) { + for (V2TimGroupMemberFullInfo item in value) { + list.add(item); + } + } + }); + + currentGroupMember = list; + } else { + currentGroupMember = []; + } + setState(() { + loading = false; + searchMemberList = isSearchTextExist(searchText) ? currentGroupMember : _groupMemberList; + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Scaffold( + appBar: AppBar( + shadowColor: theme.weakBackgroundColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + leading: TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + TIM_t("取消"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + if (selectedMember.isNotEmpty) { + Navigator.pop(context, selectedMember); + } + }, + child: Text( + TIM_t("完成"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ) + ], + centerTitle: true, + leadingWidth: 80, + title: Text( + TIM_t("发起呼叫"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 17, + ), + ), + ), + body: ((searchMemberList ?? []).isNotEmpty || loading == false) + ? GroupProfileMemberList( + customTopArea: PlatformUtils().isWeb + ? null + : GroupMemberSearchTextField( + onTextChange: (text) => handleSearchGroupMembers(text, context), + ), + memberList: (searchMemberList ?? []) + .where((element) => element?.userID != _coreServicesImpl.loginInfo.userID) + .toList(), + canSlideDelete: false, + canSelectMember: true, + onSelectedMemberChange: (member) { + selectedMember = member; + setState(() {}); + }, + ) + : Center( + child: LoadingAnimationWidget.staggeredDotsWave( + color: theme.primaryColor ?? Colors.grey, + size: 40, + ), + )); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_emoji_panel.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_emoji_panel.dart new file mode 100644 index 0000000..b29daba --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_emoji_panel.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; + +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; + +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class EmojiPanel extends TIMUIKitStatelessWidget { + final void Function(int unicode) onTapEmoji; + final void Function() onSubmitted; + final void Function() delete; + final bool showBottomContainer; + + EmojiPanel({ + Key? key, + required this.onTapEmoji, + required this.onSubmitted, + required this.delete, + this.showBottomContainer = true, // 可选参数,是否展示下方的底部导航栏 + }) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + // ignore: avoid_print + outputLogger.i(TIM_t( + "暂未安装表情包插件,如需使用表情相关功能,请根据本文档安装:https://cloud.tencent.com/document/product/269/70746")); + return SingleChildScrollView( + child: Column( + children: [ + Container( + height: showBottomContainer ? 190 : 248, + // color: theme.weakBackgroundColor, + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(TIM_t("暂无表情包")), + ], + ), + ), + showBottomContainer + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SingleChildScrollView( + child: Container( + // color: Colors.white, + margin: const EdgeInsets.only(right: 25), + // height: MediaQuery.of(context).padding.bottom, + child: ElevatedButton( + child: Text(TIM_t("发送")), + style: ElevatedButton.styleFrom(), + onPressed: () { + onSubmitted(); + })), + ), + ], + ) + : Container() + ], + )); + } +} + +class EmojiItem extends TIMUIKitStatelessWidget { + EmojiItem({Key? key, required this.name, required this.unicode}) + : super(key: key); + final String name; + final int unicode; + + // final String toUser; + // final int type; + // final Function close; + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return DefaultTextStyle( + style: TextStyle( + fontSize: (PlatformUtils().isAndroid) ? 20 : 26, + color: hexToColor("f9453d") + ), + child: Text( + String.fromCharCode(unicode), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_more_panel.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_more_panel.dart new file mode 100644 index 0000000..229e35b --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_more_panel.dart @@ -0,0 +1,741 @@ +// ignore_for_file: unused_field, avoid_print, unused_import + +import 'dart:io'; +import 'package:better_player_plus/better_player_plus.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:fc_native_video_thumbnail/fc_native_video_thumbnail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_call_invite_list.dart'; +import 'package:video_player/video_player.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:path/path.dart' as p; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +// ignore: unnecessary_import +import 'dart:typed_data'; +import 'package:universal_html/html.dart' as html; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; + +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class MorePanelConfig { + static final int FILE_MAX_SIZE = 100 * 1024 * 1024; + static final int VIDEO_MAX_SIZE = 100 * 1024 * 1024; + static final int IMAGE_MAX_SIZE = 28 * 1024 * 1024; + + final bool showGalleryPickAction; + final bool showCameraAction; + final bool showFilePickAction; + final bool showWebImagePickAction; + final bool showWebVideoPickAction; + final bool showVoiceCall; + final bool showVideoCall; + final List? extraAction; + final Widget Function(MorePanelItem item)? actionBuilder; + + MorePanelConfig({ + this.showFilePickAction = true, + this.showGalleryPickAction = true, + this.showCameraAction = true, + this.showWebImagePickAction = true, + this.showWebVideoPickAction = true, + this.showVoiceCall = true, + this.showVideoCall = true, + this.extraAction, + this.actionBuilder, + }); +} + +class MorePanelItem { + final String title; + final String id; + final Widget icon; + final Function(BuildContext context)? onTap; + + MorePanelItem({this.onTap, required this.icon, required this.id, required this.title}); +} + +class MorePanel extends StatefulWidget { + /// 会话ID + final String conversationID; + + /// 会话类型 + final ConvType conversationType; + + final MorePanelConfig? morePanelConfig; + + const MorePanel({required this.conversationID, required this.conversationType, Key? key, this.morePanelConfig}) + : super(key: key); + + @override + State createState() => _MorePanelState(); +} + +class _MorePanelState extends TIMUIKitState { + final ImagePicker _picker = ImagePicker(); + final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator(); + Uint8List? fileContent; + String? fileName; + File? tempFile; + final _tUICore = TUICore(); + final _tUILogin = TUILogin(); + bool isInstallCallkit = false; + final ScrollController _scrollController = ScrollController(); + final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + + late BetterPlayerController _betterPlayerController; + + @override + void initState() { + super.initState(); + if (PlatformUtils().isMobile) { + _tUICore.getService(TUICALLKIT_SERVICE_NAME).then((value) { + setState(() { + isInstallCallkit = value; + }); + }); + _betterPlayerController = BetterPlayerController(const BetterPlayerConfiguration()); + } + } + + List itemList(TUIChatSeparateViewModel model, TUITheme theme) { + final config = widget.morePanelConfig ?? MorePanelConfig(); + return [ + if (!PlatformUtils().isWeb) + MorePanelItem( + id: "photo", + title: TIM_t("照片"), + onTap: (c) { + _onFeatureTap( + "photo", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/photo.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (PlatformUtils().isMobile) + MorePanelItem( + id: "take_photo", + title: TIM_t("拍照"), + onTap: (c) { + _onFeatureTap("take_photo", c, model, theme); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/screen.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (PlatformUtils().isMobile) + MorePanelItem( + id: "take_video", + title: TIM_t("录像"), + onTap: (c) { + _onFeatureTap("take_video", c, model, theme); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: Image.asset( + "images/take_video.png", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (PlatformUtils().isWeb) + MorePanelItem( + id: "image", + title: TIM_t("图片"), + onTap: (c) { + _onFeatureTap( + "image", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/photo.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (PlatformUtils().isWeb) + MorePanelItem( + id: "video", + title: TIM_t("视频"), + onTap: (c) { + _onFeatureTap( + "video", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: Icon(Icons.video_file, color: hexToColor("5c6168"), size: 26), + )), + MorePanelItem( + id: "file", + title: TIM_t("文件"), + onTap: (c) { + _onFeatureTap( + "file", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/file.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (isInstallCallkit && PlatformUtils().isMobile) + MorePanelItem( + id: "videoCall", + title: TIM_t("视频通话"), + onTap: (c) { + _onFeatureTap( + "videoCall", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/video-call.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (isInstallCallkit && PlatformUtils().isMobile) + MorePanelItem( + id: "voiceCall", + title: TIM_t("语音通话"), + onTap: (c) { + _onFeatureTap( + "voiceCall", + c, + model, + theme, + ); + }, + icon: Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(5))), + child: SvgPicture.asset( + "images/voice-call.svg", + package: 'tencent_cloud_chat_uikit', + height: 64, + width: 64, + ), + )), + if (config.extraAction != null) ...?config.extraAction, + ].where((element) { + if (element.id == "screen") { + return config.showCameraAction; + } + + if (element.id == "file") { + return config.showFilePickAction; + } + + if (element.id == "photo") { + return config.showGalleryPickAction; + } + + if (element.id == "image") { + return config.showWebImagePickAction; + } + + if (element.id == "video") { + return config.showWebVideoPickAction; + } + if (element.id == "voiceCall") { + return config.showVoiceCall; + } + if (element.id == "videoCall") { + return config.showVideoCall; + } + return true; + }).toList(); + } + + _sendVideoMessage(String originFilePath, int duration, int size, TUIChatSeparateViewModel model) async { + if (size >= MorePanelConfig.VIDEO_MAX_SIZE) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("文件大小超出了限制"))); + return; + } + + final plugin = FcNativeVideoThumbnail(); + + final convID = widget.conversationID; + final convType = widget.conversationType; + + String tempPath = (await getTemporaryDirectory()).path + p.basename(originFilePath) + ".jpeg"; + + await plugin.getVideoThumbnail( + srcFile: originFilePath, + destFile: tempPath, + format: 'jpeg', + width: 1280, + quality: 100, + height: 1280, + ); + MessageUtils.handleMessageError( + model.sendVideoMessage( + videoPath: originFilePath, duration: duration, snapshotPath: tempPath, convID: convID, convType: convType), + context); + } + + _sendImageMessage(TUIChatSeparateViewModel model, TUITheme theme) async { + try { + if (PlatformUtils().isMobile) { + if (PlatformUtils().isAndroid) { + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + if ((androidInfo.version.sdkInt) >= 33) { + final videos = await Permissions.checkPermission( + context, + Permission.videos.value, + theme, + ); + final photos = await Permissions.checkPermission( + context, + Permission.photos.value, + theme, + ); + if (!videos && !photos) { + return; + } + } else { + final storage = await Permissions.checkPermission( + context, + Permission.storage.value, + theme, + ); + if (!storage) { + return; + } + } + } else { + final photos = await Permissions.checkPermission( + context, + Permission.photos.value, + theme, + ); + if (!photos) { + return; + } + } + } + + final convID = widget.conversationID; + final convType = widget.conversationType; + + if (PlatformUtils().isMobile) { + final pickedAssets = await AssetPicker.pickAssets(context); + + if (pickedAssets != null) { + for (var asset in pickedAssets) { + final originFile = await asset.originFile; + final filePath = originFile?.path; + final type = asset.type; + final size = await originFile!.length(); + if (filePath != null) { + if (type == AssetType.image) { + if (size >= MorePanelConfig.IMAGE_MAX_SIZE) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("文件大小超出了限制"))); + return; + } + + MessageUtils.handleMessageError( + model.sendImageMessage(imagePath: filePath, convID: convID, convType: convType), context); + } + + if (type == AssetType.video) { + _sendVideoMessage(originFile!.path, asset.videoDuration.inSeconds, size, model); + } + } + } + } + } else { + FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.media); + if (result != null && result.files.isNotEmpty) { + File file = File(result.files.single.path!); + final String savePath = file.path; + final String type = + TencentUtils.getFileType(savePath.split(".")[savePath.split(".").length - 1]).split("/")[0]; + + if (type == "image") { + MessageUtils.handleMessageError( + model.sendImageMessage(imagePath: savePath, convID: convID, convType: convType), context); + } else if (type == "video") { + MessageUtils.handleMessageError( + model.sendVideoMessage(videoPath: savePath, convID: convID, convType: convType), context); + } + } else { + throw TypeError(); + } + } + } catch (err) { + outputLogger.i("err: $err"); + } + } + + _sendImageFromCamera(TUIChatSeparateViewModel model, TUITheme theme, {required isVideo}) async { + try { + if (!await Permissions.checkPermission( + context, + Permission.camera.value, + theme, + )) { + return; + } + await Permissions.checkPermission( + context, + Permission.microphone.value, + theme, + ); + + final convID = widget.conversationID; + final convType = widget.conversationType; + final ImagePicker picker = ImagePicker(); + XFile? originFile; + if (isVideo) { + originFile = await picker.pickVideo(source: ImageSource.camera); + } else { + originFile = await picker.pickImage(source: ImageSource.camera); + } + final size = await originFile!.length(); + if (!isVideo) { + if (size >= MorePanelConfig.IMAGE_MAX_SIZE) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("文件大小超出了限制"))); + return; + } + + MessageUtils.handleMessageError( + model.sendImageMessage(imagePath: originFile.path, convID: convID, convType: convType), context); + } else { + // 监听视频准备完成事件 + _betterPlayerController.addEventsListener((event) { + if (event.betterPlayerEventType == BetterPlayerEventType.initialized) { + // 获取视频时长(单位:秒) + int durationInSeconds = _betterPlayerController.videoPlayerController?.value.duration?.inSeconds ?? 0; + _sendVideoMessage(originFile!.path, durationInSeconds, size, model); + } + }); + + // 加载视频源 + _betterPlayerController.setupDataSource( + BetterPlayerDataSource( + BetterPlayerDataSourceType.file, + originFile.path, // 替换为你的视频 URL + ), + ); + } + } catch (error) { + outputLogger.i("err: $error"); + } + } + + _sendImageFileOnWeb(TUIChatSeparateViewModel model) async { + try { + final pickedFile = await _picker.pickImage(source: ImageSource.gallery); + final imageContent = await pickedFile!.readAsBytes(); + fileName = pickedFile.name; + tempFile = File(pickedFile.path); + fileContent = imageContent; + + html.Node? inputElem; + inputElem = html.document.getElementById("__image_picker_web-file-input")?.querySelector("input"); + final convID = widget.conversationID; + final convType = widget.conversationType; + MessageUtils.handleMessageError( + model.sendImageMessage( + inputElement: inputElem, imagePath: tempFile?.path, convID: convID, convType: convType), + context); + } catch (e) { + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + _sendVideoFileOnWeb(TUIChatSeparateViewModel model) async { + try { + final pickedFile = await _picker.pickVideo(source: ImageSource.gallery); + final videoContent = await pickedFile!.readAsBytes(); + fileName = pickedFile.name; + tempFile = File(pickedFile.path); + fileContent = videoContent; + + if (fileName!.split(".")[fileName!.split(".").length - 1] != "mp4") { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频消息仅限 mp4 格式"), infoCode: 6660412)); + return; + } + + html.Node? inputElem; + inputElem = html.document.getElementById("__image_picker_web-file-input")?.querySelector("input"); + final convID = widget.conversationID; + final convType = widget.conversationType; + MessageUtils.handleMessageError( + model.sendVideoMessage( + inputElement: inputElem, videoPath: tempFile?.path, convID: convID, convType: convType), + context); + } catch (e) { + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + _sendFile( + TUIChatSeparateViewModel model, + TUITheme theme, + ) async { + try { + final convID = widget.conversationID; + final convType = widget.conversationType; + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.isNotEmpty) { + if (PlatformUtils().isWeb) { + html.Node? inputElem; + inputElem = html.document.getElementById("__file_picker_web-file-input")?.querySelector("input"); + fileName = result.files.single.name; + + MessageUtils.handleMessageError( + model.sendFileMessage(inputElement: inputElem, fileName: fileName, convID: convID, convType: convType), + context); + return; + } + + String? option2 = result.files.single.path ?? ""; + outputLogger.i(TIM_t_para("选择成功{{option2}}", "选择成功$option2")(option2: option2)); + + File file = File(result.files.single.path!); + final int size = file.lengthSync(); + if (size >= MorePanelConfig.FILE_MAX_SIZE) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("文件大小超出了限制"))); + return; + } + + final String savePath = file.path; + + MessageUtils.handleMessageError( + model.sendFileMessage(filePath: savePath, size: size, convID: convID, convType: convType), context); + } else { + throw TypeError(); + } + } catch (e) { + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + _onFeatureTap( + String id, + BuildContext context, + TUIChatSeparateViewModel model, + TUITheme theme, + ) async { + switch (id) { + case "photo": + _sendImageMessage(model, theme); + break; + case "take_photo": + _sendImageFromCamera(model, theme, isVideo: false); + break; + case "take_video": + _sendImageFromCamera(model, theme, isVideo: true); + break; + case "file": + _sendFile(model, theme); + break; + case "image": + // only for web + _sendImageFileOnWeb(model); + break; + case "video": + // only for web + _sendVideoFileOnWeb(model); + break; + case "voiceCall": + _goToVideoUI(TYPE_AUDIO); + break; + case "videoCall": + _goToVideoUI(TYPE_VIDEO); + break; + } + } + + _goToVideoUI(String type) async { + if (!PlatformUtils().isWeb) { + bool hasCameraPermission = false; + bool hasMicrophonePermission = false; + if (type == TYPE_VIDEO) { + hasCameraPermission = await Permissions.checkPermission(context, Permission.camera.value); + hasMicrophonePermission = await Permissions.checkPermission(context, Permission.microphone.value); + if (!hasCameraPermission || !hasMicrophonePermission) { + return; + } + } else { + hasMicrophonePermission = await Permissions.checkPermission(context, Permission.microphone.value); + if (!hasMicrophonePermission) { + return; + } + } + } + + final isGroup = widget.conversationType == ConvType.group; + if (isGroup) { + List? selectedMember = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SelectCallInviter( + groupID: widget.conversationID, + ), + ), + ); + if (selectedMember != null) { + final inviteMember = selectedMember.map((e) => e.userID).toList(); + _tUICore.callService(TUICALLKIT_SERVICE_NAME, METHOD_NAME_CALL, { + PARAM_NAME_TYPE: type, + PARAM_NAME_USERIDS: inviteMember, + PARAM_NAME_GROUPID: widget.conversationType == ConvType.group ? widget.conversationID : "" + }); + } + } else { + _tUICore.callService(TUICALLKIT_SERVICE_NAME, METHOD_NAME_CALL, { + PARAM_NAME_TYPE: type, + PARAM_NAME_USERIDS: [widget.conversationID], + PARAM_NAME_GROUPID: "" + }); + } + } + + @override + void dispose() { + _betterPlayerController?.dispose(); + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final TUIChatSeparateViewModel model = Provider.of(context); + final screenWidth = MediaQuery.of(context).size.width; + return Container( + height: 248, + decoration: BoxDecoration( + // color: hexToColor("EBF0F6"), + border: Border( + top: BorderSide(width: 1, color: Colors.grey.shade300), + ), + ), + padding: const EdgeInsets.only(top: 20, left: 20, right: 20), + width: screenWidth, + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: Wrap( + spacing: (screenWidth - (23 * 2) - 64 * 4) / 3, + runSpacing: 20, + children: itemList(model, theme) + .map((item) => InkWell( + onTap: () { + if (item.onTap != null) { + item.onTap!(context); + } + }, + child: widget.morePanelConfig?.actionBuilder != null + ? widget.morePanelConfig?.actionBuilder!(item) + : SizedBox( + height: 94, + width: 64, + child: Column( + children: [ + Container( + height: 64, + width: 64, + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration(borderRadius: BorderRadius.all(Radius.circular(5))), + child: item.icon, + ), + Text( + item.title, + style: TextStyle(fontSize: 12, color: theme.darkTextColor), + ) + ], + ), + ))) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_send_sound_message.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_send_sound_message.dart new file mode 100644 index 0000000..0cc17bd --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_send_sound_message.dart @@ -0,0 +1,322 @@ +// ignore_for_file: avoid_print, unused_import + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/sound_record.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class SendSoundMessage extends StatefulWidget { + /// conversation ID + final String conversationID; + + /// control the list to bottom + final VoidCallback onDownBottom; + + /// the conversation type + final ConvType conversationType; + + const SendSoundMessage( + {required this.conversationID, + required this.conversationType, + Key? key, + required this.onDownBottom}) + : super(key: key); + + @override + State createState() => _SendSoundMessageState(); +} + +class _SendSoundMessageState extends TIMUIKitState { + final TUIChatGlobalModel model = serviceLocator(); + String soundTipsText = ""; + bool isRecording = false; + bool isInit = false; + bool isCancelSend = false; + DateTime startTime = DateTime.now(); + List> subscriptions = []; + + OverlayEntry? overlayEntry; + String voiceIcon = "images/voice_volume_1.png"; + double volume = 0.1; + + buildOverLayView(BuildContext context) { + if (overlayEntry == null) { + overlayEntry = OverlayEntry(builder: (content) { + return Positioned( + top: 0, + left: 0, + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Material( + color: Colors.transparent, + type: MaterialType.canvas, + child: Center( + child: Opacity( + opacity: 0.8, + child: Container( + width: 160, + height: 160, + decoration: const BoxDecoration( + color: Color(0xff77797A), + borderRadius: BorderRadius.all(Radius.circular(20.0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + height: 20, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(top: 10), + child: Image.asset( + "images/microphone.png", + width: 50, + height: 60, + package: 'flutter_plugin_record_plus', + ), + ), + ClipRect( + clipBehavior: Clip.hardEdge, + child: Align( + heightFactor: max(min(volume, 1), 0.1), + alignment: Alignment.bottomCenter, + child: SizedBox( + width: 50, + height: 60, + child: Image.asset( + "images/voice_volume_total.png", + width: 50, + height: 60, + package: 'flutter_plugin_record_plus', + ), + ), + ), + ), + ], + ), + const SizedBox( + height: 20, + ), + Text( + soundTipsText, + style: const TextStyle( + fontStyle: FontStyle.normal, + color: Colors.white, + fontSize: 14, + ), + ) + ], + ), + ), + ), + ), + ), + ); + }); + Overlay.of(context).insert(overlayEntry!); + } + } + + onLongPressStart(_) { + if (isInit) { + setState(() { + soundTipsText = TIM_t("手指上滑,取消发送"); + }); + startTime = DateTime.now(); + SoundPlayer.startRecord(); + buildOverLayView(context); + } + } + + onLongPressUpdate(e) { + double height = MediaQuery.of(context).size.height * 0.5 - 240; + double dy = e.localPosition.dy; + + if (dy.abs() > height) { + if (mounted && soundTipsText != TIM_t("松开取消")) { + setState(() { + soundTipsText = TIM_t("松开取消"); + }); + } + } else { + if (mounted && soundTipsText == TIM_t("松开取消")) { + setState(() { + soundTipsText = TIM_t("手指上滑,取消发送"); + }); + } + } + } + + onLongPressEnd(e) { + double dy = e.localPosition.dy; + // 此高度为 160为录音取消组件距离顶部的预留距离 + double height = MediaQuery.of(context).size.height * 0.5 - 240; + if (dy.abs() > height) { + isCancelSend = true; + } else { + isCancelSend = false; + } + if (overlayEntry != null) { + overlayEntry!.remove(); + overlayEntry = null; + } + // Did not receive onStop from FlutterPluginRecord if the duration is too short. + if (DateTime.now().difference(startTime).inSeconds < 1) { + isCancelSend = true; + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("说话时间太短"), + infoCode: 6660404)); + } + stop(); + } + + onLonePressCancel() { + if (isRecording) { + isCancelSend = true; + if (overlayEntry != null) { + overlayEntry!.remove(); + overlayEntry = null; + } + stop(); + } + } + + void stop() { + setState(() { + isRecording = false; + }); + SoundPlayer.stopRecord(); + setState(() { + soundTipsText = TIM_t("手指上滑,取消发送"); + }); + } + + sendSound( + {required String path, + required int duration, + required TUIChatSeparateViewModel model}) { + final convID = widget.conversationID; + final convType = widget.conversationType; + + if (duration > 0) { + if (!isCancelSend) { + MessageUtils.handleMessageError( + model.sendSoundMessage( + soundPath: path, + duration: duration, + convID: convID, + convType: convType), + context); + widget.onDownBottom(); + } else { + isCancelSend = false; + } + } else { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("说话时间太短"), + infoCode: 6660404)); + } + } + + @override + dispose() { + for (var subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + initRecordSound(TUIChatSeparateViewModel model) { + final responseSubscription = SoundPlayer.responseListener((recordResponse) { + final status = recordResponse.msg; + if (status == "onStop") { + if (!isCancelSend) { + final soundPath = recordResponse.path; + final recordDuration = recordResponse.audioTimeLength; + sendSound( + path: soundPath!, duration: recordDuration!.ceil(), model: model); + } + } else if (status == "onStart") { + outputLogger.i("start record"); + setState(() { + isRecording = true; + }); + } else { + outputLogger.i(status.toString()); + } + }); + final amplitudesResponseSubscription = + SoundPlayer.responseFromAmplitudeListener((recordResponse) { + setState(() { + volume = double.parse(recordResponse.msg!) * 1.1; + if (overlayEntry != null) { + overlayEntry!.markNeedsBuild(); + } + }); + }); + subscriptions = [responseSubscription, amplitudesResponseSubscription]; + SoundPlayer.initSoundPlayer(); + isInit = true; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final TUIChatSeparateViewModel model = + Provider.of(context); + return GestureDetector( + onTapDown: (detail) async { + if (!isInit) { + bool hasMicrophonePermission = await Permissions.checkPermission( + context, + Permission.microphone.value, + theme, + ); + if (!hasMicrophonePermission) { + return; + } + initRecordSound(model); + } + }, + onLongPressStart: onLongPressStart, + onLongPressMoveUpdate: onLongPressUpdate, + onLongPressEnd: onLongPressEnd, + onLongPressCancel: onLonePressCancel, + child: Container( + height: 35, + color: isRecording ? theme.weakBackgroundColor : Colors.white, + alignment: Alignment.center, + child: Text( + TIM_t("按住说话"), + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: theme.darkTextColor, + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart new file mode 100644 index 0000000..aeee4f4 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field.dart @@ -0,0 +1,1028 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:diff_match_patch/diff_match_patch.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_setting_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_at_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/narrow.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart'; + +enum MuteStatus { none, me, all } + +typedef CustomStickerPanel = Widget Function({ + void Function() sendTextMessage, + void Function(int index, String data) sendFaceMessage, + void Function() deleteText, + void Function(int unicode) addText, + void Function(String singleEmojiName) addCustomEmojiText, + List defaultCustomEmojiStickerList, + + /// If non-null, requires the child to have exactly this width. + double? width, + + /// If non-null, requires the child to have exactly this height. + double? height, +}); + +class TIMUIKitInputTextField extends StatefulWidget { + /// conversation id + final String conversationID; + + final TIMUIKitChatConfig? chatConfig; + + /// conversation type + final ConvType conversationType; + + /// init text, use for draft text re-view + final String? initText; + + /// messageList widget scroll controller + final AutoScrollController? scrollController; + + /// messageList widget scroll controller + final AutoScrollController? atMemberPanelScroll; + + /// hint text for textField widget + final String? hintText; + + /// config for more panel + final MorePanelConfig? morePanelConfig; + + /// show send audio icon + final bool showSendAudio; + + /// show send emoji icon + final bool showSendEmoji; + + /// show more panel + final bool showMorePanel; + + /// background color + final Color? backgroundColor; + + /// control input field behavior + final TIMUIKitInputTextFieldController? controller; + + /// on text changed + final void Function(String)? onChanged; + + final TUIChatSeparateViewModel model; + + /// Whether to use the default emoji + final bool isUseDefaultEmoji; + + final List customEmojiStickerList; + + /// sticker panel customization + final CustomStickerPanel? customStickerPanel; + + /// Conversation need search + final V2TimConversation currentConversation; + + final String? groupType; + + final String? groupID; + + const TIMUIKitInputTextField( + {Key? key, + required this.conversationID, + required this.conversationType, + this.initText, + this.hintText, + this.scrollController, + this.morePanelConfig, + this.customStickerPanel, + this.showSendAudio = true, + this.showSendEmoji = true, + this.showMorePanel = true, + this.backgroundColor, + this.controller, + this.onChanged, + this.isUseDefaultEmoji = false, + this.customEmojiStickerList = const [], + required this.model, + required this.currentConversation, + this.groupType, + this.atMemberPanelScroll, + this.groupID, + this.chatConfig}) + : super(key: key); + + @override + State createState() => _InputTextFieldState(); +} + +class _InputTextFieldState extends TIMUIKitState { + final TUIChatGlobalModel globalModel = serviceLocator(); + final TUISettingModel settingModel = serviceLocator(); + final RegExp atTextReg = RegExp(r'@([^@\s]*)'); + late FocusNode focusNode; + String zeroWidthSpace = '\ufeff'; + String lastText = ""; + String languageType = ""; + int? currentCursor; + bool isAddingAtSearchWords = false; + double inputWidth = 900; + Map mentionedMembersMap = {}; + late TextEditingController textEditingController; + final TUIConversationViewModel conversationModel = serviceLocator(); + final TUISelfInfoViewModel selfModel = serviceLocator(); + MuteStatus muteStatus = MuteStatus.none; + bool _isComposingText = false; + int latestSendEditStatusTime = DateTime.now().millisecondsSinceEpoch; + List stickerPackageList = []; + + generateStickerList() { + if (widget.customStickerPanel != null) { + // Keep using original scheme. + return; + } + final stickerConfig = widget.model.chatConfig.stickerPanelConfig ?? StickerPanelConfig(); + if (stickerConfig.useTencentCloudChatStickerPackage) { + final tccEmojiSet = TUIKitStickerConstData.emojiList.firstWhere((element) => element.name == "tcc1"); + stickerPackageList.add(CustomStickerPackage( + name: tccEmojiSet.name, + baseUrl: "assets/custom_face_resource/${tccEmojiSet.name}", + isEmoji: tccEmojiSet.isEmoji, + isDefaultEmoji: true, + stickerList: tccEmojiSet.list + .asMap() + .keys + .map((idx) => CustomSticker(index: idx, name: tccEmojiSet.list[idx])) + .toList(), + menuItem: CustomSticker( + index: 0, + name: tccEmojiSet.icon, + ))); + } + + if (stickerConfig.useQQStickerPackage) { + final qqEmojiSet = TUIKitStickerConstData.emojiList.firstWhere((element) => element.name == "4349"); + stickerPackageList.add(CustomStickerPackage( + name: qqEmojiSet.name, + baseUrl: "assets/custom_face_resource/${qqEmojiSet.name}", + isEmoji: qqEmojiSet.isEmoji, + isDefaultEmoji: true, + stickerList: + qqEmojiSet.list.asMap().keys.map((idx) => CustomSticker(index: idx, name: qqEmojiSet.list[idx])).toList(), + menuItem: CustomSticker( + index: 0, + name: qqEmojiSet.icon, + ))); + } + + if (stickerConfig.unicodeEmojiList.isNotEmpty) { + final defEmojiList = TUIKitStickerConstData.defaultUnicodeEmojiList.map((emojiItem) { + return CustomSticker(index: 0, name: emojiItem.toString(), unicode: emojiItem); + }).toList(); + stickerPackageList + .add(CustomStickerPackage(name: "defaultEmoji", stickerList: defEmojiList, menuItem: defEmojiList[0])); + } + + stickerPackageList.addAll(stickerConfig.customStickerPackages); + return stickerPackageList; + } + + _setCurrentCursor(int? value) { + currentCursor = value; + } + + RegExp emojiRegex() => RegExp( + r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)'); + + bool isEmoji(String input) { + return emojiRegex().hasMatch(input); + } + + void _deleteStickerFromText() { + String originalText = textEditingController.text; + + if (originalText == zeroWidthSpace) { + _handleSoftKeyBoardDelete(); + } else if (originalText.isNotEmpty) { + String text = originalText; + final cursorPosition = currentCursor ?? originalText.length; + + if (cursorPosition > 0) { + final EmojiUtil emojiUtil = EmojiUtil(); + int removeLength = 1; + int openBracketIndex = originalText.lastIndexOf('[', cursorPosition - 1); + + if (openBracketIndex != -1 && originalText[cursorPosition - 1] == ']') { + // Small png emoji + String key = originalText.substring(openBracketIndex, cursorPosition); + + if (emojiUtil.emojiMap.containsKey(key)) { + removeLength = cursorPosition - openBracketIndex; + } + } else if (cursorPosition > 1 && isEmoji(originalText.substring(cursorPosition - 2, cursorPosition))) { + removeLength = 2; + } + + text = originalText.substring(0, cursorPosition - removeLength) + originalText.substring(cursorPosition); + currentCursor = (currentCursor ?? removeLength) - removeLength; + } + + textEditingController.text = text; + + if (TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop) { + textEditingController.selection = + TextSelection.fromPosition(TextPosition(offset: currentCursor ?? textEditingController.text.length)); + focusNode.requestFocus(); + } + } + + if (originalText.isEmpty && textEditingController.text.isEmpty) { + widget.model.repliedMessage = null; + } + } + + void _onDeleteText(String oldText) { + if (oldText.isEmpty) { + if (widget.model.repliedMessage != null) { + widget.model.repliedMessage = null; + } + } + } + + void _addStickerToText(String sticker) { + final currentText = textEditingController.text; + if (currentCursor != null && currentCursor! > -1 && currentCursor! < currentText.length + 1) { + final firstString = currentText.substring(0, currentCursor); + final secondString = currentText.substring(currentCursor!); + currentCursor = currentCursor! + sticker.length; + textEditingController.text = "$firstString$sticker$secondString"; + } else { + currentCursor = null; + textEditingController.text = "$currentText$sticker"; + } + + if (TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop) { + textEditingController.selection = + TextSelection.fromPosition(TextPosition(offset: currentCursor ?? textEditingController.text.length)); + focusNode.requestFocus(); + } + } + + String _filterU200b(String text) { + return text.replaceAll(RegExp(r'\ufeff'), ""); + } + + Future handleSetDraftText({String? id, ConvType? convType, String? groupID}) async { + String text = textEditingController.text; + String convID = id ?? widget.conversationID; + final isTopic = convID.contains("@TOPIC#"); + String conversationID = isTopic + ? convID + : ((convType ?? widget.conversationType) == ConvType.c2c + ? "${TUIConversationViewModel.conversationC2CPrefix}$convID" + : "${TUIConversationViewModel.conversationGroupPrefix}$convID"); + String draftText = _filterU200b(text); + return await conversationModel.setConversationDraft( + groupID: groupID ?? widget.groupID, + isTopic: isTopic, + isAllowWeb: widget.model.chatConfig.isUseDraftOnWeb, + conversationID: conversationID, + draftText: draftText); + } + + // 和onSubmitted一样,只是保持焦点的不同 + _onEmojiSubmitted() { + lastText = ""; + final text = textEditingController.text.trim(); + final convType = widget.conversationType; + conversationModel.clearWebDraft(conversationID: widget.conversationID); + if (text.isNotEmpty && text != zeroWidthSpace) { + if (widget.model.repliedMessage != null) { + MessageUtils.handleMessageError( + widget.model.sendReplyMessage( + text: text, + convID: widget.conversationID, + convType: convType, + atUserIDList: getUserIdFromMemberInfoMap(), + ), + context); + } else { + MessageUtils.handleMessageError( + widget.model.sendTextMessage( + text: text, + convID: widget.conversationID, + convType: convType, + ), + context); + } + textEditingController.clear(); + goDownBottom(); + } + currentCursor = null; + } + + // index为emoji的index,data为baseurl+name + _onCustomEmojiFaceSubmitted(int index, String data) { + final convType = widget.conversationType; + + // This part of the code is written to adapt to the Native side requirements. + // It extracts the substring needed to interact with Native side by splitting + // and parsing the given data value. + int groupID = 1; + if (data.contains("yz")) { + groupID = 1; + } + if (data.contains("ys")) { + groupID = 2; + } + if (data.contains("gcs")) { + groupID = 3; + } + + RegExp regex = RegExp(r'assets\/custom_face_resource\/(4350|4351|4352)'); + if (regex.hasMatch(data)) { + data = (data.split("/")[3]).split("@")[0]; + } + + if (widget.model.repliedMessage != null) { + MessageUtils.handleMessageError( + widget.model.sendFaceMessage(index: groupID, data: data, convID: widget.conversationID, convType: convType), + context); + } else { + MessageUtils.handleMessageError( + widget.model.sendFaceMessage(index: groupID, data: data, convID: widget.conversationID, convType: convType), + context); + } + } + + List getUserIdFromMemberInfoMap() { + List userList = []; + mentionedMembersMap.forEach((String key, V2TimGroupMemberFullInfo info) { + userList.add(info.userID); + }); + + return userList; + } + + onSubmitted() async { + conversationModel.clearWebDraft(conversationID: widget.conversationID); + lastText = ""; + final text = textEditingController.text.trim(); + final convType = widget.conversationType; + if (text.isNotEmpty && text != zeroWidthSpace) { + if (widget.model.repliedMessage != null) { + MessageUtils.handleMessageError( + widget.model.sendReplyMessage( + text: text, + convID: widget.conversationID, + convType: convType, + atUserIDList: getUserIdFromMemberInfoMap()), + context); + } else if (mentionedMembersMap.isNotEmpty) { + widget.model.sendTextAtMessage( + text: text, + convType: widget.conversationType, + convID: widget.conversationID, + atUserList: getUserIdFromMemberInfoMap()); + } else { + MessageUtils.handleMessageError( + widget.model.sendTextMessage(text: text, convID: widget.conversationID, convType: convType), context); + } + textEditingController.clear(); + currentCursor = null; + lastText = ""; + mentionedMembersMap = {}; + + goDownBottom(); + _handleSendEditStatus("", false); + } + } + + void goDownBottom() { + if (globalModel.getMessageListPosition(widget.conversationID) == HistoryMessagePosition.notShowLatest) { + return; + } + Future.delayed(const Duration(milliseconds: 50), () { + try { + if (widget.scrollController != null) { + widget.scrollController!.animateTo( + widget.scrollController!.position.minScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } + // ignore: empty_catches + } catch (e) {} + }); + } + + _onCursorChange() { + final selection = textEditingController.selection; + currentCursor = selection.baseOffset; + } + + _handleSoftKeyBoardDelete() { + if (widget.model.repliedMessage != null) { + widget.model.repliedMessage = null; + } + } + + String _getShowName(V2TimGroupMemberFullInfo? item) { + return TencentUtils.checkStringWithoutSpace(item?.nameCard) ?? + TencentUtils.checkStringWithoutSpace(item?.nickName) ?? + TencentUtils.checkStringWithoutSpace(item?.userID) ?? + ""; + } + + mentionMemberInMessage(String? userID, String? nickName) { + if (TencentUtils.checkString(userID) == null) { + focusNode.requestFocus(); + } else { + final memberInfo = widget.model.groupMemberList?.firstWhereOrNull((element) => element?.userID == userID) ?? + V2TimGroupMemberFullInfo( + userID: userID ?? "", + nickName: nickName, + ); + final showName = _getShowName(memberInfo); + mentionedMembersMap["@$showName"] = memberInfo; + String text = "${textEditingController.text}@$showName "; + //please do not delete space + focusNode.requestFocus(); + textEditingController.text = text; + textEditingController.selection = TextSelection.fromPosition(TextPosition(offset: text.length)); + lastText = text; + _isComposingText = false; + narrowTextFieldKey.currentState?.showKeyboard = true; + } + } + + bool shouldRemoveAtTag(String atTag, String deletedChar) { + final atMemberArray = []; + mentionedMembersMap.forEach((key, value) { + atMemberArray.add(key); + }); + for (String member in atMemberArray) { + if (atTag == member && member.contains(deletedChar)) { + return true; + } + } + return false; + } + + Offset getAtPosition(String text, int atPlace) { + final textBeforeAt = text.substring(0, atPlace + 1); + final textPainter = TextPainter( + text: TextSpan(text: textBeforeAt, style: const TextStyle(fontSize: 14)), + textDirection: TextDirection.ltr, + maxLines: null, + ); + textPainter.layout(maxWidth: inputWidth); + final TextPosition lastLineOffset = textPainter.getPositionForOffset(Offset(textPainter.width, textPainter.height)); + final Offset caretPosition = textPainter.getOffsetForCaret(lastLineOffset, Rect.zero); + final dx = min(inputWidth - 180, caretPosition.dx + 16); + final dy = max(24, 21 * widget.model.chatConfig.desktopMessageInputFieldLines - caretPosition.dy).toDouble(); + + return Offset(dx, dy); + } + + calculateRemoveRemainAt(String text) { + Map map = {}; + Iterable matches = atTextReg.allMatches(text); + List parseAtList = []; + for (final item in matches) { + final str = item.group(0); + parseAtList.add(str); + } + for (String? key in parseAtList) { + if (key != null && mentionedMembersMap[key] != null) { + map[key] = mentionedMembersMap[key]!; + } + } + mentionedMembersMap = map; + } + + updateMentionedMap() { + Map map = {}; + Iterable matches = atTextReg.allMatches(textEditingController.text); + List parseAtList = []; + for (final item in matches) { + final str = item.group(0); + parseAtList.add(str); + } + for (String? key in parseAtList) { + if (key != null && mentionedMembersMap[key] != null) { + map[key] = mentionedMembersMap[key]!; + } + } + mentionedMembersMap = map; + } + + (int, String, bool)? findChangedCharacter(String originalString, String newString) { + if (newString.length < originalString.length) { + final originalStringLength = originalString.length; + final newStringLength = newString.length; + for (int i = 0; i < newString.length; ++i) { + if (originalString[originalStringLength - i - 1] != newString[newStringLength - i - 1]) { + return (newStringLength - i, originalString[originalStringLength - i - 1], false); + } + } + return (newString.length, originalString[newString.length], false); + } else if (newString.length > originalString.length) { + for (int i = 0; i < originalString.length; ++i) { + if (originalString[i] != newString[i]) { + return (i, newString[i], true); + } + } + return (originalString.length, newString[originalString.length], true); + } else { + return null; + } + } + + _handleAtText(String text, TUIChatSeparateViewModel model) async { + final text = textEditingController.text; + final String originalText = lastText; + String? groupID = widget.conversationType == ConvType.group ? widget.conversationID : null; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (groupID == null) { + lastText = text; + return; + } + + int textLength = text.length; + // 删除的话 + if (originalText.length > textLength) { + final List differencesList = diff(originalText, text); + final diffIndex = differencesList.first.text.length - 1; + int atIndex = originalText.lastIndexOf('@', diffIndex); + int spaceIndex = originalText.indexOf(' ', diffIndex); + if (diffIndex < 0 || atIndex < 0 || spaceIndex <= atIndex) { + lastText = text; + } else { + String atTag = originalText.substring(atIndex, spaceIndex); + String deletedChar = originalText[diffIndex]; + if (shouldRemoveAtTag(atTag, deletedChar)) { + final newText = originalText.substring(0, atIndex) + originalText.substring(spaceIndex + 1); + textEditingController.text = newText; + textEditingController.selection = TextSelection.collapsed(offset: atIndex); + lastText = newText; + updateMentionedMap(); + return; + } + } + updateMentionedMap(); + } + + final int selfRole = widget.model.selfMemberInfo?.role ?? 0; + final bool canAtAll = widget.model.chatConfig.isMemberCanAtAll + ? true + : (selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN || + selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER); + + if (isDesktopScreen) { + (int, String, bool)? changedCharacterRecord = findChangedCharacter(originalText, text); + int? changedTextPosition = changedCharacterRecord?.$1; + String? changedCharacter = changedCharacterRecord?.$2; + bool isAdded = changedCharacterRecord?.$3 ?? false; + + String? subText, keyword; + int? atPlace; + + if (changedTextPosition != null) { + subText = isAdded == true ? text.substring(0, changedTextPosition + 1) : text.substring(0, changedTextPosition); + atPlace = subText.lastIndexOf('@'); + if (atPlace != -1) { + keyword = text.substring(atPlace + 1, changedTextPosition + (isAdded ? 1 : 0)); + } + } else { + atPlace = -1; + } + + if (atPlace >= 0) { + if (isAdded && changedCharacter == "@") { + final atPosition = getAtPosition(text, atPlace); + model.atPositionX = atPosition.dx; + model.atPositionY = atPosition.dy; + isAddingAtSearchWords = true; + } + List showAtMemberList = (model.groupMemberList ?? []) + .where((element) { + final showName = (TencentUtils.checkStringWithoutSpace(element?.friendRemark) ?? + TencentUtils.checkStringWithoutSpace(element?.nameCard) ?? + TencentUtils.checkStringWithoutSpace(element?.nickName) ?? + TencentUtils.checkStringWithoutSpace(element?.userID) ?? + "") + .toLowerCase(); + keyword ??= ""; + return element != null && + showName.contains(keyword!.toLowerCase()) && + TencentUtils.checkString(showName) != null && + element.userID != widget.model.selfMemberInfo?.userID; + }) + .whereType() + .toList(); + + showAtMemberList.sort((V2TimGroupMemberFullInfo userA, V2TimGroupMemberFullInfo userB) { + final isUserAIsGroupAdmin = userA.role == 300; + final isUserAIsGroupOwner = userA.role == 400; + + final isUserBIsGroupAdmin = userB.role == 300; + final isUserBIsGroupOwner = userB.role == 400; + + final String userAName = _getShowName(userA); + final String userBName = _getShowName(userB); + + if (isUserAIsGroupOwner != isUserBIsGroupOwner) { + return isUserAIsGroupOwner ? -1 : 1; + } + + if (isUserAIsGroupAdmin != isUserBIsGroupAdmin) { + return isUserAIsGroupAdmin ? -1 : 1; + } + + return userAName.compareTo(userBName); + }); + + keyword ??= ""; + if (canAtAll && showAtMemberList.isNotEmpty && keyword!.isEmpty) { + showAtMemberList = [ + V2TimGroupMemberFullInfo(userID: "__kImSDK_MesssageAtALL__", nickName: TIM_t("所有人")), + ...showAtMemberList + ]; + } + + model.activeAtIndex = 0; + model.showAtMemberList = showAtMemberList; + + isAddingAtSearchWords = showAtMemberList.isNotEmpty; + } else { + model.activeAtIndex = -1; + model.showAtMemberList = []; + isAddingAtSearchWords = false; + } + } else if (textLength > 0 && text[textLength - 1] == "@" && lastText.length < textLength) { + List selectedAtMemberList = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AtText( + groupMemberList: model.groupMemberList, + groupInfo: model.groupInfo, + groupID: groupID, + canAtAll: canAtAll, + groupType: widget.groupType), + ), + ); + + for (int i = 0; i < selectedAtMemberList.length; ++i) { + V2TimGroupMemberFullInfo memberInfo = selectedAtMemberList[i]; + final showName = _getShowName(memberInfo); + if (memberInfo != null) { + mentionedMembersMap["@$showName"] = memberInfo; + String addAtCharacter = i == 0 ? "" : "@"; + textEditingController.text = "${textEditingController.text}$addAtCharacter$showName "; + lastText = "${textEditingController.text}$addAtCharacter$showName "; + } + } + } + + lastText = textEditingController.text; + } + + void replaceAtTag(String selectedMember) { + int cursorPosition = textEditingController.selection.baseOffset; + int atIndex = textEditingController.text.lastIndexOf('@', cursorPosition - 1); + if (atIndex >= 0) { + String beforeAt = textEditingController.text.substring(0, atIndex); + String afterAt = textEditingController.text.substring(cursorPosition); + textEditingController.text = beforeAt + '@' + selectedMember + ' ' + afterAt; + textEditingController.selection = TextSelection.collapsed(offset: atIndex + selectedMember.length + 2); + lastText = beforeAt + '@' + selectedMember + ' ' + afterAt; + } + } + + void handleAtMember({V2TimGroupMemberFullInfo? memberInfo, bool? isAddToCursorPosition = false}) { + if (memberInfo != null) { + final String showName = _getShowName(memberInfo); + mentionedMembersMap["@$showName"] = memberInfo; + replaceAtTag(showName); + widget.model.showAtMemberList = []; + widget.model.activeAtIndex = -1; + focusNode.requestFocus(); + } + } + + KeyEventResult handleDesktopKeyEvent(FocusNode node, RawKeyEvent event) { + final activeIndex = widget.model.activeAtIndex; + final showMemberList = widget.model.showAtMemberList; + final isPressEnter = + (event.physicalKey == PhysicalKeyboardKey.enter) || (event.physicalKey == PhysicalKeyboardKey.numpadEnter); + if (event.runtimeType == RawKeyDownEvent) { + if (event.physicalKey == PhysicalKeyboardKey.backspace) { + if (textEditingController.text.isEmpty && lastText.isEmpty) { + widget.model.repliedMessage = null; + return KeyEventResult.handled; + } + } else if ((event.isShiftPressed || event.isAltPressed || event.isControlPressed || event.isMetaPressed) && + isPressEnter) { + final offset = textEditingController.selection.baseOffset; + textEditingController.text = '${lastText.substring(0, offset)}\n${lastText.substring(offset)}'; + textEditingController.selection = TextSelection.fromPosition(TextPosition(offset: offset + 1)); + lastText = textEditingController.text; + + return KeyEventResult.handled; + } else if (isPressEnter) { + if (!_isComposingText) { + if (!isAddingAtSearchWords || widget.model.showAtMemberList.isEmpty) { + onSubmitted(); + } else { + isAddingAtSearchWords = false; + final V2TimGroupMemberFullInfo? memberInfo = showMemberList[activeIndex]; + if (memberInfo != null) { + handleAtMember(memberInfo: memberInfo, isAddToCursorPosition: true); + } + } + return KeyEventResult.handled; + } + } + + if (event.isKeyPressed(LogicalKeyboardKey.arrowUp) && isAddingAtSearchWords && showMemberList.isNotEmpty) { + final newIndex = max(activeIndex - 1, 0); + widget.model.activeAtIndex = newIndex; + widget.atMemberPanelScroll?.scrollToIndex(newIndex, preferPosition: AutoScrollPosition.middle); + return KeyEventResult.handled; + } + + if (event.isKeyPressed(LogicalKeyboardKey.arrowDown) && isAddingAtSearchWords && showMemberList.isNotEmpty) { + final newIndex = min(activeIndex + 1, showMemberList.length - 1); + widget.model.activeAtIndex = newIndex; + widget.atMemberPanelScroll?.scrollToIndex(newIndex, preferPosition: AutoScrollPosition.middle); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + @override + void initState() { + super.initState(); + if (PlatformUtils().isWeb || PlatformUtils().isDesktop) { + focusNode = FocusNode( + onKey: (node, event) => handleDesktopKeyEvent(node, event), + ); + } else { + focusNode = FocusNode(); + } + textEditingController = widget.controller?.textEditingController ?? TextEditingController(); + if (widget.initText != null) { + textEditingController.text = widget.initText!; + } + if (widget.controller != null) { + widget.controller?.addListener(controllerHandler); + } + final AppLocale appLocale = I18nUtils.findDeviceLocale(null); + languageType = (appLocale == AppLocale.zhHans || appLocale == AppLocale.zhHant) ? 'zh' : 'en'; + textEditingController.addListener(() { + _isComposingText = textEditingController.value.composing.start != -1; + }); + generateStickerList(); + } + + controllerHandler() { + final actionType = widget.controller?.actionType; + if (actionType == ActionType.longPressToAt) { + mentionMemberInMessage(widget.controller?.atUserID, widget.controller?.atUserName); + } else if (actionType == ActionType.setTextField) { + final newText = widget.controller?.inputText ?? ""; + textEditingController.text = newText; + textEditingController.selection = + TextSelection.fromPosition(TextPosition(offset: textEditingController.text.length)); + lastText = textEditingController.text; + focusNode.requestFocus(); + return; + } else if (actionType == ActionType.requestFocus) { + focusNode.requestFocus(); + return; + } else if (actionType == ActionType.handleAtMember) { + handleAtMember(memberInfo: widget.controller?.groupMemberFullInfo); + return; + } + } + + @override + void didUpdateWidget(TIMUIKitInputTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.conversationID != oldWidget.conversationID) { + mentionedMembersMap.clear(); + handleSetDraftText( + id: oldWidget.conversationID, convType: oldWidget.conversationType, groupID: oldWidget.groupID); + if (oldWidget.initText != widget.initText) { + textEditingController.text = widget.initText ?? ""; + } else { + textEditingController.clear(); + } + } + if (widget.initText != oldWidget.initText && TencentUtils.checkString(widget.initText) != null) { + textEditingController.text = widget.initText!; + focusNode.requestFocus(); + } + } + + @override + void dispose() { + handleSetDraftText(); + if (widget.controller != null) { + widget.controller?.removeListener(controllerHandler); + } + focusNode.dispose(); + super.dispose(); + } + + Future getMemberMuteStatus(String userID) async { + // Get the mute state of the members recursively + if (widget.model.groupMemberList?.any((item) => (item?.userID == userID)) ?? false) { + final int muteUntil = + widget.model.groupMemberList?.firstWhere((item) => (item?.userID == userID))?.muteUntil ?? 0; + return muteUntil * 1000 > DateTime.now().millisecondsSinceEpoch; + } else { + return false; + } + } + + _getMuteType(TUIChatSeparateViewModel model) async { + if (!mounted) { + return; + } + + final int selfRole = widget.model.selfMemberInfo?.role ?? 0; + final bool willNotBeenMuted = (selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN || + selfRole == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER); + + if (widget.conversationType == ConvType.group && !willNotBeenMuted) { + if ((model.groupInfo?.isAllMuted ?? false) && muteStatus != MuteStatus.all) { + Future.delayed(const Duration(seconds: 0), () { + setState(() { + muteStatus = MuteStatus.all; + }); + }); + } else if (selfModel.loginInfo?.userID != null && + await getMemberMuteStatus(selfModel.loginInfo!.userID!) && + muteStatus != MuteStatus.me) { + Future.delayed(const Duration(seconds: 0), () { + setState(() { + muteStatus = MuteStatus.me; + }); + }); + } else if (!(model.groupInfo?.isAllMuted ?? false) && + !(selfModel.loginInfo?.userID != null && await getMemberMuteStatus(selfModel.loginInfo!.userID!)) && + muteStatus != MuteStatus.none) { + Future.delayed(const Duration(seconds: 0), () { + setState(() { + muteStatus = MuteStatus.none; + }); + }); + } + } + } + + _handleSendEditStatus(String value, bool status) { + int now = DateTime.now().millisecondsSinceEpoch; + if (value.isNotEmpty && widget.conversationType == ConvType.c2c) { + if (status) { + if (now - latestSendEditStatusTime < 5 * 1000) { + return; + } + } + // send status + globalModel.sendEditStatusMessage(status, widget.conversationID); + latestSendEditStatusTime = now; + } else { + globalModel.sendEditStatusMessage(false, widget.conversationID); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final TUIChatSeparateViewModel model = Provider.of(context); + + _getMuteType(model); + + return Selector( + builder: ((context, value, child) { + String? getForbiddenText() { + if (!(model.isGroupExist)) { + return "群组不存在"; + } else if (model.isNotAMember) { + return "您不是群成员"; + } else if (muteStatus == MuteStatus.all) { + return "全员禁言中"; + } else if (muteStatus == MuteStatus.me) { + return "您被禁言"; + } + return null; + } + + final forbiddenText = getForbiddenText(); + return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + inputWidth = constraints.maxWidth; + return TUIKitScreenUtils.getDeviceWidget( + context: context, + defaultWidget: TIMUIKitTextFieldLayoutNarrow( + stickerPackageList: stickerPackageList, + onEmojiSubmitted: _onEmojiSubmitted, + onCustomEmojiFaceSubmitted: _onCustomEmojiFaceSubmitted, + backSpaceText: _deleteStickerFromText, + addStickerToText: _addStickerToText, + customStickerPanel: widget.customStickerPanel, + onChanged: widget.onChanged, + onDeleteText: _onDeleteText, + backgroundColor: widget.backgroundColor, + morePanelConfig: widget.morePanelConfig, + repliedMessage: value, + currentCursor: currentCursor, + hintText: widget.hintText, + isUseDefaultEmoji: widget.isUseDefaultEmoji, + languageType: languageType, + textEditingController: textEditingController, + conversationID: widget.conversationID, + conversationType: widget.conversationType, + focusNode: focusNode, + controller: widget.controller, + setCurrentCursor: _setCurrentCursor, + onCursorChange: _onCursorChange, + model: model, + handleSendEditStatus: _handleSendEditStatus, + handleAtText: (text) { + _handleAtText(text, model); + }, + handleSoftKeyBoardDelete: _handleSoftKeyBoardDelete, + onSubmitted: onSubmitted, + goDownBottom: goDownBottom, + showSendAudio: widget.showSendAudio, + showSendEmoji: widget.showSendEmoji, + showMorePanel: widget.showMorePanel, + customEmojiStickerList: widget.customEmojiStickerList), + desktopWidget: TIMUIKitTextFieldLayoutWide( + stickerPackageList: stickerPackageList, + chatConfig: widget.chatConfig ?? widget.model.chatConfig, + theme: theme, + currentConversation: widget.currentConversation, + onEmojiSubmitted: _onEmojiSubmitted, + onCustomEmojiFaceSubmitted: _onCustomEmojiFaceSubmitted, + backSpaceText: _deleteStickerFromText, + addStickerToText: _addStickerToText, + customStickerPanel: widget.customStickerPanel, + onChanged: widget.onChanged, + backgroundColor: widget.backgroundColor, + morePanelConfig: widget.morePanelConfig, + repliedMessage: value, + currentCursor: currentCursor, + hintText: widget.hintText, + isUseDefaultEmoji: widget.isUseDefaultEmoji, + languageType: languageType, + textEditingController: textEditingController, + conversationID: widget.conversationID, + conversationType: widget.conversationType, + focusNode: focusNode, + controller: widget.controller, + setCurrentCursor: _setCurrentCursor, + onCursorChange: _onCursorChange, + model: model, + handleSendEditStatus: _handleSendEditStatus, + handleAtText: (text) { + _handleAtText(text, model); + }, + onSubmitted: onSubmitted, + goDownBottom: goDownBottom, + showSendAudio: widget.showSendAudio, + showSendEmoji: widget.showSendEmoji, + showMorePanel: widget.showMorePanel, + customEmojiStickerList: widget.customEmojiStickerList)); + }); + }), + selector: (c, model) => model.repliedMessage); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart new file mode 100644 index 0000000..73dd09c --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_controller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; + +enum ActionType { hideAllPanel, longPressToAt, setTextField, requestFocus, handleAtMember } + +class TIMUIKitInputTextFieldController extends ChangeNotifier { + TextEditingController? textEditingController = TextEditingController(); + ActionType? actionType; + String? atUserName; + String? atUserID; + String inputText = ""; + V2TimGroupMemberFullInfo? groupMemberFullInfo; + + TIMUIKitInputTextFieldController([TextEditingController? controller]) { + if (controller != null) { + textEditingController = controller; + } + } + + /// text field unfocused and hide all panel + hideAllPanel() { + actionType = ActionType.hideAllPanel; + notifyListeners(); + } + + longPressToAt(String? userName, String? userID) { + actionType = ActionType.longPressToAt; + atUserName = userName; + atUserID = userID; + notifyListeners(); + } + + setTextField(String text) { + inputText = text; + actionType = ActionType.setTextField; + notifyListeners(); + } + + requestFocus() { + actionType = ActionType.requestFocus; + notifyListeners(); + } + + handleAtMember(V2TimGroupMemberFullInfo? memberInfo) { + actionType = ActionType.handleAtMember; + groupMemberFullInfo = memberInfo; + notifyListeners(); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/narrow.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/narrow.dart new file mode 100644 index 0000000..5400a65 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/narrow.dart @@ -0,0 +1,632 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_setting_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_send_sound_message.dart'; +import 'package:tencent_keyboard_visibility/tencent_keyboard_visibility.dart'; + +GlobalKey<_TIMUIKitTextFieldLayoutNarrowState> narrowTextFieldKey = GlobalKey(); + +class TIMUIKitTextFieldLayoutNarrow extends StatefulWidget { + /// sticker panel customization + final CustomStickerPanel? customStickerPanel; + + final VoidCallback onEmojiSubmitted; + final Function(int, String) onCustomEmojiFaceSubmitted; + final Function(String, bool) handleSendEditStatus; + final VoidCallback backSpaceText; + final ValueChanged addStickerToText; + + final ValueChanged handleAtText; + + /// Whether to use the default emoji + final bool isUseDefaultEmoji; + + final bool isUseTencentCloudChatPackageOldKeys; + + final TUIChatSeparateViewModel model; + + /// background color + final Color? backgroundColor; + + /// control input field behavior + final TIMUIKitInputTextFieldController? controller; + + /// config for more panel + final MorePanelConfig? morePanelConfig; + + final String languageType; + + final TextEditingController textEditingController; + + /// conversation id + final String conversationID; + + /// conversation type + final ConvType conversationType; + + final FocusNode focusNode; + + /// show more panel + final bool showMorePanel; + + /// hint text for textField widget + final String? hintText; + + final int? currentCursor; + + final ValueChanged setCurrentCursor; + + final VoidCallback onCursorChange; + + /// show send audio icon + final bool showSendAudio; + + final VoidCallback handleSoftKeyBoardDelete; + + /// on text changed + final void Function(String)? onChanged; + + final V2TimMessage? repliedMessage; + + final void Function(String)? onDeleteText; + + /// show send emoji icon + final bool showSendEmoji; + + final VoidCallback onSubmitted; + + final VoidCallback goDownBottom; + + final List customEmojiStickerList; + + final List stickerPackageList; + + const TIMUIKitTextFieldLayoutNarrow( + {Key? key, + this.customStickerPanel, + required this.onEmojiSubmitted, + required this.onCustomEmojiFaceSubmitted, + required this.backSpaceText, + required this.addStickerToText, + required this.isUseDefaultEmoji, + this.isUseTencentCloudChatPackageOldKeys = false, + required this.languageType, + required this.textEditingController, + this.morePanelConfig, + required this.conversationID, + required this.conversationType, + required this.focusNode, + this.currentCursor, + required this.setCurrentCursor, + required this.onCursorChange, + required this.model, + this.backgroundColor, + this.onChanged, + this.onDeleteText, + required this.handleSendEditStatus, + required this.handleAtText, + required this.handleSoftKeyBoardDelete, + this.repliedMessage, + required this.onSubmitted, + required this.goDownBottom, + required this.showSendAudio, + required this.showSendEmoji, + required this.showMorePanel, + this.hintText, + required this.customEmojiStickerList, + this.controller, + required this.stickerPackageList}) + : super(key: key); + + @override + State createState() => _TIMUIKitTextFieldLayoutNarrowState(); +} + +class _TIMUIKitTextFieldLayoutNarrowState extends TIMUIKitState { + final TUISettingModel settingModel = serviceLocator(); + + bool showMore = false; + bool showMoreButton = true; + bool showSendSoundText = false; + bool showEmojiPanel = false; + bool showKeyboard = false; + Function? setKeyboardHeight; + double? bottomPadding; + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + widget.controller?.addListener( + () { + final actionType = widget.controller?.actionType; + if (actionType == ActionType.hideAllPanel) { + hideAllPanel(); + } + }, + ); + } + } + + void setSendButton() { + final value = widget.textEditingController.text; + if (isWebDevice() || isAndroidDevice()) { + if (value.isEmpty && showMoreButton != true) { + setState(() { + showMoreButton = true; + }); + } else if (value.isNotEmpty && showMoreButton == true) { + setState(() { + showMoreButton = false; + }); + } + } + } + + hideAllPanel() { + widget.focusNode.unfocus(); + widget.currentCursor == null; + if (showKeyboard != false || showMore != false || showEmojiPanel != false) { + setState(() { + showKeyboard = false; + showMore = false; + showEmojiPanel = false; + }); + } + } + + Widget _getBottomContainer(TUITheme theme) { + if (showEmojiPanel) { + return widget.customStickerPanel != null + ? widget.customStickerPanel!( + sendTextMessage: () { + widget.onEmojiSubmitted(); + setSendButton(); + }, + sendFaceMessage: widget.onCustomEmojiFaceSubmitted, + deleteText: () { + widget.backSpaceText(); + setSendButton(); + }, + addText: (int unicode) { + final newText = String.fromCharCode(unicode); + widget.addStickerToText(newText); + setSendButton(); + // handleSetDraftText(); + }, + addCustomEmojiText: ((String singleEmojiName) { + String? emojiName = singleEmojiName.split('.png')[0]; + String compatibleEmojiName = emojiName; + if (widget.isUseTencentCloudChatPackageOldKeys) { + compatibleEmojiName = EmojiUtil.getCompatibleEmojiName(emojiName); + } + + String newText = '[$compatibleEmojiName]'; + widget.addStickerToText(newText); + setSendButton(); + }), + defaultCustomEmojiStickerList: widget.isUseDefaultEmoji ? TUIKitStickerConstData.emojiList : []) + : StickerPanel( + isWideScreen: false, + sendTextMsg: () { + widget.onEmojiSubmitted(); + setSendButton(); + }, + sendFaceMsg: widget.onCustomEmojiFaceSubmitted, + deleteText: () { + widget.backSpaceText(); + setSendButton(); + }, + addText: (int unicode) { + final newText = String.fromCharCode(unicode); + widget.addStickerToText(newText); + setSendButton(); + // handleSetDraftText(); + }, + addCustomEmojiText: ((String singleEmojiName) { + String? emojiName = singleEmojiName.split('.png')[0]; + String compatibleEmojiName = emojiName; + if (widget.isUseTencentCloudChatPackageOldKeys) { + compatibleEmojiName = EmojiUtil.getCompatibleEmojiName(emojiName); + } + + String newText = '[$compatibleEmojiName]'; + widget.addStickerToText(newText); + setSendButton(); + }), + customStickerPackageList: widget.stickerPackageList, + lightPrimaryColor: theme.lightPrimaryColor); + } + + if (showMore) { + return MorePanel( + morePanelConfig: widget.morePanelConfig, + conversationID: widget.conversationID, + conversationType: widget.conversationType); + } + + return const SizedBox(height: 0); + } + + double _getBottomHeight() { + if (showKeyboard) { + final currentKeyboardHeight = MediaQuery.of(context).viewInsets.bottom; + double originHeight = settingModel.keyboardHeight; + if (currentKeyboardHeight != 0) { + if (currentKeyboardHeight >= originHeight) { + originHeight = currentKeyboardHeight; + } + if (setKeyboardHeight != null) { + setKeyboardHeight!(currentKeyboardHeight); + } + } + final height = originHeight != 0 ? originHeight : currentKeyboardHeight; + return height; + } else if (showMore || showEmojiPanel) { + return 248.0 + (bottomPadding ?? 0.0); + } else if (widget.textEditingController.text.length >= 46 && showKeyboard == false) { + return 25 + (bottomPadding ?? 0.0); + } else { + return bottomPadding ?? 0; + } + } + + _openMore() { + if (!showMore) { + widget.focusNode.unfocus(); + widget.setCurrentCursor(null); + } + setState(() { + showKeyboard = false; + showEmojiPanel = false; + showSendSoundText = false; + showMore = !showMore; + }); + } + + _openEmojiPanel() { + widget.onCursorChange(); + showKeyboard = showEmojiPanel; + if (showEmojiPanel) { + widget.focusNode.requestFocus(); + } else { + widget.focusNode.unfocus(); + } + + setState(() { + showMore = false; + showSendSoundText = false; + showEmojiPanel = !showEmojiPanel; + }); + } + + _debounce( + Function(String text) fun, [ + Duration delay = const Duration(milliseconds: 30), + ]) { + Timer? timer; + return (String text) { + if (timer != null) { + timer?.cancel(); + } + + timer = Timer(delay, () { + fun(text); + }); + }; + } + + String getAbstractMessage(V2TimMessage message) { + final String? customAbstractMessage = + widget.model.abstractMessageBuilder != null ? widget.model.abstractMessageBuilder!(message) : null; + return customAbstractMessage ?? MessageUtils.getAbstractMessageAsync(message, widget.model.groupMemberList ?? []); + } + + _buildRepliedMessage(V2TimMessage? repliedMessage) { + final haveRepliedMessage = repliedMessage != null; + if (haveRepliedMessage) { + final String text = "${MessageUtils.getDisplayName(repliedMessage)}:${getAbstractMessage(repliedMessage)}"; + return Container( + color: widget.backgroundColor ?? hexToColor("f5f5f6"), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + text, + softWrap: true, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: hexToColor("8f959e"), fontSize: 14), + ), + ), + const SizedBox( + width: 16, + ), + InkWell( + onTap: () { + widget.model.repliedMessage = null; + }, + child: Icon(Icons.clear, color: hexToColor("8f959e"), size: 18), + ) + ], + ), + ); + } + return Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + + setKeyboardHeight ??= OptimizeUtils.debounce((height) { + settingModel.keyboardHeight = height; + }, const Duration(seconds: 1)); + + final debounceFunc = _debounce((value) { + if (isWebDevice() || isAndroidDevice()) { + if (value.isEmpty && showMoreButton != true) { + setState(() { + showMoreButton = true; + }); + } else if (value.isNotEmpty && showMoreButton == true) { + setState(() { + showMoreButton = false; + }); + } + } + if (widget.onChanged != null) { + widget.onChanged!(value); + } + widget.handleAtText(value); + widget.handleSendEditStatus(value, true); + final isEmpty = value.isEmpty; + if (isEmpty) { + widget.handleSoftKeyBoardDelete(); + } + }, const Duration(milliseconds: 80)); + + final MediaQueryData data = MediaQuery.of(context); + EdgeInsets padding = data.padding; + if (bottomPadding == null || padding.bottom > bottomPadding!) { + bottomPadding = padding.bottom; + } + + return GestureDetector( + onTap: () {}, + child: Column( + children: [ + _buildRepliedMessage(widget.repliedMessage), + Container( + color: widget.backgroundColor ?? hexToColor("f5f5f6"), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + constraints: const BoxConstraints(minHeight: 50), + child: Row( + children: [ + if (PlatformUtils().isMobile && widget.showSendAudio) + InkWell( + onTap: () async { + showKeyboard = showSendSoundText; + if (showSendSoundText) { + widget.focusNode.requestFocus(); + } + if (await Permissions.checkPermission( + context, + Permission.microphone.value, + theme, + )) { + setState(() { + showEmojiPanel = false; + showMore = false; + showSendSoundText = !showSendSoundText; + }); + } + }, + child: SvgPicture.asset( + showSendSoundText ? 'images/keyboard.svg' : 'images/voice.svg', + package: 'tencent_cloud_chat_uikit', + color: const Color.fromRGBO(68, 68, 68, 1), + height: 28, + width: 28, + ), + ), + const SizedBox( + width: 10, + ), + Expanded( + child: showSendSoundText + ? SendSoundMessage( + onDownBottom: widget.goDownBottom, + conversationID: widget.conversationID, + conversationType: widget.conversationType) + : Stack(children: [ + Center( + child: KeyboardVisibility( + child: ExtendedTextField( + maxLines: 4, + minLines: 1, + focusNode: widget.focusNode, + onChanged: debounceFunc, + onTap: () { + showKeyboard = true; + widget.goDownBottom(); + setState(() { + showEmojiPanel = false; + showMore = false; + }); + }, + keyboardType: TextInputType.multiline, + textInputAction: PlatformUtils().isAndroid + ? TextInputAction.newline + : TextInputAction.send, + onEditingComplete: () { + widget.onSubmitted(); + if (showKeyboard) { + widget.focusNode.requestFocus(); + } + setState(() { + if (widget.textEditingController.text.isEmpty) { + showMoreButton = true; + } + }); + }, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + border: InputBorder.none, + hintStyle: const TextStyle( + // fontSize: 10, + color: Color(0xffAEA4A3), + ), + fillColor: Colors.white, + filled: true, + isDense: true, + hintText: widget.hintText ?? ''), + controller: widget.textEditingController, + specialTextSpanBuilder: PlatformUtils().isWeb + ? null + : DefaultSpecialTextSpanBuilder( + isUseQQPackage: + widget.model.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? + true, + isUseTencentCloudChatPackage: widget.model.chatConfig + .stickerPanelConfig?.useTencentCloudChatStickerPackage ?? + true, + isUseTencentCloudChatPackageOldKeys: widget + .model + .chatConfig + .stickerPanelConfig + ?.useTencentCloudChatStickerPackageOldKeys ?? + false, + customEmojiStickerList: widget.customEmojiStickerList, + showAtBackground: true, + checkHttpLink: false, + )), + onChanged: (bool visibility) { + if (showKeyboard != visibility) { + setState(() { + showKeyboard = visibility; + }); + } + }), + ), + RawKeyboardListener( + autofocus: true, + focusNode: FocusNode(), + onKey: (key) { + if (key is RawKeyDownEvent && key.logicalKey == LogicalKeyboardKey.backspace) { + if (widget.onDeleteText != null) { + widget.onDeleteText!(widget.textEditingController.text); + } + } + }, + child: Container(), + ), + ]), + ), + const SizedBox( + width: 10, + ), + if (widget.showSendEmoji) + InkWell( + onTap: () { + _openEmojiPanel(); + widget.goDownBottom(); + }, + child: PlatformUtils().isWeb + ? Icon(showEmojiPanel ? Icons.keyboard_alt_outlined : Icons.mood_outlined, + color: hexToColor("5c6168"), size: 32) + : SvgPicture.asset( + showEmojiPanel ? 'images/keyboard.svg' : 'images/face.svg', + package: 'tencent_cloud_chat_uikit', + color: const Color.fromRGBO(68, 68, 68, 1), + height: 28, + width: 28, + ), + ), + const SizedBox( + width: 10, + ), + if (widget.showMorePanel && showMoreButton) + InkWell( + onTap: () { + // model.sendCustomMessage(data: "a", convID: model.currentSelectedConv, convType: model.currentSelectedConvType == 1 ? ConvType.c2c : ConvType.group); + _openMore(); + widget.goDownBottom(); + }, + child: PlatformUtils().isWeb + ? Icon(Icons.add_circle_outline_outlined, color: hexToColor("5c6168"), size: 32) + : SvgPicture.asset( + 'images/add.svg', + package: 'tencent_cloud_chat_uikit', + color: const Color.fromRGBO(68, 68, 68, 1), + height: 28, + width: 28, + ), + ), + if ((isAndroidDevice() || isWebDevice()) && !showMoreButton) + SizedBox( + height: 32.0, + child: ElevatedButton( + onPressed: () { + widget.onSubmitted(); + if (showKeyboard) { + widget.focusNode.requestFocus(); + } + if (widget.textEditingController.text.isEmpty) { + setState(() { + showMoreButton = true; + }); + } + }, + child: Text(TIM_t("发送")), + ), + ), + ], + ), + ), + AnimatedContainer( + duration: Duration(milliseconds: (showKeyboard && PlatformUtils().isAndroid) ? 200 : 340), + curve: Curves.fastOutSlowIn, + height: max(_getBottomHeight(), 0.0), + child: ListView( + physics: const NeverScrollableScrollPhysics(), + children: [_getBottomContainer(theme)], + ), + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart new file mode 100644 index 0000000..2d88e9a --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart @@ -0,0 +1,994 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +// ignore: unnecessary_import +import 'dart:typed_data'; + +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:fc_native_video_thumbnail/fc_native_video_thumbnail.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pasteboard/pasteboard.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_setting_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_shot.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/drag_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:url_launcher/url_launcher.dart'; +import 'package:uuid/uuid.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class DesktopControlBarItem { + final String item; + final IconData? icon; + final String? imgPath; + final String? svgPath; + final Color? color; + final ValueChanged onClick; + final String? showName; + final double? size; + + DesktopControlBarItem( + {required this.item, + this.icon, + this.color, + this.imgPath, + this.svgPath, + required this.onClick, + this.showName, + this.size}) + : assert(icon != null || TencentUtils.checkString(imgPath) != null || TencentUtils.checkString(svgPath) != null); +} + +class DesktopControlBarConfig { + final bool showStickerPanel; + final bool showScreenshotButton; + final bool showSendFileButton; + final bool showSendImageButton; + final bool showSendVideoButton; + final bool showMessageHistoryButton; + + DesktopControlBarConfig({ + this.showStickerPanel = true, + this.showScreenshotButton = true, + this.showSendFileButton = true, + this.showSendImageButton = true, + this.showSendVideoButton = true, + this.showMessageHistoryButton = true, + }); +} + +class TIMUIKitTextFieldLayoutWide extends StatefulWidget { + /// sticker panel customization + final CustomStickerPanel? customStickerPanel; + final VoidCallback onEmojiSubmitted; + final Function(int, String) onCustomEmojiFaceSubmitted; + final Function(String, bool) handleSendEditStatus; + final VoidCallback backSpaceText; + final ValueChanged addStickerToText; + final TUITheme theme; + final ValueChanged handleAtText; + + /// Whether to use the default emoji + final bool isUseDefaultEmoji; + + final bool isCompatibleWithTencentCloudChatPackageOldKeys; + + final TUIChatSeparateViewModel model; + + /// background color + final Color? backgroundColor; + + /// control input field behavior + final TIMUIKitInputTextFieldController? controller; + + /// config for more panel + final MorePanelConfig? morePanelConfig; + + final String languageType; + + final TextEditingController textEditingController; + + /// conversation id + final String conversationID; + + /// conversation type + final ConvType conversationType; + + final FocusNode focusNode; + + /// show more panel + final bool showMorePanel; + + /// hint text for textField widget + final String? hintText; + + final int? currentCursor; + + final ValueChanged setCurrentCursor; + + final VoidCallback onCursorChange; + + /// show send audio icon + final bool showSendAudio; + + final TIMUIKitChatConfig chatConfig; + + /// on text changed + final void Function(String)? onChanged; + + final V2TimMessage? repliedMessage; + + /// show send emoji icon + final bool showSendEmoji; + + final VoidCallback onSubmitted; + + final VoidCallback goDownBottom; + + final List customEmojiStickerList; + + /// Conversation need search + final V2TimConversation currentConversation; + + final List stickerPackageList; + + const TIMUIKitTextFieldLayoutWide( + {Key? key, + this.customStickerPanel, + required this.onEmojiSubmitted, + required this.onCustomEmojiFaceSubmitted, + required this.backSpaceText, + required this.addStickerToText, + required this.isUseDefaultEmoji, + this.isCompatibleWithTencentCloudChatPackageOldKeys = false, + required this.languageType, + required this.textEditingController, + this.morePanelConfig, + required this.conversationID, + required this.conversationType, + required this.focusNode, + this.currentCursor, + required this.setCurrentCursor, + required this.onCursorChange, + required this.model, + this.backgroundColor, + this.onChanged, + required this.handleSendEditStatus, + required this.handleAtText, + this.repliedMessage, + required this.onSubmitted, + required this.goDownBottom, + required this.showSendAudio, + required this.showSendEmoji, + required this.showMorePanel, + this.hintText, + required this.customEmojiStickerList, + this.controller, + required this.currentConversation, + required this.theme, + required this.chatConfig, + required this.stickerPackageList}) + : super(key: key); + + @override + State createState() => _TIMUIKitTextFieldLayoutWideState(); +} + +class _TIMUIKitTextFieldLayoutWideState extends TIMUIKitState { + final TUISettingModel settingModel = serviceLocator(); + OverlayEntry? entry; + final ImagePicker _picker = ImagePicker(); + Uint8List? fileContent; + String? fileName; + File? tempFile; + Function? setKeyboardHeight; + double? bottomPadding; + late ScrollController _scrollController; + late FocusNode textFocusNode; + late List defaultControlBarItems; + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + widget.controller?.addListener(() { + final actionType = widget.controller?.actionType; + if (actionType == ActionType.hideAllPanel) { + hideAllPanel(); + } + }); + } + textFocusNode = FocusNode(); + widget.focusNode.requestFocus(); + _scrollController = ScrollController(); + try { + if (PlatformUtils().isWeb) { + html.window.addEventListener('paste', (event) { + _handlePaste(event as html.ClipboardEvent); + }); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i(e.toString()); + } + generateDefaultControlBarItems(); + } + + Future _handlePaste(html.ClipboardEvent event) async { + try { + if (event.clipboardData!.files!.isNotEmpty) { + html.File imageFile = event.clipboardData!.files![0]; + sendFileUseJs(imageFile); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i("Paste image failed: ${e.toString()}"); + } + } + + hideAllPanel() { + widget.focusNode.unfocus(); + widget.currentCursor == null; + } + + _debounce( + Function(String text) fun, [ + Duration delay = const Duration(milliseconds: 30), + ]) { + Timer? timer; + return (String text) { + if (timer != null) { + timer?.cancel(); + } + + timer = Timer(delay, () { + fun(text); + }); + }; + } + + String getAbstractMessage(V2TimMessage message) { + final String? customAbstractMessage = widget.model.abstractMessageBuilder != null + ? widget.model.abstractMessageBuilder!(widget.model.repliedMessage!) + : null; + return customAbstractMessage ?? + MessageUtils.getAbstractMessageAsync(widget.model.repliedMessage!, widget.model.groupMemberList ?? []); + } + + _buildRepliedMessage(V2TimMessage? repliedMessage) { + final haveRepliedMessage = repliedMessage != null; + if (haveRepliedMessage) { + return Container( + color: widget.backgroundColor ?? hexToColor("f5f5f6"), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + TIM_t("回复 "), + style: TextStyle(color: hexToColor("8f959e"), fontSize: 14), + ), + Text( + MessageUtils.getDisplayName(widget.model.repliedMessage!), + softWrap: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: hexToColor("8f959e"), fontSize: 14, fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + ": ${getAbstractMessage(repliedMessage)}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: hexToColor("8f959e"), + ), + ), + ), + const SizedBox( + width: 8, + ), + InkWell( + onTap: () { + widget.model.repliedMessage = null; + }, + child: Icon(Icons.cancel, color: hexToColor("8f959e"), size: 18), + ) + ], + ), + ); + } + return Container(); + } + + _sendEmoji(Offset? offset, TUITheme theme) { + widget.onCursorChange(); + if (entry != null) { + entry?.remove(); + entry = null; + } else { + entry = OverlayEntry(builder: (BuildContext context) { + return TUIKitDragArea( + closeFun: () { + if (entry != null) { + entry?.remove(); + entry = null; + } + }, + initOffset: offset != null + ? Offset(offset.dx, max(offset.dy, 16)) + : Offset(MediaQuery.of(context).size.height * 0.5 + 20, MediaQuery.of(context).size.height * 0.5 - 100), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + color: theme.wideBackgroundColor, + border: Border.all( + width: 2, + color: theme.weakBackgroundColor ?? const Color(0xFFbebebe), + ), + boxShadow: const [ + BoxShadow( + color: Color(0xFFbebebe), + offset: Offset(5, 5), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: Container( + child: widget.customStickerPanel != null + ? widget.customStickerPanel!( + height: widget.chatConfig.desktopStickerPanelHeight, + width: 350, + sendTextMessage: () { + widget.onEmojiSubmitted(); + }, + sendFaceMessage: widget.onCustomEmojiFaceSubmitted, + deleteText: () { + widget.backSpaceText(); + }, + addText: (int unicode) { + final newText = String.fromCharCode(unicode); + widget.addStickerToText(newText); + entry?.remove(); + entry = null; + }, + addCustomEmojiText: ((String singleEmojiName) { + String? emojiName = singleEmojiName.split('.png')[0]; + String compatibleEmojiName = emojiName; + if (widget.isCompatibleWithTencentCloudChatPackageOldKeys) { + compatibleEmojiName = EmojiUtil.getCompatibleEmojiName(emojiName); + } + + String newText = '[$compatibleEmojiName]'; + widget.addStickerToText(newText); + entry?.remove(); + entry = null; + }), + defaultCustomEmojiStickerList: widget.isUseDefaultEmoji ? TUIKitStickerConstData.emojiList : []) + : Material( + color: Colors.transparent, + child: StickerPanel( + isWideScreen: true, + height: widget.chatConfig.desktopStickerPanelHeight, + width: 350, + sendTextMsg: null, + sendFaceMsg: (_, __) { + widget.onCustomEmojiFaceSubmitted(_, __); + entry?.remove(); + entry = null; + }, + deleteText: () { + widget.backSpaceText(); + }, + addText: (int unicode) { + final newText = String.fromCharCode(unicode); + widget.addStickerToText(newText); + entry?.remove(); + entry = null; + }, + addCustomEmojiText: ((String singleEmojiName) { + String? emojiName = singleEmojiName.split('.png')[0]; + String compatibleEmojiName = emojiName; + if (widget.isCompatibleWithTencentCloudChatPackageOldKeys) { + compatibleEmojiName = EmojiUtil.getCompatibleEmojiName(emojiName); + } + + String newText = '[$compatibleEmojiName]'; + widget.addStickerToText(newText); + entry?.remove(); + entry = null; + }), + customStickerPackageList: widget.stickerPackageList, + bottomColor: theme.weakBackgroundColor, + backgroundColor: theme.wideBackgroundColor, + lightPrimaryColor: theme.lightPrimaryColor), + ), + ), + )); + }); + Overlay.of(context).insert(entry!); + } + } + + _addGreyOverlay() { + if (entry != null) { + _removeOverlay(); + return; + } else { + entry = OverlayEntry(builder: (BuildContext context) { + return Container( + color: const Color(0x7F000000), + ); + }); + Overlay.of(context).insert(entry!); + } + } + + _removeOverlay() { + entry?.remove(); + entry = null; + } + + _sendFile( + TUIChatSeparateViewModel model, + TUITheme theme, + ) async { + if (!PlatformUtils().isWeb) { + _addGreyOverlay(); + } + try { + final convID = widget.conversationID; + final convType = widget.conversationType; + FilePickerResult? result = await FilePicker.platform.pickFiles(); + _removeOverlay(); + if (result != null && result.files.isNotEmpty) { + if (PlatformUtils().isWeb) { + html.Node? inputElem; + inputElem = html.document.getElementById("__file_picker_web-file-input")?.querySelector("input"); + fileName = result.files.single.name; + + MessageUtils.handleMessageError( + model.sendFileMessage(inputElement: inputElem, fileName: fileName, convID: convID, convType: convType), + context); + } else { + File file = File(result.files.single.path!); + final int size = file.lengthSync(); + final String savePath = file.path; + + MessageUtils.handleMessageError( + model.sendFileMessage(filePath: savePath, size: size, convID: convID, convType: convType), context); + } + } else { + throw TypeError(); + } + } catch (e) { + // ignore: avoid_print + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + List generateBarIcons(List items, TUITheme theme) { + final defaultItems = defaultControlBarItems.map((e) => e.item); + return items.map((e) { + final GlobalKey key = GlobalKey(); + return Container( + margin: const EdgeInsets.only(right: 10), + child: InkWell( + onTap: () { + final alignBox = key.currentContext?.findRenderObject() as RenderBox?; + var offset = alignBox?.localToGlobal(Offset.zero); + final double? dx = (offset?.dx != null) ? offset!.dx : null; + final double? dy = (offset?.dy != null && alignBox?.size.height != null) + ? offset!.dy - (widget.chatConfig.desktopStickerPanelHeight + 20) + : null; + e.onClick((dx != null && dy != null) ? Offset(dx, dy) : null); + }, + child: Tooltip( + preferBelow: false, + textStyle: TextStyle(fontSize: 12, color: theme.white), + message: e.showName, + child: Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(2)), + padding: const EdgeInsets.all(4), + child: () { + if (TencentUtils.checkString(e.svgPath) != null) { + return SvgPicture.asset( + e.svgPath!, + package: defaultItems.contains(e.item) ? 'tencent_cloud_chat_uikit' : null, + key: key, + width: e.size ?? 16, + height: e.size ?? 16, + ); + } + if (TencentUtils.checkString(e.imgPath) != null) { + return Image.asset( + e.imgPath!, + package: defaultItems.contains(e.item) ? 'tencent_cloud_chat_uikit' : null, + key: key, + width: e.size ?? 16, + height: e.size ?? 16, + ); + } + return Icon( + e.icon, + key: key, + color: e.color ?? hexToColor("646a73"), + size: e.size ?? 20, + ); + }(), + ), + ), + ), + ); + }).toList(); + } + + _sendImageFileOnWeb(TUIChatSeparateViewModel model) async { + try { + final pickedFile = await _picker.pickImage(source: ImageSource.gallery); + final imageContent = await pickedFile!.readAsBytes(); + fileName = pickedFile.name; + tempFile = File(pickedFile.path); + fileContent = imageContent; + + html.Node? inputElem; + inputElem = html.document.getElementById("__image_picker_web-file-input")?.querySelector("input"); + final convID = widget.conversationID; + final convType = widget.conversationType; + MessageUtils.handleMessageError( + model.sendImageMessage( + inputElement: inputElem, imagePath: tempFile?.path, convID: convID, convType: convType), + context); + } catch (e) { + // ignore: avoid_print + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + _sendVideoFileOnWeb(TUIChatSeparateViewModel model) async { + try { + final pickedFile = await _picker.pickVideo(source: ImageSource.gallery); + final videoContent = await pickedFile!.readAsBytes(); + fileName = pickedFile.name; + tempFile = File(pickedFile.path); + fileContent = videoContent; + + if (fileName!.split(".")[fileName!.split(".").length - 1] != "mp4") { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频消息仅限 mp4 格式"), infoCode: 6660412)); + return; + } + + html.Node? inputElem; + inputElem = html.document.getElementById("__image_picker_web-file-input")?.querySelector("input"); + final convID = widget.conversationID; + final convType = widget.conversationType; + MessageUtils.handleMessageError( + model.sendVideoMessage( + inputElement: inputElem, videoPath: tempFile?.path, convID: convID, convType: convType), + context); + } catch (e) { + // ignore: avoid_print + outputLogger.i("_sendFileErr: ${e.toString()}"); + } + } + + _sendVideoMessage(AssetEntity asset, TUIChatSeparateViewModel model) async { + try { + final plugin = FcNativeVideoThumbnail(); + final originFile = await asset.originFile; + final size = await originFile!.length(); + if (size >= 104857600) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("发送失败,视频不能大于100MB"), infoCode: 6660405)); + return; + } + + final duration = asset.videoDuration.inSeconds; + final filePath = originFile.path; + final convID = widget.conversationID; + final convType = widget.conversationType; + + String tempPath = (await getTemporaryDirectory()).path + p.extension(originFile.path, 3) + ".jpeg"; + + await plugin.getVideoThumbnail( + srcFile: originFile.path, + destFile: tempPath, + format: 'jpeg', + width: 128, + quality: 100, + height: 128, + ); + MessageUtils.handleMessageError( + model.sendVideoMessage( + videoPath: filePath, duration: duration, snapshotPath: tempPath, convID: convID, convType: convType), + context); + } catch (e) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频文件异常"), infoCode: 6660415)); + } + } + + _sendMediaMessage(TUIChatSeparateViewModel model, TUITheme theme, FileType fileType) async { + try { + final convID = widget.conversationID; + final convType = widget.conversationType; + + if (PlatformUtils().isMobile) { + final pickedAssets = await AssetPicker.pickAssets(context); + + if (pickedAssets != null) { + for (var asset in pickedAssets) { + final originFile = await asset.originFile; + final filePath = originFile?.path; + final type = asset.type; + if (filePath != null) { + if (type == AssetType.image) { + MessageUtils.handleMessageError( + model.sendImageMessage(imagePath: filePath, convID: convID, convType: convType), context); + } + + if (type == AssetType.video) { + _sendVideoMessage(asset, model); + } + } + } + } + } else { + final plugin = FcNativeVideoThumbnail(); + _addGreyOverlay(); + FilePickerResult? result = await FilePicker.platform.pickFiles(type: fileType); + _removeOverlay(); + if (result != null && result.files.isNotEmpty) { + File file = File(result.files.single.path!); + final String savePath = file.path; + final String type = + TencentUtils.getFileType((savePath.split(".")[savePath.split(".").length - 1]).toLowerCase()) + .split("/")[0]; + + if (type == "image") { + MessageUtils.handleMessageError( + model.sendImageMessage(imagePath: savePath, convID: convID, convType: convType), context); + } else if (type == "video") { + String tempPath = (await getTemporaryDirectory()).path + p.basename(savePath) + ".jpeg"; + await plugin.getVideoThumbnail( + srcFile: savePath, + destFile: tempPath, + format: 'jpeg', + width: 128, + quality: 100, + height: 128, + ); + MessageUtils.handleMessageError( + model.sendVideoMessage(videoPath: savePath, convID: convID, convType: convType, snapshotPath: tempPath), + context); + } + } else { + throw TypeError(); + } + } + } catch (err) { + // ignore: avoid_print + outputLogger.i("send media err: $err"); + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频文件异常"), infoCode: 6660415)); + } + } + + _sendImageWithConfirmation({String? fileName, Size? fileSize, required String filePath}) async { + final option1 = + widget.currentConversation.showName ?? (widget.conversationType == ConvType.group ? TIM_t("群聊") : TIM_t("对方")); + final size = fileSize ?? await ScreenshotHelper.getImageSize(filePath); + + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.beforeSendScreenShot, + context: context, + isDarkBackground: false, + width: 500, + height: min(500, size.height / 2 + 140), + title: TIM_t_para("发送给{{option1}}", "发送给$option1")(option1: option1), + child: (closeFunc) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + height: min(360, size.height / 2), + child: InkWell( + onTap: () { + launchUrl(PlatformUtils().isWeb ? Uri.parse(filePath) : Uri.file(filePath)); + }, + child: PlatformUtils().isWeb + ? Image.network( + filePath, + height: min(360, size.height / 2), + ) + : Image.file( + File(filePath), + height: min(360, size.height / 2), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + children: [ + OutlinedButton( + onPressed: () { + closeFunc(); + }, + child: Text(TIM_t("取消"))), + const SizedBox( + width: 20, + ), + ElevatedButton( + onPressed: () { + MessageUtils.handleMessageError( + widget.model.sendImageMessage( + imagePath: filePath, + imageName: fileName, + convID: widget.conversationID, + convType: widget.conversationType), + context); + closeFunc(); + }, + child: Text(TIM_t("发送"))) + ], + ) + ], + ), + ), + ); + } + + _sendScreenShot() async { + final file = await ScreenshotHelper.captureScreen(); + if (file != null) { + _sendImageWithConfirmation(filePath: file); + } else {} + } + + generateDefaultControlBarItems() { + final DesktopControlBarConfig config = widget.chatConfig.desktopControlBarConfig ?? DesktopControlBarConfig(); + final List itemsList = [ + if (config.showStickerPanel) + DesktopControlBarItem( + item: "face", + showName: TIM_t("表情"), + onClick: (offset) { + _sendEmoji(offset, widget.theme); + }, + imgPath: "images/svg/send_face.png"), + if (config.showScreenshotButton && PlatformUtils().isDesktop) + DesktopControlBarItem( + item: "screenShot", + showName: TIM_t("截图"), + onClick: (offset) { + _sendScreenShot(); + }, + svgPath: "images/svg/send_screenshot.svg"), + if (config.showSendFileButton) + DesktopControlBarItem( + item: "file", + showName: TIM_t("文件"), + onClick: (offset) { + _sendFile(widget.model, widget.theme); + }, + svgPath: "images/svg/send_file.svg"), + if (config.showSendImageButton) + DesktopControlBarItem( + item: "photo", + showName: TIM_t("图片"), + onClick: (offset) { + if (PlatformUtils().isWeb) { + _sendImageFileOnWeb(widget.model); + } else { + _sendMediaMessage(widget.model, widget.theme, FileType.image); + } + }, + svgPath: "images/svg/send_image.svg"), + if (config.showSendVideoButton) + DesktopControlBarItem( + item: "video", + showName: TIM_t("视频"), + onClick: (offset) { + if (PlatformUtils().isWeb) { + _sendVideoFileOnWeb(widget.model); + } else { + _sendMediaMessage(widget.model, widget.theme, FileType.video); + } + }, + svgPath: "images/svg/send_video.svg"), + if (config.showMessageHistoryButton) + DesktopControlBarItem( + item: "history", + showName: TIM_t("消息历史"), + onClick: (offset) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.chatHistory, + context: context, + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.width * 0.5, + child: (onClose) => TIMUIKitSearchMsgDetail( + currentConversation: widget.currentConversation, + keyword: '', + initMessageList: widget.model + .getOriginMessageList() + .getRange(0, min(widget.model.getOriginMessageList().length, 100)) + .toList(), + onTapConversation: (V2TimConversation conversation, V2TimMessage? message) {}, + ), + theme: widget.theme); + }, + svgPath: "images/svg/message_history.svg"), + ]; + defaultControlBarItems = itemsList; + } + + List generateControlBar(TUIChatSeparateViewModel model, TUITheme theme) { + final List itemsList = [ + ...defaultControlBarItems, + ...(widget.chatConfig.additionalDesktopControlBarItems ?? []) + ]; + + return generateBarIcons(itemsList, theme); + } + + sendFileUseJs(html.File file) { + final mimeType = file.type.split('/'); + final type = mimeType[0]; + final blobUrl = html.Url.createObjectUrl(file); + if (type == 'image') { + _sendImageWithConfirmation(filePath: blobUrl, fileName: file.name, fileSize: const Size(500, 500)); + } + } + + Future _handleKeyEvent(RawKeyEvent event) async { + if (PlatformUtils().isDesktop && + ((event.isKeyPressed(LogicalKeyboardKey.controlLeft) && event.logicalKey == LogicalKeyboardKey.keyV) || + (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV))) { + final bytes = await Pasteboard.image; + if (bytes != null) { + String directory; + if (PlatformUtils().isWindows) { + final String documentsDirectoryPath = "${Platform.environment['USERPROFILE']}"; + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + String pkgName = packageInfo.packageName; + directory = p.join(documentsDirectoryPath, "Documents", ".TencentCloudChat", pkgName, "screenshots"); + } else { + final dic = await getApplicationSupportDirectory(); + directory = dic.path; + } + const uuid = Uuid(); + final fileName = 'paste_image_${uuid.v4()}.png'; + final scDirectory = Directory(directory); + final filePath = '${scDirectory.path}${PlatformUtils().isWindows ? "\\" : "/"}$fileName'; + final file = File(filePath); + if (!await scDirectory.exists()) { + await scDirectory.create(recursive: true); + } + await file.writeAsBytes(bytes.toList()); + _sendImageWithConfirmation(filePath: filePath); + } + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + + setKeyboardHeight ??= OptimizeUtils.debounce((height) { + settingModel.keyboardHeight = height; + }, const Duration(seconds: 1)); + + final debounceFunc = _debounce((value) { + if (widget.onChanged != null) { + widget.onChanged!(value); + } + widget.handleAtText(value); + widget.handleSendEditStatus(value, true); + }, const Duration(milliseconds: 80)); + + final MediaQueryData data = MediaQuery.of(context); + EdgeInsets padding = data.padding; + if (bottomPadding == null || padding.bottom > bottomPadding!) { + bottomPadding = padding.bottom; + } + + return RawKeyboardListener( + focusNode: textFocusNode, + onKey: _handleKeyEvent, + child: Container( + color: widget.backgroundColor ?? theme.desktopChatMessageInputBgColor, + child: Column( + children: [ + _buildRepliedMessage(widget.repliedMessage), + SizedBox(height: 1, child: Container(color: theme.weakDividerColor ?? Colors.black12)), + Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: generateControlBar(widget.model, theme), + ), + ), + Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + constraints: const BoxConstraints(minHeight: 50), + child: Row( + children: [ + Expanded( + child: ExtendedTextField( + scrollController: _scrollController, + autofocus: true, + maxLines: widget.chatConfig.desktopMessageInputFieldLines, + minLines: widget.chatConfig.desktopMessageInputFieldLines, + focusNode: widget.focusNode, + onChanged: debounceFunc, + keyboardType: TextInputType.multiline, + onEditingComplete: () { + // // widget.onSubmitted(); + }, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hoverColor: Colors.transparent, + border: InputBorder.none, + hintStyle: const TextStyle( + color: Color(0xffAEA4A3), + ), + fillColor: + widget.backgroundColor ?? theme.desktopChatMessageInputBgColor ?? hexToColor("fafafa"), + filled: true, + isDense: true, + hintText: widget.hintText ?? '', + ), + controller: widget.textEditingController, + specialTextSpanBuilder: PlatformUtils().isWeb + ? null + : DefaultSpecialTextSpanBuilder( + isUseTencentCloudChatPackage: + widget.model.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? + true, + customEmojiStickerList: widget.customEmojiStickerList, + showAtBackground: true, + )), + ), + ], + ), + ), + ], + ), + )); + } +} diff --git a/lib/ui/views/TIMUIKitChat/tim_uikit_chat.dart b/lib/ui/views/TIMUIKitChat/tim_uikit_chat.dart new file mode 100644 index 0000000..934fc21 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/tim_uikit_chat.dart @@ -0,0 +1,703 @@ +// ignore_for_file: must_be_immutable, avoid_print + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/chat_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/constants/history_message_constant.dart'; +import 'package:tencent_cloud_chat_uikit/ui/controller/tim_uikit_chat_controller.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/frame.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/logger.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/at_member_panel.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_multi_select_panel.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_send_file.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; +import 'TIMUIKItMessageList/TIMUIKitTongue/tim_uikit_chat_history_message_list_tongue.dart'; +import 'TIMUIKItMessageList/tim_uikit_chat_history_message_list_config.dart'; +import 'TIMUIKItMessageList/tim_uikit_history_message_list_container.dart'; + +class TIMUIKitChat extends StatefulWidget { + int startTime = 0; + int endTime = 0; + + /// The chat controller you tend to used. + /// You have to provide this before using it since tencent_cloud_chat_uikit 0.1.4. + final TIMUIKitChatController? controller; + + /// [Update] It is suggested to provide the `V2TimConversation` once directly, since tencent_cloud_chat_uikit 1.5.0. + /// `conversationID` / `conversationType` / `groupAtInfoList` / `conversationShowName` are not necessary to be provided, unless you want to cover these fields manually. + final V2TimConversation conversation; + + /// The ID of the Group that the topic belongs to, only need for topic. + final String? groupID; + + /// Conversation id, use for load history message list. + /// This field is not necessary to be provided, when `conversation` is provided, unless you want to cover this field manually. + final String? conversationID; + + /// Conversation type. + /// This field is not necessary to be provided, when `conversation` is provided, unless you want to cover this field manually. + final ConvType? conversationType; + + /// use for customize avatar + final Widget Function(BuildContext context, V2TimMessage message)? userAvatarBuilder; + + /// Use for show conversation name. + /// This field is not necessary to be provided, when `conversation` is provided, unless you want to cover this field manually. + final String? conversationShowName; + + /// Avatar and name in message reaction tap callback. + final void Function(String userID, TapDownDetails tapDetails)? onTapAvatar; + + /// Avatar and name in message reaction secondary tap callback. + final void Function(String userID, TapDownDetails tapDetails)? onSecondaryTapAvatar; + + @Deprecated( + "Nickname will not shows in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + + /// Should show the nick name. + final bool showNickName; + + /// Message item builder, can customize partial message item for different types or the layout for the whole line. + final MessageItemBuilder? messageItemBuilder; + + /// Is show unread message count, default value is false + final bool showTotalUnReadCount; + + /// Deprecated("Please use [extraTipsActionItemBuilder] instead") + final Widget? Function(V2TimMessage message, Function() closeTooltip, [Key? key, BuildContext? context])? + exteraTipsActionItemBuilder; + + /// The builder for extra tips action. + final Widget? Function(V2TimMessage message, Function() closeTooltip, [Key? key, BuildContext? context])? + extraTipsActionItemBuilder; + + /// The text of draft shows in TextField. + /// [Recommend]: You can specify this field with the draftText from V2TimConversation. + final String? draftText; + + /// The target message been jumped just after entering the chat page. + final V2TimMessage? initFindingMsg; + + /// The hint text shows at input field. + final String? textFieldHintText; + + /// The configuration for appbar. + final AppBar? appBarConfig; + + /// The configuration for historical message list. + final TIMUIKitHistoryMessageListConfig? mainHistoryListConfig; + + /// The configuration for more panel, can customize actions. + final MorePanelConfig? morePanelConfig; + + /// The builder for the tongue on the right bottom. + /// Used for back to bottom, shows the count of unread new messages, + /// and prompts the messages that @ user. + final TongueItemBuilder? tongueItemBuilder; + + /// The `groupAtInfoList` from `V2TimConversation`. + /// This field is not necessary to be provided, when `conversation` is provided, + /// unless you want to cover this field manually. + final List? groupAtInfoList; + + /// The configuration for the whole `TIMUIKitChat` widget. + final TIMUIKitChatConfig? config; + + /// The callback for jumping to the page for `TIMUIKitGroupApplicationList` + /// or other pages to deal with enter group application for group administrator manually, + /// in the case of [public group]. + /// The parameter here is `String groupID` + final ValueChanged? onDealWithGroupApplication; + + /// The generator for the abstract summary preview of a message, + /// typically used in replied and forwarded messages. + /// Returns `null` to use the default message summary. + final String? Function(V2TimMessage message)? abstractMessageBuilder; + + /// The configuration for tool tips panel, long press messages will show this panel. + final ToolTipsConfig? toolTipsConfig; + + /// The life cycle for chat business logic. + final ChatLifeCycle? lifeCycle; + + /// The top fixed widget. + final Widget? topFixWidget; + + /// Specify the custom small png emoji packages. + final List customEmojiStickerList; + + final Widget? customAppBar; + + final Widget? inputTopBuilder; + + /// Custom emoji panel. + final CustomStickerPanel? customStickerPanel; + + /// This parameter accepts a custom widget to be displayed when the mouse hovers over a message, + /// replacing the default message hover action bar. + /// Applicable only on desktop platforms. + /// If provided, the default message action functionality will appear in the right-click context menu instead. + /// Returns `null` to use default hover bar. + final Widget? Function(V2TimMessage message)? customMessageHoverBarOnDesktop; + + /// Custom text field + final Widget Function(BuildContext context)? textFieldBuilder; + + /// An optional parameter `groupMemberList` can be provided. + /// `groupMemberList` accepts a list of nullable `V2TimGroupMemberFullInfo` objects. + /// The purpose of this parameter is to allow the client to supply a pre-fetched list + /// of group member information. If this list is provided, it will not make + /// additional network requests to fetch the group member information internally. + List? groupMemberList; + + TIMUIKitChat( + {Key? key, + this.groupID, + required this.conversation, + this.conversationID, + this.conversationType, + this.groupMemberList, + this.conversationShowName, + this.abstractMessageBuilder, + this.onTapAvatar, + @Deprecated( + "Nickname will not show in one-to-one chat, if you tend to control it in group chat, please use `isShowSelfNameInGroup` and `isShowOthersNameInGroup` from `config: TIMUIKitChatConfig` instead") + this.showNickName = false, + this.showTotalUnReadCount = false, + this.messageItemBuilder, + @Deprecated("Please use [extraTipsActionItemBuilder] instead") this.exteraTipsActionItemBuilder, + this.extraTipsActionItemBuilder, + this.draftText, + this.textFieldHintText, + this.initFindingMsg, + this.userAvatarBuilder, + this.appBarConfig, + this.controller, + this.morePanelConfig, + this.customStickerPanel, + this.config = const TIMUIKitChatConfig(), + this.tongueItemBuilder, + this.groupAtInfoList, + this.mainHistoryListConfig, + this.onDealWithGroupApplication, + this.toolTipsConfig, + this.lifeCycle, + this.topFixWidget = const SizedBox(), + this.textFieldBuilder, + this.customEmojiStickerList = const [], + this.customAppBar, + this.inputTopBuilder, + this.onSecondaryTapAvatar, + this.customMessageHoverBarOnDesktop}) + : super(key: key) { + startTime = DateTime.now().millisecondsSinceEpoch; + } + + @override + State createState() => _TUIChatState(); +} + +class _TUIChatState extends TIMUIKitState { + TUIChatSeparateViewModel model = TUIChatSeparateViewModel(); + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + final TUIThemeViewModel themeViewModel = serviceLocator(); + final TUIConversationViewModel conversationViewModel = serviceLocator(); + TIMUIKitInputTextFieldController textFieldController = TIMUIKitInputTextFieldController(); + bool isInit = false; + final TUIChatGlobalModel chatGlobalModel = serviceLocator(); + bool _dragging = false; + + final GlobalKey alignKey = GlobalKey(); + final GlobalKey listContainerKey = GlobalKey(); + + late AutoScrollController autoController = AutoScrollController( + viewportBoundaryGetter: () => Rect.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), + axis: Axis.vertical, + ); + + late AutoScrollController atMemberPanelScroll = AutoScrollController( + viewportBoundaryGetter: () => Rect.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), + axis: Axis.vertical, + ); + + Widget? _joinInGroupCallWidget; + + @override + void initState() { + super.initState(); + if (kProfileMode) { + Frame.init(); + } + model.abstractMessageBuilder = widget.abstractMessageBuilder; + model.onTapAvatar = widget.onTapAvatar; + WidgetsBinding.instance.addPostFrameCallback((_) async { + widget.endTime = DateTime.now().millisecondsSinceEpoch; + int timeSpend = widget.endTime - widget.startTime; + outputLogger.i("Page render time:$timeSpend ms"); + }); + Future.delayed(const Duration(milliseconds: 500), () { + updateDraft(); + }); + } + + @override + void dispose() { + super.dispose(); + if (kProfileMode) { + Frame.destroy(); + } + model.dispose(); + } + + @override + void didUpdateWidget(TIMUIKitChat oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.conversationID != oldWidget.conversationID) { + isInit = false; + chatGlobalModel.clearCurrentConversation(); + model = TUIChatSeparateViewModel(); + model.abstractMessageBuilder = widget.abstractMessageBuilder; + model.onTapAvatar = widget.onTapAvatar; + Future.delayed(const Duration(milliseconds: 50), () { + updateDraft(); + textFieldController.requestFocus(); + try { + autoController.jumpTo( + autoController.position.minScrollExtent, + ); + autoController.jumpTo( + autoController.position.minScrollExtent, + ); + // ignore: empty_catches + } catch (e) {} + }); + } + if (oldWidget.textFieldBuilder != null && widget.textFieldBuilder == null) { + textFieldController = TIMUIKitInputTextFieldController(); + } + if (oldWidget.groupMemberList != widget.groupMemberList) { + model.groupMemberList = widget.groupMemberList; + } + } + + updateDraft() async { + final isTopic = widget.conversation.conversationID.contains("@TOPIC#"); + if (isTopic) { + final topicInfoList = await TencentImSDKPlugin.v2TIMManager + .getGroupManager() + .getTopicInfoList(groupID: widget.groupID!, topicIDList: [widget.conversation.conversationID]); + final topicInfo = topicInfoList.data?.first.topicInfo; + final draftText = topicInfo?.draftText; + if (TencentUtils.checkString(draftText) != null) { + textFieldController.setTextField(draftText!); + } + } + } + + Widget _renderJoinGroupApplication(int amount, TUITheme theme) { + String option1 = amount.toString(); + return Container( + height: 36, + decoration: BoxDecoration(color: hexToColor("f6eabc")), + child: GestureDetector( + onTap: () { + if (widget.onDealWithGroupApplication != null) { + widget.onDealWithGroupApplication!(_getConvID()); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + TIM_t_para("{{option1}} 条入群请求", "$option1 条入群请求")(option1: option1), + style: const TextStyle( + fontSize: 12, + ), + ), + Container( + margin: const EdgeInsets.only(left: 12), + child: Text( + TIM_t("去处理"), + style: TextStyle(fontSize: 12, color: theme.primaryColor), + ), + ) + ], + ), + ), + ); + } + + String _getTitle() { + return TencentUtils.checkString(widget.conversationShowName) ?? widget.conversation.showName ?? "Chat"; + } + + String _getConvID() { + return TencentUtils.checkString(widget.conversationID) ?? + (widget.conversation.type == 1 ? widget.conversation.userID : widget.conversation.groupID) ?? + ""; + } + + ConvType _getConvType() { + return widget.conversation.type == 1 ? ConvType.c2c : ConvType.group; + } + + _updateJoinInGroupCallWidget() async { + if (_getConvType() != ConvType.group) { + return; + } + final w = await TUICore.instance.raiseExtension(TUIExtensionID.joinInGroup, {GROUP_ID: widget.conversationID!}); + if (w != _joinInGroupCallWidget) { + setState(() { + _joinInGroupCallWidget = w; + }); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isBuild = isInit; + isInit = true; + _updateJoinInGroupCallWidget(); + + return TIMUIKitChatProviderScope( + model: model, + groupID: widget.groupID, + scrollController: autoController, + textFieldController: textFieldController, + conversationID: _getConvID(), + groupMemberList: widget.groupMemberList, + conversationType: _getConvType(), + lifeCycle: widget.lifeCycle, + config: widget.config, + isBuild: isBuild, + providers: [ + Provider(create: (_) => widget.config), + ], + builder: (context, model, w) { + final TUIChatGlobalModel chatGlobalModel = Provider.of(context, listen: true); + + widget.controller?.model = model; + widget.controller?.textFieldController = textFieldController; + widget.controller?.scrollController = autoController; + List filteredApplicationList = []; + if (widget.conversationType == ConvType.group && widget.onDealWithGroupApplication != null) { + filteredApplicationList = chatGlobalModel.groupApplicationList.where((item) { + return (item.groupID == widget.conversationID) && item.handleStatus == 0; + }).toList(); + } + + final selfUserID = selfInfoViewModel.loginInfo?.userID; + final TUIGroupListenerModel groupListenerModel = Provider.of(context, listen: true); + final NeedUpdate? needUpdate = groupListenerModel.needUpdate; + if (needUpdate != null && needUpdate.groupID == widget.conversationID) { + groupListenerModel.needUpdate = null; + switch (needUpdate.updateType) { + case UpdateType.groupInfo: + model.loadGroupInfo(_getConvID()); + break; + case UpdateType.memberList: + if (widget.groupMemberList == null) { + model.loadGroupMemberList(groupID: _getConvID()); + } + model.loadGroupInfo(_getConvID()); + break; + default: + break; + } + } + + List customImageSmallPngEmojiPackages = []; + if (widget.config?.stickerPanelConfig?.customStickerPackages != null && + widget.config!.stickerPanelConfig!.customStickerPackages.isNotEmpty) { + customImageSmallPngEmojiPackages = widget.config!.stickerPanelConfig!.customStickerPackages + .where((element) => element.isEmoji == true) + .map((e) { + return CustomEmojiFaceData( + name: e.name, + isEmoji: true, + icon: e.menuItem.url ?? "", + list: e.stickerList.map((e) => e.url ?? "").toList()); + }).toList(); + } + if (customImageSmallPngEmojiPackages.isEmpty) { + customImageSmallPngEmojiPackages.addAll(widget.customEmojiStickerList); + } + + return GestureDetector( + onTap: () { + textFieldController.hideAllPanel(); + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: (widget.customAppBar == null) + ? TIMUIKitAppBar( + showTotalUnReadCount: widget.showTotalUnReadCount, + config: widget.appBarConfig, + conversationShowName: _getTitle(), + conversationID: _getConvID(), + showC2cMessageEditStatus: widget.config?.showC2cMessageEditStatus ?? true, + ) + : null, + body: DropTarget( + onDragDone: (detail) { + setState(() { + _dragging = false; + sendFileWithConfirmation( + files: detail.files, + conversation: widget.conversation, + conversationType: _getConvType(), + model: model, + theme: theme, + context: context); + }); + }, + onDragEntered: (detail) { + setState(() { + _dragging = true; + }); + }, + onDragExited: (detail) { + setState(() { + _dragging = false; + }); + }, + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.customAppBar != null) widget.customAppBar!, + if (filteredApplicationList.isNotEmpty) + _renderJoinGroupApplication(filteredApplicationList.length, theme), + if (widget.topFixWidget != null) widget.topFixWidget!, + if (_joinInGroupCallWidget != null) Center(child: _joinInGroupCallWidget!), + Expanded( + child: Container( + color: theme.chatBgColor, + child: Align( + key: alignKey, + alignment: Alignment.topCenter, + child: Listener( + child: TIMUIKitHistoryMessageListContainer( + customMessageHoverBarOnDesktop: widget.customMessageHoverBarOnDesktop, + conversation: widget.conversation, + groupMemberInfo: model.groupMemberList + ?.firstWhere((element) => element?.userID == selfUserID, orElse: () => null), + textFieldController: textFieldController, + customEmojiStickerList: widget.customEmojiStickerList, + key: listContainerKey, + isAllowScroll: true, + userAvatarBuilder: widget.userAvatarBuilder, + toolTipsConfig: widget.toolTipsConfig, + groupAtInfoList: widget.groupAtInfoList, + tongueItemBuilder: widget.tongueItemBuilder, + onLongPressForOthersHeadPortrait: (String? userId, String? nickName) { + textFieldController.longPressToAt(nickName, userId); + }, + mainHistoryListConfig: widget.mainHistoryListConfig, + initFindingMsg: widget.initFindingMsg, + extraTipsActionItemBuilder: + widget.extraTipsActionItemBuilder ?? widget.exteraTipsActionItemBuilder, + conversationType: _getConvType(), + scrollController: autoController, + onSecondaryTapAvatar: widget.onSecondaryTapAvatar, + onTapAvatar: widget.onTapAvatar, + // ignore: deprecated_member_use_from_same_package + showNickName: widget.showNickName, + messageItemBuilder: widget.messageItemBuilder, + conversationID: _getConvID(), + ), + )), + )), + widget.inputTopBuilder ?? Container(), + Selector( + builder: (context, value, child) { + return value + ? MultiSelectPanel( + conversationType: _getConvType(), + ) + : (widget.textFieldBuilder != null + ? widget.textFieldBuilder!(context) + : TIMUIKitInputTextField( + chatConfig: widget.config, + groupID: widget.groupID, + atMemberPanelScroll: atMemberPanelScroll, + groupType: widget.conversation.groupType, + currentConversation: widget.conversation, + model: model, + controller: textFieldController, + customEmojiStickerList: customImageSmallPngEmojiPackages, + customStickerPanel: widget.customStickerPanel, + morePanelConfig: widget.morePanelConfig, + scrollController: autoController, + conversationID: _getConvID(), + conversationType: _getConvType(), + initText: TencentUtils.checkString(widget.draftText) ?? + (PlatformUtils().isWeb + ? TencentUtils.checkString(conversationViewModel.getWebDraft( + conversationID: widget.conversation.conversationID)) + : TencentUtils.checkString(widget.conversation.draftText)), + hintText: widget.textFieldHintText, + showMorePanel: widget.config?.isAllowShowMorePanel ?? true, + showSendAudio: widget.config?.isAllowSoundMessage ?? true, + showSendEmoji: widget.config?.isAllowEmojiPanel ?? true, + )); + }, + selector: (c, model) { + return model.isMultiSelect; + }, + ) + ], + ), + if (_dragging) + TIMUIKitSendFile( + conversation: widget.conversation, + ), + AtMemberPanel( + atMemberPanelScroll: atMemberPanelScroll, + onSelectMember: (member) => textFieldController.handleAtMember(member), + ) + ], + ), + )), + ); + }); + } +} + +class TIMUIKitChatProviderScope extends StatelessWidget { + final TUIChatGlobalModel globalModel = serviceLocator(); + TUIChatSeparateViewModel? model; + final TUIGroupListenerModel groupListenerModel = serviceLocator(); + final TUIThemeViewModel themeViewModel = serviceLocator(); + final Widget? child; + + /// You could get the model from here, and transfer it to other widget from TUIKit. + final Widget Function(BuildContext, TUIChatSeparateViewModel, Widget?) builder; + final List? providers; + + /// `TIMUIKitChatController` needs to be provided if you use it outside. + final TIMUIKitChatController? controller; + + /// The global config for TIMUIKitChat. + final TIMUIKitChatConfig? config; + + /// Conversation id, use for get history message list. + final String conversationID; + + final String? groupID; + + /// Conversation type + final ConvType conversationType; + + /// The life cycle for chat business logic. + final ChatLifeCycle? lifeCycle; + + /// The controller for text field. + final TIMUIKitInputTextFieldController? textFieldController; + + final bool? isBuild; + + final AutoScrollController? scrollController; + + /// An optional parameter `groupMemberList` can be provided. + /// `groupMemberList` accepts a list of nullable `V2TimGroupMemberFullInfo` objects. + /// The purpose of this parameter is to allow the client to supply a pre-fetched list + /// of group member information. If this list is provided, it will not make + /// additional network requests to fetch the group member information internally. + List? groupMemberList; + + TIMUIKitChatProviderScope( + {Key? key, + this.child, + this.providers, + this.groupMemberList, + this.textFieldController, + required this.builder, + this.model, + this.groupID, + this.isBuild, + required this.conversationID, + required this.conversationType, + this.controller, + this.config, + this.lifeCycle, + this.scrollController}) + : super(key: key) { + if (isBuild ?? false) { + return; + } + model ??= TUIChatSeparateViewModel(); + controller?.model = model; + controller?.textFieldController = textFieldController; + controller?.scrollController = scrollController; + if (config != null) { + model?.chatConfig = config!; + } + model?.lifeCycle = lifeCycle; + model?.initForEachConversation( + conversationType, + conversationID, + (String value) { + textFieldController?.textEditingController?.text = value; + }, + preGroupMemberList: groupMemberList, + groupID: groupID, + ); + model?.showC2cMessageEditStatus = + (conversationType == ConvType.c2c ? config?.showC2cMessageEditStatus ?? true : false); + loadData(); + } + + loadData() { + // if (model!.haveMoreData) { + model!.loadChatRecord(count: kIsWeb ? 15 : HistoryMessageDartConstant.getCount); + // } + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ChangeNotifierProvider.value(value: globalModel), + ChangeNotifierProvider.value(value: themeViewModel), + ChangeNotifierProvider.value(value: groupListenerModel), + Provider(create: (_) => const TIMUIKitChatConfig()), + ...?providers + ], + child: child, + builder: (context, w) => builder(context, model!, w), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart b/lib/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart new file mode 100644 index 0000000..8e47ce9 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/tim_uikit_chat_config.dart @@ -0,0 +1,306 @@ +import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +import 'TIMUIKitTextField/tim_uikit_text_field_layout/wide.dart'; + +enum GroupReceptAllowType { work, public, meeting } + +enum GroupReceiptAllowType { work, public, meeting } + +enum UrlPreviewType { none, onlyHyperlink, previewCardAndHyperlink } + +class TimeDividerConfig { + /// Defines the interval of adding a time divider among the two messages. + /// [Unit]: second. + /// [Default]: 300. + final int? timeInterval; + + /// Defines the parser of a specific timestamp, + /// transform it into a semantic time description. + final String Function(int timeStamp)? timestampParser; + + TimeDividerConfig({this.timeInterval, this.timestampParser}); +} + +/// StickerPanelConfig is a configuration class for the sticker panel component. +/// It allows customization of specific features such as display options for the +/// message area, sticker packages, unicode emoji lists, and custom sticker packages. +class StickerPanelConfig { + /// Determines whether to use the QQ Sticker Package. + /// Default value: true + final bool useQQStickerPackage; + + /// Determines whether to use the Tencent Cloud Chat Sticker Package. + /// Default value: true + final bool useTencentCloudChatStickerPackage; + + /// Determines whether to compatible with the Tencent Cloud Chat Sticker Package 3.x version. + /// Default value : false + final bool useTencentCloudChatStickerPackageOldKeys; + + /// A list of unicode emoji, represented as integers. + /// Default value: a list of common Unicode Emojis. + /// To exclude Unicode Emoji from the display, pass an empty list. + final List unicodeEmojiList; + + /// A list of CustomStickerPackage instances, where each instance represents a sticker package. + /// Default value: an empty list. + final List customStickerPackages; + + StickerPanelConfig({ + this.useQQStickerPackage = true, + this.useTencentCloudChatStickerPackage = true, + this.useTencentCloudChatStickerPackageOldKeys = false, + this.unicodeEmojiList = TUIKitStickerConstData.defaultUnicodeEmojiList, + this.customStickerPackages = const [], + }); +} + +class TIMUIKitChatConfig { + /// A StickerPanelConfig instance to configure the sticker panel's behavior and appearance. + /// This includes options such as display settings, usage of specific sticker packages, + /// unicode emoji lists, and custom sticker packages. + final StickerPanelConfig? stickerPanelConfig; + + /// Customize the time divider among the two messages. + final TimeDividerConfig? timeDividerConfig; + + /// Control if allowed to show reading status. + /// [Default]: true. + final bool isShowReadingStatus; + + /// Control if allowed to show reading status for group. + /// [Default]: true. + /// [Deprecated: ] Please use [isShowReadingStatus] instead. + final bool isShowGroupReadingStatus; + + /// Control if allowed to report reading status for group. + /// [Default]: true. + /// [Deprecated: ] Please use [isShowReadingStatus] instead. + final bool isReportGroupReadingStatus; + + /// Control if allowed to show the message operation menu after long pressing message. + /// [Default]: true. + final bool isAllowLongPressMessage; + + /// Control if allowed to callback after clicking the avatar. + /// [Default]: true. + final bool isAllowClickAvatar; + + /// Control if allowed to show emoji face message panel. + /// [Default]: true. + final bool isAllowEmojiPanel; + + /// Control if allowed to show more plus panel. + /// [Default]: true. + final bool isAllowShowMorePanel; + + /// Control if allowed to send voice sound message. + /// [Default]: true. + final bool isAllowSoundMessage; + + /// Control if allowed to at when reply automatically. + /// [Default]: true. + final bool isAtWhenReply; + + /// Control if allowed to at when reply automatically. + /// [Default]: true. + final bool Function(V2TimMessage message)? isAtWhenReplyDynamic; + + /// The main switch of the group read receipt. + /// [Deprecated: ] Please use [isShowReadingStatus] instead. + final bool isShowGroupMessageReadReceipt; + + /// [Deprecated: ] not support. + final List? groupReadReceiptPermisionList; + + /// Control which group can send message read receipt. + /// [Deprecated: ] not support. + final List? groupReadReceiptPermissionList; + + /// Control if show self name in group chat. + /// [Default]: false. + final bool isShowSelfNameInGroup; + + /// Control if others name in group chat. + /// [Default]: true. + final bool isShowOthersNameInGroup; + + /// Configuration for offline push. + /// If this field is specified, `notificationTitle`, `notificationOPPOChannelID`, `notificationIOSSound`, `notificationAndroidSound`, `notificationBody` and `notificationExt` will not work. + final OfflinePushInfo? Function(V2TimMessage message, String convID, ConvType convType)? offlinePushInfo; + + /// The title shows in push notification + final String notificationTitle; + + /// The channel ID for OPPO in push notification. + final String notificationOPPOChannelID; + + /// The notification sound in iOS devices. + /// When `iOSSound` = `kIOSOfflinePushNoSound`, the sound will not play when message received. When `iOSSound` = `kIOSOfflinePushDefaultSound`, the system sound is played when message received. If you want to customize `iOSSound`, you need to link the voice file into the Xcode project, and then set the voice file name (with a suffix) to iOSSound. + final String notificationIOSSound; + + /// The notification sound in Android devices. + final String notificationAndroidSound; + + ///Used to set the line height of text messages + final double textHeight; + + /// The body content shows in push notification. + /// Returning `null` means using default body in this case. + final String? Function(V2TimMessage message, String convID, ConvType convType)? notificationBody; + + /// External information (String) for notification message, recommend used for jumping to target conversation with JSON format, + /// Returning `null` means using default ext in this case. + final String? Function(V2TimMessage message, String convID, ConvType convType)? notificationExt; + + /// The type of URL preview level, none preview, only hyperlink in text, or shows a preview card for website. + /// [Default]: UrlPreviewType.previewCardAndHyperlink. + final UrlPreviewType urlPreviewType; + + /// Whether to display the sending status of c2c messages + /// [Default]: true. + final bool showC2cMessageEditStatus; + + /// Control if take emoji stickers as message reaction. + /// [Default]: true. + final bool isUseMessageReaction; + + /// Determine how long a message is allowed to be recalled after it is sent. + /// You must modify the configuration on control dashboard synchronized at: https://console.cloud.tencent.com/im/login-message. + /// [Unit]: second. + /// [Default]: 120. + final int upperRecallTime; + + /// The prefix of face sticker URI. + final String Function(String data)? faceURIPrefix; + + /// The suffix of face sticker URI. + final String Function(String data)? faceURISuffix; + + /// Controls whether text and replied messages can be displayed with Markdown formatting. + /// Also, when enabled, `isEnableTextSelection` will not works. + /// [Default]: false. + final bool isSupportMarkdownForTextMessage; + + /// The callback after user clicking the URL link in text messages. + /// The default action is opening the link with the default browser of system. + final void Function(String url)? onTapLink; + + /// Whether shows avatar on history message list. + /// [Default]: true. + final bool isShowAvatar; + + /// This list contains additional operation items that are displayed on the hover bar + /// of a message on desktop (macOS, Windows, and desktop version of Web). These items + /// are in addition to the default ones and do not affect them. + final List? additionalDesktopMessageHoverBarItem; + + /// This list contains additional items that are displayed + /// on the message sending area control bar on desktop (macOS, Windows, and desktop version of Web). + /// Use `desktopControlBarConfig` to configure whether or not to show the default control items. + final List? additionalDesktopControlBarItems; + + /// This configuration is used for the control bar + /// on desktop (macOS, Windows, and desktop version of Web). + /// Use `desktopControlBarConfig` to add additional items to the desktop message sending area control bar, in addition to the default ones. + final DesktopControlBarConfig? desktopControlBarConfig; + + /// Controls whether users are allowed to mention another user in the group by long-pressing on their avatar. + /// [Default]: true. + final bool isAllowLongPressAvatarToAt; + + /// Controls whether auto report message read status when new messages come. + /// [Default]: true. + final bool isAutoReportRead; + + /// Controls whether enable text selection. + /// [Default]: true on Desktop while false on Mobile. + final bool? isEnableTextSelection; + + /// Controls whether enable the control bar shows when hovering a message on Desktop. + /// [Default]: true. + final bool isUseMessageHoverBarOnDesktop; + + /// Define the lines in the text message input field on Desktop. + final int desktopMessageInputFieldLines; + + /// Specifies whether to use the draft feature on the Web, as the Chat SDK does not support this functionality. + /// If enabled, draft data will be stored in TUIKit's memory. + /// Note that the draft text will be lost upon refreshing the website. + /// [Default]: true. + final bool isUseDraftOnWeb; + + /// Determines whether a group administrator is allowed to recall any + /// message from any group member. If this capability is enabled, + /// recalled messages will not be interoperable with Native clients + /// and will only take effect on other Flutter clients. + /// + /// [Default]: false + final bool isGroupAdminRecallEnabled; + + /// Defines the height of the sticker panel on desktop platforms. + /// If the height of the sticker list exceeds this container height, + /// the sticker list will automatically become scrollable. + /// + /// [Default]: 400 + final double desktopStickerPanelHeight; + + /// Determine whether the normal members can @All in a group chat. + /// If enabled, normal members can @All in a group chat. + /// If disabled, only the group owner or administrators can @All. + /// + /// [Default]: false + final bool isMemberCanAtAll; + + const TIMUIKitChatConfig( + {this.onTapLink, + this.timeDividerConfig, + this.desktopStickerPanelHeight = 400, + this.stickerPanelConfig, + this.isGroupAdminRecallEnabled = false, + this.isAutoReportRead = true, + this.faceURIPrefix, + this.faceURISuffix, + this.textHeight = 1.3, + this.desktopMessageInputFieldLines = 6, + this.isAtWhenReply = true, + this.notificationAndroidSound = "", + this.isUseMessageHoverBarOnDesktop = true, + this.isSupportMarkdownForTextMessage = false, + this.notificationExt, + this.isUseMessageReaction = true, + this.isShowAvatar = true, + this.isShowSelfNameInGroup = false, + this.isAtWhenReplyDynamic, + this.offlinePushInfo, + @Deprecated("Please use [isShowReadingStatus] instead") this.isShowGroupMessageReadReceipt = true, + this.upperRecallTime = 120, + this.isShowOthersNameInGroup = true, + this.urlPreviewType = UrlPreviewType.onlyHyperlink, + this.notificationBody, + this.notificationOPPOChannelID = "", + this.notificationTitle = "", + this.notificationIOSSound = "", + this.isAllowSoundMessage = true, + @Deprecated("not support") this.groupReadReceiptPermisionList, + @Deprecated("not support") this.groupReadReceiptPermissionList, + this.isAllowEmojiPanel = true, + this.isAllowShowMorePanel = true, + this.isShowReadingStatus = true, + this.desktopControlBarConfig, + this.isAllowLongPressMessage = true, + this.isUseDraftOnWeb = true, + this.isAllowClickAvatar = true, + this.isEnableTextSelection, + this.additionalDesktopMessageHoverBarItem, + this.isShowGroupReadingStatus = true, + @Deprecated("Please use [isShowReadingStatus] instead") this.isReportGroupReadingStatus = true, + this.showC2cMessageEditStatus = true, + this.additionalDesktopControlBarItems, + this.isAllowLongPressAvatarToAt = true, + this.isMemberCanAtAll = false}); +} diff --git a/lib/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart b/lib/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart new file mode 100644 index 0000000..f63c1c9 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart @@ -0,0 +1,70 @@ +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +class MessageRepliedData { + late String messageAbstract; + late String messageSender; + late String messageID; + + MessageRepliedData.fromJson(Map messageReply) { + messageAbstract = messageReply["messageAbstract"]; + messageSender = messageReply["messageSender"] ?? ""; + messageID = messageReply["messageID"]; + } +} + +class RepliedMessageAbstract { + final int? elemType; + final String? msgID; + final int? timestamp; + final String? seq; + final String? summary; + + RepliedMessageAbstract( + {this.elemType, this.msgID, this.timestamp, this.seq, this.summary}); + + // fromJson constructor + RepliedMessageAbstract.fromJson(Map json) + : elemType = json['elemType'], + msgID = json['msgID'], + timestamp = json['timestamp'], + seq = json['seq'], + summary = json['summary']; + + // toJson function + Map toJson() { + return { + 'summary': summary, + 'elemType': elemType, + 'msgID': msgID, + 'timestamp': timestamp, + 'seq': seq, + }; + } + + // isNotEmpty method + bool get isNotEmpty => + TencentUtils.checkString(msgID) != null && + TencentUtils.checkString(timestamp.toString()) != null && + TencentUtils.checkString(seq) != null; +} + +class CloudCustomData { + Map? messageReply; + Map? messageReaction = {}; + + CloudCustomData.fromJson(Map jsonMap) { + messageReply = jsonMap["messageReply"]; + messageReaction = jsonMap["messageReaction"] ?? {}; + } + + Map toMap() { + final Map data = {}; + if (messageReply != null) { + data['messageReply'] = messageReply; + } + data['messageReaction'] = messageReaction ?? {}; + return data; + } + + CloudCustomData(); +} diff --git a/lib/ui/views/TIMUIKitChat/tim_uikit_multi_select_panel.dart b/lib/ui/views/TIMUIKitChat/tim_uikit_multi_select_panel.dart new file mode 100644 index 0000000..aaca3a3 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/tim_uikit_multi_select_panel.dart @@ -0,0 +1,317 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/forward_message_screen.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class MultiSelectPanel extends TIMUIKitStatelessWidget { + final int forwardMsgNumLimit = 30; + + final ConvType conversationType; + + MultiSelectPanel({Key? key, required this.conversationType}) + : super(key: key); + + _handleForwardMessage(BuildContext context, bool isMergerForward, + TUIChatSeparateViewModel model) { + + // 是否有选中消息 + if (model.getSelectedMessageList().isEmpty) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("请选择要操作的消息!"))); + return; + } + + for (var v2TimMessage in model.getSelectedMessageList()) { + // 失败消息不支持转发 + if (v2TimMessage.status == MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("发送失败消息不支持转发!"))); + return; + } + + // 投票消息不支持转发 + if (model.isVoteMessage(v2TimMessage)) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: TIM_t("投票消息不支持转发!"))); + return; + } + } + + // 逐条转发限制在 30 条以内 + if (!isMergerForward && model.getSelectedMessageList().length > forwardMsgNumLimit) { + _showForwardLimitDialog(context); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ForwardMessageScreen( + model: model, + isMergerForward: isMergerForward, + conversationType: conversationType, + ))); + } + + // 弹出逐条转发超限的对话框 + Future _showForwardLimitDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: Text(TIM_t("转发消息过多,暂不支持逐条转发")), + actions: [ + CupertinoDialogAction( + child: Text(TIM_t("确定")), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + CupertinoDialogAction( + child: Text(TIM_t("取消")), + isDestructiveAction: true, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + _handleForwardMessageWide(BuildContext context, bool isMergerForward, + TUIChatSeparateViewModel model) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.forward, + context: context, + isDarkBackground: false, + title: TIM_t("转发"), + submitWidget: Text(TIM_t("发送")), + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + onSubmit: (){ + forwardMessageScreenKey.currentState?.handleForwardMessage(); + }, + child: (onClose) => Container( + padding: const EdgeInsets.symmetric( horizontal: 10), + child: ForwardMessageScreen( + model: model, + key: forwardMessageScreenKey, + onClose: onClose, + isMergerForward: isMergerForward, + conversationType: conversationType, + ), + ) + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final TUIChatSeparateViewModel model = + Provider.of(context); + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + decoration: BoxDecoration( + color: theme.selectPanelBgColor ?? theme.primaryColor, + border: Border( + top: BorderSide( + color: theme.weakDividerColor ?? Colors.grey, + width: 1.0, + ), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, + spacing: 64, + children: [ + Column( + children: [ + IconButton( + icon: Image.asset('images/forward.png', + package: 'tencent_cloud_chat_uikit', + color: theme.selectPanelTextIconColor), + iconSize: 30, + onPressed: () { + _handleForwardMessageWide(context, false, model); + }, + ), + Text(TIM_t("逐条转发"), + style: TextStyle( + color: hexToColor("646a73"), fontSize: 12)) + ], + ), + Column( + children: [ + IconButton( + icon: Image.asset('images/merge_forward.png', + package: 'tencent_cloud_chat_uikit', + color: theme.selectPanelTextIconColor), + iconSize: 30, + onPressed: () { + _handleForwardMessageWide(context, true, model); + }, + ), + Text( + TIM_t("合并转发"), + style: + TextStyle(color: theme.selectPanelTextIconColor, fontSize: 12), + ) + ], + ), + Column( + children: [ + IconButton( + icon: Image.asset('images/delete.png', + package: 'tencent_cloud_chat_uikit', + color: theme.selectPanelTextIconColor), + iconSize: 30, + onPressed: () { + TUIKitWidePopup.showSecondaryConfirmDialog( + operationKey: TUIKitWideModalOperationKey.confirmDeleteMessages, + context: context, + text: TIM_t("确定删除已选消息"), + theme: theme, + onCancel: () {}, + onConfirm: () async { + model.deleteSelectedMsg(); + model.updateMultiSelectStatus(false); + }); + }, + ), + Text(TIM_t("删除"), + style: TextStyle( + color: theme.selectPanelTextIconColor, fontSize: 12)) + ], + ), + InkWell( + onTap: (){ + model.updateMultiSelectStatus(false); + }, + child: Icon(Icons.close, color: theme.darkTextColor,), + ) + ], + )) + ], + ), + ), + defaultWidget: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: theme.weakDividerColor ?? + CommonColor.weakDividerColor)), + color: theme.selectPanelBgColor ?? theme.primaryColor, + ), + padding: const EdgeInsets.only(top: 12, bottom: 48), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + IconButton( + icon: Image.asset('images/forward.png', + package: 'tencent_cloud_chat_uikit', color: theme.selectPanelTextIconColor), + iconSize: 40, + onPressed: () { + _handleForwardMessage(context, false, model); + }, + ), + Text(TIM_t("逐条转发"), + style: TextStyle(color: theme.selectPanelTextIconColor, fontSize: 12)) + ], + ), + Column( + children: [ + IconButton( + icon: Image.asset('images/merge_forward.png', + package: 'tencent_cloud_chat_uikit', color: theme.selectPanelTextIconColor), + iconSize: 40, + onPressed: () { + _handleForwardMessage(context, true, model); + }, + ), + Text( + TIM_t("合并转发"), + style: TextStyle(color: theme.selectPanelTextIconColor, fontSize: 12), + ) + ], + ), + Column( + children: [ + IconButton( + icon: Image.asset('images/delete.png', + package: 'tencent_cloud_chat_uikit', color: theme.selectPanelTextIconColor), + iconSize: 40, + onPressed: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + title: Text(TIM_t("确定删除已选消息")), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + "cancel", + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + model.deleteSelectedMsg(); + model.updateMultiSelectStatus(false); + Navigator.pop( + context, + "cancel", + ); + }, + child: Text( + TIM_t("删除"), + style: TextStyle(color: theme.cautionColor), + ), + isDefaultAction: false, + ) + ], + ); + }, + ); + }, + ), + Text(TIM_t("删除"), + style: TextStyle(color: theme.selectPanelTextIconColor, fontSize: 12)) + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitChat/tim_uikit_send_file.dart b/lib/ui/views/TIMUIKitChat/tim_uikit_send_file.dart new file mode 100644 index 0000000..2a5dc03 --- /dev/null +++ b/lib/ui/views/TIMUIKitChat/tim_uikit_send_file.dart @@ -0,0 +1,220 @@ +import 'dart:io'; +import 'package:dotted_border/dotted_border.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'TIMUIKitMessageItem/tim_uikit_chat_file_icon.dart'; + +String _getConvID(V2TimConversation conversation) { + return (conversation.type == 1 ? conversation.userID : conversation.groupID) ?? ""; +} + +sendFileWithConfirmation( + {required List files, + required V2TimConversation conversation, + required ConvType conversationType, + required TUIChatSeparateViewModel model, + required TUITheme theme, + required BuildContext context}) async { + bool isCanSend = true; + + if (!PlatformUtils().isWeb) { + files.map((e) => e.path).any((filePath) { + final directory = Directory(filePath); + final isDirectoryExists = directory.existsSync(); + if (isDirectoryExists) { + isCanSend = false; + return false; + } + return true; + }); + } else { + files.map((e) => e.name).any((fileName) { + String fileExtension = path.extension(fileName); + bool hasNoExtension = fileExtension.isEmpty; + if (hasNoExtension) { + isCanSend = false; + return false; + } + return true; + }); + } + + if (!isCanSend) { + TUIKitWidePopup.showSecondaryConfirmDialog( + text: TIM_t("无法发送,包含文件夹"), + onConfirm: () {}, + operationKey: TUIKitWideModalOperationKey.unableToSendDueToFolders, + context: context, + theme: theme); + return; + } + + final option1 = conversation.showName ?? (conversationType == ConvType.group ? TIM_t("群聊") : TIM_t("对方")); + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.beforeSendScreenShot, + context: context, + isDarkBackground: false, + width: 600, + height: files.length < 4 ? 300 : 500, + title: TIM_t_para("发送给{{option1}}", "发送给$option1")(option1: option1), + child: (closeFunc) => Container( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Scrollbar( + child: ListView.separated( + itemBuilder: (BuildContext context, int index) { + final file = files[index]; + final fileName = PlatformUtils().isWeb ? file.name : path.basename(file.path); + return Material( + color: theme.wideBackgroundColor, + child: InkWell( + onTap: () { + launchUrl(Uri.file(file.path)); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 20), + child: Row( + children: [ + TIMUIKitFileIcon( + size: 44, + fileFormat: fileName.split(".")[fileName.split(".").length - 1], + ), + const SizedBox(width: 16), + Expanded( + child: Text( + fileName, + style: TextStyle(fontSize: 16, color: theme.darkTextColor), + ), + ), + ], + ), + ), + ), + ); + }, + separatorBuilder: (BuildContext context, int index) { + return Divider( + height: 1, + thickness: 1, + color: theme.weakDividerColor, + ); + }, + itemCount: files.length, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 16, top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + children: [ + OutlinedButton( + onPressed: () { + closeFunc(); + }, + child: Text(TIM_t("取消"))), + const SizedBox( + width: 20, + ), + ElevatedButton( + onPressed: () { + sendFiles(files, model, conversation, conversationType, context); + closeFunc(); + }, + child: Text(TIM_t("发送"))) + ], + ), + ) + ], + ), + )); +} + +Future sendFiles(List files, TUIChatSeparateViewModel model, V2TimConversation conversation, + ConvType conversationType, BuildContext context) async { + for (final file in files) { + final fileName = file.name; + final filePath = file.path; + await MessageUtils.handleMessageError( + model.sendFileMessage( + fileName: fileName, filePath: filePath, convID: _getConvID(conversation), convType: conversationType), + context); + await Future.delayed(const Duration(microseconds: 300)); + } +} + +class TIMUIKitSendFile extends TIMUIKitStatelessWidget { + final V2TimConversation conversation; + + TIMUIKitSendFile({required this.conversation, Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final conversationType = conversation.type; + final option1 = conversation.showName ?? (conversationType == 2 ? TIM_t("群聊") : TIM_t("会话")); + + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Opacity( + opacity: 0.85, + child: Container( + color: theme.wideBackgroundColor, + padding: const EdgeInsets.all(40), + child: DottedBorder( + borderType: BorderType.RRect, + radius: const Radius.circular(20), + color: theme.primaryColor ?? theme.weakTextColor!, + dashPattern: const [6, 3], + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.file_copy_outlined, + size: 60, + color: theme.primaryColor, + ), + const SizedBox( + height: 40, + ), + Text( + TIM_t_para("发送给{{option1}}", "发送给$option1")(option1: option1), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: theme.darkTextColor), + ) + ], + )) + ], + ), + ), + ), + )) + ], + ); + } +} diff --git a/lib/ui/views/TIMUIKitContact/tim_uikit_contact.dart b/lib/ui/views/TIMUIKitContact/tim_uikit_contact.dart new file mode 100644 index 0000000..9b1df80 --- /dev/null +++ b/lib/ui/views/TIMUIKitContact/tim_uikit_contact.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/friend_list_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; + +export 'package:tencent_cloud_chat_uikit/ui/widgets/contact_list.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; + +class TIMUIKitContact extends StatefulWidget { + /// the callback after clicking contact item + final void Function(V2TimFriendInfo item)? onTapItem; + + /// the list on top + final List? topList; + + /// the builder for the list on top + final Widget? Function(TopListItem item)? topListItemBuilder; + + /// The widget shows when no contacts exists. + final Widget Function(BuildContext context)? emptyBuilder; + + /// the life cycle hooks for friend list or contacts list business logic + final FriendListLifeCycle? lifeCycle; + + /// Control if shows the online status for each user on its avatar. + final bool isShowOnlineStatus; + + const TIMUIKitContact( + {Key? key, + this.onTapItem, + this.lifeCycle, + this.topList, + this.topListItemBuilder, + this.emptyBuilder, + this.isShowOnlineStatus = true}) + : super(key: key); + + @override + State createState() => _TIMUIKitContactState(); +} + +class _TIMUIKitContactState extends TIMUIKitState { + final TUIFriendShipViewModel model = serviceLocator(); + String currentItem = ""; + + @override + void dispose() { + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ], + builder: (context, w) { + final model = Provider.of(context); + model.contactListLifeCycle = widget.lifeCycle; + final memberList = model.friendList ?? []; + + return ContactList( + currentItem: currentItem, + emptyBuilder: widget.emptyBuilder, + isShowOnlineStatus: widget.isShowOnlineStatus, + contactList: memberList, + onTapItem: (item) { + if (isDesktopScreen) { + setState(() { + currentItem = item.userID; + }); + } + if (widget.onTapItem != null) { + widget.onTapItem!(item); + } + }, + bgColor: isDesktopScreen ? theme.wideBackgroundColor : null, + topList: widget.topList, + topListItemBuilder: widget.topListItemBuilder, + ); + }); + } +} diff --git a/lib/ui/views/TIMUIKitConversation/tim_ui_kit_conversation_total_unread.dart b/lib/ui/views/TIMUIKitConversation/tim_ui_kit_conversation_total_unread.dart new file mode 100644 index 0000000..7b3abe2 --- /dev/null +++ b/lib/ui/views/TIMUIKitConversation/tim_ui_kit_conversation_total_unread.dart @@ -0,0 +1,52 @@ +// ignore_for_file: camel_case_types + +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/unread_message.dart'; + +typedef unreadCountBuilder = Widget Function(int unreadCount); + +class TIMUIKitConversationTotalUnread extends TIMUIKitStatelessWidget { + final TUIConversationViewModel model = + serviceLocator(); + final int? unreadCount; + final unreadCountBuilder? builder; + final double? width; + final double? height; + + TIMUIKitConversationTotalUnread( + {this.width = 22.0, + this.height = 22.0, + this.unreadCount, + this.builder, + Key? key}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ], + child: Consumer( + builder: (context, value, child) { + if (value.totalUnReadCount == 0) { + return Container(); + } + + if (builder != null) { + return builder!(value.totalUnReadCount); + } + return UnreadMessage( + unreadCount: unreadCount ?? value.totalUnReadCount, + width: width, + height: height); + }, + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart new file mode 100644 index 0000000..7f49603 --- /dev/null +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation.dart @@ -0,0 +1,452 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_easyrefresh/easy_refresh.dart'; +import 'package:flutter_slidable_plus_plus/flutter_slidable_plus_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/conversation_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/controller/tim_uikit_conversation_controller.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/customize_ball_pulse_header.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef ConversationItemBuilder = Widget Function(V2TimConversation conversationItem, [V2TimUserStatus? onlineStatus]); + +typedef ConversationItemSlideBuilder = List Function(V2TimConversation conversationItem); + +typedef ConversationItemSecondaryMenuBuilder = Widget Function( + V2TimConversation conversationItem, VoidCallback onClose); + +class TIMUIKitConversation extends StatefulWidget { + /// the callback after clicking conversation item + final ValueChanged? onTapItem; + + /// conversation controller + final TIMUIKitConversationController? controller; + + /// the builder for conversation item + final ConversationItemBuilder? itemBuilder; + + /// the builder for Slidable item for each conversation item, shows on narrow screens. + final ConversationItemSlideBuilder? itemSlideBuilder; + + /// the widget of secondary tap menu for each conversation item, shows on wide screens. + final ConversationItemSecondaryMenuBuilder? itemSecondaryMenuBuilder; + + /// the widget shows when no conversation exists + final Widget Function()? emptyBuilder; + + /// the filter for conversation + final bool Function(V2TimConversation? conversation)? conversationCollector; + + /// the builder for the second line in each conservation item, + /// usually shows the summary of the last message + final LastMessageBuilder? lastMessageBuilder; + + /// The life cycle hooks for `TIMUIKitConversation` + final ConversationLifeCycle? lifeCycle; + + /// Control if shows the online status for each user on its avatar. + final bool isShowOnlineStatus; + + /// Control if shows the identifier that the conversation has a draft text, inputted in previous. + final bool isShowDraft; + + const TIMUIKitConversation( + {Key? key, + this.lifeCycle, + this.onTapItem, + this.controller, + this.itemSecondaryMenuBuilder, + this.itemBuilder, + this.isShowDraft = true, + this.itemSlideBuilder, + this.conversationCollector, + this.emptyBuilder, + this.lastMessageBuilder, + this.isShowOnlineStatus = true}) + : super(key: key); + + @override + State createState() { + return _TIMUIKitConversationState(); + } +} + +class ConversationItemSlidePanel extends TIMUIKitStatelessWidget { + ConversationItemSlidePanel({ + Key? key, + this.flex = 1, + this.backgroundColor = Colors.white, + this.foregroundColor, + this.autoClose = true, + required this.onPressed, + this.icon, + this.spacing = 4, + this.label, + }) : assert(flex > 0), + assert(icon != null || label != null), + super(key: key); + + /// {@macro slidable.actions.flex} + final int flex; + + /// {@macro slidable.actions.backgroundColor} + final Color backgroundColor; + + /// {@macro slidable.actions.foregroundColor} + final Color? foregroundColor; + + /// {@macro slidable.actions.autoClose} + final bool autoClose; + + /// {@macro slidable.actions.onPressed} + final SlidableActionCallback? onPressed; + + /// An icon to display above the [label]. + final IconData? icon; + + /// The space between [icon] and [label] if both set. + /// + /// Defaults to 4. + final double spacing; + + /// A label to display below the [icon]. + final String? label; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return SlidableAction( + onPressed: onPressed, + flex: flex, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + autoClose: autoClose, + label: label, + spacing: spacing, + ); + } +} + +class _TIMUIKitConversationState extends TIMUIKitState { + final TUIConversationViewModel model = serviceLocator(); + late TIMUIKitConversationController _timuiKitConversationController; + final TUIThemeViewModel themeViewModel = serviceLocator(); + final TUIFriendShipViewModel friendShipViewModel = serviceLocator(); + final TUIGroupListenerModel groupListenerModel = serviceLocator(); + late AutoScrollController _autoScrollController; + + @override + void initState() { + super.initState(); + final controller = getController(); + _timuiKitConversationController = controller; + _timuiKitConversationController.model = model; + _autoScrollController = AutoScrollController(); + } + + TIMUIKitConversationController getController() { + return widget.controller ?? TIMUIKitConversationController(); + } + + void onTapConvItem(V2TimConversation conversation) { + if (widget.onTapItem != null) { + widget.onTapItem!(conversation); + } + model.setSelectedConversation(conversation); + } + + _clearHistory(V2TimConversation conversationItem) { + _timuiKitConversationController.clearHistoryMessage(conversation: conversationItem); + } + + _pinConversation(V2TimConversation conversation) { + _timuiKitConversationController.pinConversation( + conversationID: conversation.conversationID, isPinned: !conversation.isPinned!); + } + + _deleteConversation(V2TimConversation conversation) { + _timuiKitConversationController.deleteConversation(conversationID: conversation.conversationID); + } + + List getFilteredConversation() { + List filteredConversationList = + model.conversationList.where((element) => (element?.groupID != null || element?.userID != null)).toList(); + if (widget.conversationCollector != null) { + filteredConversationList = filteredConversationList.where(widget.conversationCollector!).toList(); + } + return filteredConversationList; + } + + _onScrollToConversation(String conversationID) { + final msgList = getFilteredConversation(); + bool isFound = false; + int targetIndex = 1; + for (int i = msgList.length - 1; i >= 0; i--) { + final currentConversation = msgList[i]; + if (currentConversation?.conversationID == conversationID) { + isFound = true; + targetIndex = i; + break; + } + } + + if (isFound) { + _autoScrollController.scrollToIndex( + targetIndex, + preferPosition: AutoScrollPosition.begin, + ); + } + } + + Widget _defaultSecondaryMenu(V2TimConversation conversationItem, VoidCallback onClose) { + return TUIKitColumnMenu(data: [ + if (!PlatformUtils().isWeb) + ColumnMenuItem( + label: TIM_t("清除消息"), + icon: const Icon(Icons.clear_all, size: 16), + onClick: () { + onClose(); + _clearHistory(conversationItem); + }), + ColumnMenuItem( + label: conversationItem.isPinned! ? TIM_t("取消置顶") : TIM_t("置顶"), + icon: Icon(conversationItem.isPinned! ? Icons.vertical_align_bottom : Icons.vertical_align_top, size: 16), + onClick: () { + onClose(); + _pinConversation(conversationItem); + }), + ColumnMenuItem( + label: TIM_t("删除会话"), + icon: const Icon(Icons.delete_outline, size: 16), + onClick: () { + onClose(); + _deleteConversation(conversationItem); + }), + ]); + } + + List _defaultSlideBuilder( + V2TimConversation conversationItem, + ) { + final theme = themeViewModel.theme; + return [ + if (!PlatformUtils().isWeb) + ConversationItemSlidePanel( + onPressed: (context) { + _clearHistory(conversationItem); + }, + backgroundColor: theme.conversationItemSliderClearBgColor ?? CommonColor.primaryColor, + foregroundColor: theme.conversationItemSliderTextColor, + label: TIM_t("清除"), + spacing: 0, + autoClose: true, + ), + ConversationItemSlidePanel( + onPressed: (context) { + _pinConversation(conversationItem); + }, + backgroundColor: theme.conversationItemSliderPinBgColor ?? CommonColor.infoColor, + foregroundColor: theme.conversationItemSliderTextColor, + label: conversationItem.isPinned! ? TIM_t("取消置顶") : TIM_t("置顶"), + ), + ConversationItemSlidePanel( + onPressed: (context) { + _deleteConversation(conversationItem); + }, + backgroundColor: theme.conversationItemSliderDeleteBgColor ?? Colors.red, + foregroundColor: theme.conversationItemSliderTextColor, + label: TIM_t("删除"), + ) + ]; + } + + Widget _getSecondaryMenu(V2TimConversation conversation, VoidCallback onClose) { + if (widget.itemSecondaryMenuBuilder != null) { + return widget.itemSecondaryMenuBuilder!(conversation, onClose); + } + return _defaultSecondaryMenu(conversation, onClose); + } + + ConversationItemSlideBuilder _getSlideBuilder() { + return widget.itemSlideBuilder ?? _defaultSlideBuilder; + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ChangeNotifierProvider.value(value: friendShipViewModel), + ChangeNotifierProvider.value(value: groupListenerModel) + ], + builder: (BuildContext context, Widget? w) { + final _model = Provider.of(context); + bool haveMoreData = _model.haveMoreData; + final _friendShipViewModel = Provider.of(context); + _model.lifeCycle = widget.lifeCycle; + + final TUIGroupListenerModel groupListenerModel = Provider.of(context, listen: true); + final NeedUpdate? needUpdate = groupListenerModel.needUpdate; + if (needUpdate != null) { + groupListenerModel.needUpdate = null; + if (needUpdate.updateType == UpdateType.groupDismissed) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: "${needUpdate!.extraData}${TIM_t("已解散")}", + infoCode: 6661402)); + } else if (needUpdate.updateType == UpdateType.kickedFromGroup) { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: "${TIM_t("您已被踢出")}${needUpdate!.extraData}", + infoCode: 6661402)); + } + } + + List filteredConversationList = getFilteredConversation(); + + if (TencentUtils.checkString(_model.scrollToConversation) != null) { + _onScrollToConversation(_model.scrollToConversation!); + _model.clearScrollToConversation(); + } + + Widget conversationList() { + return filteredConversationList.isNotEmpty + ? ListView.builder( + controller: _autoScrollController, + shrinkWrap: true, + itemCount: filteredConversationList.length, + itemBuilder: (context, index) { + if (index == filteredConversationList.length - 1) { + if (haveMoreData) { + _timuiKitConversationController.loadData(); + } + } + + final conversationItem = filteredConversationList[index]; + + final V2TimUserStatus? onlineStatus = _friendShipViewModel.userStatusList.firstWhere( + (item) => item.userID == conversationItem?.userID, + orElse: () => V2TimUserStatus(statusType: 0)); + + if (widget.itemBuilder != null) { + return widget.itemBuilder!(conversationItem!, onlineStatus); + } + + final slideChildren = _getSlideBuilder()(conversationItem!); + + final isCurrent = conversationItem.conversationID == model.selectedConversation?.conversationID; + + final isPined = conversationItem.isPinned ?? false; + + Widget conversationLineItem() { + return Material( + color: (isCurrent && isDesktopScreen) + ? theme.conversationItemChooseBgColor + : isPined + ? theme.conversationItemPinedBgColor + : theme.conversationItemBgColor, + child: GestureDetector( + child: TIMUIKitConversationItem( + isCurrent: isCurrent, + lastMessageBuilder: widget.lastMessageBuilder, + faceUrl: conversationItem.faceUrl ?? "", + nickName: conversationItem.showName ?? "", + isDisturb: + (conversationItem.groupType == "Meeting" ? false : conversationItem.recvOpt != 0), + lastMsg: conversationItem.lastMessage, + isPined: isPined, + groupAtInfoList: conversationItem.groupAtInfoList ?? [], + unreadCount: conversationItem.unreadCount ?? 0, + draftText: conversationItem.draftText, + onlineStatus: (widget.isShowOnlineStatus && + conversationItem.userID != null && + conversationItem.userID!.isNotEmpty) + ? onlineStatus + : null, + draftTimestamp: conversationItem.draftTimestamp, + convType: conversationItem.type), + onTap: () => onTapConvItem(conversationItem), + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: AutoScrollTag( + key: ValueKey(conversationItem.conversationID), + controller: _autoScrollController, + index: index, + child: InkWell( + onSecondaryTapDown: (details) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.conversationSecondaryMenu, + isDarkBackground: false, + borderRadius: const BorderRadius.all(Radius.circular(4)), + context: context, + offset: Offset( + min(details.globalPosition.dx, MediaQuery.of(context).size.width - 80), + min(details.globalPosition.dy, MediaQuery.of(context).size.height - 130)), + child: (onClose) => _getSecondaryMenu(conversationItem, onClose)); + }, + child: conversationLineItem(), + ), + ), + defaultWidget: AutoScrollTag( + key: ValueKey(conversationItem.conversationID), + controller: _autoScrollController, + index: index, + child: Slidable( + groupTag: 'conversation-list', + child: conversationLineItem(), + endActionPane: ActionPane( + extentRatio: slideChildren.length > 2 ? 0.77 : 0.5, + motion: const DrawerMotion(), + children: slideChildren)), + )); + }) + : (widget.emptyBuilder != null ? widget.emptyBuilder!() : Container()); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + defaultWidget: SlidableAutoCloseBehavior( + child: EasyRefresh( + header: CustomizeBallPulseHeader(color: theme.primaryColor), + onRefresh: () async { + model.refresh(); + }, + child: conversationList(), + ), + ), + desktopWidget: Scrollbar(controller: _autoScrollController, child: conversationList())); + }); + } +} diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_draft_text.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_draft_text.dart new file mode 100644 index 0000000..07ed8ce --- /dev/null +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_draft_text.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitDraftText extends TIMUIKitStatelessWidget { + final BuildContext context; + final String draftText; + final double fontSize; + + TIMUIKitDraftText({ + Key? key, + this.fontSize = 14.0, + required this.context, + required this.draftText, + }) : super(key: key); + + String _getDraftShowText() { + final draftShowText = TIM_t("草稿"); + + return '[$draftShowText] '; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + return Row(children: [ + Text(_getDraftShowText(), + style: TextStyle( + color: theme.conversationItemDraftTextColor, + )), + Expanded( + child: Text( + draftText, + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + height: 1.5, + color: theme.conversationItemLastMessageTextColor, + fontSize: fontSize), + )), + ]); + } +} diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart new file mode 100644 index 0000000..95c232f --- /dev/null +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_item.dart @@ -0,0 +1,197 @@ +// ignore_for_file: empty_catches + +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/time_ago.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitConversation/tim_uikit_conversation_last_msg.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/unread_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +typedef LastMessageBuilder = Widget? Function(V2TimMessage? lastMsg, List groupAtInfoList); + +class TIMUIKitConversationItem extends TIMUIKitStatelessWidget { + final String faceUrl; + final String nickName; + final V2TimMessage? lastMsg; + final int unreadCount; + final bool isPined; + final List groupAtInfoList; + final String? draftText; + final int? draftTimestamp; + final bool isDisturb; + final LastMessageBuilder? lastMessageBuilder; + final V2TimUserStatus? onlineStatus; + final int? convType; + final bool isCurrent; + + TIMUIKitConversationItem({ + Key? key, + required this.faceUrl, + required this.nickName, + required this.lastMsg, + this.onlineStatus, + required this.isPined, + this.isCurrent = false, + required this.unreadCount, + required this.groupAtInfoList, + required this.isDisturb, + this.draftText, + this.draftTimestamp, + this.lastMessageBuilder, + this.convType, + }) : super(key: key); + + Widget _getShowMsgWidget(BuildContext context) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (lastMsg != null && lastMessageBuilder != null && lastMessageBuilder!(lastMsg, groupAtInfoList) != null) { + return lastMessageBuilder!(lastMsg, groupAtInfoList)!; + } + + if (lastMsg != null || (draftText != null && draftText != "")) { + return TIMUIKitLastMsg( + fontSize: isDesktopScreen ? 12 : 14, + groupAtInfoList: groupAtInfoList, + lastMsg: lastMsg, + isDisturb: isDisturb, + unreadCount: unreadCount, + context: context, + draftText: draftText ?? "", + ); + } + + return Container( + height: 0, + ); + } + + bool isHaveSecondLine() { + return (draftText != null && draftText != "") || (lastMsg != null); + } + + Widget _getTimeStringForChatWidget(BuildContext context, TUITheme theme) { + try { + if (draftTimestamp != null && draftTimestamp != 0) { + return Text(TimeAgo().getTimeStringForChat(draftTimestamp as int) ?? "", + style: TextStyle( + fontSize: 12, + color: theme.conversationItemTitmeTextColor, + )); + } else if (lastMsg != null) { + return Text(TimeAgo().getTimeStringForChat(lastMsg!.timestamp as int) ?? "", + style: TextStyle( + fontSize: 11, + color: theme.conversationItemTitmeTextColor, + )); + } + } catch (err) {} + + return Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return Container( + padding: const EdgeInsets.only(top: 6, bottom: 6, left: 16, right: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.conversationItemBorderColor ?? CommonColor.weakDividerColor, + width: 1, + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.only(top: 0, bottom: 2, right: 0), + child: SizedBox( + width: isDesktopScreen ? 40 : 44, + height: isDesktopScreen ? 40 : 44, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + Avatar(onlineStatus: onlineStatus, faceUrl: faceUrl, showName: nickName, type: convType), + if (unreadCount != 0) + Positioned( + top: isDisturb ? -2.5 : -4.5, + right: isDisturb ? -2.5 : -4.5, + child: UnconstrainedBox( + child: UnreadMessage( + width: isDisturb ? 10 : 18, + height: isDisturb ? 10 : 18, + unreadCount: isDisturb ? 0 : unreadCount), + ), + ) + ], + ), + ), + ), + Expanded( + child: Container( + height: 60, + margin: EdgeInsets.only(left: isDesktopScreen ? 10 : 12), + padding: const EdgeInsets.only(top: 0, bottom: 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + nickName, + softWrap: true, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + height: 1, + color: theme.conversationItemTitleTextColor, + fontSize: isDesktopScreen ? 14 : 18, + fontWeight: FontWeight.w400, + ), + )), + _getTimeStringForChatWidget(context, theme), + ], + ), + if (isHaveSecondLine()) + const SizedBox( + height: 6, + ), + Row( + children: [ + Expanded(child: _getShowMsgWidget(context)), + if (isDisturb) + SizedBox( + width: 18, + height: 18, + child: Icon( + Icons.notifications_off, + color: theme.conversationItemNoNotificationIconColor, + size: isDesktopScreen ? 14 : 16.0, + ), + ) + ], + ), + ], + ), + )) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_last_msg.dart b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_last_msg.dart new file mode 100644 index 0000000..8105555 --- /dev/null +++ b/lib/ui/views/TIMUIKitConversation/tim_uikit_conversation_last_msg.dart @@ -0,0 +1,242 @@ +// ignore_for_file: unrelated_type_equality_checks + +import 'dart:convert'; + +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_at_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_at_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class TIMUIKitLastMsg extends StatefulWidget { + final V2TimMessage? lastMsg; + final List groupAtInfoList; + final BuildContext context; + final double fontSize; + bool isDisturb; + int unreadCount; + String draftText; + + TIMUIKitLastMsg( + {Key? key, + this.lastMsg, + required this.groupAtInfoList, + this.isDisturb = false, + this.unreadCount = 0, + required this.draftText, + required this.context, + this.fontSize = 14.0}) + : super(key: key); + + @override + State createState() => _TIMUIKitLastMsgState(); +} + +class _TIMUIKitLastMsgState extends TIMUIKitState { + String groupTipsAbstractText = ""; + + @override + void initState() { + super.initState(); + _getMsgElem(); + } + + @override + void didUpdateWidget(covariant TIMUIKitLastMsg oldWidget) { + super.didUpdateWidget(oldWidget); + if ((oldWidget.lastMsg?.msgID != widget.lastMsg?.msgID) || + (oldWidget.lastMsg?.id != widget.lastMsg?.id) || + (oldWidget.lastMsg?.status != widget.lastMsg?.status) || + (oldWidget.unreadCount != widget.unreadCount)) { + _getMsgElem(); + } + } + + (bool isRevoke, bool isRevokeByAdmin) isRevokeMessage(V2TimMessage? message) { + if (message == null) { + return (false, false); + } + if (message.status == 6) { + return (true, false); + } else { + try { + final customData = jsonDecode(message.cloudCustomData ?? "{}"); + final isRevoke = customData["isRevoke"] ?? false; + final revokeByAdmin = customData["revokeByAdmin"] ?? false; + return (isRevoke, revokeByAdmin); + } catch (e) { + return (false, false); + } + } + } + + void _getMsgElem() async { + final revokeStatus = isRevokeMessage(widget.lastMsg); + final isRevokedMessage = revokeStatus.$1; + final isAdminRevoke = revokeStatus.$2; + if (isRevokedMessage) { + final isSelf = widget.lastMsg!.isSelf ?? true; + final option1 = + isAdminRevoke ? TIM_t("管理员") : (isSelf ? TIM_t("您") : widget.lastMsg!.nickName ?? widget.lastMsg?.sender); + if (mounted) { + setState(() { + groupTipsAbstractText = TIM_t_para("{{option1}}撤回了一条消息", "$option1撤回了一条消息")(option1: option1); + }); + } + } else { + String msgShowText = await _getLastMsgShowText(widget.lastMsg, widget.context) ?? ""; + if (mounted) { + setState(() { + groupTipsAbstractText = msgShowText; + }); + } + } + } + + String _getDisturbUnreadCountInfo() { + if (widget.isDisturb && widget.unreadCount > 0) { + final option1 = widget.unreadCount.toString(); + String unreadCountText = TIM_t_para("[{{option1}} 条]", "[$option1 条]")(option1: option1); + return unreadCountText; + } + + return ""; + } + + Future _getLastMsgShowText(V2TimMessage? message, BuildContext context) async { + final msgType = message!.elemType; + switch (msgType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return TIM_t("[自定义]"); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return TIM_t("[语音]"); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return (widget.lastMsg?.textElem?.text)?.trim() ?? ""; + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return TIM_t("[表情]"); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + final option1 = widget.lastMsg!.fileElem!.fileName; + return TIM_t_para("[文件] {{option1}}", "[文件] $option1")(option1: option1); + case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: + return await MessageUtils.groupTipsMessageAbstract(widget.lastMsg!.groupTipsElem!, []); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return TIM_t("[图片]"); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return TIM_t("[视频]"); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return TIM_t("[位置]"); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return TIM_t("[聊天记录]"); + default: + return null; + } + } + + Icon? _getIconByMsgStatus(BuildContext context) { + final msgStatus = widget.lastMsg!.status; + final theme = Provider.of(context).theme; + if (msgStatus == MessageStatus.V2TIM_MSG_STATUS_SEND_FAIL) { + return Icon(Icons.error, color: theme.cautionColor, size: 16); + } + if (msgStatus == MessageStatus.V2TIM_MSG_STATUS_SENDING) { + return Icon(Icons.arrow_back, color: theme.weakTextColor, size: 16); + } + return null; + } + + String _getAtMessage() { + bool atMe = false; + bool atAll = false; + String msg = ""; + for (var item in widget.groupAtInfoList) { + if (item!.atType == 1) { + atMe = true; + continue; + } else if (item!.atType == 2) { + atAll = true; + continue; + } else if (item!.atType == 3) { + atMe = true; + atAll = true; + continue; + } + } + + if (atAll && atMe) { + msg = TIM_t("[@所有人][有人@我]"); + } else if (atAll) { + msg = TIM_t("[@所有人]"); + } else if (atMe) { + msg = TIM_t("[有人@我]"); + } + + return msg; + } + + String _getDraftShowText() { + final draftShowText = TIM_t("草稿"); + return '[$draftShowText]'; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final TUITheme theme = value.theme; + final icon = _getIconByMsgStatus(context); + String disturbUnreadCountInfo = _getDisturbUnreadCountInfo(); + return Row(children: [ + if (icon != null) + Container( + margin: const EdgeInsets.only(right: 2), + child: icon, + ), + if (widget.groupAtInfoList.isNotEmpty) + Text(_getAtMessage(), style: TextStyle(color: theme.cautionColor, fontSize: widget.fontSize)), + if (widget.draftText != null && widget.draftText != "") + Text(_getDraftShowText(), + style: TextStyle(color: theme.conversationItemDraftTextColor, fontSize: widget.fontSize)), + if (disturbUnreadCountInfo != "") + Text(disturbUnreadCountInfo, style: TextStyle(color: theme.weakTextColor, fontSize: widget.fontSize)), + if (widget.draftText != null && widget.draftText != "") + Expanded( + child: ExtendedText(groupTipsAbstractText, + softWrap: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1, color: theme.weakTextColor, fontSize: widget.fontSize), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: true, + isUseTencentCloudChatPackage: true, + showAtBackground: true, + )), + ), + if (widget.draftText == null || widget.draftText == "" && TencentUtils.checkString(groupTipsAbstractText) != null) + Expanded( + child: ExtendedText(groupTipsAbstractText, + softWrap: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1, color: theme.weakTextColor, fontSize: widget.fontSize), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: true, + isUseTencentCloudChatPackage: true, + showAtBackground: true, + )), + ) + ]); + } +} diff --git a/lib/ui/views/TIMUIKitGroup/tim_uikit_group.dart b/lib/ui/views/TIMUIKitGroup/tim_uikit_group.dart new file mode 100644 index 0000000..70c2868 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroup/tim_uikit_group.dart @@ -0,0 +1,230 @@ +import 'package:azlistview_all_platforms/azlistview_all_platforms.dart'; +import 'package:flutter/material.dart'; +import 'package:lpinyin/lpinyin.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef GroupItemBuilder = + Widget Function(BuildContext context, V2TimGroupInfo groupInfo); + +/// TIMUIKitGroup State的类型定义,用于外部引用 +typedef TIMUIKitGroupState = _TIMUIKitGroupState; + +class TIMUIKitGroup extends StatefulWidget { + final void Function(V2TimGroupInfo groupInfo, V2TimConversation conversation)? + onTapItem; + final Widget Function(BuildContext context)? emptyBuilder; + final GroupItemBuilder? itemBuilder; + + /// the filter for group conversation + final bool Function(V2TimGroupInfo? groupInfo)? groupCollector; + + const TIMUIKitGroup({ + Key? key, + this.onTapItem, + this.emptyBuilder, + this.itemBuilder, + this.groupCollector, + }) : super(key: key); + + @override + State createState() => _TIMUIKitGroupState(); + + /// 刷新群组列表数据的静态方法 + /// Static method to refresh group list data + static void refreshGroupListData(GlobalKey key) { + key.currentState?.refreshGroupListData(); + } +} + +class _TIMUIKitGroupState extends TIMUIKitState { + final TUIFriendShipViewModel _friendshipViewModel = + serviceLocator(); + final TUIGroupListenerModel _groupListenerModel = + serviceLocator(); + + /// 刷新群组列表数据 + /// Refresh group list data + void refreshGroupListData() { + _friendshipViewModel.loadGroupListData(); + } + + List> _getShowList( + List groupList, + ) { + final List> showList = List.empty( + growable: true, + ); + for (var i = 0; i < groupList.length; i++) { + final item = groupList[i]; + + final showName = item.groupName ?? item.groupID; + String pinyin = PinyinHelper.getPinyinE(showName); + String tag = pinyin.substring(0, 1).toUpperCase(); + if (RegExp("[A-Z]").hasMatch(tag)) { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: tag)); + } else { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#")); + } + } + + SuspensionUtil.sortListBySuspensionTag(showList); + + return showList; + } + + Widget _itemBuilder(BuildContext context, V2TimGroupInfo groupInfo) { + final theme = Provider.of(context).theme; + final showName = groupInfo.groupName ?? groupInfo.groupID; + final faceUrl = groupInfo.faceUrl ?? ""; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.weakDividerColor ?? CommonColor.weakDividerColor, + ), + ), + ), + child: Material( + color: isDesktopScreen ? theme.wideBackgroundColor : null, + child: InkWell( + onTap: (() async { + if (widget.onTapItem != null) { + V2TimConversation conversation = V2TimConversation( + conversationID: "group_${groupInfo.groupID}", + groupID: groupInfo.groupID, + type: 2, + showName: groupInfo.groupName, + groupType: groupInfo.groupType, + faceUrl: groupInfo.faceUrl, + ); + final res = await TencentImSDKPlugin.v2TIMManager + .getConversationManager() + .getConversation( + conversationID: "group_${groupInfo.groupID}", + ); + if (res.code == 0 && res.data != null) { + conversation = res.data!; + } + widget.onTapItem!(groupInfo, conversation); + } + }), + child: Container( + padding: const EdgeInsets.only(top: 10, left: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + child: Avatar( + faceUrl: faceUrl, + showName: showName, + type: 2, + ), + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 10, bottom: 20), + child: Text( + showName, + style: TextStyle( + color: Colors.black, + fontSize: isDesktopScreen ? 14 : 18, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + GroupItemBuilder _getItemBuilder() { + return widget.itemBuilder ?? _itemBuilder; + } + + @override + void initState() { + super.initState(); + _friendshipViewModel.loadGroupListData(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _friendshipViewModel), + ChangeNotifierProvider.value(value: _groupListenerModel), + ChangeNotifierProvider.value( + value: serviceLocator(), + ), + ], + builder: (BuildContext context, Widget? w) { + final NeedUpdate? needUpdate = Provider.of( + context, + ).needUpdate; + if (needUpdate != null) { + _groupListenerModel.needUpdate = null; + switch (needUpdate.updateType) { + case UpdateType.groupInfo: + Provider.of(context).loadGroupListData(); + break; + case UpdateType.memberList: + Provider.of(context).loadGroupListData(); + break; + default: + break; + } + } + List groupList = Provider.of( + context, + ).groupList; + if (widget.groupCollector != null) { + groupList = groupList.where(widget.groupCollector!).toList(); + } + if (groupList.isNotEmpty) { + final showList = _getShowList(groupList); + return AZListViewContainer( + isShowIndexBar: false, + memberList: showList, + itemBuilder: (context, index) { + final groupInfo = showList[index].memberInfo; + final itemBuilder = _getItemBuilder(); + return itemBuilder(context, groupInfo); + }, + ); + } + + if (widget.emptyBuilder != null) { + return widget.emptyBuilder!(context); + } + + return Container(); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list.dart b/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list.dart new file mode 100644 index 0000000..8090e89 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/group/group_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef GroupApplicationItemBuilder = Widget Function( + BuildContext context, V2TimGroupApplication applicationInfo, int index); + +enum ApplicationStatus { + none, + accept, + reject, +} + +class TIMUIKitGroupApplicationList extends StatefulWidget { + /// the builder for the request item + final GroupApplicationItemBuilder? itemBuilder; + + /// group ID + final String groupID; + + const TIMUIKitGroupApplicationList({Key? key, this.itemBuilder, required this.groupID}) : super(key: key); + + @override + State createState() => TIMUIKitGroupApplicationListState(); +} + +class TIMUIKitGroupApplicationListState extends TIMUIKitState { + final TUIChatGlobalModel model = serviceLocator(); + final GroupServices _groupServices = serviceLocator(); + List groupApplicationList = []; + List applicationStatusList = []; + + @override + void initState() { + super.initState(); + groupApplicationList = model.groupApplicationList.where((item) => (item.groupID == widget.groupID)).toList(); + applicationStatusList = groupApplicationList.map((item) => ApplicationStatus.none).toList(); + } + + GroupApplicationItemBuilder _getItemBuilder() { + return widget.itemBuilder ?? _defaultItemBuilder; + } + + Widget _defaultItemBuilder(BuildContext context, V2TimGroupApplication applicationInfo, int index) { + final theme = Provider.of(context).theme; + final ApplicationStatus currentStatus = applicationStatusList[index]; + + String _getUserName() { + if (applicationInfo.fromUserNickName != null && + applicationInfo.fromUserNickName!.isNotEmpty && + applicationInfo.fromUserNickName != applicationInfo.fromUser) { + return "${applicationInfo.fromUserNickName} (${applicationInfo.fromUser})"; + } else { + return "${applicationInfo.fromUser}"; + } + } + + String _getRequestMessage() { + String option2 = applicationInfo.requestMsg ?? ""; + return TIM_t_para("验证消息: {{option2}}", "验证消息: $option2")(option2: option2); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? const Color(0xFFDBDBDB))), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: 40, + width: 40, + child: Avatar( + faceUrl: applicationInfo.fromUserFaceUrl ?? "", + showName: applicationInfo.fromUserNickName ?? applicationInfo.fromUser ?? ""), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _getUserName(), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: theme.darkTextColor), + ), + Text( + _getRequestMessage(), + style: TextStyle(fontSize: 15, color: theme.weakTextColor), + ), + ], + )), + if (currentStatus == ApplicationStatus.none && applicationInfo.handleStatus == 0) + Container( + margin: const EdgeInsets.only(left: 8, right: 8), + child: InkWell( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: theme.primaryColor, + border: Border.all(width: 1, color: theme.weakTextColor ?? CommonColor.weakTextColor)), + child: Text( + TIM_t("同意"), // agree + style: const TextStyle( + color: Colors.white, + ), + ), + ), + onTap: () async { + final res = await _groupServices.acceptGroupApplication( + groupID: applicationInfo.groupID, + fromUser: applicationInfo.fromUser!, + toUser: applicationInfo.toUser!, + type: applicationInfo.type, + addTime: applicationInfo.addTime ?? 0, + ); + if (res.code == 0) { + setState(() { + applicationStatusList[index] = ApplicationStatus.accept; + }); + Future.delayed(const Duration(seconds: 1), () { + model.refreshGroupApplicationList(); + }); + } + }), + ), + if (currentStatus == ApplicationStatus.none && applicationInfo.handleStatus == 0) + InkWell( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.white, + border: Border.all(width: 1, color: theme.weakTextColor ?? CommonColor.weakTextColor)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Text( + TIM_t("拒绝"), // reject + style: TextStyle( + color: theme.primaryColor, + ), + ), + ), + onTap: () async { + final res = await _groupServices.refuseGroupApplication( + addTime: applicationInfo.addTime!, + groupID: applicationInfo.groupID, + fromUser: applicationInfo.fromUser!, + toUser: applicationInfo.toUser!, + type: GroupApplicationTypeEnum.values[applicationInfo.type]); + if (res.code == 0) { + setState(() { + applicationStatusList[index] = ApplicationStatus.reject; + }); + Future.delayed(const Duration(seconds: 1), () { + model.refreshGroupApplicationList(); + }); + } + }, + ), + if (currentStatus == ApplicationStatus.accept || applicationInfo.handleResult == 1) + Container( + margin: const EdgeInsets.only(left: 8), + child: Text( + TIM_t("已同意"), + style: TextStyle(fontSize: 15, color: theme.weakTextColor), + ), + ), + if (currentStatus == ApplicationStatus.reject || applicationInfo.handleResult == 2) + Container( + margin: const EdgeInsets.only(left: 8), + child: Text( + TIM_t("已拒绝"), + style: TextStyle(fontSize: 15, color: theme.weakTextColor), + ), + ) + ], + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return MultiProvider( + providers: [ChangeNotifierProvider.value(value: model)], + builder: (context, w) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return Container( + decoration: isDesktopScreen ? null : BoxDecoration(color: theme.weakBackgroundColor), + child: ListView.builder( + shrinkWrap: true, + itemCount: groupApplicationList.length, + itemBuilder: (context, index) { + final applicationInfo = groupApplicationList[index]; + final itemBuilder = _getItemBuilder(); + return itemBuilder(context, applicationInfo, index); + }, + ), + ); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list_item.dart b/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list_item.dart new file mode 100644 index 0000000..3c03621 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroup/tim_uikit_group_application_list_item.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_application.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +enum ApplicationStatus { + none, + accept, + refuse, +} + +class TIMUIKitGroupApplicationListItem extends StatefulWidget { + final V2TimGroupApplication applicationInfo; + + const TIMUIKitGroupApplicationListItem({Key? key, required this.applicationInfo}) : super(key: key); + + @override + State createState() => TIMUIKitGroupApplicationListItemState(); +} + +class TIMUIKitGroupApplicationListItemState extends TIMUIKitState { + ApplicationStatus applicationStatus = ApplicationStatus.none; + + String _getUserName() { + if (widget.applicationInfo.fromUserNickName != null && + widget.applicationInfo.fromUserNickName!.isNotEmpty && + widget.applicationInfo.fromUserNickName != widget.applicationInfo.fromUser) { + return "${widget.applicationInfo.fromUserNickName} (${widget.applicationInfo.fromUser})"; + } else { + return "${widget.applicationInfo.fromUser}"; + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration(color: Colors.white), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: 40, + width: 40, + child: Avatar( + faceUrl: widget.applicationInfo.fromUserFaceUrl ?? "", + showName: widget.applicationInfo.fromUserNickName ?? widget.applicationInfo.fromUser ?? ""), + ), + ), + Column( + children: [ + Text( + _getUserName(), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: theme.darkTextColor), + ), + Text( + _getUserName(), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: theme.darkTextColor), + ), + ], + ) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_add_group_member.dart b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_add_group_member.dart new file mode 100644 index 0000000..b6a0de1 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_add_group_member.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/contact_list.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +GlobalKey<_AddGroupMemberPageState> addGroupMemberKey = GlobalKey(); + +class AddGroupMemberPage extends StatefulWidget { + final TUIGroupProfileModel model; + final VoidCallback? onClose; + + const AddGroupMemberPage({Key? key, required this.model, this.onClose}) : super(key: key); + + @override + State createState() => _AddGroupMemberPageState(); +} + +class _AddGroupMemberPageState extends TIMUIKitState { + List selectedContacts = []; + + void submitAdd() async { + if (selectedContacts.isNotEmpty) { + final userIDs = selectedContacts.map((e) => e.userID).toList(); + await widget.model.inviteUserToGroup(userIDs); + widget.onClose ?? Navigator.pop(context); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ContactList( + bgColor: theme.wideBackgroundColor, + groupMemberList: widget.model.groupMemberList, + contactList: widget.model.contactList, + isCanSelectMemberItem: true, + onSelectedMemberItemChange: (selectedMember) { + selectedContacts = selectedMember; + }, + ), + ), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("添加群成员"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + actions: [ + TextButton( + onPressed: () async { + submitAdd(); + }, + child: Text( + TIM_t("确定"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 16, + ), + ), + ) + ], + shadowColor: theme.weakDividerColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + )), + body: ContactList( + groupMemberList: widget.model.groupMemberList, + contactList: widget.model.contactList, + isCanSelectMemberItem: true, + onSelectedMemberItemChange: (selectedMember) { + selectedContacts = selectedMember; + }, + ))); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_delete_group_member.dart b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_delete_group_member.dart new file mode 100644 index 0000000..e0d7428 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_delete_group_member.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +GlobalKey<_DeleteGroupMemberPageState> deleteGroupMemberKey = GlobalKey(); + +class DeleteGroupMemberPage extends StatefulWidget { + final TUIGroupProfileModel model; + final VoidCallback? onClose; + + const DeleteGroupMemberPage({Key? key, required this.model, this.onClose}) : super(key: key); + + @override + State createState() => _DeleteGroupMemberPageState(); +} + +class _DeleteGroupMemberPageState extends TIMUIKitState { + List selectedGroupMember = []; + List? searchMemberList; + + bool isSearchTextExist(String? searchText) { + return searchText != null && searchText != ""; + } + + handleSearchGroupMembers(String searchText, context) async { + searchText = searchText; + List currentGroupMember = + Provider.of(context, listen: false).groupMemberList; + final res = await widget.model.searchGroupMember(V2TimGroupMemberSearchParam( + keywordList: [searchText], + groupIDList: [widget.model.groupInfo!.groupID], + )); + + if (res.code == 0) { + List list = []; + final searchResult = res.data!.groupMemberSearchResultItems!; + searchResult.forEach((key, value) { + if (value is List) { + for (V2TimGroupMemberFullInfo item in value) { + list.add(item); + } + } + }); + + currentGroupMember = list; + } else { + currentGroupMember = []; + } + setState(() { + searchMemberList = isSearchTextExist(searchText) ? currentGroupMember : null; + }); + } + + handleRole(groupMemberList) { + return groupMemberList + ?.where((value) => value?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER) + .toList() ?? + []; + } + + void submitDelete() async { + if (selectedGroupMember.isNotEmpty) { + final userIDs = selectedGroupMember.map((e) => e.userID).toList(); + widget.model.kickOffMember(userIDs); + widget.onClose ?? Navigator.pop(context); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GroupProfileMemberList( + memberList: handleRole(searchMemberList ?? widget.model.groupMemberList), + canSelectMember: true, + canSlideDelete: false, + onSelectedMemberChange: (selectedMember) { + selectedGroupMember = selectedMember; + }, + touchBottomCallBack: () {}, + ), + ), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("删除群成员"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + actions: [ + TextButton( + onPressed: submitDelete, + child: Text( + TIM_t("确定"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 16, + ), + ), + ) + ], + shadowColor: theme.weakBackgroundColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + )), + body: GroupProfileMemberList( + memberList: handleRole(searchMemberList ?? widget.model.groupMemberList), + canSelectMember: true, + canSlideDelete: false, + onSelectedMemberChange: (selectedMember) { + selectedGroupMember = selectedMember; + }, + touchBottomCallBack: () {}, + ))); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_group_member_list.dart b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_group_member_list.dart new file mode 100644 index 0000000..1d05e35 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/group_member/tui_group_member_list.dart @@ -0,0 +1,140 @@ +// ignore_for_file: must_be_immutable + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart'; + +import '../../../../theme/tui_theme.dart'; + +class GroupProfileMemberListPage extends StatefulWidget { + List memberList; + TUIGroupProfileModel model; + + GroupProfileMemberListPage({ + Key? key, + required this.memberList, + required this.model, + }) : super(key: key); + + @override + State createState() => GroupProfileMemberListPageState(); +} + +class GroupProfileMemberListPageState extends TIMUIKitState { + List? searchMemberList; + String? searchText; + + _kickedOffMember(String userID) async { + widget.model.kickOffMember([userID]); + } + + bool isSearchTextExist(String? searchText) { + return searchText != null && searchText != ""; + } + + handleSearchGroupMembers(String searchText, context) async { + searchText = searchText; + List currentGroupMember = + Provider.of(context, listen: false).groupMemberList; + + if (!isSearchTextExist(searchText)) { + setState(() { + searchMemberList = null; + }); + return; + } + + final res = await widget.model.searchGroupMember(V2TimGroupMemberSearchParam( + keywordList: [searchText], + groupIDList: [widget.model.groupInfo!.groupID], + )); + + if (res.code == 0) { + List list = []; + final searchResult = res.data!.groupMemberSearchResultItems!; + searchResult.forEach((key, value) { + if (value is List) { + for (V2TimGroupMemberFullInfo item in value) { + list.add(item); + } + } + }); + + currentGroupMember = list; + } else { + currentGroupMember = []; + } + setState(() { + searchMemberList = currentGroupMember; + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: widget.model), + ], + builder: (BuildContext context, Widget? w) { + final TUIGroupProfileModel groupProfileModel = Provider.of(context); + String option1 = groupProfileModel.groupInfo?.memberCount.toString() ?? widget.memberList.length.toString(); + if (isDesktopScreen) { + return GroupProfileMemberList( + customTopArea: PlatformUtils().isWeb + ? null + : GroupMemberSearchTextField( + onTextChange: (text) => handleSearchGroupMembers(text, context), + ), + memberList: searchMemberList ?? groupProfileModel.groupMemberList, + removeMember: _kickedOffMember, + touchBottomCallBack: () {}, + onTapMemberItem: (memberInfo, details) { + if (widget.model.onClickUser != null) { + widget.model.onClickUser!(memberInfo, details); + } + }, + ); + } + return Scaffold( + appBar: AppBar( + title: Text( + TIM_t_para("群成员({{option1}}人)", "群成员($option1人)")(option1: option1), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.weakBackgroundColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + )), + body: GroupProfileMemberList( + customTopArea: PlatformUtils().isWeb + ? null + : GroupMemberSearchTextField( + onTextChange: (text) => handleSearchGroupMembers(text, context), + ), + memberList: searchMemberList ?? groupProfileModel.groupMemberList, + removeMember: _kickedOffMember, + touchBottomCallBack: () {}, + onTapMemberItem: (memberInfo, details) { + if (widget.model.onClickUser != null) { + widget.model.onClickUser!(memberInfo, details); + } + }, + )); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/group_profile_widget.dart b/lib/ui/views/TIMUIKitGroupProfile/group_profile_widget.dart new file mode 100644 index 0000000..d05070d --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/group_profile_widget.dart @@ -0,0 +1,143 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; + +enum GroupProfileWidgetEnum { + /// The detail card for group. + detailCard, + + /// The tile shows the members in the group. + memberListTile, + + /// The entrance to the page editing the group notice. + groupNotice, + + /// The entrance to the page managing the group. + /// Works only for group owner and group admin. + groupManage, + + /// The entrance to the search page with conversation ID. + searchMessage, + + /// The divider between sets of profile widget. + operationDivider, + + /// Shows the group type, + /// includes "work group", "public group", "meeting group" and "AVChatRoom". + groupTypeBar, + + /// Shows and modify the mode or options users add the group. + /// Includes "disallow group joining", "automatic approval" and "admin approval". + groupJoiningModeBar, + + /// Shows and modify the nickname for user in a specific group, + /// only shows and used in this group, as the name card here. + nameCardBar, + + /// The switch of if mute the message notification from a specific group + muteGroupMessageBar, + + /// The switch of if pin this group to the top in conversation list. + pinedConversationBar, + + /// The button area, includes + /// "clear chat history", "transfer group owner", "disband group" and "quit group" + /// as default. + buttonArea, + + /// Custom area, you may define anything you want here. + customBuilderOne, + + /// Custom area, you may define anything you want here. + customBuilderTwo, + + /// Custom area, you may define anything you want here. + customBuilderThree, + + /// Custom area, you may define anything you want here. + customBuilderFour, + + /// Custom area, you may define anything you want here. + customBuilderFive +} + +class GroupProfileWidgetBuilder { + /// The detail card for group. + Widget Function(V2TimGroupInfo groupInfo, Function(String updateGroupName)? updateGroupName)? detailCard; + + /// The tile shows the members in the group. + Widget Function(List memberList)? memberListTile; + + /// The entrance to the page editing the group notice. + Widget Function(String currentNotice, Function() toDefaultNoticeEditPage, Function(String newNotice) setGroupNotice)? + groupNotice; + + /// The entrance to the page managing the group. + /// Works only for group owner and group admin. + Widget Function(Function() toDefaultGroupManagementPage)? groupManage; + + /// The entrance to the search page with conversation ID. + Widget Function()? searchMessage; + + /// The divider between sets of profile widget. + Widget Function()? operationDivider; + + /// Shows the group type, + /// includes "work group", "public group", "meeting group" and "AVChatRoom". + Widget Function(String groupType)? groupTypeBar; + + /// Shows and modify the mode or options users add the group. + /// Includes "0: disallow group joining", "1: admin approval" and "2: automatic approval". + Widget Function(int groupAddOptType, Function(int addOpt) handleActionTap)? groupJoiningModeBar; + + /// Shows and modify the nickname for user in a specific group, + /// only shows and used in this group, as the name card here. + Widget Function(String nameCard, Function(String newName) setNameCard)? nameCardBar; + + /// The switch of if mute the message notification from a specific group. + Widget Function(bool isMute, Function(bool isMute) setMute)? muteGroupMessageBar; + + /// The switch of if pin this group to the top in conversation list. + Widget Function(bool isPined, Function(bool isMute) pinedConversation)? pinedConversationBar; + + /// The button area, includes + /// "clear chat history", "transfer group owner", "disband group" and "quit group" + /// as default. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? buttonArea; + + /// Custom area, you may define anything you want here. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? customBuilderOne; + + /// Custom area, you may define anything you want here. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? customBuilderTwo; + + /// Custom area, you may define anything you want here. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? customBuilderThree; + + /// Custom area, you may define anything you want here. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? customBuilderFour; + + /// Custom area, you may define anything you want here. + Widget Function(V2TimGroupInfo groupInfo, List groupMemberList)? customBuilderFive; + + GroupProfileWidgetBuilder( + {this.detailCard, + this.memberListTile, + this.groupNotice, + this.groupManage, + this.searchMessage, + this.operationDivider, + this.groupTypeBar, + this.groupJoiningModeBar, + this.nameCardBar, + this.muteGroupMessageBar, + this.pinedConversationBar, + this.buttonArea, + this.customBuilderOne, + this.customBuilderTwo, + this.customBuilderThree, + this.customBuilderFour, + this.customBuilderFive}); +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/shared_data_widget.dart b/lib/ui/views/TIMUIKitGroupProfile/shared_data_widget.dart new file mode 100644 index 0000000..bc195ad --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/shared_data_widget.dart @@ -0,0 +1,19 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; + +class SharedDataWidget extends InheritedWidget { + final TUIGroupProfileModel model; + + const SharedDataWidget({Key? key, required Widget child, required this.model}) + : super(key: key, child: child); + + // Define a method to get the shared data from sub-tree + static SharedDataWidget? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(covariant SharedDataWidget oldWidget) { + return oldWidget.model != model; + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/tim_uikit_group_profile.dart b/lib/ui/views/TIMUIKitGroupProfile/tim_uikit_group_profile.dart new file mode 100644 index 0000000..f9a7a4d --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/tim_uikit_group_profile.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_change_info_type.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/group_profile_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/listener_model/tui_group_listener_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/group_profile_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_profile_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_button_area.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +export 'package:tencent_cloud_chat_uikit/ui/widgets/transimit_group_owner_select.dart'; + +typedef GroupProfileBuilder = Widget Function( + BuildContext context, V2TimGroupInfo groupInfo, List groupMemberList); + +class TIMUIKitGroupProfile extends StatefulWidget { + /// Group ID + final String groupID; + final Color? backGroundColor; + + /// [Deprecated:] The builder for custom bottom operation area. + /// [operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead. + final Widget Function( + BuildContext context, V2TimGroupInfo groupInfo, List groupMemberList)? + bottomOperationBuilder; + + /// [Deprecated:] The builder for custom bottom operation area. + /// [operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead. + final Widget Function( + BuildContext context, V2TimGroupInfo groupInfo, List groupMemberList)? + operationListBuilder; + + /// [If you tend to customize the profile page, use [profileWidgetBuilder] with [profileWidgetsOrder] as priority.] + /// The builder for each widgets in profile page, + /// you can customize some of it by pass your own widget into here. + /// Or, you can add your custom widget to the three custom widgets. + final GroupProfileWidgetBuilder? profileWidgetBuilder; + + /// [If you tend to customize the profile page, use [profileWidgetBuilder] with [profileWidgetsOrder] as priority.] + /// If the default widget order can not meet you needs, + /// you may change the order by this array with widget enum. + final List? profileWidgetsOrder; + + /// The builder for the whole group profile page, you can use this to customize all the element here. + /// Mentioned: If you use this builder, [profileWidgetBuilder] and [profileWidgetsOrder] will no longer works. + final GroupProfileBuilder? builder; + + /// The life cycle hooks for group profile business logic. + /// You have better to implement the `didLeaveGroup` in it. + final GroupProfileLifeCycle? lifeCycle; + + /// The callback after user clicking a user, + /// you may navigating to the specific profile page, or anywhere you want. + final Function(V2TimGroupMemberFullInfo groupMemberFullInfo, TapDownDetails? tapDetails)? onClickUser; + + const TIMUIKitGroupProfile( + {Key? key, + required this.groupID, + this.backGroundColor, + @Deprecated("[operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead") + this.bottomOperationBuilder, + @Deprecated("[operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead") + this.operationListBuilder, + this.builder, + this.profileWidgetBuilder, + this.onClickUser, + this.profileWidgetsOrder, + this.lifeCycle}) + : super(key: key); + + @override + State createState() => _TIMUIKitGroupProfileState(); +} + +class _TIMUIKitGroupProfileState extends TIMUIKitState { + bool isSingleUse = false; + final model = TUIGroupProfileModel(); + final TUIGroupListenerModel groupListenerModel = serviceLocator(); + + @override + void initState() { + super.initState(); + model.loadData(widget.groupID); + model.onClickUser = widget.onClickUser; + } + + @override + void dispose() { + super.dispose(); + } + + @override + void didUpdateWidget(covariant TIMUIKitGroupProfile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.groupID != widget.groupID) { + model.loadData(widget.groupID); + } + } + + final List _defaultWidgetOrder = [ + GroupProfileWidgetEnum.detailCard, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.memberListTile, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.searchMessage, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.groupNotice, + GroupProfileWidgetEnum.groupManage, + GroupProfileWidgetEnum.groupJoiningModeBar, + GroupProfileWidgetEnum.groupTypeBar, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.pinedConversationBar, + GroupProfileWidgetEnum.muteGroupMessageBar, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.nameCardBar, + GroupProfileWidgetEnum.operationDivider, + GroupProfileWidgetEnum.buttonArea + ]; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue buildValue) { + final TUITheme theme = buildValue.theme; + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ChangeNotifierProvider.value(value: groupListenerModel), + ], + builder: (context, w) { + final model = Provider.of(context); + model.lifeCycle = widget.lifeCycle; + final V2TimGroupInfo? groupInfo = model.groupInfo; + final memberList = model.groupMemberList; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (groupInfo == null) { + return Center( + child: LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 48, + ), + ); + } + + final TUIGroupListenerModel groupListenerModel = Provider.of(context); + final NeedUpdate? needUpdate = groupListenerModel.needUpdate; + if (needUpdate != null && needUpdate.groupID == widget.groupID) { + groupListenerModel.needUpdate = null; + switch (needUpdate.updateType) { + case UpdateType.groupInfo: + if (needUpdate.groupInfoSubType == GroupChangeInfoType.V2TIM_GROUP_INFO_CHANGE_TYPE_OWNER) { + model.onOwnerChanged(needUpdate.ownerID); + } + + model.loadGroupInfo(widget.groupID); + break; + case UpdateType.memberList: + model.loadGroupMemberList(groupID: widget.groupID); + model.loadGroupInfo(widget.groupID); + break; + default: + break; + } + } + + final isGroupOwner = groupInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER; + final isAdmin = groupInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN; + + Widget groupProfilePage({required Widget child}) { + return SingleChildScrollView( + child: Container( + color: + widget.backGroundColor ?? (isDesktopScreen ? theme.wideBackgroundColor : theme.weakBackgroundColor), + child: child, + ), + ); + } + + void toDefaultNoticePage() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + GroupProfileNotificationPage(model: model, notification: groupInfo.notification ?? ""))); + } + + void toDefaultManagePage() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupProfileGroupManagePage( + model: model, + ))); + } + + List _renderWidgetsWithOrder(List order) { + final GroupProfileWidgetBuilder? customBuilder = widget.profileWidgetBuilder; + return order.map((element) { + switch (element) { + case GroupProfileWidgetEnum.detailCard: + return (customBuilder?.detailCard != null + ? customBuilder?.detailCard!(groupInfo, model.setGroupName) + : TIMUIKitGroupProfileWidget.detailCard( + isHavePermission: isAdmin || isGroupOwner, groupInfo: groupInfo))!; + case GroupProfileWidgetEnum.memberListTile: + return (customBuilder?.memberListTile != null + ? customBuilder?.memberListTile!(memberList) + : TIMUIKitGroupProfileWidget.memberTile())!; + case GroupProfileWidgetEnum.groupNotice: + return (customBuilder?.groupNotice != null + ? customBuilder?.groupNotice!( + groupInfo.notification ?? "", toDefaultNoticePage, model.setGroupNotification) + : TIMUIKitGroupProfileWidget.groupNotification(isHavePermission: isAdmin || isGroupOwner))!; + case GroupProfileWidgetEnum.groupManage: + if (isAdmin || isGroupOwner) { + return (customBuilder?.groupManage != null + ? customBuilder?.groupManage!(toDefaultManagePage) + : TIMUIKitGroupProfileWidget.groupManage())!; + } else { + return Container(); + } + case GroupProfileWidgetEnum.searchMessage: + return (customBuilder?.searchMessage != null + ? customBuilder?.searchMessage!() + : Text(TIM_t("你必须自定义search bar,并处理点击跳转")))!; + case GroupProfileWidgetEnum.operationDivider: + return (customBuilder?.operationDivider != null + ? customBuilder?.operationDivider!() + : TIMUIKitGroupProfileWidget.operationDivider(theme))!; + case GroupProfileWidgetEnum.groupTypeBar: + return (customBuilder?.groupTypeBar != null + ? customBuilder?.groupTypeBar!(groupInfo.groupType) + : TIMUIKitGroupProfileWidget.groupType())!; + case GroupProfileWidgetEnum.groupJoiningModeBar: + final String groupType = groupInfo.groupType; + if (!(isGroupOwner || isAdmin) || + groupType == "Work" || + groupType == "Meeting" || + groupType == "AVChatRoom") { + return Container(); + } + return (customBuilder?.groupJoiningModeBar != null + ? customBuilder?.groupJoiningModeBar!(groupInfo.groupAddOpt ?? 1, model.setGroupAddOpt) + : TIMUIKitGroupProfileWidget.groupAddOpt())!; + case GroupProfileWidgetEnum.nameCardBar: + return (customBuilder?.nameCardBar != null + ? customBuilder?.nameCardBar!(model.getSelfNameCard(), model.setNameCard) + : TIMUIKitGroupProfileWidget.nameCard())!; + case GroupProfileWidgetEnum.muteGroupMessageBar: + return (customBuilder?.muteGroupMessageBar != null + ? customBuilder?.muteGroupMessageBar!(model.conversation?.recvOpt != 0, model.setMessageDisturb) + : TIMUIKitGroupProfileWidget.messageDisturb())!; + case GroupProfileWidgetEnum.pinedConversationBar: + return (customBuilder?.pinedConversationBar != null + ? customBuilder?.pinedConversationBar!( + model.conversation?.isPinned ?? false, model.pinedConversation) + : TIMUIKitGroupProfileWidget.pinedConversation())!; + case GroupProfileWidgetEnum.buttonArea: + return (customBuilder?.buttonArea != null + ? customBuilder?.buttonArea!(groupInfo, memberList) + : GroupProfileButtonArea(groupInfo.groupID, model))!; + case GroupProfileWidgetEnum.customBuilderOne: + return (customBuilder?.customBuilderOne != null + ? customBuilder?.customBuilderOne!(groupInfo, memberList) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case GroupProfileWidgetEnum.customBuilderTwo: + return (customBuilder?.customBuilderTwo != null + ? customBuilder?.customBuilderTwo!(groupInfo, memberList) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case GroupProfileWidgetEnum.customBuilderThree: + return (customBuilder?.customBuilderThree != null + ? customBuilder?.customBuilderThree!(groupInfo, memberList) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case GroupProfileWidgetEnum.customBuilderFour: + return (customBuilder?.customBuilderFour != null + ? customBuilder?.customBuilderFour!(groupInfo, memberList) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case GroupProfileWidgetEnum.customBuilderFive: + return (customBuilder?.customBuilderFive != null + ? customBuilder?.customBuilderFive!(groupInfo, memberList) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + default: + return Container(); + } + }).toList(); + } + + if (widget.builder != null) { + return groupProfilePage( + child: widget.builder!(context, groupInfo, memberList), + ); + } else if (widget.profileWidgetsOrder != null) { + return groupProfilePage( + child: Column( + children: [..._renderWidgetsWithOrder(widget.profileWidgetsOrder!)], + ), + ); + } else { + return groupProfilePage( + child: Column( + children: [..._renderWidgetsWithOrder(_defaultWidgetOrder)], + )); + } + }); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart new file mode 100644 index 0000000..ff90896 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupMemberSearchTextField extends TIMUIKitStatelessWidget { + final Function(String text) onTextChange; + GroupMemberSearchTextField({Key? key, required this.onTextChange}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final FocusNode focusNode = FocusNode(); + + var debounceFunc = OptimizeUtils.debounce( + (text) => onTextChange(text), const Duration(milliseconds: 300)); + + return Container( + color: Colors.white, + child: Column(children: [ + if(!isDesktopScreen) Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.all(color: theme.weakBackgroundColor!, width: 12)), + child: TextField( + onChanged: debounceFunc, + decoration: InputDecoration( + hintText: TIM_t("搜索"), + prefixIcon: const Icon(Icons.search), + ), + ), + ), + if(isDesktopScreen) TIMUIKitSearchInput(prefixIcon: Icon( + Icons.search, + size: 16, + color: hexToColor("979797"), + ), + onChange: (text){ + focusNode.requestFocus(); + debounceFunc(text); + }, focusNode: focusNode, + ), + Divider( + thickness: 1, + indent: 74, + endIndent: 0, + color: theme.weakBackgroundColor, + height: 0) + ]), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_profile_widget.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_profile_widget.dart new file mode 100644 index 0000000..94ea466 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_profile_widget.dart @@ -0,0 +1,80 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_search_msg.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_add_opt.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_detail_card.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_member_title.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_message_disturb.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_name_card.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_pin_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_type.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitGroupProfileWidget { + static Widget detailCard( + {required V2TimGroupInfo groupInfo, + bool isHavePermission = false, + + /// You can deal with updating group name manually, or UIKIt do it automatically. + Function(String updateGroupName)? updateGroupName}) { + return GroupProfileDetailCard( + groupInfo: groupInfo, + isHavePermission: isHavePermission, + updateGroupName: updateGroupName, + ); + } + + static Widget memberTile() { + return GroupMemberTitle(); + } + + static Widget groupNotification({ + bool isHavePermission = false, + }) { + return GroupProfileNotification( + isHavePermission: isHavePermission, + ); + } + + static Widget groupManage() { + return const GroupProfileGroupManage(); + } + + static Widget searchMessage(Function(V2TimConversation?) onJumpToSearch) { + return GroupProfileGroupSearch(onJumpToSearch: onJumpToSearch); + } + + static Widget operationDivider(TUITheme theme) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + return Container( + color: theme.weakDividerColor, + height: isDesktopScreen ? 1 : 10, + ); + } + + static Widget groupType() { + return GroupProfileType(); + } + + static Widget groupAddOpt() { + return GroupProfileAddOpt(); + } + + static Widget nameCard() { + return const GroupProfileNameCard(); + } + + static Widget messageDisturb() { + return GroupMessageDisturb(); + } + + static Widget pinedConversation() { + return GroupPinConversation(); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_search_msg.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_search_msg.dart new file mode 100644 index 0000000..7ce113d --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_search_msg.dart @@ -0,0 +1,57 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/shared_data_widget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupProfileGroupSearch extends TIMUIKitStatelessWidget { + GroupProfileGroupSearch({Key? key, required this.onJumpToSearch}) : super(key: key); + final ConversationService _conversationService = serviceLocator(); + + final Function(V2TimConversation?) onJumpToSearch; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final model = Provider.of(context); + + return InkWell( + onTap: () async { + V2TimConversation? conversation = + await _conversationService.getConversation(conversationID: "group_${model.groupInfo!.groupID}"); + if (conversation != null) { + onJumpToSearch(conversation); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("查找聊天内容"), + style: TextStyle(fontSize: 16, color: theme.darkTextColor), + ), + Icon(Icons.keyboard_arrow_right, color: theme.weakTextColor) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_add_opt.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_add_opt.dart new file mode 100644 index 0000000..b860cf8 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_add_opt.dart @@ -0,0 +1,141 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_add_opt_type.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/column_menu.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class GroupProfileAddOpt extends TIMUIKitStatelessWidget { + GroupProfileAddOpt({Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final model = Provider.of(context); + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + String addOpt = TIM_t("未知"); + + final groupAddOpt = model.groupInfo?.groupAddOpt; + switch (groupAddOpt) { + case GroupAddOptType.V2TIM_GROUP_ADD_ANY: + addOpt = TIM_t("自动审批"); + break; + case GroupAddOptType.V2TIM_GROUP_ADD_AUTH: + addOpt = TIM_t("管理员审批"); + break; + case GroupAddOptType.V2TIM_GROUP_ADD_FORBID: + addOpt = TIM_t("禁止加群"); + break; + } + + final actionList = [ + {"label": TIM_t("禁止加群"), "id": GroupAddOptType.V2TIM_GROUP_ADD_FORBID}, + {"label": TIM_t("自动审批"), "id": GroupAddOptType.V2TIM_GROUP_ADD_ANY}, + {"label": TIM_t("管理员审批"), "id": GroupAddOptType.V2TIM_GROUP_ADD_AUTH} + ]; + + _handleActionTap(int addOpt) async { + model.setGroupAddOpt(addOpt).then((res) {}); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen ? null : Border( + bottom: BorderSide( + color: + theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: InkWell( + onTapDown: (details) async { + if(isDesktopScreen){ + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.groupAddOpt, + isDarkBackground: false, + borderRadius: const BorderRadius.all(Radius.circular(4)), + context: context, + offset: Offset(min(details.globalPosition.dx, + MediaQuery.of(context).size.width - 186), details.globalPosition.dy), + child: (onClose) => TUIKitColumnMenu( + data: [ + ...actionList + .map((e){ + return ColumnMenuItem(label: e["label"] as String, onClick: (){ + _handleActionTap(e["id"] as int); + onClose(); + }); + }), + ], + ) + ); + }else{ + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + title: Text(TIM_t("加群方式")), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + "cancel", + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: actionList + .map((e) => CupertinoActionSheetAction( + onPressed: () { + _handleActionTap(e["id"] as int); + Navigator.pop( + context, + "cancel", + ); + }, + child: Text( + e["label"] as String, + style: TextStyle(color: theme.primaryColor), + ), + isDefaultAction: false, + )) + .toList(), + ); + }, + ); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("加群方式"), + style: TextStyle(fontSize: isDesktopScreen ? 14 : 16, color: theme.darkTextColor), + ), + Row( + children: [ + Text( + addOpt, + style: TextStyle(fontSize: isDesktopScreen ? 14 : 16, color: Colors.black), + ), + Icon(Icons.keyboard_arrow_right, color: theme.weakTextColor) + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_button_area.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_button_area.dart new file mode 100644 index 0000000..7b14376 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_button_area.dart @@ -0,0 +1,355 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/controller/tim_uikit_chat_controller.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import '/theme/tui_theme.dart'; + +class GroupProfileButtonArea extends TIMUIKitStatelessWidget { + final String groupID; + final TUIGroupProfileModel model; + final sdkInstance = TIMUIKitCore.getSDKInstance(); + final coreInstance = TIMUIKitCore.getInstance(); + final TIMUIKitChatController _timuiKitChatController = TIMUIKitChatController(); + + GroupProfileButtonArea(this.groupID, this.model, {Key? key}) : super(key: key); + + final _operationList = [ + {"label": TIM_t("清空消息"), "id": "clearHistory"}, + {"label": TIM_t("转让群主"), "id": "transimitOwner"}, + {"label": TIM_t("退出群组"), "id": "quitGroup"}, + {"label": TIM_t("解散群组"), "id": "dismissGroup"} + ]; + + _clearHistory(BuildContext context, theme) async { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (isDesktopScreen) { + TUIKitWidePopup.showSecondaryConfirmDialog( + operationKey: TUIKitWideModalOperationKey.confirmClearChatHistory, + context: context, + text: TIM_t("清空聊天记录"), + theme: theme, + onCancel: () {}, + onConfirm: () async { + if (PlatformUtils().isWeb) { + final res = + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + if (res.code == 0) { + _timuiKitChatController.clearHistory(groupID); + } + } else { + final res = await sdkInstance.getMessageManager().clearGroupHistoryMessage(groupID: groupID); + if (res.code == 0) { + _timuiKitChatController.clearHistory(groupID); + } + } + }); + } else { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop( + context, + ); + if (PlatformUtils().isWeb) { + final res = + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + if (res.code == 0) { + _timuiKitChatController.clearHistory(groupID); + } + } else { + final res = await sdkInstance.getMessageManager().clearGroupHistoryMessage(groupID: groupID); + if (res.code == 0) { + _timuiKitChatController.clearHistory(groupID); + } + } + }, + child: Text( + TIM_t("确定"), + style: TextStyle(color: theme.cautionColor), + ), + isDefaultAction: false, + ) + ], + ); + }, + ); + } + } + + _quitGroup(BuildContext context, TUITheme theme) async { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (isDesktopScreen) { + TUIKitWidePopup.showSecondaryConfirmDialog( + operationKey: TUIKitWideModalOperationKey.confirmExitGroup, + context: context, + text: TIM_t("退出后不会接收到此群聊消息"), + theme: theme, + onCancel: () {}, + onConfirm: () async { + final res = await sdkInstance.quitGroup(groupID: groupID); + if (res.code == 0) { + final deleteConvRes = + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + if (deleteConvRes.code == 0) { + model.lifeCycle?.didLeaveGroup(); + } + } + }); + } else { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + title: Text(TIM_t("退出后不会接收到此群聊消息")), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop( + context, + ); + final res = await sdkInstance.quitGroup(groupID: groupID); + if (res.code == 0) { + final deleteConvRes = + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + if (deleteConvRes.code == 0) { + model.lifeCycle?.didLeaveGroup(); + } + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, + infoRecommendText: "${TIM_t("您已退出")}${model.groupInfo?.groupName}", + infoCode: 6661402)); + } + }, + child: Text( + TIM_t("确定"), + style: TextStyle(color: theme.cautionColor), + ), + isDefaultAction: false, + ) + ], + ); + }, + ); + } + } + + _dismissGroup(BuildContext context, theme) async { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (isDesktopScreen) { + TUIKitWidePopup.showSecondaryConfirmDialog( + operationKey: TUIKitWideModalOperationKey.confirmDisbandGroup, + context: context, + text: TIM_t("解散后不会接收到此群聊消息"), + theme: theme, + onCancel: () {}, + onConfirm: () async { + final res = await sdkInstance.dismissGroup(groupID: groupID); + if (res.code == 0) { + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + model.lifeCycle?.didLeaveGroup(); + } + }); + } else { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + title: Text(TIM_t("解散后不会接收到此群聊消息")), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + final res = await sdkInstance.dismissGroup(groupID: groupID); + if (res.code == 0) { + await sdkInstance.getConversationManager().deleteConversation(conversationID: "group_$groupID"); + model.lifeCycle?.didLeaveGroup(); + } + }, + child: Text( + TIM_t("确定"), + style: TextStyle(color: theme.cautionColor), + ), + isDefaultAction: false, + ) + ], + ); + }, + ); + } + } + + _transmitOwner(BuildContext context, String groupID) async { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.setAdmins, + context: context, + title: TIM_t("转让群主"), + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + onSubmit: () { + selectNewGroupOwnerKey.currentState?.onSubmit(); + }, + child: (onClose) => SelectNewGroupOwner( + model: model, + key: selectNewGroupOwnerKey, + groupID: groupID, + onSelectedMember: (selectedMember) async { + if (selectedMember.isNotEmpty) { + final userID = selectedMember.first.userID; + await sdkInstance.getGroupManager().transferGroupOwner(groupID: groupID, userID: userID); + } + }, + ), + ); + } else { + List? selectedMember = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SelectNewGroupOwner( + model: model, + groupID: groupID, + ), + ), + ); + if (selectedMember != null) { + final userID = selectedMember.first.userID; + await sdkInstance.getGroupManager().transferGroupOwner(groupID: groupID, userID: userID); + } + } + } + + List _renderGroupOperation(BuildContext context, TUITheme theme, bool isOwner, String groupType) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return _operationList + .where((element) { + if (!isOwner) { + return ["quitGroup", "clearHistory"].contains(element["id"]); + } else { + if (groupType == "Work") { + return ["clearHistory", "quitGroup", "transimitOwner"].contains(element["id"]); + } + if (groupType != "Work") { + return ["clearHistory", "dismissGroup", "transimitOwner"].contains(element["id"]); + } + return true; + } + }) + .map((e) => isDesktopScreen + ? OutlinedButton( + onPressed: () { + if (e["id"]! == "clearHistory") { + _clearHistory(context, theme); + } else if (e["id"] == "quitGroup") { + _quitGroup(context, theme); + } else if (e["id"] == "dismissGroup") { + _dismissGroup(context, theme); + } else if (e["id"] == "transimitOwner") { + _transmitOwner(context, groupID); + } + }, + child: Text( + e["label"]!, + style: TextStyle(color: theme.cautionColor), + )) + : InkWell( + onTap: () { + if (e["id"]! == "clearHistory") { + _clearHistory(context, theme); + } else if (e["id"] == "quitGroup") { + _quitGroup(context, theme); + } else if (e["id"] == "dismissGroup") { + _dismissGroup(context, theme); + } else if (e["id"] == "transimitOwner") { + _transmitOwner(context, groupID); + } + }, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: Colors.white, + border: + Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Text( + e["label"]!, + style: TextStyle(color: theme.cautionColor, fontSize: 17), + ), + ), + )) + .toList(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final groupInfo = model.groupInfo; + + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (isDesktopScreen) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 28, + children: [ + ..._renderGroupOperation( + context, theme, groupInfo?.owner == coreInstance.loginUserInfo?.userID, groupInfo?.groupType ?? "") + ], + ), + ); + } + + return Column( + children: [ + ..._renderGroupOperation( + context, theme, groupInfo?.owner == coreInstance.loginUserInfo?.userID, groupInfo?.groupType ?? "") + ], + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_detail_card.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_detail_card.dart new file mode 100644 index 0000000..cf26ba4 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_detail_card.dart @@ -0,0 +1,209 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/text_input_bottom_sheet.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupProfileDetailCard extends TIMUIKitStatelessWidget { + final V2TimGroupInfo groupInfo; + final void Function(String groupName)? updateGroupName; + final TextEditingController controller = TextEditingController(); + final bool isHavePermission; + + GroupProfileDetailCard({Key? key, required this.groupInfo, this.isHavePermission = false, this.updateGroupName}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final model = Provider.of(context); + final faceUrl = groupInfo.faceUrl ?? ""; + final groupID = groupInfo.groupID; + final showName = groupInfo.groupName ?? groupID; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return InkWell( + onTapDown: !isHavePermission + ? null + : ((details) { + if (isDesktopScreen) { + TextInputBottomSheet.showTextInputBottomSheet( + context: context, + title: TIM_t("修改群名称"), + initText: showName, + initOffset: Offset(min(details.globalPosition.dx, MediaQuery.of(context).size.width - 350), + min(details.globalPosition.dy + 20, MediaQuery.of(context).size.height - 470)), + onSubmitted: (String newText) async { + final text = newText.trim(); + if (updateGroupName != null) { + updateGroupName!(text); + } else { + model.setGroupName(text); + } + }, + theme: theme); + } else { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop( + context, + ); + }, + child: Text(TIM_t("取消")), + isDefaultAction: false, + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + controller.text = groupInfo.groupName ?? ""; + showModalBottomSheet( + isScrollControlled: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + context: context, + builder: (context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10.0), topRight: Radius.circular(10.0))), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text(TIM_t("修改群名称")), + ), + Divider(height: 2, color: theme.weakDividerColor), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + decoration: InputDecoration( + border: InputBorder.none, + fillColor: theme.weakBackgroundColor, + filled: true, + isDense: true, + hintText: ''), + ), + const SizedBox( + height: 10, + ), + Text( + TIM_t("修改群名称"), + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 30, + ), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + final text = controller.text.trim(); + if (updateGroupName != null) { + updateGroupName!(text); + } else { + model.setGroupName(text); + } + Navigator.pop(context); + Navigator.pop(context); + }, + child: Text(TIM_t("确定")), + )), + const SizedBox( + height: 20, + ), + Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + ) + ], + ), + ) + ], + ), + ); + }); + }, + child: Text( + TIM_t("修改群名称"), + style: TextStyle(color: theme.primaryColor), + ), + isDefaultAction: false, + ) + ]); + }, + ); + } + }), + child: Container( + color: Colors.white, + padding: EdgeInsets.only( + top: isDesktopScreen ? 20 : 12, + bottom: isDesktopScreen ? 20 : 12, + right: isDesktopScreen ? 16 : 0, + left: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: isDesktopScreen ? 40 : 48, + height: isDesktopScreen ? 40 : 48, + child: Avatar( + faceUrl: faceUrl, + showName: showName, + type: 2, + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.only(left: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + showName, + style: TextStyle(fontSize: isDesktopScreen ? 15 : 18, fontWeight: FontWeight.w600), + ), + SizedBox( + height: isDesktopScreen ? 4 : 8, + ), + SelectableText("ID: $groupID", + style: TextStyle(fontSize: isDesktopScreen ? 13 : 13, color: theme.weakTextColor)) + ], + ), + ), + ), + if (isHavePermission) + Icon( + Icons.keyboard_arrow_right, + color: theme.weakTextColor, + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart new file mode 100644 index 0000000..631a52c --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_manage.dart @@ -0,0 +1,817 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable_plus_plus/flutter_slidable_plus_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/column_menu.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +GlobalKey<_GroupProfileAddAdminState> groupProfileAddAdminKey = GlobalKey(); + +class GroupProfileGroupManage extends StatefulWidget { + const GroupProfileGroupManage({Key? key}) : super(key: key); + + @override + State createState() => GroupProfileGroupManageState(); +} + +class GroupProfileGroupManageState extends TIMUIKitState { + bool isShowManageBox = false; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final model = Provider.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen + ? null + : Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Column( + children: [ + InkWell( + onTap: () { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (!isDesktopScreen) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupProfileGroupManagePage( + model: model, + ))); + } else { + setState(() { + isShowManageBox = !isShowManageBox; + }); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("群管理"), + style: TextStyle(fontSize: isDesktopScreen ? 14 : 16, color: theme.darkTextColor), + ), + AnimatedRotation( + turns: isShowManageBox ? 0.25 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.keyboard_arrow_right, color: theme.weakTextColor), + ) + ], + ), + ), + if (isShowManageBox) + GroupProfileGroupManagePage( + model: model, + ) + ], + ), + ); + } +} + +/// 管理员设置页面 +class GroupProfileGroupManagePage extends StatefulWidget { + final TUIGroupProfileModel model; + + const GroupProfileGroupManagePage({ + Key? key, + required this.model, + }) : super(key: key); + + @override + State createState() => _GroupProfileGroupManagePageState(); +} + +class _GroupProfileGroupManagePageState extends TIMUIKitState { + int? serverTime; + + @override + void initState() { + super.initState(); + getServerTime(); + } + + void getServerTime() async { + final res = await TencentImSDKPlugin.v2TIMManager.getServerTime(); + setState(() { + serverTime = res.data; + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: widget.model), + ChangeNotifierProvider.value(value: serviceLocator()) + ], + builder: (context, w) { + final memberList = Provider.of(context).groupMemberList; + final theme = Provider.of(context).theme; + final isAllMuted = widget.model.groupInfo?.isAllMuted ?? false; + final bool isAllowMuteMember = (widget.model.groupInfo?.groupType ?? "") != GroupType.Work; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget managePage() { + return Column( + children: [ + Container( + padding: EdgeInsets.only( + top: 12, + left: isDesktopScreen ? 0 : 16, + bottom: isDesktopScreen ? 0 : 12, + right: isDesktopScreen ? 0 : 12), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen + ? null + : Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: InkWell( + onTap: isDesktopScreen + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupProfileSetManagerPage( + model: widget.model, + ), + )); + }, + child: isDesktopScreen + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(TIM_t("群管理员"), style: TextStyle(fontSize: 14, color: theme.darkTextColor)), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(TIM_t("设置管理员"), + style: TextStyle(fontSize: isDesktopScreen ? 14 : 16, color: theme.darkTextColor)), + Icon(Icons.keyboard_arrow_right, color: theme.weakTextColor) + ], + ), + ), + ), + if (isDesktopScreen) + GroupProfileSetManagerPage( + model: widget.model, + ), + if (!isDesktopScreen) + Container( + padding: const EdgeInsets.only(top: 12, left: 16, bottom: 12, right: 12), + decoration: BoxDecoration( + color: Colors.white, + border: + Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("全员禁言"), + style: TextStyle(fontSize: 16, color: theme.darkTextColor), + ), + CupertinoSwitch( + value: isAllMuted, + onChanged: (value) async { + widget.model.setMuteAll(value); + }, + activeColor: theme.primaryColor) + ], + ), + ), + if (isDesktopScreen && isAllowMuteMember) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(TIM_t("禁言"), style: TextStyle(fontSize: 14, color: theme.darkTextColor)), + ], + ), + if (isDesktopScreen) + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TIMUIKitOperationItem( + isEmpty: false, + operationName: TIM_t("全员禁言"), + type: "switch", + isUseCheckedBoxOnWide: true, + operationDescription: TIM_t("全员禁言开启后,只允许群主和管理员发言。"), + operationValue: isAllMuted, + onSwitchChange: (value) { + widget.model.setMuteAll(value); + }, + ), + ), + if (!isDesktopScreen) + Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + color: theme.weakBackgroundColor, + alignment: Alignment.topLeft, + child: Text( + TIM_t("全员禁言开启后,只允许群主和管理员发言。"), + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ), + ), + if (!isAllMuted && isAllowMuteMember) + InkWell( + child: Container( + color: Colors.white, + padding: const EdgeInsets.only(left: 16), + child: Container( + padding: !isDesktopScreen + ? const EdgeInsets.symmetric( + vertical: 12, + ) + : const EdgeInsets.only( + bottom: 4, + ), + decoration: isDesktopScreen + ? null + : BoxDecoration( + color: Colors.white, + border: Border( + bottom: + BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + children: [ + Icon( + Icons.add_circle_outline, + color: theme.primaryColor, + size: 20, + ), + const SizedBox( + width: 12, + ), + Text(TIM_t("添加需要禁言的群成员")) + ], + ), + )), + onTap: () async { + Widget muteMember() { + return GroupProfileAddAdmin( + key: groupProfileAddAdminKey, + appbarTitle: TIM_t("设置禁言"), + memberList: memberList.where((element) { + final isMute = (serverTime != null ? (element?.muteUntil ?? 0) > serverTime! : false); + final isMember = element!.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER; + return !isMute && isMember; + }).toList(), + selectCompletedHandler: (context, selectedMember) async { + if (selectedMember.isNotEmpty) { + for (var member in selectedMember) { + final userID = member!.userID; + widget.model.muteGroupMember(userID, true, serverTime); + } + } + }, + ); + } + + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.setMute, + context: context, + title: TIM_t("设置禁言"), + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + onSubmit: () { + groupProfileAddAdminKey.currentState?.onSubmit(); + }, + child: (onClose) => muteMember()); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => muteMember())); + } + }, + ), + if (!isAllMuted && isAllowMuteMember) + ...memberList + .where((element) => (serverTime != null ? (element?.muteUntil ?? 0) > serverTime! : false)) + .map((e) => Container( + padding: isDesktopScreen ? const EdgeInsets.only(left: 16) : null, + child: GestureDetector( + onSecondaryTapDown: (details) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.setUnmute, + isDarkBackground: false, + borderRadius: const BorderRadius.all(Radius.circular(4)), + context: context, + offset: Offset( + min(details.globalPosition.dx, MediaQuery.of(context).size.width - 80), + details.globalPosition.dy), + child: (onClose) => TUIKitColumnMenu(data: [ + ColumnMenuItem( + label: TIM_t("删除"), + icon: const Icon(Icons.remove_circle_outline, size: 16), + onClick: () { + widget.model.muteGroupMember(e.userID, false, serverTime); + onClose(); + }), + ])); + }, + child: _buildListItem( + context, + e!, + ActionPane(motion: const DrawerMotion(), children: [ + SlidableAction( + onPressed: (_) { + widget.model.muteGroupMember(e.userID, false, serverTime); + }, + flex: 1, + backgroundColor: theme.cautionColor ?? CommonColor.cautionColor, + autoClose: true, + label: TIM_t("删除"), + ) + ])), + ), + )) + .toList() + ], + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: managePage(), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("群管理"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + shadowColor: theme.weakDividerColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + leading: IconButton( + padding: const EdgeInsets.only(left: 16), + constraints: const BoxConstraints(), + icon: Image.asset( + 'images/arrow_back.png', + package: 'tencent_cloud_chat_uikit', + height: 34, + width: 34, + color: theme.appbarTextColor, + ), + onPressed: () async { + if (isAllMuted != widget.model.groupInfo?.isAllMuted) { + widget.model.setMuteAll(isAllMuted); + } + Navigator.pop(context); + }, + ), + ), + body: managePage(), + )); + }); + } +} + +_getShowName(V2TimGroupMemberFullInfo? item) { + final friendRemark = item?.friendRemark ?? ""; + final nameCard = item?.nameCard ?? ""; + final nickName = item?.nickName ?? ""; + final userID = item?.userID ?? ""; + return friendRemark.isNotEmpty + ? friendRemark + : nameCard.isNotEmpty + ? nameCard + : nickName.isNotEmpty + ? nickName + : userID; +} + +Widget _buildListItem(BuildContext context, V2TimGroupMemberFullInfo memberInfo, ActionPane? endActionPane) { + final theme = Provider.of(context).theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget nameItem() { + return Container( + color: Colors.white, + child: Column(children: [ + ListTile( + tileColor: Colors.black, + leading: SizedBox( + width: isDesktopScreen ? 30 : 36, + height: isDesktopScreen ? 30 : 36, + child: Avatar( + faceUrl: memberInfo.faceUrl ?? "", + showName: _getShowName(memberInfo), + type: 2, + ), + ), + title: Row( + children: [ + Text(_getShowName(memberInfo), style: TextStyle(fontSize: isDesktopScreen ? 14 : 16)), + ], + ), + onTap: () {}, + ), + if (!isDesktopScreen) Divider(thickness: 1, indent: 74, endIndent: 0, color: theme.weakDividerColor, height: 0) + ]), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: nameItem(), + defaultWidget: SingleChildScrollView(child: Slidable(endActionPane: endActionPane, child: nameItem()))); +} + +/// 选择管理员 +class GroupProfileSetManagerPage extends StatefulWidget { + final TUIGroupProfileModel model; + + const GroupProfileSetManagerPage({Key? key, required this.model}) : super(key: key); + + @override + State createState() => _GroupProfileSetManagerPageState(); +} + +class _GroupProfileSetManagerPageState extends TIMUIKitState { + List _getAdminMemberList(List memberList) { + return memberList.where((member) => member?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN).toList(); + } + + List _getOwnerList(List memberList) { + return memberList.where((member) => member?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER).toList(); + } + + _removeAdmin(BuildContext context, V2TimGroupMemberFullInfo memberFullInfo) async { + final res = await widget.model.setMemberToNormal(memberFullInfo.userID); + if (res.code == 0) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("成功取消管理员身份"), infoCode: 6661003)); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return MultiProvider( + providers: [ChangeNotifierProvider.value(value: widget.model)], + builder: (context, w) { + final model = Provider.of(context); + final memberList = model.groupMemberList; + final adminList = _getAdminMemberList(memberList); + final ownerList = _getOwnerList(memberList); + final String option2 = adminList.length.toString(); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget adminPage() { + return SingleChildScrollView( + child: Column( + children: [ + if (!isDesktopScreen) + Container( + alignment: Alignment.topLeft, + color: theme.weakDividerColor, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Text( + TIM_t("群主"), + style: TextStyle(fontSize: 14, color: theme.weakTextColor), + ), + ), + if (isDesktopScreen) + Container( + alignment: Alignment.topLeft, + padding: const EdgeInsets.only(top: 10, bottom: 4, left: 16), + child: Text( + TIM_t("群主"), + style: TextStyle(fontSize: 14, color: theme.primaryColor), + ), + ), + ...ownerList + .map( + (e) => Container( + padding: isDesktopScreen ? const EdgeInsets.only(left: 16) : null, + child: _buildListItem(context, e!, null), + ), + ) + .toList(), + if (!isDesktopScreen) + Container( + alignment: Alignment.topLeft, + color: theme.weakDividerColor, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Text( + TIM_t_para("管理员 ({{option2}}/10)", "管理员 ($option2/10)")(option2: option2), + style: TextStyle(fontSize: 14, color: theme.weakTextColor), + ), + ), + if (isDesktopScreen) + Container( + alignment: Alignment.topLeft, + padding: const EdgeInsets.only(top: 10, bottom: 4, left: 16), + child: Text( + TIM_t_para("管理员 ({{option2}}/10)", "管理员 ($option2/10)")(option2: option2), + style: TextStyle(fontSize: 14, color: theme.primaryColor), + ), + ), + InkWell( + child: Container( + color: Colors.white, + padding: const EdgeInsets.only(left: 16), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + decoration: isDesktopScreen + ? null + : BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + children: [ + Icon( + Icons.add_circle_outline, + color: theme.primaryColor, + size: 20, + ), + const SizedBox( + width: 12, + ), + Text(TIM_t("添加管理员")) + ], + ), + )), + onTap: () async { + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.setAdmins, + context: context, + title: TIM_t("设置管理员"), + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + onSubmit: () { + groupProfileAddAdminKey.currentState?.onSubmit(); + }, + child: (onClose) => GroupProfileAddAdmin( + key: groupProfileAddAdminKey, + memberList: memberList + .where( + (element) => element?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER) + .toList(), + appbarTitle: TIM_t("设置管理员"), + selectCompletedHandler: (context, selectedMember) async { + if (selectedMember.isNotEmpty) { + for (var member in selectedMember) { + final userID = member!.userID; + widget.model.setMemberToAdmin(userID); + } + } + }, + )); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GroupProfileAddAdmin( + key: groupProfileAddAdminKey, + memberList: memberList + .where((element) => + element?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER) + .toList(), + appbarTitle: TIM_t("设置管理员"), + selectCompletedHandler: (context, selectedMember) async { + if (selectedMember.isNotEmpty) { + for (var member in selectedMember) { + final userID = member!.userID; + widget.model.setMemberToAdmin(userID); + } + } + }, + ))); + } + }, + ), + ...adminList + .map((e) => GestureDetector( + onSecondaryTapDown: (details) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.deleteAdmin, + isDarkBackground: false, + borderRadius: const BorderRadius.all(Radius.circular(4)), + context: context, + offset: Offset(min(details.globalPosition.dx, MediaQuery.of(context).size.width - 80), + details.globalPosition.dy), + child: (onClose) => TUIKitColumnMenu(data: [ + ColumnMenuItem( + label: TIM_t("删除"), + icon: const Icon(Icons.remove_circle_outline, size: 16), + onClick: () { + _removeAdmin(context, e); + onClose(); + }), + ])); + }, + child: Container( + padding: isDesktopScreen ? const EdgeInsets.only(left: 16) : null, + child: _buildListItem( + context, + e!, + ActionPane(motion: const DrawerMotion(), children: [ + SlidableAction( + onPressed: (_) { + _removeAdmin(context, e); + }, + flex: 1, + backgroundColor: theme.cautionColor ?? CommonColor.cautionColor, + autoClose: true, + label: TIM_t("删除"), + ) + ])), + ), + )) + .toList(), + ], + )); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: adminPage(), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("设置管理员"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.weakDividerColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + ), + body: adminPage(), + )); + }, + ); + } +} + +/// 添加管理员 +class GroupProfileAddAdmin extends StatefulWidget { + final List memberList; + final String appbarTitle; + final void Function(BuildContext context, List selectedMemberList)? selectCompletedHandler; + + const GroupProfileAddAdmin( + {Key? key, required this.memberList, this.selectCompletedHandler, required this.appbarTitle}) + : super(key: key); + + @override + State createState() => _GroupProfileAddAdminState(); +} + +class _GroupProfileAddAdminState extends TIMUIKitState { + List selectedMemberList = []; + + void onSubmit() { + if (widget.selectCompletedHandler != null) { + widget.selectCompletedHandler!(context, selectedMemberList); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + Widget addAdminPage() { + return SingleChildScrollView( + child: Column( + children: [ + Container( + alignment: Alignment.topLeft, + color: theme.weakDividerColor, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Text( + TIM_t("群成员"), + style: TextStyle(fontSize: 14, color: theme.weakTextColor), + ), + ), + ...widget.memberList + .map((e) => Container( + decoration: BoxDecoration( + color: Colors.white, + border: + Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: InkWell( + onTap: () { + final isChecked = selectedMemberList.contains(e); + if (isChecked) { + selectedMemberList.remove(e); + } else { + selectedMemberList.add(e); + } + setState(() {}); + }, + child: Row( + children: [ + CheckBoxButton( + onlyShow: true, + isChecked: selectedMemberList.contains(e), + ), + const SizedBox( + width: 10, + ), + SizedBox( + width: 36, + height: 36, + child: Avatar( + faceUrl: e?.faceUrl ?? "", + showName: _getShowName(e), + type: 2, + ), + ), + const SizedBox( + width: 10, + ), + Text(_getShowName(e), style: const TextStyle(fontSize: 16)) + ], + ), + ), + )) + .toList(), + ], + )); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: addAdminPage(), + ), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + widget.appbarTitle, + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.weakDividerColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + leadingWidth: 80, + leading: TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + TIM_t("取消"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + onSubmit(); + Navigator.of(context).pop(); + }, + child: Text( + TIM_t("完成"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ) + ], + ), + body: addAdminPage())); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_member_title.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_member_title.dart new file mode 100644 index 0000000..aadaad3 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_member_title.dart @@ -0,0 +1,381 @@ +// ignore_for_file: unused_element + +import 'package:dotted_border/dotted_border.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/group_member/tui_add_group_member.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/group_member/tui_delete_group_member.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/group_member/tui_group_member_list.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/wide_popup.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupMemberTitle extends TIMUIKitStatelessWidget { + final VoidCallback? onAddMemberPressed; + final VoidCallback? onRemoveMemberPressed; + + GroupMemberTitle({ + Key? key, + this.onAddMemberPressed, + this.onRemoveMemberPressed, + }) : super(key: key); + + List _getMemberList(memberList, int showRange) { + if (memberList.length > showRange) { + return memberList.getRange(0, showRange).toList(); + } else { + return memberList; + } + } + + _getShowName(V2TimGroupMemberFullInfo? item) { + final friendRemark = item?.friendRemark ?? ""; + final nickName = item?.nickName ?? ""; + final userID = item?.userID; + final showName = nickName != "" ? nickName : userID; + return friendRemark != "" ? friendRemark : showName; + } + + List _groupMemberListBuilder( + List memberList, + TUITheme theme, + TUIGroupProfileModel model, + int showRange, + ) { + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + return _getMemberList(memberList, showRange).map((element) { + final faceUrl = element?.faceUrl ?? ""; + final showName = _getShowName(element); + return InkWell( + onTapDown: (details) { + if (model.onClickUser != null && element?.userID != null) { + model.onClickUser!(element!, details); + } + }, + child: SizedBox( + width: isDesktopScreen ? 36 : 60, + height: isDesktopScreen ? 36 : 76, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: isDesktopScreen ? 36 : 50, + height: isDesktopScreen ? 36 : 50, + child: Avatar( + borderRadius: isDesktopScreen + ? BorderRadius.circular(18) + : null, + faceUrl: faceUrl, + showName: showName, + type: 1, + ), + ), + if (!isDesktopScreen) const SizedBox(height: 8), + if (!isDesktopScreen) + Text( + showName, + textAlign: TextAlign.center, + style: TextStyle( + overflow: TextOverflow.ellipsis, + color: theme.weakTextColor, + fontSize: 10, + ), + ), + ], + ), + ), + ); + }).toList(); + } + + List _inviteMemberBuilder( + bool isCanInviteMember, + bool isCanKickOffMember, + theme, + BuildContext context, + ) { + return []; + } + + void navigateToMemberList( + BuildContext context, + TUIGroupProfileModel model, + List memberList, + ) { + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (!isDesktopScreen) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + GroupProfileMemberListPage(model: model, memberList: memberList), + ), + ); + } else { + final option1 = memberList.length.toString(); + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey.groupMembersList, + context: context, + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.8, + title: TIM_t_para("群成员({{option1}}人)", "群成员($option1人)")( + option1: option1, + ), + child: (onClose) => + GroupProfileMemberListPage(model: model, memberList: memberList), + ); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final model = Provider.of(context); + final memberAmount = model.groupInfo?.memberCount ?? 0; + final option1 = memberAmount.toString(); + final memberList = model.groupMemberList; + final isCanInviteMember = model.canInviteMember(); + final isCanKickOffMember = model.canKickOffMember(); + + int showRange = isDesktopScreen ? 7 : 8; + if (isDesktopScreen && isCanInviteMember) { + showRange--; + } + if (isDesktopScreen && isCanKickOffMember) { + showRange--; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(bottom: 12), + decoration: isDesktopScreen + ? null + : BoxDecoration( + border: Border( + bottom: BorderSide( + color: + theme.weakDividerColor ?? + CommonColor.weakDividerColor, + ), + ), + ), + child: InkWell( + onTap: () async { + navigateToMemberList(context, model, memberList); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("群成员"), + style: TextStyle( + color: theme.darkTextColor, + fontSize: isDesktopScreen ? 14 : 16, + ), + ), + Row( + children: [ + Text( + TIM_t_para("{{option1}}人", "$option1人")( + option1: option1, + ), + style: TextStyle( + color: theme.darkTextColor, + fontSize: isDesktopScreen ? 14 : 16, + ), + ), + Icon( + Icons.keyboard_arrow_right, + color: theme.weakTextColor, + ), + ], + ), + ], + ), + ), + ), + if (isDesktopScreen) + InkWell( + onTap: () async { + navigateToMemberList(context, model, memberList); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: + theme.weakDividerColor ?? CommonColor.weakDividerColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + // height: 30, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.search, color: hexToColor("979797"), size: 16), + const SizedBox(width: 6), + Text( + TIM_t("搜索"), + style: TextStyle( + color: theme.weakTextColor, + fontSize: 12, + ), + ), + ], + ), + ), + ), + Container( + // height: 90, + padding: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: isDesktopScreen ? 10 : 20, + runSpacing: 10, + alignment: WrapAlignment.start, + children: [ + ..._groupMemberListBuilder(memberList, theme, model, showRange), + if (isCanInviteMember) + DottedBorder( + borderType: BorderType.RRect, + radius: Radius.circular(isDesktopScreen ? 18 : 4.5), + color: theme.weakTextColor!, + dashPattern: const [6, 3], + child: SizedBox( + width: isDesktopScreen ? 32 : 48, + height: isDesktopScreen ? 32 : 48, + child: IconButton( + onPressed: () { + if (onAddMemberPressed != null) { + onAddMemberPressed!(); + } else { + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + context: context, + operationKey: + TUIKitWideModalOperationKey.addGroupMembers, + width: 350, + title: TIM_t("添加群成员"), + height: 460, + onSubmit: () { + addGroupMemberKey.currentState?.submitAdd(); + }, + child: (onClose) => AddGroupMemberPage( + model: model, + onClose: onClose, + key: addGroupMemberKey, + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AddGroupMemberPage(model: model), + ), + ); + } + } + }, + icon: Icon(Icons.add, size: isDesktopScreen ? 16 : 18), + color: theme.weakTextColor, + ), + ), + ), + if (isCanKickOffMember) + DottedBorder( + borderType: BorderType.RRect, + radius: Radius.circular(isDesktopScreen ? 18 : 4.5), + color: theme.weakTextColor!, + dashPattern: const [6, 3], + child: SizedBox( + width: isDesktopScreen ? 32 : 48, + height: isDesktopScreen ? 32 : 48, + child: IconButton( + onPressed: () { + if (onRemoveMemberPressed != null) { + onRemoveMemberPressed!(); + } else { + if (isDesktopScreen) { + TUIKitWidePopup.showPopupWindow( + operationKey: TUIKitWideModalOperationKey + .kickOffGroupMembers, + context: context, + width: 350, + title: TIM_t("删除群成员"), + height: 460, + onSubmit: () { + deleteGroupMemberKey.currentState + ?.submitDelete(); + }, + child: (onClose) => DeleteGroupMemberPage( + model: model, + onClose: onClose, + key: deleteGroupMemberKey, + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DeleteGroupMemberPage(model: model), + ), + ); + } + } + }, + icon: Icon( + Icons.remove, + size: isDesktopScreen ? 16 : 18, + ), + color: theme.weakTextColor, + ), + ), + ), + ], + ), + ), + if (memberList.length > showRange) + InkWell( + child: Container( + alignment: Alignment.center, + margin: EdgeInsets.only(top: isDesktopScreen ? 12 : 16), + child: Text( + TIM_t("查看更多群成员"), + style: TextStyle( + color: theme.weakTextColor, + fontSize: isDesktopScreen ? 12 : 14, + ), + ), + ), + onTap: () async { + navigateToMemberList(context, model, memberList); + }, + ), + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_message_disturb.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_message_disturb.dart new file mode 100644 index 0000000..e6e9417 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_message_disturb.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class GroupMessageDisturb extends TIMUIKitStatelessWidget { + GroupMessageDisturb({Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final model = Provider.of(context); + final isShowDisturb = model.groupInfo?.groupType == "Meeting" ? false : true; + final isDisturb = model.conversation?.recvOpt != 0; + if (!isShowDisturb) { + return Container(); + } + return TIMUIKitOperationItem( + isEmpty: false, + operationName: TIM_t("消息免打扰"), + type: "switch", + isUseCheckedBoxOnWide: true, + operationValue: isDisturb, + onSwitchChange: (value) { + model.setMessageDisturb(value); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_name_card.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_name_card.dart new file mode 100644 index 0000000..7bb8a91 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_name_card.dart @@ -0,0 +1,145 @@ +// ignore_for_file: unnecessary_null_comparison + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/text_input_bottom_sheet.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupProfileNameCard extends StatefulWidget { + const GroupProfileNameCard({Key? key}) : super(key: key); + + @override + State createState() => GroupProfileNameCardState(); + +} + +class GroupProfileNameCardState extends TIMUIKitState{ + final TextEditingController controller = TextEditingController(); + String? nameCard; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final model = Provider.of(context); + if (model == null) { + return Container(); + } + nameCard = model.getSelfNameCard(); + controller.text = nameCard ?? ""; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen ? null : Border( + bottom: BorderSide( + color: + theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: GestureDetector( + onTap: () async { + if (!isDesktopScreen) { + TextInputBottomSheet.showTextInputBottomSheet( + context: context, + title: TIM_t("修改我的群昵称"), + tips: TIM_t("仅限中文、字母、数字和下划线,2-20个字"), + onSubmitted: (String nameCard) async { + final text = nameCard.trim(); + model.setNameCard(text); + }, + theme: theme); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + margin: const EdgeInsets.only(right: 10), + child: Text( + TIM_t("我的群昵称"), + style: TextStyle( + fontSize: isDesktopScreen ? 14 : 16, + color: theme.darkTextColor), + ), + ), + if (!isDesktopScreen) + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: Text( + nameCard ?? "", + style: TextStyle( + fontSize: isDesktopScreen ? 14 : 16, + color: theme.darkTextColor), + )), + Icon(Icons.keyboard_arrow_right, + color: theme.weakTextColor) + ], + )), + ], + ), + if (isDesktopScreen) + Text( + TIM_t("仅限中文、字母、数字和下划线,2-20个字"), + style: TextStyle(color: theme.weakTextColor, fontSize: 12), + ), + if (isDesktopScreen) + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + height: 30, + child: TextField( + minLines: 1, + controller: controller, + maxLines: 1, + onSubmitted: (text) { + model.setNameCard(text.trim()); + }, + keyboardType: TextInputType.multiline, + autofocus: true, + textAlignVertical: TextAlignVertical.center, + textAlign: TextAlign.start, + style: const TextStyle(fontSize: 12), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakDividerColor ?? Colors.grey, + )), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakDividerColor ?? Colors.grey, + ), + ), + focusedBorder: OutlineInputBorder( + //选中时外边框颜色 + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakTextColor ?? Colors.grey, + ), + ), + hintStyle: const TextStyle( + color: Color(0xFFAEA4A3), + ), + hintText: TIM_t("修改我的群昵称"))), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart new file mode 100644 index 0000000..88b5a61 --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_notification.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupProfileNotification extends StatefulWidget { + final bool isHavePermission; + + const GroupProfileNotification({Key? key, this.isHavePermission = false}) : super(key: key); + + @override + State createState() => GroupProfileNotificationState(); +} + +class GroupProfileNotificationState extends TIMUIKitState { + bool isShowEditBox = false; + final TextEditingController _controller = TextEditingController(); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + final model = Provider.of(context); + final String notification = (model.groupInfo?.notification != null && model.groupInfo!.notification!.isNotEmpty) + ? model.groupInfo!.notification! + : TIM_t("暂无群公告"); + + _setGroupNotification() async { + setState(() { + isShowEditBox = false; + }); + final notification = _controller.text; + await model.setGroupNotification(notification); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen + ? null + : Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: InkWell( + onTap: !widget.isHavePermission + ? null + : (() { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (!isDesktopScreen) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + GroupProfileNotificationPage(model: model, notification: notification))); + } else { + setState(() { + isShowEditBox = !isShowEditBox; + if (isShowEditBox) { + _controller.text = notification; + } + }); + } + }), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + TIM_t("群公告"), + style: TextStyle(color: theme.darkTextColor, fontSize: isDesktopScreen ? 14 : 16), + ), + ), + if (widget.isHavePermission) + AnimatedRotation( + turns: isShowEditBox ? 0.25 : 0, + duration: const Duration(milliseconds: 200), + child: Icon(Icons.keyboard_arrow_right, color: theme.weakTextColor), + ) + ], + ), + if (!isShowEditBox) + Padding( + padding: EdgeInsets.only(top: isDesktopScreen ? 4 : 0), + child: SelectableText(notification, + // overflow: isDesktopScreen ? null : TextOverflow.ellipsis, + // softWrap: true, + style: TextStyle(color: theme.weakTextColor, fontSize: 12)), + ), + if (isShowEditBox) + Container( + margin: const EdgeInsets.only(top: 10, bottom: 10), + // height: 150, + child: TextField( + minLines: 1, + maxLines: 6, + controller: _controller, + keyboardType: TextInputType.multiline, + autofocus: true, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakDividerColor ?? Colors.grey, + )), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakDividerColor ?? Colors.grey, + ), + ), + focusedBorder: OutlineInputBorder( + //选中时外边框颜色 + borderRadius: BorderRadius.circular(5.0), + borderSide: BorderSide( + color: theme.weakTextColor ?? Colors.grey, + ), + ), + hintStyle: const TextStyle( + color: Color(0xFFAEA4A3), + ), + hintText: '')), + ), + if (isShowEditBox) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: _setGroupNotification, + child: Text( + TIM_t("保存"), + style: TextStyle(fontSize: 13, color: theme.primaryColor), + )) + ], + ) + ], + ), + ), + ); + } +} + +class GroupProfileNotificationPage extends StatefulWidget { + final String notification; + final TUIGroupProfileModel model; + + const GroupProfileNotificationPage({Key? key, required this.notification, required this.model}) : super(key: key); + + @override + State createState() => _GroupProfileNotificationPageState(); +} + +class _GroupProfileNotificationPageState extends TIMUIKitState { + final TextEditingController _controller = TextEditingController(); + bool isUpdated = false; + + _setGroupNotification() async { + final notification = _controller.text; + await widget.model.setGroupNotification(notification); + setState(() { + isUpdated = true; + }); + } + + @override + void initState() { + _controller.text = widget.notification; + super.initState(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Scaffold( + appBar: AppBar( + title: Text( + TIM_t("群公告"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + shadowColor: theme.weakDividerColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + actions: [ + TextButton( + onPressed: () { + if (isUpdated) { + setState(() { + isUpdated = false; + }); + } else { + _setGroupNotification(); + } + }, + child: Text( + isUpdated ? TIM_t("编辑") : TIM_t("完成"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ) + ], + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TextField( + readOnly: isUpdated, + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + expands: true, + autofocus: true, + decoration: const InputDecoration( + border: InputBorder.none, + hintStyle: TextStyle( + // fontSize: 10, + color: Color(0xFFAEA4A3), + ), + hintText: '')), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_pin_conversation.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_pin_conversation.dart new file mode 100644 index 0000000..4b2522e --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_pin_conversation.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class GroupPinConversation extends TIMUIKitStatelessWidget { + GroupPinConversation({Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final model = Provider.of(context); + final isPined = model.conversation?.isPinned ?? false; + return TIMUIKitOperationItem( + isEmpty: false, + operationName: TIM_t("置顶聊天"), + type: "switch", + isUseCheckedBoxOnWide: true, + operationValue: isPined, + onSwitchChange: (value) { + model.pinedConversation(value); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_type.dart b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_type.dart new file mode 100644 index 0000000..d35538f --- /dev/null +++ b/lib/ui/views/TIMUIKitGroupProfile/widgets/tim_uikit_group_type.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_type.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class GroupProfileType extends TIMUIKitStatelessWidget { + GroupProfileType({Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + String groupType; + final model = Provider.of(context); + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + final type = model.groupInfo?.groupType; + switch (type) { + case GroupType.AVChatRoom: + groupType = TIM_t("聊天室"); + break; + case GroupType.Meeting: + groupType = TIM_t("会议群"); + break; + case GroupType.Public: + groupType = TIM_t("公开群"); + break; + case GroupType.Work: + groupType = TIM_t("工作群"); + break; + case GroupType.Community: + groupType = TIM_t("社群"); + break; + default: + groupType = TIM_t("未知群"); + break; + } + + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + border: isDesktopScreen ? null : Border( + bottom: BorderSide( + color: + theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + TIM_t("群类型"), + style: TextStyle( + fontSize: isDesktopScreen ? 14 : 16, color: theme.darkTextColor), + ), + Text( + groupType, + style: TextStyle( + fontSize: isDesktopScreen ? 14 : 16, color: theme.weakTextColor), + ) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitNewContact/tim_uikit_new_contact.dart b/lib/ui/views/TIMUIKitNewContact/tim_uikit_new_contact.dart new file mode 100644 index 0000000..53f1e20 --- /dev/null +++ b/lib/ui/views/TIMUIKitNewContact/tim_uikit_new_contact.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_application.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/new_contact_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +typedef NewContactItemBuilder = Widget Function(BuildContext context, V2TimFriendApplication applicationInfo); + +class TIMUIKitNewContact extends StatefulWidget { + /// the callback when accept friend request + final void Function(V2TimFriendApplication applicationInfo)? onAccept; + + /// the callback when reject friend request + final void Function(V2TimFriendApplication applicationInfo)? onRefuse; + + /// the widget builder when no friend request exists + final Widget Function(BuildContext context)? emptyBuilder; + + /// the builder for the request item + final NewContactItemBuilder? itemBuilder; + + /// the life cycle hooks for new contact business logic + final NewContactLifeCycle? lifeCycle; + + const TIMUIKitNewContact( + {Key? key, this.lifeCycle, this.onAccept, this.onRefuse, this.emptyBuilder, this.itemBuilder}) + : super(key: key); + + @override + State createState() => _TIMUIKitNewContactState(); +} + +class _TIMUIKitNewContactState extends TIMUIKitState { + late TUIFriendShipViewModel model = serviceLocator(); + + _getShowName(V2TimFriendApplication item) { + return TencentUtils.checkString(item.nickname) ?? TencentUtils.checkString(item.userID); + } + + Widget _itemBuilder(BuildContext context, V2TimFriendApplication applicationInfo) { + final theme = Provider.of(context).theme; + final showName = _getShowName(applicationInfo); + final faceUrl = applicationInfo.faceUrl ?? ""; + final applicationText = applicationInfo.addWording ?? ""; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return Material( + color: theme.wideBackgroundColor, + child: InkWell( + onTap: () {}, + child: Container( + padding: EdgeInsets.only(top: isDesktopScreen ? 6 : 10, left: 16, right: isDesktopScreen ? 16 : 0), + child: Row( + children: [ + Container( + padding: EdgeInsets.only(bottom: isDesktopScreen ? 10 : 12), + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + ), + Expanded( + child: Container( + padding: const EdgeInsets.only(top: 10, bottom: 20), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: EdgeInsets.only(top: (applicationText.isNotEmpty && isDesktopScreen) ? 10 : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + showName, + style: TextStyle(color: theme.darkTextColor, fontSize: isDesktopScreen ? 14 : 18), + ), + if (applicationText.isNotEmpty && isDesktopScreen) + const SizedBox( + height: 4, + ), + if (applicationText.isNotEmpty && isDesktopScreen) + Text( + applicationText, + style: TextStyle(color: theme.weakTextColor, fontSize: 12), + ), + ], + ), + ), + Expanded(child: Container()), + Container( + margin: const EdgeInsets.only(right: 8), + child: InkWell( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.primaryColor, + border: Border.all(width: 1, color: theme.weakTextColor ?? CommonColor.weakTextColor)), + child: Text( + TIM_t("同意"), + style: TextStyle( + color: Colors.white, + fontSize: isDesktopScreen ? 12 : null, + ), + ), + ), + onTap: () async { + await model.acceptFriendApplication( + applicationInfo.userID, + applicationInfo.type, + ); + model.loadData(); + if (widget.onAccept != null) { + widget.onAccept!(applicationInfo); + } + // widget?.onAccept(); + }, + ), + ), + Container( + margin: const EdgeInsets.only(right: 8), + child: InkWell( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white, + border: Border.all(width: 1, color: theme.weakTextColor ?? CommonColor.weakTextColor)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Text( + TIM_t("拒绝"), + style: TextStyle( + color: theme.primaryColor, + fontSize: isDesktopScreen ? 12 : null, + ), + ), + ), + onTap: () async { + await model.refuseFriendApplication( + applicationInfo.userID, + applicationInfo.type, + ); + model.loadData(); + if (widget.onRefuse != null) { + widget.onRefuse!(applicationInfo); + } + // refuse(context); + }, + )) + ], + ), + )) + ], + ), + ), + ), + ); + } + + NewContactItemBuilder _getItemBuilder() { + return widget.itemBuilder ?? _itemBuilder; + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: model), + ], + builder: (BuildContext context, Widget? w) { + final model = Provider.of(context); + model.newContactLifeCycle = widget.lifeCycle; + final newContactList = model.friendApplicationList; + if (newContactList != null && newContactList.isNotEmpty) { + return ListView.builder( + shrinkWrap: true, + itemCount: newContactList.length, + itemBuilder: (context, index) { + final friendInfo = newContactList[index]!; + final itemBuilder = _getItemBuilder(); + return itemBuilder(context, friendInfo); + }, + ); + } + + if (widget.emptyBuilder != null) { + return widget.emptyBuilder!(context); + } + + return Container(); + }); + } +} diff --git a/lib/ui/views/TIMUIKitNewContact/tim_uikit_unread_count.dart b/lib/ui/views/TIMUIKitNewContact/tim_uikit_unread_count.dart new file mode 100644 index 0000000..230fbd5 --- /dev/null +++ b/lib/ui/views/TIMUIKitNewContact/tim_uikit_unread_count.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/unread_message.dart'; + +class TIMUIKitUnreadCount extends StatefulWidget { + final double width; + final double height; + + const TIMUIKitUnreadCount({Key? key, this.width = 22.0, this.height = 22.0}) + : super(key: key); + + @override + State createState() => _TIMUIKitUnreadCountState(); +} + +class _TIMUIKitUnreadCountState extends TIMUIKitState { + final TUIFriendShipViewModel model = serviceLocator(); + + + @override + void dispose() { + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return ChangeNotifierProvider.value( + value: model, + child: + Consumer(builder: (context, value, child) { + final friendApplicationAmount = value.friendApplicationAmount; + if (friendApplicationAmount > 0) { + return UnreadMessage( + width: widget.width, + height: widget.height, + unreadCount: friendApplicationAmount); + } + return Container(); + })); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/profile_widget.dart b/lib/ui/views/TIMUIKitProfile/profile_widget.dart new file mode 100644 index 0000000..358cb48 --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/profile_widget.dart @@ -0,0 +1,179 @@ +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; + +enum ProfileWidgetEnum { + /// THe card shows the user info of a specific user. + userInfoCard, + + /// The switch of if add another user to block list. + /// This will not shows when friendship relation not exist as default. + addToBlockListBar, + + /// The switch of if pin the one-to-one conversation to the top of the conversation list. + /// This will not shows when friendship relation not exist as default. + pinConversationBar, + + /// The switch of if mute the message notification from a specific user. + /// This will not shows when friendship relation not exist as default. + messageMute, + + /// The entrance to search page, please set the `onTap` callback yourself, to the search page with conversation ID. + /// You can develop it with `TIMUIKitProfileWidget.searchBar`. + searchBar, + + /// The bar shows the portrait. + portraitBar, + + /// The bar shows the nickname of a specific user. + nicknameBar, + + /// The bar shows the user account of a specific user. + userAccountBar, + + /// The bar shows the signature of a specific user. + signatureBar, + + /// The bar shows the gender of a specific user. + /// 1 represent male, 2 represent female. + genderBar, + + /// The bar shows the birthday of a specific user. + /// Int like "19981112", means November 12, 1998. + birthdayBar, + + /// The area shows the buttons, + /// contains "Send message", "Voice/Video Call", "Delete friend" when has the friend relationship, + /// while contains "Add friend" when no relationship exists, as default. + addAndDeleteArea, + + /// The divider between sets of profile widget + operationDivider, + + /// The setting of remark for a specific user. + /// This will not shows when friendship relation not exist as default. + remarkBar, + + /// Custom area, you may define send message, make calling, search or anything you want here. + customBuilderOne, + + /// Custom area, you may define send message, make calling, search or anything you want here. + customBuilderTwo, + + /// Custom area, you may define send message, make calling, search or anything you want here. + customBuilderThree, + + /// Custom area, you may define send message, make calling, search or anything you want here. + customBuilderFour, + + /// Custom area, you may define send message, make calling, search or anything you want here. + customBuilderFive +} + +typedef ProfileWidgetItemContent = Widget? Function( + bool isShowJump, + VoidCallback clearJump, +); + +class ProfileWidgetBuilder { + /// The divider between sets of profile widget + Widget Function()? operationDivider; + + /// The setting of remark for a specific user + Widget Function(String remark, Function()? handleTap)? remarkBar; + + /// The switch of if add another user to block list. + /// This will not shows when friendship relation not exist as default. + Widget Function(bool isAsBlocked, Function(bool value)? onChange)? addToBlockListBar; + + /// The switch of if pin the one-to-one conversation to the top of the conversation list. + /// This will not shows when friendship relation not exist as default. + Widget Function(bool isPinned, Function(bool value)? onChange)? pinConversationBar; + + /// The switch of if mute the message notification from a specific user. + /// This will not shows when friendship relation not exist as default. + Widget Function(bool isMute, Function(bool value)? onChange)? messageMute; + + /// Override the default operation item style for un-customized profile widget. + Widget Function({ + required String operationName, + required String type, + bool? operationValue, + String? operationText, + void Function(bool newValue)? onSwitchChange, + })? operationItem; + + /// The entrance to search page, please set the `onTap` callback yourself, to the search page with conversation ID. + /// You can develop it with `TIMUIKitProfileWidget.searchBar`. + Widget Function(V2TimConversation conversation)? searchBar; + + /// The bar shows the portrait. + Widget Function(V2TimUserFullInfo? userInfo)? portraitBar; + + /// The bar shows the nickname of a specific user. + Widget Function(String nickName)? nicknameBar; + + /// The bar shows the user account of a specific user. + Widget Function(String userAccount)? userAccountBar; + + /// The bar shows the signature of a specific user. + Widget Function(String signature)? signatureBar; + + /// The bar shows the gender of a specific user. + /// 1 represent male, 2 represent female. + Widget Function(int gender)? genderBar; + + /// The bar shows the birthday of a specific user. + /// Int like "19981111", means November 11, 1998. + Widget Function(int? birthday)? birthdayBar; + + /// THe card shows the user info of a specific user. + Widget Function(V2TimUserFullInfo? userInfo)? userInfoCard; + + /// The area shows the buttons, + /// contains "Send message", "Voice/Video Call", "Delete friend" when has the friend relationship, + /// while contains "Add friend" when no relationship exists, as default. + Widget Function(V2TimFriendInfo friendInfo, V2TimConversation conversation, int friendType, bool isDisturb)? + addAndDeleteArea; + + /// Custom area, you may define send message, make calling, search or anything you want here. + Widget Function(bool isFriend, V2TimFriendInfo friendInfo, V2TimConversation conversation)? customBuilderOne; + + /// Custom area, you may define send message, make calling, search or anything you want here. + Widget Function(bool isFriend, V2TimFriendInfo friendInfo, V2TimConversation conversation)? customBuilderTwo; + + /// Custom area, you may define send message, make calling, search or anything you want here. + Widget Function(bool isFriend, V2TimFriendInfo friendInfo, V2TimConversation conversation)? customBuilderThree; + + /// Custom area, you may define send message, make calling, search or anything you want here. + Widget Function(bool isFriend, V2TimFriendInfo friendInfo, V2TimConversation conversation)? customBuilderFour; + + /// Custom area, you may define send message, make calling, search or anything you want here. + Widget Function(bool isFriend, V2TimFriendInfo friendInfo, V2TimConversation conversation)? customBuilderFive; + + ProfileWidgetBuilder( + {this.operationDivider, + this.remarkBar, + this.addToBlockListBar, + this.pinConversationBar, + this.messageMute, + this.operationItem, + this.searchBar, + this.portraitBar, + this.nicknameBar, + this.userAccountBar, + this.signatureBar, + this.genderBar, + this.birthdayBar, + this.userInfoCard, + this.addAndDeleteArea, + this.customBuilderOne, + this.customBuilderTwo, + this.customBuilderThree, + this.customBuilderFive, + this.customBuilderFour}); +} diff --git a/lib/ui/views/TIMUIKitProfile/tim_uikit_profile.dart b/lib/ui/views/TIMUIKitProfile/tim_uikit_profile.dart new file mode 100644 index 0000000..856b8c5 --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/tim_uikit_profile.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/life_cycle/profile_life_cycle.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_profile_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/profile_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_widget.dart'; + +typedef OnSelfAvatarTap = void Function(); + +typedef ProfileBuilder = Widget Function( + BuildContext context, V2TimFriendInfo userInfo, V2TimConversation conversation, int friendType, bool isMute); + +class TIMUIKitProfile extends StatefulWidget { + /// user ID + final String userID; + + /// [Deprecated:] the builder for custom operation list. + /// [operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead. + final Widget Function(BuildContext context, V2TimFriendInfo friendInfo, V2TimConversation conversation, + int friendType, bool isMute)? operationListBuilder; + + /// [Deprecated:] The builder for custom bottom operation area. + /// [operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead. + final Widget Function( + BuildContext context, V2TimFriendInfo? friendInfo, V2TimConversation? conversation, int friendType)? + bottomOperationBuilder; + + /// [Deprecated:] Callback when clicking profile detail card. + /// This widget will no longer shows the personal info card and can not jump to personal info page automatically, + /// please navigate to your custom personal info page manually and directly, you may refer to our demo. + final void Function(BuildContext context, V2TimUserFullInfo? userFullInfo)? handleProfileDetailCardTap; + + /// Profile Controller + final TIMUIKitProfileController? controller; + + /// [Deprecated:] If allows jump to personal profiler page. + /// This widget will no longer shows the personal info card and can not jump to personal info page automatically, + /// please navigate to your custom personal info page manually and directly, you may refer to our demo. + final bool canJumpToPersonalProfile; + + /// [Deprecated:] The callback when clicking self avatar. + /// This widget will no longer shows the personal info card and will not support to change self avatar, + /// please navigate to your custom personal info page manually and directly, you may refer to our demo. + final OnSelfAvatarTap? onSelfAvatarTap; + + /// [If you tend to customize the profile page, use [profileWidgetsBuilder] with [profileWidgetsOrder] as priority.] + /// The builder for each widgets in profile page, + /// you can customize some of it by pass your own widget into here. + /// Or, you can add your custom widget to the three custom widgets. + final ProfileWidgetBuilder? profileWidgetBuilder; + + /// [If you tend to customize the profile page, use [profileWidgetsBuilder] with [profileWidgetsOrder] as priority.] + /// If the default widget order can not meet you needs, + /// you may change the order by this array with widget enum. + final List? profileWidgetsOrder; + + /// The builder for the whole profile page, you can use this to customize all the element here. + /// Mentioned: If you use this builder, [profileWidgetBuilder] and [profileWidgetsOrder] will no longer works. + final ProfileBuilder? builder; + + /// The life cycle hooks for user profile business logic + final ProfileLifeCycle? lifeCycle; + + /// Whether the specify user is current logged in user. + /// Default: [false]. + final bool isSelf; + + /// Whether use the small card mode on Desktop. Usually shows on the Chat page. + final bool smallCardMode; + + const TIMUIKitProfile( + {Key? key, + required this.userID, + @Deprecated("[operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead") + this.operationListBuilder, + @Deprecated("[operationListBuilder] and [bottomOperationBuilder] merged into [builder], please use it instead") + this.bottomOperationBuilder, + @Deprecated( + "This widget will no longer shows the personal info card and can not jump to personal info page automatically, please navigate to your custom personal info page manually and directly, you may refer to our demo") + this.handleProfileDetailCardTap, + @Deprecated( + "This widget will no longer shows the personal info card and can not jump to personal info page automatically, please navigate to your custom personal info page manually and directly, you may refer to our demo") + this.canJumpToPersonalProfile = false, + @Deprecated( + "This widget will no longer shows the personal info card and will not support to change self avatar, please navigate to your custom personal info page manually and directly, you may refer to our demo") + this.onSelfAvatarTap, + this.controller, + this.profileWidgetBuilder, + this.profileWidgetsOrder, + this.builder, + this.isSelf = false, + this.lifeCycle, + this.smallCardMode = false}) + : super(key: key); + + @override + State createState() => _TIMUIKitProfileState(); +} + +class _TIMUIKitProfileState extends TIMUIKitState { + final TUIProfileViewModel _model = TUIProfileViewModel(); + late TIMUIKitProfileController _controller; + + @override + void initState() { + _controller = widget.controller ?? TIMUIKitProfileController(); + _model.lifeCycle = widget.lifeCycle; + _model.loadData(userID: widget.userID, isNeedConversation: !widget.isSelf); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant TIMUIKitProfile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userID != widget.userID || _model.userProfile?.friendInfo?.userID != widget.userID) { + _model.userProfile = null; + _model.loadData(userID: widget.userID, isNeedConversation: !widget.isSelf); + } + } + + final List _defaultWidgetOrder = [ + ProfileWidgetEnum.userInfoCard, + ProfileWidgetEnum.operationDivider, + ProfileWidgetEnum.remarkBar, + ProfileWidgetEnum.operationDivider, + ProfileWidgetEnum.addToBlockListBar, + ProfileWidgetEnum.pinConversationBar, + ProfileWidgetEnum.messageMute, + ProfileWidgetEnum.operationDivider, + ProfileWidgetEnum.addAndDeleteArea + ]; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return ChangeNotifierProvider.value( + value: _model, + child: Consumer( + builder: (context, value, child) { + final TUIProfileViewModel model = Provider.of(context); + _controller.model = model; + final V2TimFriendInfo? userInfo = model.userProfile?.friendInfo; + + if (userInfo == null) { + return Center( + child: LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 48, + ), + ); + } + + final conversation = model.userProfile?.conversation ?? + V2TimConversation( + conversationID: "c2c_${widget.userID}", + type: 1, + userID: widget.userID, + faceUrl: model.userProfile?.friendInfo?.userProfile?.faceUrl, + showName: TencentUtils.checkString(model.userProfile?.friendInfo?.friendRemark) ?? + TencentUtils.checkString(model.userProfile?.friendInfo?.userProfile?.nickName) ?? + widget.userID); + final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator(); + + final isFriend = model.friendType != 0; + final isSelf = (model.userProfile?.friendInfo?.userID == _selfInfoViewModel.loginInfo?.userID); + final isMute = model.isDisturb ?? false; + + Widget profilePage({required Widget child}) { + return Container( + color: isDesktopScreen ? theme.wideBackgroundColor : null, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + child: Container( + child: child, + ), + ), + ); + } + + void handleAddToBlockList(bool value) async { + model.addToBlackList(value, userInfo.userID); + } + + void handlePinConversation(bool value) async { + model.pinedConversation(value, conversation.conversationID); + } + + void handleMuteMessage(bool value) async { + model.setMessageDisturb(userInfo.userID, value); + } + + void handleTapRemarkBar({Offset? offset, String? initText}) { + _controller.showTextInputBottomSheet( + initOffset: offset, + initText: initText, + context: context, + title: TIM_t("修改备注名"), + tips: TIM_t("仅限汉字、英文、数字和下划线"), + onSubmitted: (String remark) async { + final res = await _controller.updateRemarks(widget.userID, remark); + if (res.code == 0) { + widget.lifeCycle?.didRemarkUpdated(remark); + } + }, + theme: theme); + } + + void handleAddFriend() async { + model.addFriend(userInfo.userID); + } + + Future handleDeleteFriend() async { + var result = await model.deleteFriend(userInfo.userID, needUpdateData: false); + if (result != null) { + Navigator.pop(context); + } + } + + List _renderWidgetsWithOrder(List order) { + final ProfileWidgetBuilder? customBuilder = widget.profileWidgetBuilder; + return order.map((element) { + switch (element) { + case ProfileWidgetEnum.userInfoCard: + return (customBuilder?.userInfoCard != null + ? customBuilder?.userInfoCard!(userInfo.userProfile) + : TIMUIKitProfileUserInfoCard(userInfo: userInfo.userProfile))!; + case ProfileWidgetEnum.addToBlockListBar: + if (isSelf) { + return Container(); + } + return (customBuilder?.addToBlockListBar != null + ? customBuilder?.addToBlockListBar!(model.isAddToBlackList ?? false, handleAddToBlockList) + : TIMUIKitProfileWidget.addToBlackListBar( + model.isAddToBlackList ?? false, context, handleAddToBlockList, widget.smallCardMode))!; + case ProfileWidgetEnum.pinConversationBar: + // if (!isFriend) { + // return Container(); + // } + return (customBuilder?.pinConversationBar != null + ? customBuilder?.pinConversationBar!(conversation.isPinned ?? false, handlePinConversation) + : TIMUIKitProfileWidget.pinConversationBar( + conversation.isPinned ?? false, context, handlePinConversation, widget.smallCardMode))!; + case ProfileWidgetEnum.messageMute: + // if (!isFriend) { + // return Container(); + // } + return (customBuilder?.messageMute != null + ? customBuilder?.messageMute!(isMute, handleMuteMessage) + : TIMUIKitProfileWidget.messageDisturb( + context, isMute, handleMuteMessage, widget.smallCardMode))!; + case ProfileWidgetEnum.searchBar: + return (customBuilder?.searchBar != null + ? customBuilder?.searchBar!(conversation) + // Please define the search bar with navigating in `profileWidgetBuilder` before using it here. + : Text(TIM_t("你必须自定义search bar,并处理点击跳转")))!; + case ProfileWidgetEnum.portraitBar: + return (customBuilder?.portraitBar != null + ? customBuilder?.portraitBar!(userInfo.userProfile) + : TIMUIKitProfileWidget.portraitBar( + TIMUIKitProfileWidget.defaultPortraitWidget(userInfo.userProfile, widget.smallCardMode), + widget.smallCardMode))!; + case ProfileWidgetEnum.nicknameBar: + return (customBuilder?.nicknameBar != null + ? customBuilder?.nicknameBar!(userInfo.userProfile?.nickName ?? "") + : TIMUIKitProfileWidget.nicknameBar(userInfo.userProfile?.nickName ?? "", widget.smallCardMode))!; + case ProfileWidgetEnum.userAccountBar: + return (customBuilder?.userAccountBar != null + ? customBuilder?.userAccountBar!(userInfo.userProfile?.userID ?? "") + : TIMUIKitProfileWidget.userAccountBar( + userInfo.userProfile?.userID ?? "", widget.smallCardMode))!; + case ProfileWidgetEnum.signatureBar: + return (customBuilder?.signatureBar != null + ? customBuilder?.signatureBar!(userInfo.userProfile?.selfSignature ?? "") + : TIMUIKitProfileWidget.signatureBar( + userInfo.userProfile?.selfSignature ?? "", widget.smallCardMode))!; + case ProfileWidgetEnum.genderBar: + return (customBuilder?.genderBar != null + ? customBuilder?.genderBar!(userInfo.userProfile?.gender ?? 0) + : TIMUIKitProfileWidget.genderBar(userInfo.userProfile?.gender ?? 0, widget.smallCardMode))!; + case ProfileWidgetEnum.birthdayBar: + return (customBuilder?.birthdayBar != null + ? customBuilder?.birthdayBar!(userInfo.userProfile?.birthday) + : TIMUIKitProfileWidget.birthdayBar(userInfo.userProfile?.birthday, widget.smallCardMode))!; + case ProfileWidgetEnum.addAndDeleteArea: + if (isSelf) { + return Container(); + } + return (customBuilder?.addAndDeleteArea != null + ? customBuilder?.addAndDeleteArea!( + userInfo, + conversation, + value.friendType, + isMute, + ) + : isDesktopScreen + ? TIMUIKitProfileWidget.addAndDeleteAreaWide( + userInfo, + conversation, + value.friendType, + isMute, + model.isAddToBlackList ?? false, + theme, + handleAddFriend, + handleDeleteFriend, + widget.smallCardMode) + : TIMUIKitProfileWidget.addAndDeleteArea( + userInfo, + conversation, + value.friendType, + isMute, + model.isAddToBlackList ?? false, + theme, + handleAddFriend, + handleDeleteFriend, + widget.smallCardMode))!; + case ProfileWidgetEnum.operationDivider: + return (customBuilder?.operationDivider != null + ? customBuilder?.operationDivider!() + : TIMUIKitProfileWidget.operationDivider( + color: theme.weakDividerColor, + height: isDesktopScreen ? 1 : 10, + margin: + isDesktopScreen ? EdgeInsets.symmetric(vertical: widget.smallCardMode ? 4 : 20) : null))!; + case ProfileWidgetEnum.remarkBar: + if (!isFriend) { + return Container(); + } + return (customBuilder?.remarkBar != null + ? customBuilder?.remarkBar!(userInfo.friendRemark ?? "", handleTapRemarkBar) + : TIMUIKitProfileWidget.remarkBar( + context, userInfo.friendRemark ?? "", handleTapRemarkBar, widget.smallCardMode))!; + case ProfileWidgetEnum.customBuilderOne: + return (customBuilder?.customBuilderOne != null + ? customBuilder?.customBuilderOne!(isFriend, userInfo, conversation) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case ProfileWidgetEnum.customBuilderTwo: + return (customBuilder?.customBuilderTwo != null + ? customBuilder?.customBuilderTwo!(isFriend, userInfo, conversation) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case ProfileWidgetEnum.customBuilderThree: + return (customBuilder?.customBuilderThree != null + ? customBuilder?.customBuilderThree!(isFriend, userInfo, conversation) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case ProfileWidgetEnum.customBuilderFour: + return (customBuilder?.customBuilderFour != null + ? customBuilder?.customBuilderFour!(isFriend, userInfo, conversation) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + case ProfileWidgetEnum.customBuilderFive: + return (customBuilder?.customBuilderFive != null + ? customBuilder?.customBuilderFive!(isFriend, userInfo, conversation) + // Please define the corresponding custom widget in `profileWidgetBuilder` before using it here. + : Text(TIM_t("如使用自定义区域,请在profileWidgetBuilder传入对应组件")))!; + + default: + return Container(); + } + }).toList(); + } + + if (widget.builder != null) { + return widget.builder!(context, userInfo, conversation, value.friendType, isMute); + } else if (widget.profileWidgetsOrder != null) { + return profilePage( + child: Column( + children: [..._renderWidgetsWithOrder(widget.profileWidgetsOrder!)], + ), + ); + } else { + return profilePage( + child: Column( + children: [..._renderWidgetsWithOrder(_defaultWidgetOrder)], + )); + } + }, + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart new file mode 100644 index 0000000..7cbfa16 --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_operation_item.dart @@ -0,0 +1,207 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitOperationItem extends TIMUIKitStatelessWidget { + final String operationName; + + /// shows on the second line + final String? operationDescription; + final bool? operationValue; + + /// Is show allow edit status, while a right icon shows on mobile, and `showAllowEditStatus` shows on desktop. + final bool showAllowEditStatus; + + /// Used on wide screen. + final String? wideEditText; + + /// Used on wide screen. + final bool isEmpty; + + /// the operationText widget for replacement, for developers to define what to do + final Widget? operationRightWidget; + final String type; + final void Function(bool newValue)? onSwitchChange; + final Key? itemBoxKey; + final bool isUseCheckedBoxOnWide; + + /// Is use the small card mode on Desktop. Usually shows on the Chat page. + final bool smallCardMode; + + TIMUIKitOperationItem( + {Key? key, + this.wideEditText, + this.itemBoxKey, + this.operationDescription, + required this.isEmpty, + required this.operationName, + this.smallCardMode = false, + this.operationValue, + this.type = "arrow", + this.isUseCheckedBoxOnWide = false, + this.onSwitchChange, + this.operationRightWidget, + this.showAllowEditStatus = true}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return Container( + padding: isDesktopScreen + ? EdgeInsets.symmetric( + horizontal: isUseCheckedBoxOnWide ? 6 : 16, + vertical: smallCardMode ? 0 : 4) + : const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + margin: isDesktopScreen ? null : const EdgeInsets.only(bottom: 1), + color: Colors.white, + child: (isDesktopScreen && isUseCheckedBoxOnWide) + ? Row( + children: [ + Transform.scale( + scale: 0.8, + child: Checkbox( + fillColor: MaterialStateProperty.all(theme.primaryColor), + value: operationValue ?? false, + onChanged: (val) { + if (onSwitchChange != null) { + onSwitchChange!(val ?? false); + } + }), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + operationName, + style: const TextStyle(fontSize: 14), + ), + if (operationDescription != null) + Text( + operationDescription!, + style: + TextStyle(color: theme.weakTextColor, fontSize: 12), + ) + ], + )), + ], + ) + : Row( + mainAxisAlignment: isDesktopScreen + ? MainAxisAlignment.start + : MainAxisAlignment.spaceBetween, + children: [ + if (isDesktopScreen) + SizedBox( + width: 130, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + operationName, + style: TextStyle( + color: + isDesktopScreen ? hexToColor("7f7f7f") : null), + ), + if (operationDescription != null) + Text( + operationDescription!, + style: TextStyle( + color: theme.weakTextColor, fontSize: 12), + ) + ], + ), + ), + if (!isDesktopScreen) + Expanded( + child: SizedBox( + width: 130, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + operationName, + style: TextStyle( + color: + isDesktopScreen ? hexToColor("7f7f7f") : null), + ), + if (operationDescription != null) + Text( + operationDescription!, + style: TextStyle( + color: theme.weakTextColor, fontSize: 12), + ) + ], + ), + )), + if (type == "switch") + Transform.scale( + key: itemBoxKey, + scale: 0.8, + child: CupertinoSwitch( + value: operationValue ?? false, + onChanged: onSwitchChange, + activeColor: theme.primaryColor, + ), + ), + if (type != "switch" && !isDesktopScreen) + Transform.scale( + scale: 0, + child: CupertinoSwitch( + value: false, + onChanged: onSwitchChange, + ), + ), + (type != "switch" && + isDesktopScreen && + showAllowEditStatus && + isEmpty) + ? MouseRegion( + key: itemBoxKey, + child: Text( + wideEditText ?? TIM_t("编辑"), + style: TextStyle(color: theme.weakTextColor), + ), + ) + : Container( + width: 0, + ), + if (type != "switch") + Expanded( + child: Row( + mainAxisAlignment: isDesktopScreen + ? MainAxisAlignment.start + : MainAxisAlignment.end, + children: [Expanded(child: operationRightWidget ?? const Text(""))], + )), + (type != "switch" && !isDesktopScreen && showAllowEditStatus) + ? const Icon(Icons.keyboard_arrow_right) + : Container( + width: 0, + ), + if (type != "switch" && isDesktopScreen) + SizedBox( + width: 0, + child: Transform.scale( + scale: 0, + child: CupertinoSwitch( + value: false, + onChanged: onSwitchChange, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card.dart b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card.dart new file mode 100644 index 0000000..9424cba --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card.dart @@ -0,0 +1,54 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:flutter/cupertino.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_narrow.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_wide.dart'; + +class TIMUIKitProfileUserInfoCard extends StatelessWidget { + /// User info + final V2TimUserFullInfo? userInfo; + final bool isJumpToPersonalProfile; + final VoidCallback? onClickAvatar; + + /// If shows the arrow icon on the right + final bool showArrowRightIcon; + + const TIMUIKitProfileUserInfoCard( + {Key? key, + this.userInfo, + @Deprecated( + "This info card can no longer navigate to default personal profile page automatically, please deal with it manually.") + this.isJumpToPersonalProfile = false, + this.showArrowRightIcon = false, + this.onClickAvatar}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return TUIKitScreenUtils.getDeviceWidget( + context: context, + defaultWidget: TIMUIKitProfileUserInfoCardNarrow( + userInfo: userInfo, + isJumpToPersonalProfile: isJumpToPersonalProfile, + showArrowRightIcon: showArrowRightIcon, + onClickAvatar: onClickAvatar, + ), + desktopWidget: TIMUIKitProfileUserInfoCardWide( + userInfo: userInfo, + onClickAvatar: onClickAvatar, + isJumpToPersonalProfile: isJumpToPersonalProfile, + showArrowRightIcon: showArrowRightIcon, + ), + mobileWidget: TIMUIKitProfileUserInfoCardNarrow( + userInfo: userInfo, + onClickAvatar: onClickAvatar, + isJumpToPersonalProfile: isJumpToPersonalProfile, + showArrowRightIcon: showArrowRightIcon, + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_narrow.dart b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_narrow.dart new file mode 100644 index 0000000..2024241 --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_narrow.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitProfileUserInfoCardNarrow extends TIMUIKitStatelessWidget { + /// User info + final V2TimUserFullInfo? userInfo; + final bool isJumpToPersonalProfile; + final VoidCallback? onClickAvatar; + + /// If shows the arrow icon on the right + final bool showArrowRightIcon; + + TIMUIKitProfileUserInfoCardNarrow( + {Key? key, + this.onClickAvatar, + this.userInfo, + @Deprecated( + "This info card can no longer navigate to default personal profile page automatically, please deal with it manually.") + this.isJumpToPersonalProfile = false, + this.showArrowRightIcon = false}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final faceUrl = userInfo?.faceUrl ?? ""; + final nickName = userInfo?.nickName ?? ""; + final signature = userInfo?.selfSignature; + final showName = nickName != "" ? nickName : userInfo?.userID; + final option1 = signature; + final signatureText = + option1 != null ? TIM_t_para("个性签名: {{option1}}", "个性签名: $option1")(option1: option1) : TIM_t("暂无个性签名"); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + color: Colors.white, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 48, + height: 48, + child: GestureDetector( + onTap: onClickAvatar, + child: Avatar( + faceUrl: faceUrl, + isShowBigWhenClick: onClickAvatar == null, + showName: showName ?? "", + type: 1, + ), + ), + ), + const SizedBox( + width: 12, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + child: SelectableText( + showName ?? "", + style: const TextStyle(fontSize: 18, color: Colors.black), + ), + ), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Text( + "ID: ", + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + ), + SelectableText( + userInfo?.userID ?? "", + style: TextStyle(fontSize: 13, color: theme.weakTextColor), + ), + ], + ), + ), + SelectableText(signatureText, style: TextStyle(fontSize: 13, color: theme.weakTextColor)) + ], + ), + ), + showArrowRightIcon ? const Icon(Icons.keyboard_arrow_right) : Container() + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_wide.dart b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_wide.dart new file mode 100644 index 0000000..3a3a109 --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_userinfo_card/tim_uikit_profile_userinfo_card_wide.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitProfileUserInfoCardWide extends TIMUIKitStatelessWidget { + /// User info + final V2TimUserFullInfo? userInfo; + final bool isJumpToPersonalProfile; + final VoidCallback? onClickAvatar; + + /// If shows the arrow icon on the right + final bool showArrowRightIcon; + + TIMUIKitProfileUserInfoCardWide( + {Key? key, + this.userInfo, + this.onClickAvatar, + @Deprecated( + "This info card can no longer navigate to default personal profile page automatically, please deal with it manually.") + this.isJumpToPersonalProfile = false, + this.showArrowRightIcon = false}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final faceUrl = userInfo?.faceUrl ?? ""; + final nickName = userInfo?.nickName ?? ""; + final signature = userInfo?.selfSignature; + final showName = nickName != "" ? nickName : userInfo?.userID; + + return Container( + padding: const EdgeInsets.only(bottom: 10, left: 16, right: 16), + color: Colors.white, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + child: SelectableText( + showName ?? "", + style: const TextStyle(fontSize: 20, color: Colors.black), + ), + margin: const EdgeInsets.only(right: 10), + ), + Row( + children: [ + Text( + "ID: ", + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ), + Expanded( + child: SelectableText( + userInfo?.userID ?? "", + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + )), + ], + ), + if (signature != null) + Container( + margin: const EdgeInsets.only(top: 18), + child: SelectableText(signature, style: TextStyle(fontSize: 14, color: hexToColor("7f7f7f"))), + ) + ], + ), + ), + Row( + children: [ + const SizedBox( + width: 40, + ), + SizedBox( + width: 80, + height: 80, + child: InkWell( + onTap: onClickAvatar, + child: Avatar( + faceUrl: faceUrl, + isShowBigWhenClick: onClickAvatar == null, + showName: showName ?? "", + type: 1, + ), + ), + ), + showArrowRightIcon ? const Icon(Icons.keyboard_arrow_right) : Container() + ], + ) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_widget.dart b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_widget.dart new file mode 100644 index 0000000..2cd8cdf --- /dev/null +++ b/lib/ui/views/TIMUIKitProfile/widget/tim_uikit_profile_widget.dart @@ -0,0 +1,395 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_class.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitProfileWidget extends TIMUIKitClass { + static final bool isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + + static Widget operationDivider({Color? color, double? height, EdgeInsetsGeometry? margin}) { + return Container( + color: color, + margin: margin, + height: height ?? 10, + ); + } + + /// Remarks + static Widget remarkBar(BuildContext context, String remark, Function({Offset? offset, String? initText})? handleTap, + bool smallCardMode) { + final GlobalKey key = GlobalKey(); + return InkWell( + onTapDown: (details) { + if (handleTap != null) { + handleTap( + offset: Offset(min(details.globalPosition.dx, MediaQuery.of(context).size.width - 400), + min(details.globalPosition.dy, MediaQuery.of(context).size.height - 100)), + initText: remark); + } + }, + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + itemBoxKey: key, + isEmpty: remark.isEmpty, + wideEditText: TIM_t("设置备注名"), + operationName: TIM_t("备注名"), + operationRightWidget: Text(remark, textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// add to block list + static Widget addToBlackListBar( + bool value, BuildContext context, Function(bool value)? onChanged, bool smallCardMode) { + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("加入黑名单"), + type: "switch", + operationValue: value, + onSwitchChange: (value) { + if (onChanged != null) { + onChanged(value); + } + }, + ); + } + + /// pin the conversation to the top + static Widget pinConversationBar( + bool value, BuildContext context, Function(bool value)? onChanged, bool smallCardMode) { + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("置顶聊天"), + type: "switch", + operationValue: value, + onSwitchChange: (value) { + if (onChanged != null) { + onChanged(value); + } + }, + ); + } + + /// message disturb + static Widget messageDisturb( + BuildContext context, bool isDisturb, Function(bool value)? onChanged, bool smallCardMode) { + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("消息免打扰"), + type: "switch", + operationValue: isDisturb, + onSwitchChange: (value) { + if (onChanged != null) { + onChanged(value); + } + }, + ); + } + + static Widget operationItem( + {required String operationName, + required String type, + bool? operationValue, + String? operationText, + required bool isEmpty, + void Function(bool newValue)? onSwitchChange, + required bool smallCardMode}) { + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: isEmpty, + operationName: operationName, + type: type, + operationRightWidget: Text(operationText ?? "", textAlign: isDesktopScreen ? null : TextAlign.end), + operationValue: operationValue, + onSwitchChange: onSwitchChange, + ); + } + + /// find history message + static Widget searchBar( + BuildContext context, + V2TimConversation conversation, + bool smallCardMode, { + Function()? handleTap, + }) { + return InkWell( + onTap: () { + if (handleTap != null) { + handleTap(); + } + }, + child: TIMUIKitOperationItem( + isEmpty: true, + wideEditText: TIM_t("立即搜索"), + operationName: TIM_t("查找聊天内容"), + ), + ); + } + + /// portrait + static Widget portraitBar(Widget portraitWidget, bool smallCardMode) { + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("头像"), + operationRightWidget: portraitWidget, + showAllowEditStatus: false, + ), + ); + } + + /// defaultPortraitWidget + static Widget defaultPortraitWidget(V2TimUserFullInfo? userInfo, bool smallCardMode) { + return SizedBox( + width: 48, + height: 48, + child: userInfo != null + ? Avatar( + faceUrl: userInfo.faceUrl ?? "", + showName: userInfo.nickName ?? "", + type: 1, + ) + : Container(), + ); + } + + /// nickname + static Widget nicknameBar( + String nickName, + bool smallCardMode, + ) { + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: nickName.isEmpty, + showAllowEditStatus: false, + operationName: TIM_t("昵称"), + operationRightWidget: Text(nickName, textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// user account + static Widget userAccountBar(String userNum, bool smallCardMode) { + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + showAllowEditStatus: false, + operationName: TIM_t("账号"), + operationRightWidget: SelectableText(userNum, textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// signature + static Widget signatureBar(String signature, bool smallCardMode) { + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + showAllowEditStatus: false, + operationName: TIM_t("个性签名"), + operationRightWidget: Text(signature, textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// gender + static Widget genderBar(int gender, bool smallCardMode) { + Map genderMap = { + 0: TIM_t("未填写"), + 1: TIM_t("男"), + 2: TIM_t("女"), + }; + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + showAllowEditStatus: false, + operationName: TIM_t("性别"), + operationRightWidget: Text(genderMap[gender], textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// gender + static Widget genderBarWithArrow(int gender, bool smallCardMode) { + Map genderMap = { + 0: TIM_t("未填写"), + 1: TIM_t("男"), + 2: TIM_t("女"), + }; + return SizedBox( + child: TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("性别"), + operationRightWidget: Text(genderMap[gender], textAlign: isDesktopScreen ? null : TextAlign.end), + ), + ); + } + + /// birthday + static Widget birthdayBar(int? birthday, bool smallCardMode) { + try { + final date = DateTime.parse(birthday.toString()); + DateFormat formatter = DateFormat('yyyy-MM-dd'); + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("生日"), + operationRightWidget: Text(formatter.format(date), textAlign: isDesktopScreen ? null : TextAlign.end), + ); + } catch (e) { + return TIMUIKitOperationItem( + smallCardMode: smallCardMode, + isEmpty: false, + operationName: TIM_t("生日"), + operationRightWidget: Text(TIM_t("未填写"), textAlign: isDesktopScreen ? null : TextAlign.end), + ); + } + } + + /// default button area + static Widget addAndDeleteArea( + V2TimFriendInfo friendInfo, + V2TimConversation conversation, + int friendType, + bool isDisturb, + bool isBlocked, + TUITheme theme, + VoidCallback handleAddFriend, + VoidCallback handleDeleteFriend, + bool smallCardMode) { + _buildDeleteFriend(V2TimConversation conversation, theme) { + return InkWell( + onTap: () { + handleDeleteFriend(); + }, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: + BoxDecoration(color: Colors.white, border: Border(bottom: BorderSide(color: theme.weakDividerColor))), + child: Text( + TIM_t("删除好友"), + style: TextStyle(color: theme.cautionColor, fontSize: 17), + ), + ), + ); + } + + _buildAddOperation() { + return Container( + alignment: Alignment.center, + // padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row(children: [ + Expanded( + child: TextButton( + child: Text(TIM_t("加为好友"), style: TextStyle(color: theme.primaryColor, fontSize: 17)), + onPressed: () { + handleAddFriend(); + }), + ) + ]), + ); + } + + return Column( + children: [ + if (friendType != 0) _buildDeleteFriend(conversation, theme), + if (friendType == 0 && !isBlocked) _buildAddOperation() + ], + ); + } + + static Widget wideButton({ + required VoidCallback onPressed, + required String text, + required Color color, + required bool smallCardMode, + EdgeInsets? margin, + }) { + return Container( + margin: margin ?? const EdgeInsets.symmetric(vertical: 10), + child: smallCardMode + ? OutlinedButton( + onPressed: onPressed, + child: Text( + text, + style: TextStyle(color: color), + ), + style: ButtonStyle( + minimumSize: MaterialStateProperty.all(const Size(160, 40)), + )) + : ElevatedButton( + onPressed: onPressed, + child: Text(text), + style: ButtonStyle( + minimumSize: MaterialStateProperty.all(const Size(180, 46)), + backgroundColor: MaterialStateProperty.all(color)), + ), + ); + } + + /// default button area + static Widget addAndDeleteAreaWide( + V2TimFriendInfo friendInfo, + V2TimConversation conversation, + int friendType, + bool isDisturb, + bool isBlocked, + TUITheme theme, + VoidCallback handleAddFriend, + VoidCallback handleDeleteFriend, + bool smallCardMode, + ) { + _buildDeleteFriend(V2TimConversation conversation, theme) { + return wideButton( + smallCardMode: smallCardMode, + onPressed: () { + handleDeleteFriend(); + }, + color: theme.cautionColor ?? Colors.red, + text: TIM_t("删除好友"), + ); + } + + _buildAddOperation() { + return wideButton( + smallCardMode: smallCardMode, + onPressed: handleAddFriend, + color: theme.primaryColor ?? hexToColor("3e4b67"), + text: TIM_t("加为好友"), + ); + } + + return Column( + children: [ + if (friendType != 0) _buildDeleteFriend(conversation, theme), + if (friendType == 0 && !isBlocked) _buildAddOperation() + ], + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart new file mode 100644 index 0000000..70ed59f --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitSearchFolder extends TIMUIKitStatelessWidget { + final String folderName; + final List children; + + TIMUIKitSearchFolder( + {Key? key, required this.folderName, required this.children}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + width: MediaQuery.of(context).size.width, + decoration: const BoxDecoration( + color: Colors.white, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: MediaQuery.of(context).size.width, + decoration: BoxDecoration( + border: Border( + bottom: + BorderSide(color: hexToColor("DBDBDB"), width: 0.5))), + padding: const EdgeInsets.fromLTRB(0, 6, 0, 6), + child: Text( + folderName, + style: TextStyle( + color: theme.weakTextColor, height: 1.5, fontSize: 14), + ), + ), + ...children + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_indicator.dart b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_indicator.dart new file mode 100644 index 0000000..700f3da --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_indicator.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +enum SearchType { contact, group, history } + +class TIMUIKitSearchIndicator extends TIMUIKitStatelessWidget { + final List typeList; + final ValueChanged> onChange; + + TIMUIKitSearchIndicator( + {required this.typeList, required this.onChange, Key? key}) + : super(key: key); + + final titleMap = { + SearchType.contact: "联系人", + SearchType.group: "群聊", + SearchType.history: "聊天记录" + }; + + Widget renderItemBox( + IconData icon, SearchType item, bool isSelect, TUITheme theme) { + return InkWell( + onTap: () { + if (isSelect) { + typeList.remove(item); + } else { + typeList.add(item); + } + onChange(typeList); + }, + child: Container( + padding: const EdgeInsets.all(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + children: [ + Padding( + padding: const EdgeInsets.all(6), + child: Icon( + icon, + color: theme.weakTextColor, + size: 30, + ), + ), + if (isSelect) + Positioned( + right: 0, + bottom: 0, + child: Container( + height: 16, + width: 16, + decoration: BoxDecoration(shape: BoxShape.circle, color: theme.primaryColor), + child: const Icon( + Icons.check, + size: 8, + color: Colors.white, + ), + )) + ], + ), + const SizedBox(height: 4), + Text( + TIM_t(titleMap[item]!), + style: TextStyle(color: theme.textColor, fontSize: 13), + ) + ], + ), + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + return Container( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Text(TIM_t("搜索指定内容"), + style: TextStyle(color: theme.weakTextColor, fontSize: 12)), + ) + ], + ), + const SizedBox(height: 1), + Divider(thickness: 0.8, color: theme.weakDividerColor), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + renderItemBox(Icons.person, SearchType.contact, + typeList.contains(SearchType.contact), theme), + renderItemBox(Icons.people, SearchType.group, + typeList.contains(SearchType.group), theme), + renderItemBox(Icons.message, SearchType.history, + typeList.contains(SearchType.history), theme), + ], + ) + ], + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart new file mode 100644 index 0000000..24c145f --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitSearchInput extends StatefulWidget { + final ValueChanged onChange; + final String? initValue; + final TextEditingController? controller; + final Widget? prefixIcon; + final Widget? prefixText; + final bool? isAutoFocus; + final FocusNode focusNode; + + const TIMUIKitSearchInput({ + required this.onChange, + this.initValue, + this.controller, + Key? key, + this.prefixIcon, + this.isAutoFocus = true, + this.prefixText, + required this.focusNode, + }) : super(key: key); + + @override + State createState() => TIMUIKitSearchInputState(); +} + +class TIMUIKitSearchInputState extends TIMUIKitState { + late TextEditingController textEditingController = + widget.controller ?? TextEditingController(); + bool isEmptyInput = true; + + @override + void initState() { + super.initState(); + textEditingController.text = widget.initValue ?? ""; + isEmptyInput = textEditingController.text.isEmpty; + } + + hideAllPanel() { + widget.focusNode.unfocus(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + color: Colors.white, + child: GestureDetector( + onTap: () { + // 当点击搜索栏时,聚焦到输入框 + widget.focusNode.requestFocus(); + }, + child: Container( + height: 42, + decoration: BoxDecoration( + color: const Color(0xFFF4F5F6), + borderRadius: BorderRadius.circular(21), + ), + child: Row( + children: [ + const SizedBox(width: 24), + Expanded( + child: TextField( + autofocus: widget.isAutoFocus ?? true, + onChanged: (value) async { + final trimValue = value.trim(); + final isEmpty = trimValue.isEmpty; + if (isEmpty != isEmptyInput) { + setState(() { + isEmptyInput = isEmpty ? true : false; + }); + } + widget.onChange(trimValue); + }, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + maxLines: 1, + focusNode: widget.focusNode, + controller: textEditingController, + style: const TextStyle( + color: Color(0xFF333333), + fontSize: 14, + ), + decoration: InputDecoration( + border: InputBorder.none, + hintText: "请输入您要搜索的内容", + hintStyle: const TextStyle( + color: Color(0xFFC7CBD1), + fontSize: 15, + ), + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ), + ), + Container( + margin: const EdgeInsets.only(right: 24), + child: const Icon( + Icons.search, + color: Color(0xFFC7CBD1), + size: 24, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart new file mode 100644 index 0000000..8b42188 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_item_wide.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitSearchItem extends TIMUIKitStatelessWidget { + final String faceUrl; + final String showName; + final String lineOne; + final String? lineOneRight; + final String? lineTwo; + final VoidCallback? onClick; + + TIMUIKitSearchItem( + {Key? key, + required this.faceUrl, + required this.showName, + required this.lineOne, + this.lineTwo, + this.lineOneRight, + this.onClick}) + : super(key: key); + + _renderLineOneRight(String? text, TUITheme theme) { + if (text != null) { + return Text(text, + style: TextStyle( + fontSize: 12, + color: theme.weakTextColor, + )); + } else { + return Container(); + } + } + + _renderLineTwo(String? text, TUITheme theme) { + return (text != null) + ? Text( + text, + style: TextStyle( + color: theme.weakTextColor, height: 1.5, fontSize: 14), + ) + : Container( + height: 0, + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + defaultWidget: GestureDetector( + onTap: onClick, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: hexToColor("DBDBDB"), width: 0.5))), + padding: const EdgeInsets.fromLTRB(0, 12, 0, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [Avatar(faceUrl: faceUrl, showName: showName)], + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + // height: 24, + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + lineOne, + style: TextStyle( + color: theme.darkTextColor, + fontSize: 18.0, + fontWeight: FontWeight.w400), + ), + _renderLineOneRight(lineOneRight, theme), + ], + ), + ), + _renderLineTwo(lineTwo, theme), + ], + ), + )) + ], + ), + ), + ), + desktopWidget: TIMUIKitSearchWideItem( + lineOneRight: lineOneRight, + key: key, + lineTwo: lineTwo, + onClick: onClick, + faceUrl: faceUrl, + showName: showName, + lineOne: lineOne), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart new file mode 100644 index 0000000..30a028f --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart @@ -0,0 +1,69 @@ +// ignore_for_file: file_names + +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitSearchShowALl extends TIMUIKitStatelessWidget { + final String textShow; + final VoidCallback? onClick; + final bool isNeedMoreBottom; + + TIMUIKitSearchShowALl( + {Key? key, + this.onClick, + required this.textShow, + this.isNeedMoreBottom = false}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return InkWell( + onTap: onClick, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: hexToColor("DBDBDB"), width: 0.5))), + padding: EdgeInsets.fromLTRB(0, 8, 0, (isNeedMoreBottom ? 24 : 8)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.search, + color: hexToColor("979797"), + ), + Expanded( + child: Container( + margin: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + // height: 24, + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + textShow, + style: TextStyle( + color: Colors.black, + fontSize: isDesktopScreen ? 14 : 16.0, + fontWeight: FontWeight.w400), + ), + ), + ], + ), + )), + Icon( + Icons.expand_more, + color: hexToColor("979797"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search.dart new file mode 100644 index 0000000..8212b72 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_result_item.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_result_item.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_indicator.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_friend.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_group.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_msg.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitSearch extends StatefulWidget { + /// the callback after clicking the conversation item + final Function(V2TimConversation, V2TimMessage?) onTapConversation; + + /// [Deprecated] : You are supposed to use [TIMUIKitSearchMsgDetail], + /// if you tend to search inside a specific conversation, includes c2c and group. + final V2TimConversation? conversation; + + /// [Deprecated] : You are supposed to use [onEnterSearchInConversation], + /// though the effects are the same. + final Function(V2TimConversation conversation, String initKeyword)? onEnterConversation; + + /// On click each conversation from 'Chat history' and searching for historical message in it. + final Function(V2TimConversation conversation, String initKeyword)? onEnterSearchInConversation; + + final VoidCallback? onBack; + + final bool? isAutoFocus; + + const TIMUIKitSearch( + {required this.onTapConversation, + Key? key, + @Deprecated( + "You are supposed to use [TIMUIKitSearchMsgDetail], if you tend to search inside a specific conversation, includes c2c and group") + this.conversation, + @Deprecated("You are supposed to use [onEnterSearchInConversation], though the effects are the same.") + this.onEnterConversation, + this.isAutoFocus = true, + this.onEnterSearchInConversation, + this.onBack}) + : super(key: key); + + @override + State createState() => TIMUIKitSearchState(); +} + +class TIMUIKitSearchState extends TIMUIKitState { + late TextEditingController textEditingController = TextEditingController(); + final model = serviceLocator(); + final FocusNode focusNode = FocusNode(); + GlobalKey inputTextField = GlobalKey(); + List searchTypes = [SearchType.group, SearchType.contact, SearchType.history]; + + @override + void initState() { + super.initState(); + model.initSearch(); + model.initConversationMsg(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + if (PlatformUtils().isWeb) { + return TIMUIKitSearchNotSupport(); + } + return MultiProvider( + providers: [ChangeNotifierProvider.value(value: serviceLocator())], + builder: (context, w) { + List friendResultList = Provider.of(context).friendList ?? []; + List msgList = Provider.of(context).msgList ?? []; + List groupList = Provider.of(context).groupList ?? []; + int totalMsgCount = Provider.of(context).totalMsgCount; + return GestureDetector( + onTap: () { + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + child: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TIMUIKitSearchInput( + focusNode: focusNode, + key: inputTextField, + isAutoFocus: widget.isAutoFocus, + onChange: (String value) { + model.searchByKey(value); + }, + controller: textEditingController, + prefixIcon: Icon( + Icons.search, + size: 16, + color: hexToColor("979797"), + ), + ), + Expanded( + child: GestureDetector( + child: SingleChildScrollView( + child: Column( + children: [ + if ((friendResultList.isEmpty || !(searchTypes.contains(SearchType.contact))) && + (groupList.isEmpty || !(searchTypes.contains(SearchType.group))) && + (totalMsgCount == 0 || !(searchTypes.contains(SearchType.history)))) + TIMUIKitSearchIndicator( + typeList: searchTypes, + onChange: (list) { + setState(() { + searchTypes = list; + }); + }, + ), + if (searchTypes.contains(SearchType.contact)) + TIMUIKitSearchFriend( + onTapConversation: (conversation, message) { + focusNode.unfocus(); + Future.delayed(const Duration(milliseconds: 100), () { + widget.onTapConversation(conversation, message); + }); + }, + friendResultList: friendResultList), + if (searchTypes.contains(SearchType.group)) + TIMUIKitSearchGroup( + groupList: groupList, + onTapConversation: (conversation, message) { + focusNode.unfocus(); + Future.delayed(const Duration(milliseconds: 100), () { + widget.onTapConversation(conversation, message); + }); + }, + ), + if (searchTypes.contains(SearchType.history)) + TIMUIKitSearchMsg( + onTapConversation: widget.onTapConversation, + keyword: textEditingController.text, + totalMsgCount: totalMsgCount, + msgList: msgList, + onEnterConversation: (V2TimConversation conversation, String keyword) { + if (widget.onEnterSearchInConversation != null) { + widget.onEnterSearchInConversation!(conversation, keyword); + } else if (widget.onEnterConversation != null) { + widget.onEnterConversation!(conversation, keyword); + } + }, + ), + ], + ), + ), + onTap: () { + if (widget.onBack != null) { + widget.onBack!(); + } + }, + )) + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_friend.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_friend.dart new file mode 100644 index 0000000..0a9116b --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_friend.dart @@ -0,0 +1,89 @@ +// ignore_for_file: must_be_immutable + +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class TIMUIKitSearchFriend extends StatefulWidget { + List friendResultList; + final Function(V2TimConversation, V2TimMessage?) onTapConversation; + + TIMUIKitSearchFriend({required this.friendResultList, Key? key, required this.onTapConversation}) : super(key: key); + + @override + State createState() => TIMUIKitSearchFriendState(); +} + +class TIMUIKitSearchFriendState extends TIMUIKitState { + bool isShowAll = false; + int defaultShowLines = 3; + + Widget _renderShowALl(int currentLines) { + return (isShowAll == false && currentLines > defaultShowLines) + ? TIMUIKitSearchShowALl( + textShow: TIM_t("全部联系人"), + onClick: () => setState(() { + isShowAll = true; + }), + ) + : Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + List _conversationList = Provider.of(context).conversationList; + + List filteredFriendResultList = widget.friendResultList.where((friend) { + int index = _conversationList.indexWhere((conv) => friend.friendInfo?.userID == conv?.userID); + return index == -1 ? false : true; + }).toList(); + + List halfFilteredFriendResultList = isShowAll + ? filteredFriendResultList + : filteredFriendResultList.sublist(0, min(defaultShowLines, filteredFriendResultList.length)); + + if (filteredFriendResultList.isNotEmpty) { + return TIMUIKitSearchFolder(folderName: TIM_t("联系人"), children: [ + ...halfFilteredFriendResultList.map((conv) { + int convIndex = _conversationList.indexWhere((item) => conv.friendInfo?.userID == item?.userID); + V2TimConversation conversation = _conversationList[convIndex]!; + late String? showNickName; + if (conv.friendInfo?.friendRemark != null && conv.friendInfo?.friendRemark != "") { + showNickName = conv.friendInfo?.friendRemark; + } else if (conv.friendInfo?.userProfile?.nickName != null && conv.friendInfo?.userProfile?.nickName != "") { + showNickName = conv.friendInfo?.userProfile?.nickName; + } else { + showNickName = conv.friendInfo?.userID; + } + + return TIMUIKitSearchItem( + onClick: () { + widget.onTapConversation(conversation, null); + }, + faceUrl: conv.friendInfo?.userProfile?.faceUrl ?? "", + showName: "", + lineOne: conversation.userID!, + lineTwo: TIM_t("昵称") + ":" + showNickName!, + ); + }).toList(), + _renderShowALl(filteredFriendResultList.length), + ]); + } else { + return Container(); + } + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_group.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_group.dart new file mode 100644 index 0000000..6773417 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_group.dart @@ -0,0 +1,79 @@ +// ignore_for_file: must_be_immutable + +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class TIMUIKitSearchGroup extends StatefulWidget { + List groupList; + final Function(V2TimConversation, V2TimMessage?) onTapConversation; + + TIMUIKitSearchGroup({required this.groupList, Key? key, required this.onTapConversation}) : super(key: key); + + @override + State createState() => TIMUIKitSearchGroupState(); +} + +class TIMUIKitSearchGroupState extends TIMUIKitState { + bool isShowAll = false; + int defaultShowLines = 3; + + Widget _renderShowALl(int currentLines) { + return (isShowAll == false && currentLines > defaultShowLines) + ? TIMUIKitSearchShowALl( + textShow: TIM_t("全部群聊"), + onClick: () => setState(() { + isShowAll = true; + }), + ) + : Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + List _conversationList = Provider.of(context).conversationList; + + List filteredGroupResultList = widget.groupList.where((group) { + int index = _conversationList.indexWhere((conv) => group.groupID == conv?.groupID); + return index == -1 ? false : true; + }).toList(); + + List halfFilteredGroupResultList = isShowAll + ? filteredGroupResultList + : filteredGroupResultList.sublist(0, min(defaultShowLines, filteredGroupResultList.length)); + + if (filteredGroupResultList.isNotEmpty) { + return TIMUIKitSearchFolder(folderName: TIM_t("群聊"), children: [ + ...halfFilteredGroupResultList.map((group) { + int convIndex = _conversationList.indexWhere((item) => group.groupID == item?.groupID); + V2TimConversation conversation = _conversationList[convIndex]!; + return TIMUIKitSearchItem( + onClick: () { + widget.onTapConversation(conversation, null); + }, + faceUrl: conversation.faceUrl ?? group.faceUrl ?? "", + showName: "", + lineOne: conversation.showName ?? group.groupName ?? conversation.groupID ?? "", + ); + }).toList(), + _renderShowALl(filteredGroupResultList.length), + ]); + } else { + return Container(); + } + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_item_wide.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_item_wide.dart new file mode 100644 index 0000000..e0acd9f --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_item_wide.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TIMUIKitSearchWideItem extends TIMUIKitStatelessWidget { + final String faceUrl; + final String showName; + final String lineOne; + final String? lineOneRight; + final String? lineTwo; + final VoidCallback? onClick; + + TIMUIKitSearchWideItem( + {Key? key, + required this.faceUrl, + required this.showName, + required this.lineOne, + this.lineTwo, + this.lineOneRight, + this.onClick}) + : super(key: key); + + _renderLineOneRight(String? text, TUITheme theme) { + if (text != null) { + return Text(text, + style: TextStyle( + fontSize: 10, + color: theme.weakTextColor, + )); + } else { + return Container(); + } + } + + _renderLineTwo(String? text, TUITheme theme) { + return (text != null) + ? Container( + margin: const EdgeInsets.only(top: 0), + child: SelectableText( + text, + style: TextStyle( + color: theme.weakTextColor, height: 1.5, fontSize: 12), + ), + ) + : Container( + height: 0, + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Material( + color: Colors.white, + child: InkWell( + onTap: onClick, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: hexToColor("DBDBDB"), width: 0.5))), + padding: const EdgeInsets.fromLTRB(0, 12, 0, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 30, + height: 30, + child: Avatar(faceUrl: faceUrl, showName: showName, isShowBigWhenClick: false,), + ), + Expanded( + child: Container( + margin: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + // height: 24, + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: Text( + lineOne, + style: TextStyle( + color: theme.darkTextColor, + fontSize: 14.0, + fontWeight: FontWeight.w400), + )), + _renderLineOneRight(lineOneRight, theme), + ], + ), + ), + _renderLineTwo(lineTwo, theme), + ], + ), + )) + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg.dart new file mode 100644 index 0000000..531ad67 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg.dart @@ -0,0 +1,81 @@ +// ignore_for_file: must_be_immutable, unused_import + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_search_result_item.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message_search_result_item.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/conversation/conversation_services.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_folder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; + +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class TIMUIKitSearchMsg extends TIMUIKitStatelessWidget { + List msgList; + int totalMsgCount; + String keyword; + final Function(V2TimConversation, V2TimMessage?) onTapConversation; + final model = serviceLocator(); + final Function(V2TimConversation, String) onEnterConversation; + + TIMUIKitSearchMsg( + {required this.msgList, + required this.keyword, + required this.totalMsgCount, + Key? key, + required this.onTapConversation, + required this.onEnterConversation}) + : super(key: key); + + Widget _renderShowALl(bool isShowMore) { + return (isShowMore == true) + ? TIMUIKitSearchShowALl( + textShow: TIM_t("更多聊天记录"), + onClick: () => {model.searchMsgByKey(keyword, false)}, + ) + : Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + List _conversationList = Provider.of(context).conversationList; + + if (msgList.isNotEmpty) { + return TIMUIKitSearchFolder(folderName: TIM_t("聊天记录"), children: [ + ...msgList.map((conv) { + V2TimConversation? conversation; + final index = _conversationList.indexWhere((item) => item!.conversationID == conv?.conversationID); + if (index > -1) { + conversation = _conversationList[index]!; + } + if (conversation == null) { + return Container(); + } + final option1 = conv?.messageCount; + return TIMUIKitSearchItem( + onClick: () async { + onEnterConversation(conversation!, keyword); + }, + faceUrl: conversation.faceUrl ?? "", + showName: conversation.showName ?? "", + lineOne: conversation.showName ?? "", + lineTwo: TIM_t_para("{{option1}}条相关聊天记录", "$option1条相关聊天记录")(option1: option1), + ); + }).toList(), + _renderShowALl(totalMsgCount > msgList.length) + ]); + } else { + return Container(); + } + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart new file mode 100644 index 0000000..49fb5e3 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_msg_detail.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/time_ago.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_input.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_item.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_search_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/pureUI/tim_uikit_search_showAll.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitSearchMsgDetail extends StatefulWidget { + /// Conversation need search + final V2TimConversation currentConversation; + + /// initial keyword + final String keyword; + + final List? initMessageList; + + /// the callback after clicking each conversation message item + final Function(V2TimConversation, V2TimMessage?) onTapConversation; + + final bool? isAutoFocus; + + const TIMUIKitSearchMsgDetail( + {Key? key, + this.isAutoFocus = true, + required this.currentConversation, + required this.keyword, + required this.onTapConversation, + this.initMessageList}) + : super(key: key); + + @override + State createState() => TIMUIKitSearchMsgDetailState(); +} + +class TIMUIKitSearchMsgDetailState extends TIMUIKitState { + final model = serviceLocator(); + String keywordState = ""; + int currentPage = 0; + final FocusNode focusNode = FocusNode(); + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + keywordState = widget.keyword; + updateMsgResult(widget.keyword, true); + } + + (bool isRevoke, bool isRevokeByAdmin) isRevokeMessage(V2TimMessage? message) { + if (message == null) { + return (false, false); + } + if (message.status == 6) { + return (true, false); + } else { + try { + final customData = jsonDecode(message.cloudCustomData ?? "{}"); + final isRevoke = customData["isRevoke"] ?? false; + final revokeByAdmin = customData["revokeByAdmin"] ?? false; + return (isRevoke, revokeByAdmin); + } catch (e) { + return (false, false); + } + } + } + + String _getMsgElem(V2TimMessage message) { + final msgType = message.elemType; + final revokeStatus = isRevokeMessage(message); + final isRevokedMessage = revokeStatus.$1; + final isAdminRevoke = revokeStatus.$2; + if (isRevokedMessage) { + final isSelf = message.isSelf ?? true; + final option2 = isAdminRevoke ? TIM_t("管理员") : (isSelf ? TIM_t("您") : message.nickName ?? message.sender); + return TIM_t_para("{{option2}}撤回了一条消息", "$option2撤回了一条消息")(option2: option2); + } + switch (msgType) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return TIM_t("[自定义]"); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return TIM_t("[语音]"); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return message.textElem!.text as String; + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return TIM_t("[表情]"); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + final option1 = message.fileElem!.fileName; + return TIM_t_para("[文件] {{option1}}", "[文件] $option1")(option1: option1); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return TIM_t("[图片]"); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return TIM_t("[视频]"); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return TIM_t("[位置]"); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return TIM_t("[聊天记录]"); + default: + return TIM_t("未知消息"); + } + } + + List _renderListMessage(List msgList, BuildContext context, bool isDesktopScreen) { + List listWidget = []; + + listWidget = msgList.map((message) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: TIMUIKitSearchItem( + faceUrl: message.faceUrl ?? "", + showName: TencentUtils.checkString(message.nickName) ?? + TencentUtils.checkString(message.userID) ?? + message.sender ?? + "", + lineOne: TencentUtils.checkString(message.nickName) ?? + TencentUtils.checkString(message.userID) ?? + message.sender ?? + "", + lineOneRight: + (isDesktopScreen && message.timestamp != null) ? TimeAgo().getTimeForMessage(message.timestamp!) : null, + lineTwo: _getMsgElem(message), + onClick: () { + focusNode.unfocus(); + widget.onTapConversation(widget.currentConversation, message); + }, + ), + ); + }).toList(); + return listWidget; + } + + updateMsgResult(String? keyword, bool isNewSearch) { + if (isNewSearch) { + setState(() { + currentPage = 0; + keywordState = keyword!; + }); + } + model.getMsgForConversation(keyword ?? keywordState, widget.currentConversation.conversationID, currentPage); + setState(() { + currentPage = currentPage + 1; + }); + } + + Widget _renderShowALl(bool isShowMore) { + return (isShowMore == true) + ? Container( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + decoration: const BoxDecoration( + color: Colors.white, + ), + child: TIMUIKitSearchShowALl( + textShow: TIM_t("更多聊天记录"), + onClick: () => {updateMsgResult(null, false)}, + ), + ) + : Container(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + if (PlatformUtils().isWeb) { + return TIMUIKitSearchNotSupport(); + } + return MultiProvider( + providers: [ChangeNotifierProvider.value(value: serviceLocator())], + builder: (context, w) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + List currentMsgListForConversation = + Provider.of(context).currentMsgListForConversation; + final currentText = _controller.text; + if (currentMsgListForConversation.isEmpty && + widget.initMessageList != null && + widget.initMessageList!.isNotEmpty && + currentText.isEmpty) { + currentMsgListForConversation = widget.initMessageList!; + } + + final int totalMsgInConversationCount = Provider.of(context).totalMsgInConversationCount; + return GestureDetector( + onTap: () { + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isDesktopScreen) + Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Row( + children: [ + SizedBox( + child: Avatar(faceUrl: widget.currentConversation.faceUrl ?? "", showName: ""), + width: 30, + height: 30, + ), + const SizedBox(width: 16), + Text( + widget.currentConversation.showName ?? widget.currentConversation.userID ?? "", + style: TextStyle( + fontSize: 16, + color: theme.darkTextColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + TIMUIKitSearchInput( + focusNode: focusNode, + controller: _controller, + isAutoFocus: widget.isAutoFocus, + onChange: (String value) { + updateMsgResult(value, true); + }, + initValue: widget.keyword, + prefixIcon: Icon( + Icons.search, + size: 16, + color: hexToColor("979797"), + ), + ), + Expanded( + child: Scrollbar( + controller: _scrollController, + child: ListView( + controller: _scrollController, + children: [ + ..._renderListMessage(currentMsgListForConversation, context, isDesktopScreen), + _renderShowALl( + keywordState.isNotEmpty && totalMsgInConversationCount > currentMsgListForConversation.length) + ], + ), + )), + ], + ), + ); + }, + ); + } +} diff --git a/lib/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart new file mode 100644 index 0000000..7f2fcc2 --- /dev/null +++ b/lib/ui/views/TIMUIKitSearch/tim_uikit_search_not_support.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; + +class TIMUIKitSearchNotSupport extends TIMUIKitStatelessWidget { + TIMUIKitSearchNotSupport({Key? key}) : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + return Scaffold( + body: Container( + width: MediaQuery.of(context).size.width, + decoration: BoxDecoration( + color: hexToColor("ecf3fe"), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + // 因为底部有波浪图, icon向上一点,感觉视觉上更协调 + margin: const EdgeInsets.only(bottom: 40), + child: Column( + children: [ + Text( + TIM_t("Web网页端不支持搜索"), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: theme.darkTextColor, + ), + ), + Text( + TIM_t("暂时仅限 Android/iOS 端"), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: theme.darkTextColor, + ), + ), + ], + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + ), + ), + Positioned( + bottom: 0, + child: Image.asset( + "images/logo_bottom.png", + package: 'tencent_cloud_chat_uikit', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/avatar.dart b/lib/ui/widgets/avatar.dart new file mode 100644 index 0000000..2f0f7a0 --- /dev/null +++ b/lib/ui/widgets/avatar.dart @@ -0,0 +1,166 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/image_screen.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class Avatar extends TIMUIKitStatelessWidget { + final String faceUrl; + final String showName; + final bool isFromLocalAsset; + final CoreServicesImpl coreService = serviceLocator(); + final BorderRadius? borderRadius; + final V2TimUserStatus? onlineStatus; + final int? type; // 1 c2c 2 group + final bool isShowBigWhenClick; + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + + Avatar( + {Key? key, + required this.faceUrl, + this.onlineStatus, + required this.showName, + this.isShowBigWhenClick = false, + this.isFromLocalAsset = false, + this.borderRadius, + this.type = 1}) + : super(key: key); + + Widget getImageWidget(BuildContext context, TUITheme theme) { + Widget defaultAvatar() { + if (type == 1) { + return Image.asset( + TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ?? + 'images/default_c2c_head.png', + fit: BoxFit.cover, + package: + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit'); + } else { + return Image.asset( + TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ?? + 'images/default_group_head.png', + fit: BoxFit.cover, + package: + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit'); + } + } + + // final emptyAvatarBuilder = coreService.emptyAvatarBuilder; + if (faceUrl != "") { + if (isFromLocalAsset) { + return Image.asset( + faceUrl, + fit: BoxFit.cover, + ); + } + return CachedNetworkImage( + imageUrl: faceUrl, + fadeInDuration: const Duration(milliseconds: 0), + errorWidget: (BuildContext context, String c, dynamic s) { + return defaultAvatar(); + }, + ); + } else { + return defaultAvatar(); + } + } + + ImageProvider getImageProvider() { + ImageProvider defaultAvatar() { + if (type == 1) { + return Image.asset( + TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ?? + 'images/default_c2c_head.png', + fit: BoxFit.cover, + package: + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit') + .image; + } else { + return Image.asset( + TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ?? + 'images/default_group_head.png', + fit: BoxFit.cover, + package: + selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit') + .image; + } + } + + if (faceUrl != "") { + if (isFromLocalAsset) { + return Image.asset(faceUrl).image; + } + return CachedNetworkImageProvider( + faceUrl, + ); + } else { + return defaultAvatar(); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + if (isShowBigWhenClick) + GestureDetector( + onTap: () { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, // set to false + pageBuilder: (_, __, ___) => ImageScreen(imageProvider: getImageProvider(), heroTag: faceUrl), + ), + ); + }, + child: Hero( + tag: faceUrl, + child: ClipRRect( + borderRadius: borderRadius ?? + selfInfoViewModel.globalConfig?.defaultAvatarBorderRadius ?? + BorderRadius.circular(4.8), + child: getImageWidget(context, theme), + ), + ), + ), + if (!isShowBigWhenClick) + ClipRRect( + borderRadius: + borderRadius ?? selfInfoViewModel.globalConfig?.defaultAvatarBorderRadius ?? BorderRadius.circular(4.8), + child: getImageWidget(context, theme), + ), + if (onlineStatus?.statusType != null && onlineStatus?.statusType != 0) + Positioned( + bottom: -4, + right: -4, + child: Container( + width: 12, + height: 12, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2.0, + ), + color: onlineStatus?.statusType == 1 + ? theme.conversationItemOnlineStatusBgColor + : theme.conversationItemOfflineStatusBgColor, + ), + child: null, + ), + ), + ], + ); + } +} diff --git a/lib/ui/widgets/az_list_view.dart b/lib/ui/widgets/az_list_view.dart new file mode 100644 index 0000000..32d4b38 --- /dev/null +++ b/lib/ui/widgets/az_list_view.dart @@ -0,0 +1,115 @@ +import 'package:azlistview_all_platforms/azlistview_all_platforms.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class AZListViewContainer extends StatefulWidget { + final List? memberList; + final Widget Function(BuildContext context, int index) itemBuilder; + final Widget Function(BuildContext context, int index)? susItemBuilder; + final bool isShowIndexBar; + + const AZListViewContainer( + {Key? key, + required this.memberList, + required this.itemBuilder, + this.isShowIndexBar = true, + this.susItemBuilder}) + : super(key: key); + + @override + State createState() => _AZListViewContainerState(); +} + +class _AZListViewContainerState extends TIMUIKitState { + List? _list; + + addShowSuspension(List curList) { + for (int i = 0; i < curList.length; i++) { + if (i == 0 || curList[i].tagIndex != curList[i - 1].tagIndex) { + curList[i].isShowSuspension = true; + } + } + return curList; + } + + static Widget getSusItem(BuildContext context, String tag, + {double susHeight = 40}) { + final theme = Provider.of(context).theme; + return Container( + height: susHeight, + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.only(left: 16.0), + color: theme.weakDividerColor, + alignment: Alignment.centerLeft, + child: Text( + tag, + softWrap: true, + style: TextStyle( + fontSize: 14.0, + color: theme.weakTextColor, + ), + ), + ); + } + + @override + void initState() { + super.initState(); + setState(() { + _list = addShowSuspension(widget.memberList!); + }); + } + + @override + void didUpdateWidget(covariant AZListViewContainer oldWidget) { + super.didUpdateWidget(oldWidget); + setState(() { + _list = addShowSuspension(widget.memberList!); + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final isDesktopScreen = + TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + return ChangeNotifierProvider.value( + value: serviceLocator(), + child: Consumer( + builder: (context, tuiTheme, child) => AzListView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics()), + data: _list!, + itemCount: _list!.length, + itemBuilder: widget.itemBuilder, + indexBarData: (!isDesktopScreen && widget.isShowIndexBar) + ? SuspensionUtil.getTagIndexList(_list!) + .where((element) => element != "@") + .toList() + : [], + susItemBuilder: (BuildContext context, int index) { + if (widget.susItemBuilder != null) { + return widget.susItemBuilder!(context, index); + } + ISuspensionBeanImpl model = _list![index]; + if (model.getSuspensionTag() == "@") { + return Container(); + } + return getSusItem(context, model.getSuspensionTag()); + }))); + } +} + +class ISuspensionBeanImpl extends ISuspensionBean { + String tagIndex; + T memberInfo; + + ISuspensionBeanImpl({required this.tagIndex, required this.memberInfo}); + + @override + String getSuspensionTag() => tagIndex; +} diff --git a/lib/ui/widgets/center_play_button.dart b/lib/ui/widgets/center_play_button.dart new file mode 100644 index 0000000..ffe7fa2 --- /dev/null +++ b/lib/ui/widgets/center_play_button.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +class CenterPlayButton extends TIMUIKitStatelessWidget { + CenterPlayButton({ + Key? key, + required this.show, + required this.isPlaying, + this.onPressed, + }) : super(key: key); + + final bool show; + final bool isPlaying; + final VoidCallback? onPressed; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Material( + color: Colors.transparent, + child: Container( + color: Colors.transparent, + child: Center( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: GestureDetector( + child: IconButton( + iconSize: 86, + icon: Image.asset('images/play.png', package: 'tencent_cloud_chat_uikit'), + onPressed: onPressed, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/column_menu.dart b/lib/ui/widgets/column_menu.dart new file mode 100644 index 0000000..c9cd793 --- /dev/null +++ b/lib/ui/widgets/column_menu.dart @@ -0,0 +1,91 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class ColumnMenuItem { + String label; + VoidCallback onClick; + Widget? icon; + + ColumnMenuItem({required this.label, required this.onClick, this.icon}); +} + +class TUIKitColumnMenu extends StatefulWidget { + const TUIKitColumnMenu({Key? key, required this.data, this.padding}) : super(key: key); + + final List data; + final EdgeInsetsGeometry? padding; + + @override + State createState() => TUIKitColumnMenuState(); +} + +class TUIKitColumnMenuState extends TIMUIKitState { + + List renderMenuItems(TUITheme theme) { + return widget.data + .map( + (item) => Material( + color: Colors.white, + child: InkWell( + onTap: () { + item.onClick(); + }, + child: Container( + padding: widget.padding ?? const EdgeInsets.all(10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.icon != null) item.icon!, + if (item.icon != null) const SizedBox( + height: 4, + width: 6, + ), + Text( + item.label, + style: TextStyle( + decoration: TextDecoration.none, + color: theme.darkTextColor, + fontSize: 14, + ), + ) + ], + ), + ), + ), + ), + ) + .toList(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final theme = value.theme; + return Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.of(context).size.width * 0.7, 350), + ), + child: Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + }, + children: [ + ...renderMenuItems(theme).map((e) => TableRow( + children: [ + e + ] + )) + ] + ), + ), + ); + } +} diff --git a/lib/ui/widgets/contact_list.dart b/lib/ui/widgets/contact_list.dart new file mode 100644 index 0000000..b525d6b --- /dev/null +++ b/lib/ui/widgets/contact_list.dart @@ -0,0 +1,306 @@ +import 'package:azlistview_all_platforms/azlistview_all_platforms.dart'; +import 'package:flutter/material.dart'; +import 'package:lpinyin/lpinyin.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class ContactList extends StatefulWidget { + final List contactList; + final bool isCanSelectMemberItem; + final bool isCanSlidableDelete; + final Function(List selectedMember)? onSelectedMemberItemChange; + final Function()? handleSlidableDelte; + final Color? bgColor; + + /// tap联系人列表项回调 + final void Function(V2TimFriendInfo item)? onTapItem; + + /// 顶部列表 + final List? topList; + + /// 顶部列表项构造器 + final Widget? Function(TopListItem item)? topListItemBuilder; + + /// Control if shows the online status for each user on its avatar. + final bool isShowOnlineStatus; + + final int? maxSelectNum; + + final List? groupMemberList; + + /// the builder for the empty item, especially when there is no contact + final Widget Function(BuildContext context)? emptyBuilder; + + final String? currentItem; + + const ContactList({ + Key? key, + required this.contactList, + this.isCanSelectMemberItem = false, + this.onSelectedMemberItemChange, + this.isCanSlidableDelete = false, + this.handleSlidableDelte, + this.onTapItem, + this.bgColor, + this.topList, + this.topListItemBuilder, + this.isShowOnlineStatus = false, + this.maxSelectNum, + this.groupMemberList, + this.emptyBuilder, + this.currentItem, + }) : super(key: key); + + @override + State createState() => _ContactListState(); +} + +class _ContactListState extends TIMUIKitState { + List selectedMember = []; + final TUIFriendShipViewModel friendShipViewModel = serviceLocator(); + + _getShowName(V2TimFriendInfo item) { + final friendRemark = item.friendRemark ?? ""; + final nickName = item.userProfile?.nickName ?? ""; + final userID = item.userID; + final showName = nickName != "" ? nickName : userID; + return friendRemark != "" ? friendRemark : showName; + } + + List _getShowList(List memberList) { + final List showList = List.empty(growable: true); + for (var i = 0; i < memberList.length; i++) { + final item = memberList[i]; + final showName = _getShowName(item); + String pinyin = PinyinHelper.getPinyinE(showName); + String tag = pinyin.substring(0, 1).toUpperCase(); + if (RegExp("[A-Z]").hasMatch(tag)) { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: tag)); + } else { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#")); + } + } + + SuspensionUtil.sortListBySuspensionTag(showList); + + return showList; + } + + bool selectedMemberIsOverFlow() { + if (widget.maxSelectNum == null) { + return false; + } + + return selectedMember.length >= widget.maxSelectNum!; + } + + Widget _buildItem(TUITheme theme, V2TimFriendInfo item) { + final showName = _getShowName(item); + final faceUrl = item.userProfile?.faceUrl ?? ""; + + final V2TimUserStatus? onlineStatus = widget.isShowOnlineStatus + ? friendShipViewModel.userStatusList + .firstWhere((element) => element.userID == item.userID, orElse: () => V2TimUserStatus(statusType: 0)) + : null; + + bool disabled = false; + if (widget.groupMemberList != null && widget.groupMemberList!.isNotEmpty) { + disabled = ((widget.groupMemberList?.indexWhere((element) => element?.userID == item.userID)) ?? -1) > -1; + } + + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return Container( + padding: const EdgeInsets.only(top: 8, left: 16, right: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Row( + children: [ + if (widget.isCanSelectMemberItem) + Container( + margin: const EdgeInsets.only(right: 12, bottom: 8), + child: CheckBoxButton( + disabled: disabled, + isChecked: selectedMember.contains(item), + onChanged: (isChecked) { + if (isChecked) { + if (selectedMemberIsOverFlow()) { + selectedMember = [item]; + setState(() {}); + return; + } + selectedMember.add(item); + } else { + selectedMember.remove(item); + } + if (widget.onSelectedMemberItemChange != null) { + widget.onSelectedMemberItemChange!(selectedMember); + } + setState(() {}); + }, + ), + ), + Container( + padding: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(right: 12), + child: SizedBox( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + child: Avatar(onlineStatus: onlineStatus, faceUrl: faceUrl, showName: showName), + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 10, bottom: 20, right: 28), + child: Text( + showName, + style: TextStyle(color: Colors.black, fontSize: isDesktopScreen ? 14 : 18), + ), + )), + ], + ), + ); + } + + Widget generateTopItem(memberInfo) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (widget.topListItemBuilder != null) { + final customWidget = widget.topListItemBuilder!(memberInfo); + if (customWidget != null) { + return customWidget; + } + } + return InkWell( + onTap: () { + if (memberInfo.onTap != null) { + memberInfo.onTap!(); + } + }, + child: Container( + padding: const EdgeInsets.only(top: 8, left: 16), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: hexToColor("DBDBDB")))), + child: Row( + children: [ + Container( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + margin: const EdgeInsets.only(right: 12, bottom: 12), + child: memberInfo.icon, + ), + Expanded( + child: Container( + padding: const EdgeInsets.only(top: 10, bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + memberInfo.name, + style: TextStyle(color: hexToColor("111111"), fontSize: isDesktopScreen ? 14 : 18), + ), + Expanded(child: Container()), + // if (item.id == "newContact") + // const TIMUIKitUnreadCount(), + Container( + margin: const EdgeInsets.only(right: 16), + child: Icon( + Icons.keyboard_arrow_right, + color: hexToColor('BBBBBB'), + ), + ) + ], + ), + )) + ], + ), + )); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final showList = _getShowList(widget.contactList); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + if (widget.topList != null && widget.topList!.isNotEmpty) { + final topList = widget.topList!.map((e) => ISuspensionBeanImpl(memberInfo: e, tagIndex: '@')).toList(); + showList.insertAll(0, topList); + } + + if (widget.contactList.isEmpty) { + return Column( + children: [ + ...showList.map((e) => generateTopItem(e.memberInfo)).toList(), + Expanded(child: widget.emptyBuilder != null ? widget.emptyBuilder!(context) : Container()) + ], + ); + } + + return AZListViewContainer( + memberList: showList, + itemBuilder: (context, index) { + final memberInfo = showList[index].memberInfo; + if (memberInfo is TopListItem) { + return generateTopItem(memberInfo); + } else { + return Material( + color: (isDesktopScreen) + ? (widget.currentItem == memberInfo.userProfile.userID + ? theme.conversationItemChooseBgColor + : widget.bgColor) + : null, + child: InkWell( + onTap: () { + if (widget.isCanSelectMemberItem) { + if (selectedMember.contains(memberInfo)) { + selectedMember.remove(memberInfo); + } else { + if (selectedMemberIsOverFlow()) { + selectedMember = [memberInfo]; + setState(() {}); + return; + } + selectedMember.add(memberInfo); + } + if (widget.onSelectedMemberItemChange != null) { + widget.onSelectedMemberItemChange!(selectedMember); + } + setState(() {}); + return; + } + if (widget.onTapItem != null) { + widget.onTapItem!(memberInfo); + } + }, + child: _buildItem(theme, memberInfo), + ), + ); + } + }, + ); + } +} + +class TopListItem { + final String name; + final String id; + final Widget? icon; + final Function()? onTap; + + TopListItem({required this.name, required this.id, this.icon, this.onTap}); +} diff --git a/lib/ui/widgets/customize_ball_pulse_header.dart b/lib/ui/widgets/customize_ball_pulse_header.dart new file mode 100644 index 0000000..6d984ec --- /dev/null +++ b/lib/ui/widgets/customize_ball_pulse_header.dart @@ -0,0 +1,240 @@ +// ignore_for_file: prefer_final_fields + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_easyrefresh/easy_refresh.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +/// 球脉冲Header +class CustomizeBallPulseHeader extends Header { + /// Key + final Key? key; + + /// color + final Color? color; + + /// background color + final Color? backgroundColor; + + final LinkHeaderNotifier linkNotifier = LinkHeaderNotifier(); + + CustomizeBallPulseHeader({ + this.key, + this.color = Colors.blue, + this.backgroundColor = Colors.transparent, + bool enableHapticFeedback = true, + bool enableInfiniteRefresh = false, + }) : super( + extent: 70.0, + triggerDistance: 70.0, + float: false, + enableHapticFeedback: enableHapticFeedback, + enableInfiniteRefresh: enableInfiniteRefresh, + ); + + @override + Widget contentBuilder( + BuildContext context, + RefreshMode refreshState, + double pulledExtent, + double refreshTriggerPullDistance, + double refreshIndicatorExtent, + AxisDirection axisDirection, + bool float, + Duration? completeDuration, + bool enableInfiniteRefresh, + bool success, + bool noMore) { + // 不能为水平方向 + assert( + axisDirection == AxisDirection.down || + axisDirection == AxisDirection.up, + 'Widget cannot be horizontal'); + linkNotifier.contentBuilder( + context, + refreshState, + pulledExtent, + refreshTriggerPullDistance, + refreshIndicatorExtent, + axisDirection, + float, + completeDuration, + enableInfiniteRefresh, + success, + noMore); + return BallPulseHeaderWidget( + key: key, + color: color, + backgroundColor: backgroundColor, + linkNotifier: linkNotifier, + ); + } +} + +/// 球脉冲组件 +class BallPulseHeaderWidget extends StatefulWidget { + /// 颜色 + final Color? color; + + /// 背景颜色 + final Color? backgroundColor; + + final LinkHeaderNotifier linkNotifier; + + const BallPulseHeaderWidget({ + Key? key, + this.color, + this.backgroundColor, + required this.linkNotifier, + }) : super(key: key); + + @override + BallPulseHeaderWidgetState createState() { + return BallPulseHeaderWidgetState(); + } +} + +class BallPulseHeaderWidgetState extends TIMUIKitState { + RefreshMode get _refreshState => widget.linkNotifier.refreshState; + + double get _indicatorExtent => widget.linkNotifier.refreshIndicatorExtent; + + bool get _noMore => widget.linkNotifier.noMore; + + // 球大小 + double _ballSize1 = 0.0, _ballSize2 = 0.0, _ballSize3 = 0.0; + + // 动画阶段 + int animationPhase = 1; + + // 动画过渡时间 + Duration _ballSizeDuration = const Duration(milliseconds: 200); + + // 是否运行动画 + bool _isAnimated = false; + + + // 循环动画 + void _loopAnimated() { + Future.delayed(_ballSizeDuration, () { + if (!mounted) return; + if (_isAnimated) { + setState(() { + if (animationPhase == 1) { + _ballSize1 = 11.0; + _ballSize2 = 4.0; + _ballSize3 = 11.0; + } else if (animationPhase == 2) { + _ballSize1 = 18.0; + _ballSize2 = 11.0; + _ballSize3 = 4.0; + } else if (animationPhase == 3) { + _ballSize1 = 11.0; + _ballSize2 = 18.0; + _ballSize3 = 11.0; + } else { + _ballSize1 = 4.0; + _ballSize2 = 11.0; + _ballSize3 = 18.0; + } + }); + animationPhase++; + animationPhase = animationPhase >= 5 ? 1 : animationPhase; + _loopAnimated(); + } else { + setState(() { + _ballSize1 = 0.0; + _ballSize2 = 0.0; + _ballSize3 = 0.0; + }); + animationPhase = 1; + } + }); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + if (_noMore) return Container(); + // 开启动画 + if (_refreshState == RefreshMode.done || + _refreshState == RefreshMode.inactive) { + _isAnimated = false; + } else if (!_isAnimated) { + _isAnimated = true; + _ballSize1 = 4.0; + _ballSize2 = 11.0; + _ballSize3 = 18.0; + _loopAnimated(); + } + return Stack( + children: [ + Positioned( + top: 0.0, + bottom: 0.0, + left: 0.0, + right: 0.0, + child: Container( + alignment: Alignment.center, + height: _indicatorExtent, + color: widget.backgroundColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20.0, + height: 20.0, + child: Center( + child: ClipOval( + child: AnimatedContainer( + color: widget.color, + height: _ballSize1, + width: _ballSize1, + duration: _ballSizeDuration, + ), + ), + ), + ), + Container( + width: 5.0, + ), + SizedBox( + width: 20.0, + height: 20.0, + child: Center( + child: ClipOval( + child: AnimatedContainer( + color: widget.color, + height: _ballSize2, + width: _ballSize2, + duration: _ballSizeDuration, + ), + ), + ), + ), + Container( + width: 5.0, + ), + SizedBox( + width: 20.0, + height: 20.0, + child: Center( + child: ClipOval( + child: AnimatedContainer( + color: widget.color, + height: _ballSize3, + width: _ballSize3, + duration: _ballSizeDuration, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/widgets/drag_widget.dart b/lib/ui/widgets/drag_widget.dart new file mode 100644 index 0000000..9cf12d7 --- /dev/null +++ b/lib/ui/widgets/drag_widget.dart @@ -0,0 +1,97 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class TUIKitDragArea extends StatefulWidget { + final Widget child; + final Offset? initOffset; + final VoidCallback? closeFun; + final bool isAllowDrag; + final Color? backgroundColor; + + const TUIKitDragArea( + {Key? key, + required this.child, + this.initOffset, + this.closeFun, + this.isAllowDrag = false, this.backgroundColor}) + : super(key: key); + + @override + _DragAreaStateStateful createState() => _DragAreaStateStateful(); +} + +class _DragAreaStateStateful extends State { + late Offset position; + double prevScale = 1; + double scale = 1; + + @override + void initState() { + super.initState(); + position = widget.initOffset ?? const Offset(0, 200); + } + + void updateScale(double zoom) => setState(() => scale = prevScale * zoom); + + void commitScale() => setState(() => prevScale = scale); + + void updatePosition(Offset newPosition) { + final maxY = MediaQuery.of(context).size.height - 100; + final maxX = MediaQuery.of(context).size.width - 100; + + final rebuildPosition = Offset( + max(0, min(newPosition.dx, maxX)), max(0, min(newPosition.dy, maxY))); + position = rebuildPosition; + } + + @override + Widget build(BuildContext context) { + return Container( + color: widget.backgroundColor, + child: Stack( + children: [ + Row( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + if (widget.closeFun != null) { + widget.closeFun!(); + } + }, + child: Container( + color: Colors.transparent, + ), + ) + ) + ], + )) + ], + ), + if (widget.isAllowDrag) + Positioned( + left: position.dx, + top: position.dy, + child: Draggable( + maxSimultaneousDrags: 1, + feedback: widget.child, + childWhenDragging: Container(), + onDragEnd: (details) => updatePosition(details.offset), + child: widget.child, + ), + ), + if (!widget.isAllowDrag) + Positioned( + left: position.dx, + top: position.dy, + child: widget.child, + ) + ], + ), + ); + } +} diff --git a/lib/ui/widgets/emoji.dart b/lib/ui/widgets/emoji.dart new file mode 100644 index 0000000..f7ec647 --- /dev/null +++ b/lib/ui/widgets/emoji.dart @@ -0,0 +1,21 @@ + +class Emoji { + String name; + int unicode; + + Emoji({required this.name, required this.unicode}); + + factory Emoji.fromJson(Map json) { + return Emoji( + name: json['name'], + unicode: json['unicode'], + ); + } + + Map toJson() { + final Map data = {}; + data['name'] = name; + data['unicode'] = unicode; + return data; + } +} \ No newline at end of file diff --git a/lib/ui/widgets/extended_wrap/extended_render_wrap.dart b/lib/ui/widgets/extended_wrap/extended_render_wrap.dart new file mode 100644 index 0000000..f82837d --- /dev/null +++ b/lib/ui/widgets/extended_wrap/extended_render_wrap.dart @@ -0,0 +1,827 @@ +// ignore_for_file: curly_braces_in_flow_control_structures + +import 'package:flutter/rendering.dart'; +import 'dart:math' as math; + +class _LimitRunMetrics { + _LimitRunMetrics(this.mainAxisExtent, this.crossAxisExtent, this.childCount); + + final double mainAxisExtent; + final double crossAxisExtent; + final int childCount; +} + +/// Parent data for use with [RenderWrap]. +class LimitWrapParentData extends ContainerBoxParentData { + int _runIndex = 0; + bool _isHide = false; +} + +/// Displays its children in multiple horizontal or vertical runs. +/// +/// A [RenderWrap] lays out each child and attempts to place the child adjacent +/// to the previous child in the main axis, given by [direction], leaving +/// [spacing] space in between. If there is not enough space to fit the child, +/// [RenderWrap] creates a new _run_ adjacent to the existing children in the +/// cross axis. +/// +/// After all the children have been allocated to runs, the children within the +/// runs are positioned according to the [alignment] in the main axis and +/// according to the [crossAxisAlignment] in the cross axis. +/// +/// The runs themselves are then positioned in the cross axis according to the +/// [runSpacing] and [runAlignment].runAlignment +class ExtendedRenderWrap extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + /// Creates a wrap render object. + /// + /// By default, the wrap layout is horizontal and both the children and the + /// runs are aligned to the start. + + ExtendedRenderWrap({ + List? children, + Axis direction = Axis.horizontal, + WrapAlignment alignment = WrapAlignment.start, + double spacing = 0.0, + WrapAlignment runAlignment = WrapAlignment.start, + double runSpacing = 0.0, + WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start, + TextDirection? textDirection, + VerticalDirection verticalDirection = VerticalDirection.down, + Clip clipBehavior = Clip.none, + int maxLines = 1, + bool hasOverflow = false, + }) : assert(maxLines >= 1), + _direction = direction, + _alignment = alignment, + _spacing = spacing, + _runAlignment = runAlignment, + _runSpacing = runSpacing, + _crossAxisAlignment = crossAxisAlignment, + _textDirection = textDirection, + _verticalDirection = verticalDirection, + _maxLines = maxLines, + _hasOverflow = hasOverflow, + _clipBehavior = clipBehavior { + addAll(children); + } + + /// The direction to use as the main axis. + /// + /// For example, if [direction] is [Axis.horizontal], the default, the + /// children are placed adjacent to one another in a horizontal run until the + /// available horizontal space is consumed, at which point a subsequent + /// children are placed in a new run vertically adjacent to the previous run. + Axis get direction => _direction; + Axis _direction; + set direction(Axis value) { + if (_direction == value) return; + _direction = value; + markNeedsLayout(); + } + + /// How the children within a run should be placed in the main axis. + /// + /// For example, if [alignment] is [WrapAlignment.center], the children in + /// each run are grouped together in the center of their run in the main axis. + /// + /// Defaults to [WrapAlignment.start]. + /// + /// See also: + /// + /// * [runAlignment], which controls how the runs are placed relative to each + /// other in the cross axis. + /// * [crossAxisAlignment], which controls how the children within each run + /// are placed relative to each other in the cross axis. + WrapAlignment get alignment => _alignment; + WrapAlignment _alignment; + set alignment(WrapAlignment value) { + if (_alignment == value) return; + _alignment = value; + markNeedsLayout(); + } + + /// How much space to place between children in a run in the main axis. + /// + /// For example, if [spacing] is 10.0, the children will be spaced at least + /// 10.0 logical pixels apart in the main axis. + /// + /// If there is additional free space in a run (e.g., because the wrap has a + /// minimum size that is not filled or because some runs are longer than + /// others), the additional free space will be allocated according to the + /// [alignment]. + /// + /// Defaults to 0.0. + double get spacing => _spacing; + double _spacing; + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + int get maxLines => _maxLines; + int _maxLines; + set maxLines(int value) { + assert(value >= 1); + if (_maxLines == value) return; + _maxLines = value; + markNeedsLayout(); + } + + bool get hasOverflow => _hasOverflow; + bool _hasOverflow; + set hasOverflow(bool value) { + if (_hasOverflow == value) return; + _hasOverflow = value; + markNeedsLayout(); + } + + /// How the runs themselves should be placed in the cross axis. + /// + /// For example, if [runAlignment] is [WrapAlignment.center], the runs are + /// grouped together in the center of the overall [RenderWrap] in the cross + /// axis. + /// + /// Defaults to [WrapAlignment.start]. + /// + /// See also: + /// + /// * [alignment], which controls how the children within each run are placed + /// relative to each other in the main axis. + /// * [crossAxisAlignment], which controls how the children within each run + /// are placed relative to each other in the cross axis. + WrapAlignment get runAlignment => _runAlignment; + WrapAlignment _runAlignment; + set runAlignment(WrapAlignment value) { + if (_runAlignment == value) return; + _runAlignment = value; + markNeedsLayout(); + } + + /// How much space to place between the runs themselves in the cross axis. + /// + /// For example, if [runSpacing] is 10.0, the runs will be spaced at least + /// 10.0 logical pixels apart in the cross axis. + /// + /// If there is additional free space in the overall [RenderWrap] (e.g., + /// because the wrap has a minimum size that is not filled), the additional + /// free space will be allocated according to the [runAlignment]. + /// + /// Defaults to 0.0. + double get runSpacing => _runSpacing; + double _runSpacing; + set runSpacing(double value) { + if (_runSpacing == value) return; + _runSpacing = value; + markNeedsLayout(); + } + + /// How the children within a run should be aligned relative to each other in + /// the cross axis. + /// + /// For example, if this is set to [WrapCrossAlignment.end], and the + /// [direction] is [Axis.horizontal], then the children within each + /// run will have their bottom edges aligned to the bottom edge of the run. + /// + /// Defaults to [WrapCrossAlignment.start]. + /// + /// See also: + /// + /// * [alignment], which controls how the children within each run are placed + /// relative to each other in the main axis. + /// * [runAlignment], which controls how the runs are placed relative to each + /// other in the cross axis. + WrapCrossAlignment get crossAxisAlignment => _crossAxisAlignment; + WrapCrossAlignment _crossAxisAlignment; + set crossAxisAlignment(WrapCrossAlignment value) { + if (_crossAxisAlignment == value) return; + _crossAxisAlignment = value; + markNeedsLayout(); + } + + /// Determines the order to lay children out horizontally and how to interpret + /// `start` and `end` in the horizontal direction. + /// + /// If the [direction] is [Axis.horizontal], this controls the order in which + /// children are positioned (left-to-right or right-to-left), and the meaning + /// of the [alignment] property's [WrapAlignment.start] and + /// [WrapAlignment.end] values. + /// + /// If the [direction] is [Axis.horizontal], and either the + /// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or + /// there's more than one child, then the [textDirection] must not be null. + /// + /// If the [direction] is [Axis.vertical], this controls the order in + /// which runs are positioned, the meaning of the [runAlignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the + /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and + /// [WrapCrossAlignment.end] values. + /// + /// If the [direction] is [Axis.vertical], and either the + /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the + /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or + /// [WrapCrossAlignment.end], or there's more than one child, then the + /// [textDirection] must not be null. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection != value) { + _textDirection = value; + markNeedsLayout(); + } + } + + /// Determines the order to lay children out vertically and how to interpret + /// `start` and `end` in the vertical direction. + /// + /// If the [direction] is [Axis.vertical], this controls which order children + /// are painted in (down or up), the meaning of the [alignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values. + /// + /// If the [direction] is [Axis.vertical], and either the [alignment] + /// is either [WrapAlignment.start] or [WrapAlignment.end], or there's + /// more than one child, then the [verticalDirection] must not be null. + /// + /// If the [direction] is [Axis.horizontal], this controls the order in which + /// runs are positioned, the meaning of the [runAlignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the + /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and + /// [WrapCrossAlignment.end] values. + /// + /// If the [direction] is [Axis.horizontal], and either the + /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the + /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or + /// [WrapCrossAlignment.end], or there's more than one child, then the + /// [verticalDirection] must not be null. + VerticalDirection get verticalDirection => _verticalDirection; + VerticalDirection _verticalDirection; + set verticalDirection(VerticalDirection value) { + if (_verticalDirection != value) { + _verticalDirection = value; + markNeedsLayout(); + } + } + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none], and must not be null. + Clip get clipBehavior => _clipBehavior; + Clip _clipBehavior = Clip.none; + set clipBehavior(Clip value) { + if (value != _clipBehavior) { + _clipBehavior = value; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + bool get _debugHasNecessaryDirections { + if (firstChild != null && lastChild != firstChild) { + // i.e. there's more than one child + switch (direction) { + case Axis.horizontal: + assert(textDirection != null, + 'Horizontal $runtimeType with multiple children has a null textDirection, so the layout order is undefined.'); + break; + case Axis.vertical: + break; + } + } + if (alignment == WrapAlignment.start || alignment == WrapAlignment.end) { + switch (direction) { + case Axis.horizontal: + assert(textDirection != null, + 'Horizontal $runtimeType with alignment $alignment has a null textDirection, so the alignment cannot be resolved.'); + break; + case Axis.vertical: + break; + } + } + if (runAlignment == WrapAlignment.start || + runAlignment == WrapAlignment.end) { + switch (direction) { + case Axis.horizontal: + break; + case Axis.vertical: + assert(textDirection != null, + 'Vertical $runtimeType with runAlignment $runAlignment has a null textDirection, so the alignment cannot be resolved.'); + break; + } + } + if (crossAxisAlignment == WrapCrossAlignment.start || + crossAxisAlignment == WrapCrossAlignment.end) { + switch (direction) { + case Axis.horizontal: + break; + case Axis.vertical: + assert(textDirection != null, + 'Vertical $runtimeType with crossAxisAlignment $crossAxisAlignment has a null textDirection, so the alignment cannot be resolved.'); + break; + } + } + return true; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! LimitWrapParentData) + child.parentData = LimitWrapParentData(); + } + + @override + double computeMinIntrinsicWidth(double height) { + switch (direction) { + case Axis.horizontal: + double width = 0.0; + RenderBox? child = firstChild; + while (child != null) { + width = math.max(width, child.getMinIntrinsicWidth(double.infinity)); + child = childAfter(child); + } + return width; + case Axis.vertical: + return computeDryLayout(BoxConstraints(maxHeight: height)).width; + } + } + + @override + double computeMaxIntrinsicWidth(double height) { + switch (direction) { + case Axis.horizontal: + double width = 0.0; + RenderBox? child = firstChild; + while (child != null) { + width += child.getMaxIntrinsicWidth(double.infinity); + child = childAfter(child); + } + return width; + case Axis.vertical: + return computeDryLayout(BoxConstraints(maxHeight: height)).width; + } + } + + @override + double computeMinIntrinsicHeight(double width) { + switch (direction) { + case Axis.horizontal: + return computeDryLayout(BoxConstraints(maxWidth: width)).height; + case Axis.vertical: + double height = 0.0; + RenderBox? child = firstChild; + while (child != null) { + height = + math.max(height, child.getMinIntrinsicHeight(double.infinity)); + child = childAfter(child); + } + return height; + } + } + + @override + double computeMaxIntrinsicHeight(double width) { + switch (direction) { + case Axis.horizontal: + return computeDryLayout(BoxConstraints(maxWidth: width)).height; + case Axis.vertical: + double height = 0.0; + RenderBox? child = firstChild; + while (child != null) { + height += child.getMaxIntrinsicHeight(double.infinity); + child = childAfter(child); + } + return height; + } + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + double _getMainAxisExtent(Size childSize) { + switch (direction) { + case Axis.horizontal: + return childSize.width; + case Axis.vertical: + return childSize.height; + } + } + + double _getCrossAxisExtent(Size childSize) { + switch (direction) { + case Axis.horizontal: + return childSize.height; + case Axis.vertical: + return childSize.width; + } + } + + Offset _getOffset(double mainAxisOffset, double crossAxisOffset) { + switch (direction) { + case Axis.horizontal: + return Offset(mainAxisOffset, crossAxisOffset); + case Axis.vertical: + return Offset(crossAxisOffset, mainAxisOffset); + } + } + + double _getChildCrossAxisOffset(bool flipCrossAxis, double runCrossAxisExtent, + double childCrossAxisExtent) { + final double freeSpace = runCrossAxisExtent - childCrossAxisExtent; + switch (crossAxisAlignment) { + case WrapCrossAlignment.start: + return flipCrossAxis ? freeSpace : 0.0; + case WrapCrossAlignment.end: + return flipCrossAxis ? 0.0 : freeSpace; + case WrapCrossAlignment.center: + return freeSpace / 2.0; + } + } + + bool _hasVisualOverflow = false; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeDryLayout(constraints); + } + + Size _computeDryLayout(BoxConstraints constraints, + [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) { + late BoxConstraints childConstraints; + double mainAxisLimit = 0.0; + switch (direction) { + case Axis.horizontal: + childConstraints = BoxConstraints(maxWidth: constraints.maxWidth); + mainAxisLimit = constraints.maxWidth; + break; + case Axis.vertical: + childConstraints = BoxConstraints(maxHeight: constraints.maxHeight); + mainAxisLimit = constraints.maxHeight; + break; + } + + double mainAxisExtent = 0.0; + double crossAxisExtent = 0.0; + double runMainAxisExtent = 0.0; + double runCrossAxisExtent = 0.0; + int childCount = 0; + RenderBox? child = firstChild; + while (child != null) { + final Size childSize = layoutChild(child, childConstraints); + final double childMainAxisExtent = _getMainAxisExtent(childSize); + final double childCrossAxisExtent = _getCrossAxisExtent(childSize); + // There must be at least one child before we move on to the next run. + if (childCount > 0 && + runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) { + mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); + crossAxisExtent += runCrossAxisExtent + runSpacing; + runMainAxisExtent = 0.0; + runCrossAxisExtent = 0.0; + childCount = 0; + } + runMainAxisExtent += childMainAxisExtent; + runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent); + if (childCount > 0) runMainAxisExtent += spacing; + childCount += 1; + child = childAfter(child); + } + crossAxisExtent += runCrossAxisExtent; + mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); + + switch (direction) { + case Axis.horizontal: + return constraints.constrain(Size(mainAxisExtent, crossAxisExtent)); + case Axis.vertical: + return constraints.constrain(Size(crossAxisExtent, mainAxisExtent)); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + assert(_debugHasNecessaryDirections); + _hasVisualOverflow = false; + RenderBox? child = firstChild; + if (child == null) { + size = constraints.smallest; + return; + } + BoxConstraints? childConstraints; + double mainAxisLimit = 0.0; + bool flipMainAxis = false; + bool flipCrossAxis = false; + switch (direction) { + case Axis.horizontal: + childConstraints = BoxConstraints(maxWidth: constraints.maxWidth); + mainAxisLimit = constraints.maxWidth; + if (textDirection == TextDirection.rtl) flipMainAxis = true; + if (verticalDirection == VerticalDirection.up) flipCrossAxis = true; + break; + case Axis.vertical: + childConstraints = BoxConstraints(maxHeight: constraints.maxHeight); + mainAxisLimit = constraints.maxHeight; + if (verticalDirection == VerticalDirection.up) flipMainAxis = true; + if (textDirection == TextDirection.rtl) flipCrossAxis = true; + break; + } + final double spacing = this.spacing; + final double runSpacing = this.runSpacing; + List<_LimitRunMetrics> runMetrics = <_LimitRunMetrics>[]; + double mainAxisExtent = 0.0; + double crossAxisExtent = 0.0; + double runMainAxisExtent = 0.0; + double runCrossAxisExtent = 0.0; + int childCount = 0; + + int currentRowNumber = 1; + + bool isNeedHideOverflow = true; + + while (child != null) { + if (currentRowNumber > maxLines && !hasOverflow) { + child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0), + parentUsesSize: true); + final LimitWrapParentData childParentData = + child.parentData as LimitWrapParentData; + child = childParentData.nextSibling; + continue; + } else { + child.layout(childConstraints, parentUsesSize: true); + } + + double childMainAxisExtent = _getMainAxisExtent(child.size); + double childCrossAxisExtent = _getCrossAxisExtent(child.size); + + final LimitWrapParentData childParentData = + child.parentData as LimitWrapParentData; + childParentData._isHide = false; + + bool needCalculateSpace = true; + + if (hasOverflow) { + lastChild!.layout(childConstraints, parentUsesSize: true); + final double overflowMainAxisExtent = + _getMainAxisExtent(lastChild!.size); + if (isNeedHideOverflow && + currentRowNumber == 1 && + childParentData.nextSibling == null) { + lastChild!.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0), + parentUsesSize: true); + childMainAxisExtent = _getMainAxisExtent(child.size); + } + + if (currentRowNumber > maxLines && + childParentData.nextSibling != null) { + needCalculateSpace = false; + isNeedHideOverflow = false; + childParentData._isHide = true; + child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0), + parentUsesSize: true); + childMainAxisExtent = _getMainAxisExtent(child.size); + childCrossAxisExtent = _getCrossAxisExtent(child.size); + } + + if (childCount > 0 && + runMainAxisExtent + + spacing * 2 + + childMainAxisExtent + + overflowMainAxisExtent > + mainAxisLimit) { + if (crossAxisExtent + runCrossAxisExtent + childCrossAxisExtent > + (childCrossAxisExtent * maxLines + spacing * (maxLines - 1))) { + if (childParentData.nextSibling != null) { + needCalculateSpace = false; + isNeedHideOverflow = false; + childParentData._isHide = true; + child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0), + parentUsesSize: true); + childMainAxisExtent = _getMainAxisExtent(child.size); + childCrossAxisExtent = _getCrossAxisExtent(child.size); + } + } + if (runMainAxisExtent + spacing + childMainAxisExtent > + mainAxisLimit) { + mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); + crossAxisExtent += runCrossAxisExtent; + if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing; + runMetrics.add(_LimitRunMetrics( + runMainAxisExtent, runCrossAxisExtent, childCount)); + runMainAxisExtent = 0.0; + runCrossAxisExtent = 0.0; + childCount = 0; + currentRowNumber++; + } + } + } else if (childCount > 0 && + runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) { + mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); + crossAxisExtent += runCrossAxisExtent; + if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing; + runMetrics.add(_LimitRunMetrics( + runMainAxisExtent, runCrossAxisExtent, childCount)); + runMainAxisExtent = 0.0; + runCrossAxisExtent = 0.0; + childCount = 0; + currentRowNumber++; + + if (currentRowNumber > maxLines) { + child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0), + parentUsesSize: true); + final LimitWrapParentData childParentData = + child.parentData as LimitWrapParentData; + child = childParentData.nextSibling; + continue; + } + } + runMainAxisExtent += childMainAxisExtent; + if (childCount > 0 && needCalculateSpace) runMainAxisExtent += spacing; + runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent); + childCount += 1; + + childParentData._runIndex = runMetrics.length; + child = childParentData.nextSibling; + } + if (childCount > 0) { + mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); + crossAxisExtent += runCrossAxisExtent; + if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing; + runMetrics.add( + _LimitRunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount)); + } + + final int runCount = runMetrics.length; + assert(runCount > 0); + + double containerMainAxisExtent = 0.0; + double containerCrossAxisExtent = 0.0; + + switch (direction) { + case Axis.horizontal: + size = constraints.constrain(Size(mainAxisExtent, crossAxisExtent)); + containerMainAxisExtent = size.width; + containerCrossAxisExtent = size.height; + break; + case Axis.vertical: + size = constraints.constrain(Size(crossAxisExtent, mainAxisExtent)); + containerMainAxisExtent = size.height; + containerCrossAxisExtent = size.width; + break; + } + + _hasVisualOverflow = containerMainAxisExtent < mainAxisExtent || + containerCrossAxisExtent < crossAxisExtent; + + final double crossAxisFreeSpace = + math.max(0.0, containerCrossAxisExtent - crossAxisExtent); + double runLeadingSpace = 0.0; + double runBetweenSpace = 0.0; + switch (runAlignment) { + case WrapAlignment.start: + break; + case WrapAlignment.end: + runLeadingSpace = crossAxisFreeSpace; + break; + case WrapAlignment.center: + runLeadingSpace = crossAxisFreeSpace / 2.0; + break; + case WrapAlignment.spaceBetween: + runBetweenSpace = + runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0; + break; + case WrapAlignment.spaceAround: + runBetweenSpace = crossAxisFreeSpace / runCount; + runLeadingSpace = runBetweenSpace / 2.0; + break; + case WrapAlignment.spaceEvenly: + runBetweenSpace = crossAxisFreeSpace / (runCount + 1); + runLeadingSpace = runBetweenSpace; + break; + } + + runBetweenSpace += runSpacing; + double crossAxisOffset = flipCrossAxis + ? containerCrossAxisExtent - runLeadingSpace + : runLeadingSpace; + + child = firstChild; + for (int i = 0; i < runCount; ++i) { + final _LimitRunMetrics metrics = runMetrics[i]; + final double runMainAxisExtent = metrics.mainAxisExtent; + final double runCrossAxisExtent = metrics.crossAxisExtent; + final int childCount = metrics.childCount; + + final double mainAxisFreeSpace = + math.max(0.0, containerMainAxisExtent - runMainAxisExtent); + double childLeadingSpace = 0.0; + double childBetweenSpace = 0.0; + + switch (alignment) { + case WrapAlignment.start: + break; + case WrapAlignment.end: + childLeadingSpace = mainAxisFreeSpace; + break; + case WrapAlignment.center: + childLeadingSpace = mainAxisFreeSpace / 2.0; + break; + case WrapAlignment.spaceBetween: + childBetweenSpace = + childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0; + break; + case WrapAlignment.spaceAround: + childBetweenSpace = mainAxisFreeSpace / childCount; + childLeadingSpace = childBetweenSpace / 2.0; + break; + case WrapAlignment.spaceEvenly: + childBetweenSpace = mainAxisFreeSpace / (childCount + 1); + childLeadingSpace = childBetweenSpace; + break; + } + + childBetweenSpace += spacing; + double childMainPosition = flipMainAxis + ? containerMainAxisExtent - childLeadingSpace + : childLeadingSpace; + + if (flipCrossAxis) crossAxisOffset -= runCrossAxisExtent; + + while (child != null) { + final LimitWrapParentData childParentData = + child.parentData as LimitWrapParentData; + if (childParentData._runIndex != i) break; + final double childMainAxisExtent = _getMainAxisExtent(child.size); + + final double childCrossAxisExtent = _getCrossAxisExtent(child.size); + final double childCrossAxisOffset = _getChildCrossAxisOffset( + flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent); + if (flipMainAxis) childMainPosition -= childMainAxisExtent; + childParentData.offset = _getOffset( + childMainPosition, crossAxisOffset + childCrossAxisOffset); + if (flipMainAxis) + childMainPosition -= childBetweenSpace; + else + childMainPosition += childMainAxisExtent + + (childParentData._isHide ? 0 : childBetweenSpace); + child = childParentData.nextSibling; + } + + if (flipCrossAxis) + crossAxisOffset -= runBetweenSpace; + else + crossAxisOffset += runCrossAxisExtent + runBetweenSpace; + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + // ignore: todo + // TODO(ianh): move the debug flex overflow paint logic somewhere common so + // it can be reused here + if (_hasVisualOverflow && clipBehavior != Clip.none) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + defaultPaint, + clipBehavior: clipBehavior, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + defaultPaint(context, offset); + } + } + + final LayerHandle _clipRectLayer = + LayerHandle(); + + @override + void dispose() { + _clipRectLayer.layer = null; + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('direction', direction)); + properties.add(EnumProperty('alignment', alignment)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('runAlignment', runAlignment)); + properties.add(DoubleProperty('runSpacing', runSpacing)); + properties.add(DoubleProperty('crossAxisAlignment', runSpacing)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add(EnumProperty( + 'verticalDirection', verticalDirection, + defaultValue: VerticalDirection.down)); + } +} diff --git a/lib/ui/widgets/extended_wrap/extended_wrap.dart b/lib/ui/widgets/extended_wrap/extended_wrap.dart new file mode 100644 index 0000000..4f6cfda --- /dev/null +++ b/lib/ui/widgets/extended_wrap/extended_wrap.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/extended_wrap/extended_render_wrap.dart'; + +class ExtendedWrap extends MultiChildRenderObjectWidget { + /// Creates a wrap layout. + /// + /// By default, the wrap layout is horizontal and both the children and the + /// runs are aligned to the start. + /// + /// The [textDirection] argument defaults to the ambient [Directionality], if + /// any. If there is no ambient directionality, and a text direction is going + /// to be necessary to decide which direction to lay the children in or to + /// disambiguate `start` or `end` values for the main or cross axis + /// directions, the [textDirection] must not be null. + + final int maxLines; + + final Widget? overflowWidget; + + ExtendedWrap({ + Key? key, + this.direction = Axis.horizontal, + this.alignment = WrapAlignment.start, + this.spacing = 0.0, + this.runAlignment = WrapAlignment.start, + this.runSpacing = 0.0, + this.crossAxisAlignment = WrapCrossAlignment.start, + this.textDirection, + this.verticalDirection = VerticalDirection.down, + this.clipBehavior = Clip.none, + this.maxLines = 1, + this.overflowWidget, + List children = const [], + }) : assert(maxLines >= 1), + super(key: key, children: [ + ...children, + if (overflowWidget != null) overflowWidget + ]); + + /// The direction to use as the main axis. + /// + /// For example, if [direction] is [Axis.horizontal], the default, the + /// children are placed adjacent to one another in a horizontal run until the + /// available horizontal space is consumed, at which point a subsequent + /// children are placed in a new run vertically adjacent to the previous run. + final Axis direction; + + /// How the children within a run should be placed in the main axis. + /// + /// For example, if [alignment] is [WrapAlignment.center], the children in + /// each run are grouped together in the center of their run in the main axis. + /// + /// Defaults to [WrapAlignment.start]. + /// + /// See also: + /// + /// * [runAlignment], which controls how the runs are placed relative to each + /// other in the cross axis. + /// * [crossAxisAlignment], which controls how the children within each run + /// are placed relative to each other in the cross axis. + final WrapAlignment alignment; + + /// How much space to place between children in a run in the main axis. + /// + /// For example, if [spacing] is 10.0, the children will be spaced at least + /// 10.0 logical pixels apart in the main axis. + /// + /// If there is additional free space in a run (e.g., because the wrap has a + /// minimum size that is not filled or because some runs are longer than + /// others), the additional free space will be allocated according to the + /// [alignment]. + /// + /// Defaults to 0.0. + final double spacing; + + /// How the runs themselves should be placed in the cross axis. + /// + /// For example, if [runAlignment] is [WrapAlignment.center], the runs are + /// grouped together in the center of the overall [Wrap] in the cross axis. + /// + /// Defaults to [WrapAlignment.start]. + /// + /// See also: + /// + /// * [alignment], which controls how the children within each run are placed + /// relative to each other in the main axis. + /// * [crossAxisAlignment], which controls how the children within each run + /// are placed relative to each other in the cross axis. + final WrapAlignment runAlignment; + + /// How much space to place between the runs themselves in the cross axis. + /// + /// For example, if [runSpacing] is 10.0, the runs will be spaced at least + /// 10.0 logical pixels apart in the cross axis. + /// + /// If there is additional free space in the overall [Wrap] (e.g., because + /// the wrap has a minimum size that is not filled), the additional free space + /// will be allocated according to the [runAlignment]. + /// + /// Defaults to 0.0. + final double runSpacing; + + /// How the children within a run should be aligned relative to each other in + /// the cross axis. + /// + /// For example, if this is set to [WrapCrossAlignment.end], and the + /// [direction] is [Axis.horizontal], then the children within each + /// run will have their bottom edges aligned to the bottom edge of the run. + /// + /// Defaults to [WrapCrossAlignment.start]. + /// + /// See also: + /// + /// * [alignment], which controls how the children within each run are placed + /// relative to each other in the main axis. + /// * [runAlignment], which controls how the runs are placed relative to each + /// other in the cross axis. + final WrapCrossAlignment crossAxisAlignment; + + /// Determines the order to lay children out horizontally and how to interpret + /// `start` and `end` in the horizontal direction. + /// + /// Defaults to the ambient [Directionality]. + /// + /// If the [direction] is [Axis.horizontal], this controls order in which the + /// children are positioned (left-to-right or right-to-left), and the meaning + /// of the [alignment] property's [WrapAlignment.start] and + /// [WrapAlignment.end] values. + /// + /// If the [direction] is [Axis.horizontal], and either the + /// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or + /// there's more than one child, then the [textDirection] (or the ambient + /// [Directionality]) must not be null. + /// + /// If the [direction] is [Axis.vertical], this controls the order in which + /// runs are positioned, the meaning of the [runAlignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the + /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and + /// [WrapCrossAlignment.end] values. + /// + /// If the [direction] is [Axis.vertical], and either the + /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the + /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or + /// [WrapCrossAlignment.end], or there's more than one child, then the + /// [textDirection] (or the ambient [Directionality]) must not be null. + final TextDirection? textDirection; + + /// Determines the order to lay children out vertically and how to interpret + /// `start` and `end` in the vertical direction. + /// + /// If the [direction] is [Axis.vertical], this controls which order children + /// are painted in (down or up), the meaning of the [alignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values. + /// + /// If the [direction] is [Axis.vertical], and either the [alignment] + /// is either [WrapAlignment.start] or [WrapAlignment.end], or there's + /// more than one child, then the [verticalDirection] must not be null. + /// + /// If the [direction] is [Axis.horizontal], this controls the order in which + /// runs are positioned, the meaning of the [runAlignment] property's + /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the + /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and + /// [WrapCrossAlignment.end] values. + /// + /// If the [direction] is [Axis.horizontal], and either the + /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the + /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or + /// [WrapCrossAlignment.end], or there's more than one child, then the + /// [verticalDirection] must not be null. + final VerticalDirection verticalDirection; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + @override + ExtendedRenderWrap createRenderObject(BuildContext context) { + return ExtendedRenderWrap( + direction: direction, + alignment: alignment, + spacing: spacing, + runAlignment: runAlignment, + runSpacing: runSpacing, + crossAxisAlignment: crossAxisAlignment, + textDirection: textDirection ?? Directionality.maybeOf(context), + verticalDirection: verticalDirection, + clipBehavior: clipBehavior, + maxLines: maxLines, + hasOverflow: (overflowWidget != null)); + } + + @override + void updateRenderObject( + BuildContext context, ExtendedRenderWrap renderObject) { + renderObject + ..direction = direction + ..alignment = alignment + ..spacing = spacing + ..runAlignment = runAlignment + ..runSpacing = runSpacing + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = textDirection ?? Directionality.maybeOf(context) + ..verticalDirection = verticalDirection + ..maxLines = maxLines + // ..overflowWidget = overflowWidget + ..hasOverflow = (overflowWidget != null) + ..clipBehavior = clipBehavior; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('direction', direction)); + properties.add(EnumProperty('alignment', alignment)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('runAlignment', runAlignment)); + properties.add(DoubleProperty('runSpacing', runSpacing)); + properties.add(DoubleProperty('crossAxisAlignment', runSpacing)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add(EnumProperty( + 'verticalDirection', verticalDirection, + defaultValue: VerticalDirection.down)); + properties.add(IntProperty('maxLines', maxLines)); + } +} diff --git a/lib/ui/widgets/forward_message_screen.dart b/lib/ui/widgets/forward_message_screen.dart new file mode 100644 index 0000000..663865b --- /dev/null +++ b/lib/ui/widgets/forward_message_screen.dart @@ -0,0 +1,204 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/recent_conversation_list.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +GlobalKey<_ForwardMessageScreenState> forwardMessageScreenKey = GlobalKey(); + +class ForwardMessageScreen extends StatefulWidget { + final bool isMergerForward; + final ConvType conversationType; + final TUIChatSeparateViewModel model; + final VoidCallback? onClose; + + const ForwardMessageScreen( + {Key? key, this.isMergerForward = false, required this.conversationType, required this.model, this.onClose}) + : super(key: key); + + @override + State createState() => _ForwardMessageScreenState(); +} + +class _ForwardMessageScreenState extends TIMUIKitState { + final TUIChatGlobalModel model = serviceLocator(); + final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator(); + List _conversationList = []; + bool isMultiSelect = false; + + String _getMergerMessageTitle() { + if (widget.conversationType == ConvType.c2c) { + final option1 = + (_selfInfoViewModel.loginInfo?.nickName != null && _selfInfoViewModel.loginInfo!.nickName!.isNotEmpty) + ? _selfInfoViewModel.loginInfo?.nickName + : _selfInfoViewModel.loginInfo?.userID; + // Chat History for xx + return TIM_t_para("{{option1}}的聊天记录", "$option1的聊天记录")(option1: option1); + } else { + return TIM_t("群聊的聊天记录"); + } + } + + List _getAbstractList() { + return widget.model.getSelectedMessageList().map((e) { + final sender = (e.nickName != null && e.nickName!.isNotEmpty) ? e.nickName : e.sender; + return "$sender: ${model.abstractMessageBuilder != null ? model.abstractMessageBuilder!(e) : MessageUtils.getAbstractMessageAsync(e, [])}"; + }).toList(); + } + + handleForwardMessage() async { + var confirmResult = await _showConfirmForwardDialog(context); + if (confirmResult == null) { + return; + } + + if (widget.isMergerForward) { + await widget.model.sendMergerMessage( + conversationList: _conversationList, + title: _getMergerMessageTitle(), + abstractList: _getAbstractList(), + context: context, + ); + } else { + await widget.model.sendForwardMessage(conversationList: _conversationList); + } + widget.model.updateMultiSelectStatus(false); + + if (widget.onClose != null) { + // widget.onClose!(); + } else { + Navigator.pop(context); + } + } + + // 弹出转发确认对话框 + Future _showConfirmForwardDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: Text(TIM_t("您确定进行转发吗?")), + actions: [ + CupertinoDialogAction( + child: Text(TIM_t("确定")), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + CupertinoDialogAction( + child: Text(TIM_t("取消")), + isDestructiveAction: true, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + @override + void dispose() { + super.dispose(); + widget.model.updateMultiSelectStatus(false); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + final TUITheme theme = value.theme; + if (isDesktopScreen) { + isMultiSelect = true; + return RecentForwardList( + isMultiSelect: isMultiSelect, + onChanged: (conversationList) { + _conversationList = conversationList; + + if (!isMultiSelect) { + handleForwardMessage(); + } + }, + ); + } + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + isMultiSelect ? TIM_t("选择多个会话") : TIM_t("选择一个会话"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 17, + ), + ), + shadowColor: theme.weakBackgroundColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + leadingWidth: 80, + leading: TextButton( + onPressed: () { + if (isMultiSelect) { + setState(() { + isMultiSelect = false; + _conversationList = []; + }); + } else { + widget.model.updateMultiSelectStatus(false); + if (widget.onClose != null) { + widget.onClose!(); + } else { + Navigator.pop(context); + } + } + }, + child: Text( + TIM_t("取消"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + if (!isMultiSelect) { + setState(() { + isMultiSelect = true; + }); + } else { + handleForwardMessage(); + } + }, + child: Text( + !isMultiSelect ? TIM_t("多选") : TIM_t("完成"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 14, + ), + ), + ) + ], + ), + body: RecentForwardList( + isMultiSelect: isMultiSelect, + onChanged: (conversationList) { + _conversationList = conversationList; + + if (!isMultiSelect) { + handleForwardMessage(); + } + }, + ), + ); + } +} diff --git a/lib/ui/widgets/gestured_image.dart b/lib/ui/widgets/gestured_image.dart new file mode 100644 index 0000000..66e5437 --- /dev/null +++ b/lib/ui/widgets/gestured_image.dart @@ -0,0 +1,475 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:extended_image/extended_image.dart'; + +Map _gestureDetailsCache = + {}; + +///clear the gesture details +void clearGestureDetailsCache() { + _gestureDetailsCache.clear(); +} + +/// scale idea from https://github.com/flutter/flutter/blob/master/examples/layers/widgets/gestures.dart +/// zoom image +class GesturedImage extends ExtendedImageGesture { + const GesturedImage(ExtendedImageState extendedImageState, + {ImageBuilderForGesture? imageBuilder, + CanScaleImage? canScaleImage, + Key? key}) + : super(extendedImageState, + imageBuilder: imageBuilder, canScaleImage: canScaleImage, key: key); + + @override + GesturedImageState createState() => GesturedImageState(); +} + +class GesturedImageState extends ExtendedImageGestureState { + ///details for gesture + GestureDetails? _gestureDetails; + late Offset _normalizedOffset; + double? _startingScale; + late Offset _startingOffset; + Offset? _pointerDownPosition; + late GestureAnimation _gestureAnimation; + GestureConfig? _gestureConfig; + ExtendedImageGesturePageViewState? _pageViewState; + @override + ExtendedImageSlidePageState? get extendedImageSlidePageState => + widget.extendedImageState.slidePageState; + + @override + GestureDetails? get gestureDetails => _gestureDetails; + + @override + set gestureDetails(GestureDetails? value) { + if (mounted) { + setState(() { + _gestureDetails = value; + _gestureConfig?.gestureDetailsIsChanged?.call(_gestureDetails); + }); + } + } + + @override + GestureConfig? get imageGestureConfig => _gestureConfig; + + @override + Offset? get pointerDownPosition => _pointerDownPosition; + + @override + Widget build(BuildContext context) { + if (_gestureConfig!.cacheGesture) { + _gestureDetailsCache[widget.extendedImageState.imageStreamKey] = + _gestureDetails; + } + + Widget image = ExtendedRawImage( + image: widget.extendedImageState.extendedImageInfo?.image, + width: widget.extendedImageState.imageWidget.width, + height: widget.extendedImageState.imageWidget.height, + scale: widget.extendedImageState.extendedImageInfo?.scale ?? 1.0, + color: widget.extendedImageState.imageWidget.color, + colorBlendMode: widget.extendedImageState.imageWidget.colorBlendMode, + fit: widget.extendedImageState.imageWidget.fit, + alignment: widget.extendedImageState.imageWidget.alignment, + repeat: widget.extendedImageState.imageWidget.repeat, + centerSlice: widget.extendedImageState.imageWidget.centerSlice, + matchTextDirection: + widget.extendedImageState.imageWidget.matchTextDirection, + invertColors: widget.extendedImageState.invertColors, + filterQuality: widget.extendedImageState.imageWidget.filterQuality, + beforePaintImage: widget.extendedImageState.imageWidget.beforePaintImage, + afterPaintImage: widget.extendedImageState.imageWidget.afterPaintImage, + gestureDetails: _gestureDetails, + ); + + if (extendedImageSlidePageState != null) { + image = widget.extendedImageState.imageWidget.heroBuilderForSlidingPage + ?.call(image) ?? + image; + if (extendedImageSlidePageState!.widget.slideType == + SlideType.onlyImage) { + image = Transform.translate( + offset: extendedImageSlidePageState!.offset, + child: Transform.scale( + scale: extendedImageSlidePageState!.scale, + child: image, + ), + ); + } + } + + image = widget.imageBuilder?.call(image) ?? image; + + image = GestureDetector( + onScaleStart: handleScaleStart, + onScaleUpdate: handleScaleUpdate, + onScaleEnd: handleScaleEnd, + onDoubleTap: _handleDoubleTap, + child: image, + behavior: _gestureConfig?.hitTestBehavior, + ); + + image = Listener( + child: image, + onPointerDown: _handlePointerDown, + onPointerSignal: _handlePointerSignal, + behavior: _gestureConfig!.hitTestBehavior, + ); + + return image; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pageViewState = null; + if (_gestureConfig!.inPageView) { + _pageViewState = + context.findAncestorStateOfType(); + _pageViewState?.extendedImageGestureState = this; + } + } + + @override + void didUpdateWidget(GesturedImage oldWidget) { + super.didUpdateWidget(oldWidget); + _initGestureConfig(); + _pageViewState = null; + if (_gestureConfig!.inPageView) { + _pageViewState = + context.findAncestorStateOfType(); + _pageViewState?.extendedImageGestureState = this; + } + } + + @override + void dispose() { + _gestureAnimation.stop(); + _gestureAnimation.dispose(); + _pageViewState?.extendedImageGestureStates.remove(this); + super.dispose(); + } + + @override + void handleDoubleTap({double? scale, Offset? doubleTapPosition}) { + doubleTapPosition ??= _pointerDownPosition; + scale ??= _gestureConfig!.initialScale; + //scale = scale.clamp(_gestureConfig.minScale, _gestureConfig.maxScale); + handleScaleStart(ScaleStartDetails(focalPoint: doubleTapPosition!)); + handleScaleUpdate(ScaleUpdateDetails( + focalPoint: doubleTapPosition, + scale: scale / _startingScale!, + focalPointDelta: Offset.zero, + )); + if (scale < _gestureConfig!.minScale || scale > _gestureConfig!.maxScale) { + handleScaleEnd(ScaleEndDetails()); + } + } + + @override + void initState() { + super.initState(); + _initGestureConfig(); + } + + @override + void reset() { + _gestureConfig = widget + .extendedImageState.imageWidget.initGestureConfigHandler + ?.call(widget.extendedImageState) ?? + GestureConfig(); + + gestureDetails = GestureDetails( + totalScale: _gestureConfig!.initialScale, + offset: Offset.zero, + initialAlignment: _gestureConfig!.initialAlignment, + ); + } + + @override + void slide() { + if (mounted) { + setState(() { + _gestureDetails!.slidePageOffset = extendedImageSlidePageState?.offset; + }); + } + } + + void _handleDoubleTap() { + if (widget.extendedImageState.imageWidget.onDoubleTap != null) { + widget.extendedImageState.imageWidget.onDoubleTap!(this); + return; + } + + if (!mounted) { + return; + } + + gestureDetails = GestureDetails( + offset: Offset.zero, + totalScale: _gestureConfig!.initialScale, + ); + } + + void _handlePointerDown(PointerDownEvent pointerDownEvent) { + _pointerDownPosition = pointerDownEvent.position; + _gestureAnimation.stop(); + + _pageViewState?.extendedImageGestureState = this; + } + + void _handlePointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent && event.kind == PointerDeviceKind.mouse) { + handleScaleStart(ScaleStartDetails(focalPoint: event.position)); + final double dy = event.scrollDelta.dy; + final double dx = event.scrollDelta.dx; + handleScaleUpdate(ScaleUpdateDetails( + focalPoint: event.position, + scale: 1.0 + + _reverseIf((dy.abs() > dx.abs() ? dy : dx) * + _gestureConfig!.speed / + 1000.0), + focalPointDelta: Offset.zero)); + handleScaleEnd(ScaleEndDetails()); + } + } + + @override + void handleScaleEnd(ScaleEndDetails details) { + if (extendedImageSlidePageState != null && + extendedImageSlidePageState!.isSliding) { + extendedImageSlidePageState!.endSlide(details); + return; + } + + if (_pageViewState != null && _pageViewState!.isDraging) { + _pageViewState!.onDragEnd(DragEndDetails( + velocity: details.velocity, + primaryVelocity: + _pageViewState!.widget.scrollDirection == Axis.horizontal + ? details.velocity.pixelsPerSecond.dx + : details.velocity.pixelsPerSecond.dy, + )); + return; + } + + //animate back to maxScale if gesture exceeded the maxScale specified + if (_gestureDetails!.totalScale!.greaterThan(_gestureConfig!.maxScale)) { + final double velocity = + (_gestureDetails!.totalScale! - _gestureConfig!.maxScale) / + _gestureConfig!.maxScale; + + _gestureAnimation.animationScale( + _gestureDetails!.totalScale, _gestureConfig!.maxScale, velocity); + return; + } + + //animate back to minScale if gesture fell smaller than the minScale specified + if (_gestureDetails!.totalScale!.lessThan(_gestureConfig!.minScale)) { + final double velocity = + (_gestureConfig!.minScale - _gestureDetails!.totalScale!) / + _gestureConfig!.minScale; + + _gestureAnimation.animationScale( + _gestureDetails!.totalScale, _gestureConfig!.minScale, velocity); + return; + } + + if (_gestureDetails!.actionType == ActionType.pan) { + // get magnitude from gesture velocity + final double magnitude = details.velocity.pixelsPerSecond.distance; + + // do a significant magnitude + if (magnitude.greaterThanOrEqualTo(minMagnitude)) { + final Offset direction = details.velocity.pixelsPerSecond / + magnitude * + _gestureConfig!.inertialSpeed; + + _gestureAnimation.animationOffset( + _gestureDetails!.offset, _gestureDetails!.offset! + direction); + } + } + } + + @override + void handleScaleStart(ScaleStartDetails details) { + _gestureAnimation.stop(); + _normalizedOffset = (details.focalPoint - _gestureDetails!.offset!) / + _gestureDetails!.totalScale!; + _startingScale = _gestureDetails!.totalScale; + _startingOffset = details.focalPoint; + } + + @override + void handleScaleUpdate(ScaleUpdateDetails details) { + // 取消原组件对totalScale的判断。这样scale大于1也能执行slidePage的slide方法 + if (extendedImageSlidePageState != null && + _gestureDetails!.userOffset && + _gestureDetails!.actionType == ActionType.pan) { + final Offset totalDelta = details.focalPointDelta; + bool updateGesture = false; + if (!extendedImageSlidePageState!.isSliding) { + if (totalDelta.dx != 0 && + totalDelta.dx.abs().greaterThan(totalDelta.dy.abs())) { + if (_gestureDetails!.computeHorizontalBoundary) { + if (totalDelta.dx > 0) { + updateGesture = _gestureDetails!.boundary.left; + } else { + updateGesture = _gestureDetails!.boundary.right; + } + } else { + updateGesture = true; + } + } + if (totalDelta.dy != 0 && + totalDelta.dy.abs().greaterThan(totalDelta.dx.abs())) { + if (_gestureDetails!.computeVerticalBoundary) { + if (totalDelta.dy < 0) { + updateGesture = _gestureDetails!.boundary.bottom; + } else { + updateGesture = _gestureDetails!.boundary.top; + } + } else { + updateGesture = true; + } + } + } else { + updateGesture = true; + } + + if (details.focalPointDelta.distance.greaterThan(minGesturePageDelta) && + updateGesture) { + extendedImageSlidePageState!.slide( + details.focalPointDelta, + extendedImageGestureState: this, + ); + } + } + + if (extendedImageSlidePageState != null && + extendedImageSlidePageState!.isSliding) { + return; + } + + // totalScale > 1 and page view is starting to move + if (_pageViewState != null) { + final ExtendedImageGesturePageViewState pageViewState = _pageViewState!; + + final Axis axis = pageViewState.widget.scrollDirection; + final bool movePage = _pageViewState!.isDraging || + (details.pointerCount == 1 && + details.scale == 1 && + _gestureDetails!.movePage(details.focalPointDelta, axis)); + + if (movePage) { + if (!pageViewState.isDraging) { + pageViewState + .onDragDown(DragDownDetails(globalPosition: details.focalPoint)); + pageViewState.onDragStart( + DragStartDetails(globalPosition: details.focalPoint)); + //assert(!pageViewState.isDraging); + } + Offset delta = details.focalPointDelta; + delta = + axis == Axis.horizontal ? Offset(delta.dx, 0) : Offset(0, delta.dy); + + pageViewState.onDragUpdate(DragUpdateDetails( + globalPosition: details.focalPoint, + delta: delta, + primaryDelta: axis == Axis.horizontal ? delta.dx : delta.dy, + )); + + return; + } + } + final double? scale = widget.canScaleImage(_gestureDetails) + ? clampScale( + _startingScale! * details.scale * _gestureConfig!.speed, + _gestureConfig!.animationMinScale, + _gestureConfig!.animationMaxScale) + : _gestureDetails!.totalScale; + + //Round the scale to three points after comma to prevent shaking + //scale = roundAfter(scale, 3); + //no more zoom + if (details.scale != 1.0 && + ((_gestureDetails!.totalScale! + .equalTo(_gestureConfig!.animationMinScale) && + scale!.lessThanOrEqualTo(_gestureDetails!.totalScale!)) || + (_gestureDetails!.totalScale! + .equalTo(_gestureConfig!.animationMaxScale) && + scale!.greaterThanOrEqualTo(_gestureDetails!.totalScale!)))) { + return; + } + + final Offset offset = (details.scale == 1.0 + ? details.focalPoint * _gestureConfig!.speed + : _startingOffset) - + _normalizedOffset * scale!; + + if (mounted && + (offset != _gestureDetails!.offset || + scale != _gestureDetails!.totalScale)) { + gestureDetails = GestureDetails( + offset: offset, + totalScale: scale, + gestureDetails: _gestureDetails, + actionType: details.scale != 1.0 ? ActionType.zoom : ActionType.pan); + } + } + + void _initGestureConfig() { + final double? initialScale = _gestureConfig?.initialScale; + final InitialAlignment? initialAlignment = _gestureConfig?.initialAlignment; + _gestureConfig = widget + .extendedImageState.imageWidget.initGestureConfigHandler + ?.call(widget.extendedImageState) ?? + GestureConfig(); + + if (_gestureDetails == null || + initialScale != _gestureConfig!.initialScale || + initialAlignment != _gestureConfig!.initialAlignment) { + _gestureDetails = GestureDetails( + totalScale: _gestureConfig!.initialScale, + offset: Offset.zero, + initialAlignment: _gestureConfig!.initialAlignment, + ); + } + + if (_gestureConfig!.cacheGesture) { + final GestureDetails? cache = + _gestureDetailsCache[widget.extendedImageState.imageStreamKey]; + if (cache != null) { + _gestureDetails = cache; + } + } + _gestureDetails ??= GestureDetails( + totalScale: _gestureConfig!.initialScale, + offset: Offset.zero, + ); + + _gestureAnimation = GestureAnimation(this, offsetCallBack: (Offset value) { + gestureDetails = GestureDetails( + offset: value, + totalScale: _gestureDetails!.totalScale, + gestureDetails: _gestureDetails); + }, scaleCallBack: (double scale) { + gestureDetails = GestureDetails( + offset: _gestureDetails!.offset, + totalScale: scale, + gestureDetails: _gestureDetails, + actionType: ActionType.zoom, + userOffset: false); + }); + } + + double _reverseIf(double scaleDetal) { + if (_gestureConfig?.reverseMousePointerScrollDirection ?? false) { + return -scaleDetal; + } else { + return scaleDetal; + } + } +} diff --git a/lib/ui/widgets/group_member_list.dart b/lib/ui/widgets/group_member_list.dart new file mode 100644 index 0000000..48a9582 --- /dev/null +++ b/lib/ui/widgets/group_member_list.dart @@ -0,0 +1,307 @@ +// ignore_for_file: must_be_immutable + +import 'package:azlistview_all_platforms/azlistview_all_platforms.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable_plus_plus/flutter_slidable_plus_plus.dart'; +import 'package:lpinyin/lpinyin.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class GroupProfileMemberList extends StatefulWidget { + static String AT_ALL_USER_ID = "__kImSDK_MesssageAtALL__"; + final List memberList; + final Function(String userID)? removeMember; + final bool canSlideDelete; + final bool canSelectMember; + final bool canAtAll; + + // when the @ need filter some group types + final String? groupType; + final Function(List selectedMember)? onSelectedMemberChange; + // notice: onTapMemberItem and onSelectedMemberChange use together will triger together + final Function(V2TimGroupMemberFullInfo memberInfo, TapDownDetails? tapDetails)? onTapMemberItem; + // When sliding to the bottom bar callBack + final Function()? touchBottomCallBack; + + final int? maxSelectNum; + + Widget? customTopArea; + + GroupProfileMemberList({ + Key? key, + required this.memberList, + this.groupType, + this.removeMember, + this.canSlideDelete = true, + this.canSelectMember = false, + this.canAtAll = false, + this.onSelectedMemberChange, + this.onTapMemberItem, + this.customTopArea, + this.touchBottomCallBack, + this.maxSelectNum, + }) : super(key: key); + + @override + State createState() => _GroupProfileMemberListState(); +} + +class _GroupProfileMemberListState extends TIMUIKitState { + List selectedMemberList = []; + + _getShowName(V2TimGroupMemberFullInfo? item) { + final friendRemark = item?.friendRemark ?? ""; + final nameCard = item?.nameCard ?? ""; + final nickName = item?.nickName ?? ""; + final userID = item?.userID ?? ""; + return friendRemark.isNotEmpty + ? friendRemark + : nameCard.isNotEmpty + ? nameCard + : nickName.isNotEmpty + ? nickName + : userID; + } + + List _getShowList(List memberList) { + final List showList = List.empty(growable: true); + for (var i = 0; i < memberList.length; i++) { + final item = memberList[i]; + final showName = _getShowName(item); + if (item?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER || + item?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN) { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "@")); + } else { + String pinyin = PinyinHelper.getPinyinE(showName); + String tag = pinyin.substring(0, 1).toUpperCase(); + if (RegExp("[A-Z]").hasMatch(tag)) { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: tag)); + } else { + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#")); + } + } + } + + SuspensionUtil.sortListBySuspensionTag(showList); + + // add @everyone item + if (widget.canAtAll) { + final canAtGroupType = ["Work", "Public", "Meeting"]; + if (canAtGroupType.contains(widget.groupType)) { + showList.insert( + 0, + ISuspensionBeanImpl( + memberInfo: + V2TimGroupMemberFullInfo(userID: GroupProfileMemberList.AT_ALL_USER_ID, nickName: TIM_t("所有人")), + tagIndex: "")); + } + } + + return showList; + } + + Widget _buildListItem(BuildContext context, V2TimGroupMemberFullInfo memberInfo) { + final theme = Provider.of(context).theme; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + final isGroupMember = memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER; + return Container( + color: Colors.white, + child: Slidable( + endActionPane: widget.canSlideDelete && isGroupMember + ? ActionPane(motion: const DrawerMotion(), children: [ + SlidableAction( + onPressed: (_) { + if (widget.removeMember != null) { + widget.removeMember!(memberInfo.userID); + } + }, + flex: 1, + backgroundColor: theme.cautionColor ?? CommonColor.cautionColor, + autoClose: true, + label: TIM_t("删除"), + ) + ]) + : null, + child: Column(children: [ + ListTile( + tileColor: Colors.black, + title: Row( + children: [ + if (widget.canSelectMember) + Container( + margin: const EdgeInsets.only(right: 10), + child: CheckBoxButton( + onChanged: (isChecked) { + if (isChecked) { + if (widget.maxSelectNum != null && selectedMemberList.length >= widget.maxSelectNum!) { + return; + } + selectedMemberList.add(memberInfo); + } else { + selectedMemberList.removeWhere((element) => element.userID == memberInfo.userID); + } + + if (widget.onSelectedMemberChange != null) { + widget.onSelectedMemberChange!(selectedMemberList); + } + setState(() {}); + }, + isChecked: selectedMemberList + .where((element) => element.userID == memberInfo.userID) + .toList() + .isNotEmpty), + ), + Container( + width: isDesktopScreen ? 30 : 36, + height: isDesktopScreen ? 30 : 36, + margin: const EdgeInsets.only(right: 10), + child: Avatar( + faceUrl: memberInfo.faceUrl ?? "", + showName: _getShowName(memberInfo), + type: 1, + ), + ), + Text(_getShowName(memberInfo), style: TextStyle(fontSize: isDesktopScreen ? 14 : 16)), + memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER + ? Container( + margin: const EdgeInsets.only(left: 5), + child: Text(TIM_t("群主"), + style: TextStyle( + color: theme.ownerColor, + fontSize: isDesktopScreen ? 10 : 12, + )), + padding: const EdgeInsets.fromLTRB(5, 0, 5, 0), + decoration: BoxDecoration( + border: Border.all(color: theme.ownerColor ?? CommonColor.ownerColor, width: 1), + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + ), + ) + : memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN + ? Container( + margin: const EdgeInsets.only(left: 5), + child: Text(TIM_t("管理员"), + style: TextStyle( + color: theme.adminColor, + fontSize: 12, + )), + padding: const EdgeInsets.fromLTRB(5, 0, 5, 0), + decoration: BoxDecoration( + border: Border.all(color: theme.adminColor ?? CommonColor.adminColor, width: 1), + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + ), + ) + : Container() + ], + ), + onTap: () { + if (widget.onTapMemberItem != null) { + widget.onTapMemberItem!(memberInfo, null); + } + if (widget.canSelectMember) { + final isChecked = selectedMemberList.contains(memberInfo); + if (isChecked) { + selectedMemberList.remove(memberInfo); + } else { + if (widget.maxSelectNum != null && selectedMemberList.length >= widget.maxSelectNum!) { + return; + } + selectedMemberList.add(memberInfo); + } + if (widget.onSelectedMemberChange != null) { + widget.onSelectedMemberChange!(selectedMemberList); + } + setState(() {}); + } + }, + ), + Divider(thickness: 1, indent: 74, endIndent: 0, color: theme.weakBackgroundColor, height: 0) + ]))); + } + + static Widget getSusItem(BuildContext context, TUITheme theme, String tag, {double susHeight = 40}) { + if (tag == '@') { + tag = TIM_t("群主、管理员"); + } + return Container( + height: susHeight, + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.only(left: 16.0), + color: theme.weakBackgroundColor, + alignment: Alignment.centerLeft, + child: Text( + tag, + softWrap: true, + style: TextStyle( + fontSize: 14.0, + color: theme.darkTextColor, + ), + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop; + + final throteFunction = OptimizeUtils.throttle((ScrollNotification notification) { + final pixels = notification.metrics.pixels; + // 总像素高度 + final maxScrollExtent = notification.metrics.maxScrollExtent; + // 滑动百分比 + final progress = pixels / maxScrollExtent; + if (progress >= 0.9 && widget.touchBottomCallBack != null) { + widget.touchBottomCallBack!(); + } + }, 300); + final showList = _getShowList(widget.memberList); + return Container( + color: isDesktopScreen ? null : theme.weakBackgroundColor, + child: SafeArea( + child: Column( + children: [ + widget.customTopArea != null ? widget.customTopArea! : Container(), + Expanded( + child: NotificationListener( + onNotification: (ScrollNotification notification) { + throteFunction(notification); + return true; + }, + child: (showList.isEmpty) + ? Center( + child: Text(TIM_t("暂无群成员")), + ) + : Container( + padding: isDesktopScreen ? const EdgeInsets.symmetric(horizontal: 16) : null, + child: AZListViewContainer( + memberList: showList, + susItemBuilder: (context, index) { + final model = showList[index]; + return getSusItem(context, theme, model.getSuspensionTag()); + }, + itemBuilder: (context, index) { + final memberInfo = showList[index].memberInfo as V2TimGroupMemberFullInfo; + + return _buildListItem(context, memberInfo); + }), + ), + )) + ], + )), + ); + } +} diff --git a/lib/ui/widgets/image_hero.dart b/lib/ui/widgets/image_hero.dart new file mode 100644 index 0000000..3fe7ed6 --- /dev/null +++ b/lib/ui/widgets/image_hero.dart @@ -0,0 +1,106 @@ +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; + +/// make hero better when slide out +class HeroWidget extends StatefulWidget { + const HeroWidget( + {required this.child, + required this.tag, + required this.slidePagekey, + this.slideType = SlideType.onlyImage, + Key? key}) + : super(key: key); + final Widget child; + final SlideType slideType; + final Object tag; + final GlobalKey slidePagekey; + @override + _HeroWidgetState createState() => _HeroWidgetState(); +} + +class _HeroWidgetState extends TIMUIKitState { + RectTween? _rectTween; + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return Hero( + tag: widget.tag, + createRectTween: (Rect? begin, Rect? end) { + _rectTween = RectTween(begin: begin, end: end); + return _rectTween!; + }, + // make hero better when slide out + flightShuttleBuilder: (BuildContext flightContext, + Animation animation, + HeroFlightDirection flightDirection, + BuildContext fromHeroContext, + BuildContext toHeroContext) { + // make hero more smoothly + final Hero hero = (flightDirection == HeroFlightDirection.pop + ? fromHeroContext.widget + : toHeroContext.widget) as Hero; + if (_rectTween == null) { + return hero; + } + + if (flightDirection == HeroFlightDirection.pop) { + final bool fixTransform = widget.slideType == SlideType.onlyImage && + (widget.slidePagekey.currentState!.offset != Offset.zero || + widget.slidePagekey.currentState!.scale != 1.0); + + final Widget toHeroWidget = (toHeroContext.widget as Hero).child; + return AnimatedBuilder( + animation: animation, + builder: (BuildContext buildContext, Widget? child) { + Widget animatedBuilderChild = hero.child; + + // make hero more smoothly + animatedBuilderChild = Stack( + clipBehavior: Clip.antiAlias, + alignment: Alignment.center, + children: [ + Opacity( + opacity: 1 - animation.value, + child: UnconstrainedBox( + child: SizedBox( + width: _rectTween!.begin!.width, + height: _rectTween!.begin!.height, + child: toHeroWidget, + ), + ), + ), + Opacity( + opacity: animation.value, + child: animatedBuilderChild, + ) + ], + ); + + // fix transform when slide out + if (fixTransform) { + final Tween offsetTween = Tween( + begin: Offset.zero, + end: widget.slidePagekey.currentState!.offset); + + final Tween scaleTween = Tween( + begin: 1.0, end: widget.slidePagekey.currentState!.scale); + animatedBuilderChild = Transform.translate( + offset: offsetTween.evaluate(animation), + child: Transform.scale( + scale: scaleTween.evaluate(animation), + child: animatedBuilderChild, + ), + ); + } + + return animatedBuilderChild; + }, + ); + } + return hero.child; + }, + child: widget.child, + ); + } +} diff --git a/lib/ui/widgets/image_screen.dart b/lib/ui/widgets/image_screen.dart new file mode 100644 index 0000000..50d987e --- /dev/null +++ b/lib/ui/widgets/image_screen.dart @@ -0,0 +1,298 @@ +import 'dart:math'; + +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/gestured_image.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/image_hero.dart'; + +typedef DoubleClickAnimationListener = void Function(); + +class ImageScreen extends StatefulWidget { + const ImageScreen( + {required this.imageProvider, + required this.heroTag, + this.downloadFn, + this.messageID, + Key? key}) + : super(key: key); + + final ImageProvider imageProvider; + final String heroTag; + final String? messageID; + final Future Function()? downloadFn; + + @override + State createState() { + return _ImageScreenState(); + } +} + +class _ImageScreenState extends TIMUIKitState + with TickerProviderStateMixin { + Animation? _doubleClickAnimation; + late DoubleClickAnimationListener _doubleClickAnimationListener; + late AnimationController _doubleClickAnimationController; + List doubleTapScales = [1.0, 2.0]; + double currentScale = 1.0; + double fittedScale = 1.0; + double initialScale = 1.0; + bool isLoading = false; + + GlobalKey slidePageKey = + GlobalKey(); + GlobalKey extendedImageGestureKey = + GlobalKey(); + + void close() { + slidePageKey.currentState!.popPage(); + Navigator.pop(context); + } + + @override + void initState() { + super.initState(); + // 允许横屏 + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + _doubleClickAnimationController = AnimationController( + duration: const Duration(milliseconds: 150), vsync: this); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + _doubleClickAnimationController.dispose(); + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return OrientationBuilder(builder: ((context, orientation) { + if (extendedImageGestureKey.currentState != null) { + extendedImageGestureKey.currentState!.reset(); + } + + return Material( + color: Colors.transparent, + child: Container( + color: Colors.transparent, + constraints: BoxConstraints.expand( + height: MediaQuery.of(context).size.height, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned( + top: 0, + left: 0, + bottom: 0, + right: 0, + child: ExtendedImageSlidePage( + key: slidePageKey, + slideAxis: SlideAxis.both, + slidePageBackgroundHandler: (Offset offset, Size size) { + if (orientation == Orientation.landscape) { + return Colors.black; + } + double opacity = 0.0; + opacity = offset.distance / + (Offset(size.width, size.height).distance / 2.0); + return Colors.black + .withOpacity(min(1.0, max(1.0 - opacity, 0.0))); + }, + slideType: SlideType.onlyImage, + slideEndHandler: ( + Offset offset, { + ExtendedImageSlidePageState? state, + ScaleEndDetails? details, + }) { + final vy = details?.velocity.pixelsPerSecond.dy ?? 0; + final oy = offset.dy; + if (vy > 300 || oy > 100) { + return true; + } + return null; + }, + child: GestureDetector( + onTap: close, + child: HeroWidget( + tag: widget.heroTag, + slidePagekey: slidePageKey, + child: ExtendedImage( + image: widget.imageProvider, + extendedImageGestureKey: + extendedImageGestureKey, + enableSlideOutPage: true, + // fit: BoxFit.scaleDown, + initGestureConfigHandler: (state) { + return GestureConfig( + minScale: 0.8, + animationMinScale: 0.6, + maxScale: 2 * fittedScale, + animationMaxScale: 2.5 * fittedScale, + speed: 1.0, + inertialSpeed: 100.0, + initialScale: initialScale, + initialAlignment: + InitialAlignment.topCenter, + hitTestBehavior: HitTestBehavior.opaque, + ); + }, + loadStateChanged: (ExtendedImageState state) { + switch (state.extendedImageLoadState) { + case LoadState.loading: + return Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator( + color: Colors.white)) + ); + case LoadState.completed: + final screenHeight = + MediaQuery.of(context).size.height; + final screenWidth = + MediaQuery.of(context).size.width; + final imgHeight = state.extendedImageInfo + ?.image.height ?? + 1; + final imgWidth = state + .extendedImageInfo?.image.width ?? + 0; + final imgRatio = imgWidth / imgHeight; + final screenRatio = + screenWidth / screenHeight; + final fitWidthScale = + screenRatio / imgRatio; + if (screenRatio > imgRatio) { + // Long Image + // initialScale = fitWidthScale; + fittedScale = fitWidthScale; + doubleTapScales[1] = fitWidthScale; + } else { + fittedScale = + 1 / fitWidthScale; // fittedHeight + doubleTapScales[1] = 1 / fitWidthScale; + } + + return GesturedImage(state, + key: extendedImageGestureKey); + case LoadState.failed: + break; + } + return null; + }, + onDoubleTap: (ExtendedImageGestureState state) { + ///you can use define pointerDownPosition as you can, + ///default value is double tap pointer down position. + final Offset? pointerDownPosition = + state.pointerDownPosition; + final double? begin = + state.gestureDetails!.totalScale; + double end; + + //remove old + _doubleClickAnimation?.removeListener( + _doubleClickAnimationListener); + + //stop pre + _doubleClickAnimationController.stop(); + + //reset to use + _doubleClickAnimationController.reset(); + + if (begin == doubleTapScales[0]) { + end = doubleTapScales[1]; + } else { + end = doubleTapScales[0]; + } + + _doubleClickAnimationListener = () { + //outputLogger.i(_animation.value); + state.handleDoubleTap( + scale: _doubleClickAnimation!.value, + doubleTapPosition: pointerDownPosition); + }; + _doubleClickAnimation = + _doubleClickAnimationController.drive( + Tween( + begin: begin, end: end)); + + _doubleClickAnimation!.addListener( + _doubleClickAnimationListener); + + _doubleClickAnimationController.forward(); + }, + mode: ExtendedImageMode.gesture, + )), + ), + ), + ), + Positioned( + left: 10, + bottom: 50, + child: SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Image.asset( + 'images/close.png', + package: 'tencent_cloud_chat_uikit', + ), + iconSize: 30, + onPressed: close, + ), + )), + if (widget.downloadFn != null) + Positioned( + right: 10, + bottom: 50, + child: SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Image.asset( + 'images/download.png', + package: 'tencent_cloud_chat_uikit', + ), + iconSize: 30, + onPressed: () async { + setState(() { + isLoading = true; + }); + await widget.downloadFn!(); + Future.delayed(const Duration(milliseconds: 200),(){ + setState(() { + isLoading = false; + }); + }); + }, + ), + ), + ), + if (isLoading) + Container( + child: LoadingAnimationWidget.staggeredDotsWave( + size: 35, + color: Colors.white, + ), + padding: const EdgeInsets.all(30), + decoration: const BoxDecoration( + color: Color(0xB22b2b2b), + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + ])), + ); + })); + } +} diff --git a/lib/ui/widgets/keepalive_wrapper.dart b/lib/ui/widgets/keepalive_wrapper.dart new file mode 100644 index 0000000..da8f5bf --- /dev/null +++ b/lib/ui/widgets/keepalive_wrapper.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; + +class KeepAliveWrapper extends StatefulWidget { + const KeepAliveWrapper({ + Key? key, + this.keepAlive = true, + required this.child, + }) : super(key: key); + final bool keepAlive; + final Widget child; + + @override + _KeepAliveWrapperState createState() => _KeepAliveWrapperState(); +} + +class _KeepAliveWrapperState extends State with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return RepaintBoundary( + child: widget.child, + ); + } + + @override + void didUpdateWidget(covariant KeepAliveWrapper oldWidget) { + if (oldWidget.keepAlive != widget.keepAlive) { + // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中 + updateKeepAlive(); + } + super.didUpdateWidget(oldWidget); + } + + @override + bool get wantKeepAlive => widget.keepAlive; +} diff --git a/lib/ui/widgets/link_preview/common/extensions.dart b/lib/ui/widgets/link_preview/common/extensions.dart new file mode 100644 index 0000000..50ab8c1 --- /dev/null +++ b/lib/ui/widgets/link_preview/common/extensions.dart @@ -0,0 +1,8 @@ +/// Extensions on [Uri] +extension UriX on Uri { + /// Return the URI adding the http scheme if it is missing + Uri get withScheme { + if (hasScheme) return this; + return Uri.parse('http://${toString()}'); + } +} diff --git a/lib/ui/widgets/link_preview/common/utils.dart b/lib/ui/widgets/link_preview/common/utils.dart new file mode 100644 index 0000000..1ded50b --- /dev/null +++ b/lib/ui/widgets/link_preview/common/utils.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:link_preview_generator_for_us/link_preview_generator.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/extensions.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LinkUtils { + static RegExp urlReg = RegExp( + r"([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/|[wW]{3}.|[wW][aA][pP].|[fF][tT][pP].|[fF][iI][lL][eE].)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]"); + + /// Get all the URL from a text message + static List getURLMatches(String textMessage) { + final matches = urlReg.allMatches(textMessage).toList(); + + List urlMatches = []; + + for (Match m in matches) { + String match = m.group(0) ?? ""; + urlMatches.add(match); + } + + return urlMatches; + } + + /// Launch URL + static Future launchURL(BuildContext context, String url) async { + try { + await launchUrl( + Uri.parse(url).withScheme, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(TIM_t("无法打开URL"))), // Cannot launch the url + ); + } + } + + /// Get color + static Color hexToColor(String hexString) { + return Color(int.parse(hexString, radix: 16)).withAlpha(255); + } + + /// Get the URL preview information + static Future> getURLPreview(List urlMatches) async { + // Request for preview information for all URL links synchronously + final List urlPreview = await Future.wait(urlMatches.map((e) async { + String url = e; + if (!e.contains("http")) { + url = 'http://$e'; + } + final WebInfo info = await LinkPreviewForUs.scrapeFromURL(url); + + return LocalCustomDataModel(url: e, title: info.title, image: info.image, description: info.description); + })); + + return urlPreview; + } + + /// save the link info to local and call updating the message on UI, only works with [onUpdateMessage] + static Future saveToLocalAndUpdate( + V2TimMessage message, LocalCustomDataModel previewItem, ValueChanged onUpdateMessage) async { + if (message.msgID != null) { + String saveInfo = LinkPreviewEntry.linkInfoToString(previewItem); + final currentInfo = message.localCustomData; + if (currentInfo != null && currentInfo.isNotEmpty) { + final Map data = json.decode(currentInfo); + data['url'] = previewItem.url; + data['image'] = previewItem.image; + data['title'] = previewItem.title; + data['description'] = previewItem.description; + saveInfo = json.encode(data); + } + message.localCustomData = saveInfo; + if (saveInfo != currentInfo) { + final result = await TencentImSDKPlugin.v2TIMManager.v2TIMMessageManager + .setLocalCustomData(msgID: message.msgID!, localCustomData: saveInfo); + if (result.code == 0) { + onUpdateMessage(message); + } + } + } + } +} diff --git a/lib/ui/widgets/link_preview/compiler/md_text.dart b/lib/ui/widgets/link_preview/compiler/md_text.dart new file mode 100644 index 0000000..988add7 --- /dev/null +++ b/lib/ui/widgets/link_preview/compiler/md_text.dart @@ -0,0 +1,31 @@ +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +RegExp emojiExp = RegExp(r'\[([\u4e00-\u9fa5A-Za-z0-9]+)\]'); + +String mdTextCompiler(String originalText, { + bool isUseQQPackage = false, + bool isUseTencentCloudChatPackage = false, + bool isUseTencentCloudChatPackageOldKeys = false, + List customEmojiStickerList = const [], +}) { + String text = originalText; + final EmojiUtil emojiUtil = EmojiUtil( + isUseQQPackage: isUseQQPackage, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + customEmojiStickerList: customEmojiStickerList); + + text = text.replaceAllMapped(emojiExp, (match) { + String key = match.group(0)!; + + // Check if the emoji exists in the emoji map + if (emojiUtil.emojiMap.containsKey(key)) { + String assetPath = emojiUtil.emojiMap[key]!; + return '![sticker](resource:$assetPath#22x22)'; + } + return key; + }); + + return text; +} \ No newline at end of file diff --git a/lib/ui/widgets/link_preview/link_preview_entry.dart b/lib/ui/widgets/link_preview/link_preview_entry.dart new file mode 100644 index 0000000..6ae3f98 --- /dev/null +++ b/lib/ui/widgets/link_preview/link_preview_entry.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_preview.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_text.dart'; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +import 'models/link_preview_content.dart'; + +class LinkPreviewEntry { + /// get the text message with hyperlinks + static LinkPreviewText? getHyperlinksText(String messageText, bool isMarkdown, + {Function(String)? onLinkTap, + bool isEnableTextSelection = false, + bool isUseQQPackage = false, + bool isUseTencentCloudChatPackage = false, + bool isUseTencentCloudChatPackageOldKeys = false, + List customEmojiStickerList = const []}) { + return ({TextStyle? style}) { + return isMarkdown + ? LinkTextMarkdown( + isUseQQPackage: isUseQQPackage, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + customEmojiStickerList: customEmojiStickerList, + isEnableTextSelection: isEnableTextSelection, + messageText: addSpaceAfterLeftBracket(addSpaceBeforeHttp(replaceSingleNewlineWithTwo(messageText))), + style: style, + onLinkTap: onLinkTap) + : LinkText( + isEnableTextSelection: isEnableTextSelection, + messageText: messageText, + style: style, + onLinkTap: onLinkTap, + isUseQQPackage: isUseQQPackage, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + customEmojiStickerList: customEmojiStickerList); + }; + } + + static String addSpaceAfterLeftBracket(String inputText) { + return inputText.splitMapJoin( + RegExp(r'<\w+[^<>]*>'), + onMatch: (match) { + return match.group(0)!.replaceFirst('<', '< '); + }, + onNonMatch: (text) => text, + ); + } + + static String replaceSingleNewlineWithTwo(String inputText) { + return inputText.split('\n').join('\n\n'); + } + + static String addSpaceBeforeHttp(String inputText) { + return inputText.splitMapJoin( + RegExp(r'http'), + onMatch: (match) { + return ' http'; + }, + onNonMatch: (text) => text, + ); + } + + /// get the [LinkPreviewContent] with preview widget and website information for the first link. + /// If you provide `onUpdateMessage(String linkInfoJson)`, it can save the link info to local custom data than call updating the message on UI automatically. + static Future getFirstLinkPreviewContent( + {required V2TimMessage message, ValueChanged? onUpdateMessage}) async { + final String? messageText = message.textElem?.text; + if (messageText == null) { + return null; + } + + final List urlMatches = LinkUtils.getURLMatches(messageText); + if (urlMatches.isEmpty) { + return null; + } + + final List previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]); + if (previewItemList.isNotEmpty) { + final LocalCustomDataModel previewItem = previewItemList.first!; + if (onUpdateMessage != null) { + LinkUtils.saveToLocalAndUpdate(message, previewItem, onUpdateMessage); + } + return LinkPreviewContent( + linkInfo: previewItem, + linkPreviewWidget: LinkPreviewWidget(linkPreview: previewItem), + ); + } else { + return null; + } + } + + /// get the [LinkPreviewContent] with preview widget and website information for all the links + static Future?> getAllLinkPreviewContent(V2TimMessage message) async { + final String? messageText = message.textElem?.text; + if (messageText == null) { + return null; + } + + final List urlMatches = LinkUtils.getURLMatches(messageText); + if (urlMatches.isEmpty) { + return []; + } + + final List previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]); + if (previewItemList.isNotEmpty) { + final List resultList = previewItemList + .map((e) => LinkPreviewContent( + linkInfo: e, + linkPreviewWidget: LinkPreviewWidget(linkPreview: e), + )) + .toList(); + + return resultList; + } else { + return []; + } + } + + static String linkInfoToString(LocalCustomDataModel linkInfo) { + return linkInfo.toString(); + } +} diff --git a/lib/ui/widgets/link_preview/models/link_preview_content.dart b/lib/ui/widgets/link_preview/models/link_preview_content.dart new file mode 100644 index 0000000..e9dcbe8 --- /dev/null +++ b/lib/ui/widgets/link_preview/models/link_preview_content.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; + +typedef LinkPreviewText = Widget Function({TextStyle? style}); + +class LocalCustomDataModel { + final String? description; + final String? image; + final String? url; + final String? title; + String? translatedText; + + LocalCustomDataModel( + {this.description, this.image, this.url, this.title, this.translatedText}); + + Map toMap() { + final Map data = {}; + data['url'] = url; + data['image'] = image; + data['title'] = title; + data['description'] = description; + data['translatedText'] = translatedText; + return data; + } + + LocalCustomDataModel.fromMap(Map map) + : description = map['description'], + image = map['image'], + url = map['url'], + translatedText = map['translatedText'], + title = map['title']; + + @override + String toString() { + return json.encode(toMap()); + } + + bool isLinkPreviewEmpty() { + if ((image == null || image!.isEmpty) && + (title == null || title!.isEmpty) && + (description == null || description!.isEmpty)) { + return true; + } + return false; + } +} + +class LinkPreviewContent { + const LinkPreviewContent({ + this.linkInfo, + this.linkPreviewWidget, + }); + + final LocalCustomDataModel? linkInfo; + final Widget? linkPreviewWidget; +} diff --git a/lib/ui/widgets/link_preview/widgets/link_preview.dart b/lib/ui/widgets/link_preview/widgets/link_preview.dart new file mode 100644 index 0000000..be01979 --- /dev/null +++ b/lib/ui/widgets/link_preview/widgets/link_preview.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/models/link_preview_content.dart'; + +class LinkPreviewWidget extends TIMStatelessWidget { + final LocalCustomDataModel linkPreview; + + const LinkPreviewWidget({Key? key, required this.linkPreview}) + : super(key: key); + + @override + Widget timBuild(BuildContext context) { + if (linkPreview.isLinkPreviewEmpty()) { + return Container(); + } + return GestureDetector( + onTap: () { + if(linkPreview.url != null){ + LinkUtils.launchURL(context, linkPreview.url!); + } + }, + child: Container( + padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8, right: 8), + decoration: const BoxDecoration( + color: Color(0x19696969), + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (linkPreview.title != null && linkPreview.title!.isNotEmpty) + Text( + linkPreview.title!, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFF444444), + fontWeight: FontWeight.w400), + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (linkPreview.image != null && linkPreview.image!.isNotEmpty) + Container( + margin: const EdgeInsets.only(right: 8), + child: SizedBox( + height: 40, + width: 40, + child: Image.network(linkPreview.image!), + ), + ), + if (linkPreview.description != null && + linkPreview.description!.isNotEmpty) + Expanded( + child: Text( + linkPreview.description!, + style: const TextStyle( + fontSize: 12.0, color: Color(0xFF999999)), + )), + if ((linkPreview.description == null || + linkPreview.description!.isEmpty) && + linkPreview.title != null && + linkPreview.title!.isNotEmpty) + Expanded( + child: Text( + linkPreview.title!, + style: const TextStyle( + fontSize: 12.0, color: Color(0xFF999999)), + )), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/link_preview/widgets/link_text.dart b/lib/ui/widgets/link_preview/widgets/link_text.dart new file mode 100644 index 0000000..6637d59 --- /dev/null +++ b/lib/ui/widgets/link_preview/widgets/link_text.dart @@ -0,0 +1,187 @@ +// ignore_for_file: deprecated_member_use + +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/compiler/md_text.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart'; + +typedef ImageBuilder = Widget Function( + Uri uri, String? imageDirectory, double? width, double? height); + +class LinkTextMarkdown extends TIMStatelessWidget { + /// Callback for when link is tapped + final void Function(String)? onLinkTap; + + /// message text + final String messageText; + + /// text style for default words + final TextStyle? style; + + final bool? isEnableTextSelection; + + final bool isUseQQPackage; + + final bool isUseTencentCloudChatPackage; + + final bool isUseTencentCloudChatPackageOldKeys; + + final List customEmojiStickerList; + + const LinkTextMarkdown( + {Key? key, + required this.messageText, + this.isUseQQPackage = false, + this.isUseTencentCloudChatPackage = false, + this.isUseTencentCloudChatPackageOldKeys = false, + this.customEmojiStickerList = const [], + this.isEnableTextSelection, + this.onLinkTap, + this.style}) + : super(key: key); + + @override + Widget timBuild(BuildContext context) { + return MarkdownBody( + data: mdTextCompiler(messageText, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + customEmojiStickerList: customEmojiStickerList), + selectable: isEnableTextSelection ?? false, + styleSheet: MarkdownStyleSheet.fromTheme(ThemeData( + textTheme: TextTheme( + bodyMedium: style ?? const TextStyle(fontSize: 16.0)))) + .copyWith( + a: TextStyle(color: LinkUtils.hexToColor("015fff")), + ), + extensionSet: md.ExtensionSet.gitHubWeb, + onTapLink: ( + String link, + String? href, + String title, + ) { + if (onLinkTap != null) { + onLinkTap!(href ?? ""); + } else { + LinkUtils.launchURL(context, href ?? ""); + } + }, + ); + } +} + +class LinkText extends TIMStatelessWidget { + /// Callback for when link is tapped + final void Function(String)? onLinkTap; + + /// message text + final String messageText; + + /// text style for default words + final TextStyle? style; + + final bool isUseQQPackage; + + final bool isUseTencentCloudChatPackage; + + final bool isUseTencentCloudChatPackageOldKeys; + + final List customEmojiStickerList; + + final bool? isEnableTextSelection; + + const LinkText( + {Key? key, + required this.messageText, + this.onLinkTap, + this.isEnableTextSelection, + this.style, + this.isUseQQPackage = false, + this.isUseTencentCloudChatPackage = false, + this.isUseTencentCloudChatPackageOldKeys = false, + this.customEmojiStickerList = const []}) + : super(key: key); + + String _getContentSpan(String text, BuildContext context) { + List _contentList = []; + String contentData = PlatformUtils().isWeb ? '\u200B' : ""; + + Iterable matches = LinkUtils.urlReg.allMatches(text); + + int index = 0; + for (RegExpMatch match in matches) { + String c = text.substring(match.start, match.end); + if (match.start == index) { + index = match.end; + } + if (index < match.start) { + String a = text.substring(index, match.start); + index = match.end; + contentData += a; + _contentList.add( + TextSpan(text: a), + ); + } + + if (LinkUtils.urlReg.hasMatch(c)) { + contentData += HttpText.flag + c + HttpText.flag; + _contentList.add(TextSpan( + text: c, + style: TextStyle(color: LinkUtils.hexToColor("015fff")), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (onLinkTap != null) { + onLinkTap!(text.substring(match.start, match.end)); + } else { + LinkUtils.launchURL( + context, text.substring(match.start, match.end)); + } + })); + } else { + contentData += c; + _contentList.add( + TextSpan(text: c, style: style ?? const TextStyle(fontSize: 16.0)), + ); + } + } + if (index < text.length) { + String a = text.substring(index, text.length); + contentData += a; + _contentList.add( + TextSpan(text: a, style: style ?? const TextStyle(fontSize: 16.0)), + ); + } + + return contentData; + } + + @override + Widget timBuild(BuildContext context) { + return ExtendedText(_getContentSpan(messageText, context), softWrap: true, + onSpecialTextTap: (dynamic parameter) { + if (parameter.toString().startsWith(HttpText.flag)) { + if (onLinkTap != null) { + onLinkTap!((parameter.toString()).replaceAll(HttpText.flag, '')); + } else { + LinkUtils.launchURL( + context, (parameter.toString()).replaceAll(HttpText.flag, '')); + } + } + }, + style: style ?? const TextStyle(fontSize: 16.0), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: isUseQQPackage, + isUseTencentCloudChatPackage: isUseTencentCloudChatPackage, + isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys, + customEmojiStickerList: customEmojiStickerList, + showAtBackground: true, + )); + } +} diff --git a/lib/ui/widgets/loading.dart b/lib/ui/widgets/loading.dart new file mode 100644 index 0000000..b5eb3e7 --- /dev/null +++ b/lib/ui/widgets/loading.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +class Loading extends StatefulWidget { + final double width; + final double height; + + const Loading({Key? key, this.width = 14, this.height = 14}) + : super(key: key); + + @override + State createState() => _LoadingState(); +} + +class _LoadingState extends State with TickerProviderStateMixin { + late AnimationController _animationController; + + @override + initState() { + // 初始化旋转动画 + _animationController = + AnimationController(duration: const Duration(seconds: 1), vsync: this); + _animationController.forward(); + _animationController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _animationController.reset(); + _animationController.forward(); + } + }); + super.initState(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: Tween(begin: .0, end: .9).animate(_animationController), + child: Image.asset( + "images/message_sending.png", + package: 'tencent_cloud_chat_uikit', + height: widget.width, + width: widget.height, + ), + ); + } +} diff --git a/lib/ui/widgets/merger_message_screen.dart b/lib/ui/widgets/merger_message_screen.dart new file mode 100644 index 0000000..efd760c --- /dev/null +++ b/lib/ui/widgets/merger_message_screen.dart @@ -0,0 +1,332 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart'; + +class MergerMessageScreen extends StatefulWidget { + final TUIChatSeparateViewModel model; + final String msgID; + final MessageItemBuilder? messageItemBuilder; + + const MergerMessageScreen({Key? key, required this.model, required this.msgID, this.messageItemBuilder}) + : super(key: key); + + @override + State createState() => MergerMessageScreenState(); +} + +class MergerMessageScreenState extends TIMUIKitState { + final MessageService _messageService = serviceLocator(); + + List messageList = []; + + @override + initState() { + super.initState(); + initMessageList(); + } + + void initMessageList() async { + final mergerMessageList = await _messageService.downloadMergerMessage(msgID: widget.msgID); + setState(() { + messageList = mergerMessageList ?? []; + }); + } + + bool isReplyMessage(V2TimMessage message) { + final hasCustomData = message.cloudCustomData != null && message.cloudCustomData != ""; + if (hasCustomData) { + try { + final CloudCustomData messageCloudCustomData = CloudCustomData.fromJson( + json.decode(TencentUtils.checkString(message.cloudCustomData) != null ? message.cloudCustomData! : "{}")); + if (messageCloudCustomData.messageReply != null) { + MessageRepliedData.fromJson(messageCloudCustomData.messageReply!); + return true; + } + return false; + } catch (error) { + return false; + } + } + return false; + } + + Widget _getMsgItem(V2TimMessage message) { + final type = message.elemType; + final isFromSelf = message.isSelf ?? true; + + switch (type) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + if (widget.messageItemBuilder?.customMessageItemBuilder != null) { + return widget.messageItemBuilder!.customMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return Text(TIM_t("[自定义]")); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + if (widget.messageItemBuilder?.soundMessageItemBuilder != null) { + return widget.messageItemBuilder!.soundMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitSoundElem( + chatModel: widget.model, + isShowMessageReaction: false, + message: message, + soundElem: message.soundElem!, + msgID: message.msgID ?? "", + isFromSelf: isFromSelf, + localCustomInt: message.localCustomInt); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + if (isReplyMessage(message)) { + if (widget.messageItemBuilder?.textReplyMessageItemBuilder != null) { + return widget.messageItemBuilder!.textReplyMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitReplyElem( + isShowMessageReaction: false, + chatModel: widget.model, + message: message, + scrollToIndex: () {}, + clearJump: () {}); + } + if (widget.messageItemBuilder?.textMessageItemBuilder != null) { + return widget.messageItemBuilder!.textMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitTextElem( + chatModel: widget.model, + message: message, + isFromSelf: message.isSelf ?? true, + clearJump: () {}, + isShowJump: false, + isShowMessageReaction: false, + ); + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + if (widget.messageItemBuilder?.faceMessageItemBuilder != null) { + return widget.messageItemBuilder!.faceMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitFaceElem( + model: widget.model, + isShowJump: false, + isShowMessageReaction: false, + path: message.faceElem?.data ?? "", + message: message); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + if (widget.messageItemBuilder?.fileMessageItemBuilder != null) { + return widget.messageItemBuilder!.fileMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitFileElem( + chatModel: widget.model, + isShowMessageReaction: false, + message: message, + messageID: message.msgID, + fileElem: message.fileElem, + isSelf: isFromSelf, + isShowJump: false); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + if (widget.messageItemBuilder?.imageMessageItemBuilder != null) { + return widget.messageItemBuilder!.imageMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitImageElem( + chatModel: widget.model, + isShowMessageReaction: false, + message: message, + isFrom: "merger", + key: Key("${message.seq}_${message.timestamp}"), + ); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + if (widget.messageItemBuilder?.videoMessageItemBuilder != null) { + return widget.messageItemBuilder!.videoMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitVideoElem(message, chatModel: widget.model, isFrom: "merger", isShowMessageReaction: false); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + if (widget.messageItemBuilder?.locationMessageItemBuilder != null) { + return widget.messageItemBuilder!.locationMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return Text(TIM_t("[位置]")); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + if (widget.messageItemBuilder?.mergerMessageItemBuilder != null) { + return widget.messageItemBuilder!.mergerMessageItemBuilder!( + message, + false, + () {}, + )!; + } + return TIMUIKitMergerElem( + model: widget.model, + isShowJump: false, + isShowMessageReaction: false, + message: message, + mergerElem: message.mergerElem!, + isSelf: isFromSelf, + messageID: message.msgID!); + default: + return Text(TIM_t("未知消息")); + } + } + + double getMaxWidth(BuildContext context) { + final size = MediaQuery.of(context).size; + final width = size.width; + return width - 150; + } + + Widget _itemBuilder(V2TimMessage message, BuildContext context) { + final faceUrl = message.faceUrl ?? ""; + final showName = message.nickName ?? message.userID ?? ""; + final theme = Provider.of(context).theme; + final isSelf = message.isSelf ?? false; + return Container( + margin: const EdgeInsets.only(top: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: isSelf ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!isSelf) + SizedBox( + width: 40, + height: 40, + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + if (!isSelf) + const SizedBox( + width: 12, + ), + Column( + crossAxisAlignment: isSelf ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Text(showName, style: TextStyle(fontSize: 12, color: theme.weakTextColor)), + const SizedBox( + height: 4, + ), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: getMaxWidth(context)), + child: _getMsgItem(message), + ) + ], + ), + if (isSelf) + const SizedBox( + width: 12, + ), + if (isSelf) + SizedBox( + width: 40, + height: 40, + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + ], + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget messageListPage() { + return messageList.isEmpty + ? Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoadingAnimationWidget.staggeredDotsWave( + color: theme.weakTextColor ?? Colors.grey, + size: 48, + ), + const SizedBox(height: 20), + Text(TIM_t("消息列表加载中")) + ], + )) + ], + ) + : Container( + padding: isDesktopScreen ? null : const EdgeInsets.all(16), + child: ListView.builder( + shrinkWrap: true, + itemCount: messageList.length, + itemBuilder: (context, index) { + final message = messageList[index]; + return _itemBuilder(message, context); + }, + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: messageListPage(), + ), + defaultWidget: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("聊天记录"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.weakDividerColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + )), + body: messageListPage(), + )); + } +} diff --git a/lib/ui/widgets/message_read_receipt.dart b/lib/ui/widgets/message_read_receipt.dart new file mode 100644 index 0000000..c1e747c --- /dev/null +++ b/lib/ui/widgets/message_read_receipt.dart @@ -0,0 +1,385 @@ +import 'package:extended_text/extended_text.dart'; +import 'package:flutter/material.dart'; + +// ignore: unused_import +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/get_group_message_read_member_list_filter.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/time_ago.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class MessageReadReceipt extends StatefulWidget { + final V2TimMessage messageItem; + final int unreadCount; + final int readCount; + final void Function(String userID, TapDownDetails tapDetails)? onTapAvatar; + final TUIChatSeparateViewModel model; + + const MessageReadReceipt( + {Key? key, + required this.messageItem, + required this.unreadCount, + required this.readCount, + this.onTapAvatar, + required this.model}) + : super(key: key); + + @override + State createState() => _MessageReadReceiptState(); +} + +class _MessageReadReceiptState extends TIMUIKitState { + bool readMemberIsFinished = false; + bool unreadMemberIsFinished = false; + int readMemberListNextSeq = 0; + int unreadMemberListNextSeq = 0; + List readMemberList = []; + List unreadMemberList = []; + int currentIndex = 0; + + _getUnreadMemberList() async { + final unReadMemberRes = await widget.model.getGroupMessageReadMemberList(widget.messageItem.msgID!, + GetGroupMessageReadMemberListFilter.V2TIM_GROUP_MESSAGE_READ_MEMBERS_FILTER_UNREAD, unreadMemberListNextSeq); + if (unReadMemberRes.code == 0) { + final res = unReadMemberRes.data; + if (res != null) { + unreadMemberList = [...unreadMemberList, ...res.memberInfoList]; + unreadMemberIsFinished = res.isFinished; + unreadMemberListNextSeq = res.nextSeq; + } + } + setState(() {}); + } + + _getReadMemberList() async { + final readMemberRes = await widget.model.getGroupMessageReadMemberList( + widget.messageItem.msgID!, + GetGroupMessageReadMemberListFilter.V2TIM_GROUP_MESSAGE_READ_MEMBERS_FILTER_READ, + readMemberListNextSeq, + ); + if (readMemberRes.code == 0) { + final res = readMemberRes.data; + if (res != null) { + readMemberList = [...readMemberList, ...res.memberInfoList]; + readMemberIsFinished = res.isFinished; + readMemberListNextSeq = res.nextSeq; + } + } + setState(() {}); + } + + @override + void initState() { + super.initState(); + _getReadMemberList(); + _getUnreadMemberList(); + } + + Widget _getMsgItem(V2TimMessage message) { + final type = message.elemType; + final isFromSelf = message.isSelf ?? true; + + switch (type) { + case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: + return Text(TIM_t("[自定义]")); + case MessageElemType.V2TIM_ELEM_TYPE_SOUND: + return TIMUIKitSoundElem( + isShowMessageReaction: false, + chatModel: widget.model, + message: message, + soundElem: message.soundElem!, + msgID: message.msgID ?? "", + isFromSelf: isFromSelf, + localCustomInt: message.localCustomInt); + case MessageElemType.V2TIM_ELEM_TYPE_TEXT: + return ExtendedText(message.textElem!.text!, + softWrap: true, + style: const TextStyle(fontSize: 16), + specialTextSpanBuilder: DefaultSpecialTextSpanBuilder( + isUseQQPackage: widget.model.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true, + isUseTencentCloudChatPackage: + widget.model.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true, + isUseTencentCloudChatPackageOldKeys: + widget.model.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? false, + showAtBackground: true, + checkHttpLink: true, + )); + // return Text(message.textElem!.text!); + case MessageElemType.V2TIM_ELEM_TYPE_FACE: + return TIMUIKitFaceElem( + isShowMessageReaction: false, + model: widget.model, + isShowJump: false, + path: message.faceElem?.data ?? "", + message: message); + case MessageElemType.V2TIM_ELEM_TYPE_FILE: + return TIMUIKitFileElem( + chatModel: widget.model, + isShowMessageReaction: false, + message: message, + messageID: message.msgID, + fileElem: message.fileElem, + isSelf: isFromSelf, + isShowJump: false, + ); + case MessageElemType.V2TIM_ELEM_TYPE_IMAGE: + return TIMUIKitImageElem( + chatModel: widget.model, + isShowMessageReaction: false, + message: message, + isFrom: "merger", + key: Key("${message.seq}_${message.timestamp}"), + ); + case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: + return TIMUIKitVideoElem(message, chatModel: widget.model, isShowMessageReaction: false, isFrom: "merger"); + case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: + return Text(TIM_t("[位置]")); + case MessageElemType.V2TIM_ELEM_TYPE_MERGER: + return TIMUIKitMergerElem( + isShowMessageReaction: false, + model: widget.model, + isShowJump: false, + message: message, + mergerElem: message.mergerElem!, + isSelf: isFromSelf, + messageID: message.msgID!); + default: + return Text(TIM_t("未知消息")); + } + } + + _getShowName(V2TimGroupMemberInfo item) { + final friendRemark = item.friendRemark ?? ""; + final nickName = item.nickName ?? ""; + final userID = item.userID; + final showName = nickName != "" ? nickName : userID; + return friendRemark != "" ? friendRemark : showName; + } + + Widget _memberItemBuilder(V2TimGroupMemberInfo item, TUITheme theme) { + final faceUrl = item.faceUrl ?? ''; + final showName = _getShowName(item); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return InkWell( + onTapDown: (details) { + if (widget.onTapAvatar != null) { + widget.onTapAvatar!(item.userID!, details); + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10, left: 16), + child: Row( + children: [ + Container( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + margin: EdgeInsets.only(right: 12, bottom: isDesktopScreen ? 6 : 0), + child: Avatar(faceUrl: faceUrl, showName: showName), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.only(top: 10, bottom: isDesktopScreen ? 14 : 19, right: 28), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + child: Text( + showName, + style: TextStyle(color: Colors.black, fontSize: isDesktopScreen ? 14 : 18), + ), + )), + ], + ), + ), + ); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + final option1 = widget.readCount; + final option2 = widget.unreadCount; + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + Widget pageBody() { + return Container( + color: isDesktopScreen ? null : Colors.white, + child: Column( + children: [ + // The top part of the message content + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height / 2, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(MessageUtils.getDisplayName(widget.messageItem)), + const SizedBox(width: 8), + Text( + TimeAgo().getTimeForMessage(widget.messageItem.timestamp ?? 0), + softWrap: true, + style: TextStyle(fontSize: 12, color: theme.weakTextColor), + ) + ], + ), + const SizedBox(height: 6), + // message content + _getMsgItem(widget.messageItem), + ], + ), + ), + ), + ), + // divider + Container( + height: 8, + color: theme.weakBackgroundColor, + ), + // The bottom part shows the read/unread list + Expanded( + child: Column( + children: [ + // read/unread switch button + Row( + children: [ + Expanded( + flex: 1, + child: InkWell( + onTap: () { + currentIndex = 0; + setState(() {}); + }, + child: Container( + height: isDesktopScreen ? 40 : 50.0, + alignment: Alignment.bottomCenter, + padding: EdgeInsets.only(bottom: isDesktopScreen ? 8 : 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide( + width: 2, color: currentIndex == 0 ? theme.primaryColor! : Colors.white))), + child: Text( + TIM_t_para("{{option1}}人已读", "$option1人已读")(option1: option1), + style: TextStyle( + color: currentIndex != 0 ? theme.weakTextColor : Colors.black, + fontSize: isDesktopScreen ? 14 : 18, + ), + ), + ), + ), + ), + Expanded( + flex: 1, + child: InkWell( + onTap: () { + currentIndex = 1; + setState(() {}); + }, + child: Container( + alignment: Alignment.bottomCenter, + height: isDesktopScreen ? 40 : 50.0, + padding: EdgeInsets.only(bottom: isDesktopScreen ? 8 : 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide( + width: 2, color: currentIndex == 1 ? theme.primaryColor! : Colors.white))), + child: Text( + TIM_t_para("{{option2}}人未读", "$option2人未读")(option2: option2), + style: TextStyle( + color: currentIndex != 1 ? theme.weakTextColor : Colors.black, + fontSize: isDesktopScreen ? 14 : 18, + ), + ), + ), + ), + ), + ], + ), + Container( + height: 1, + decoration: BoxDecoration( + border: + Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))), + ), + // member list + Expanded( + child: IndexedStack( + index: currentIndex, + children: [ + ListView.builder( + shrinkWrap: false, + itemCount: readMemberList.length, + itemBuilder: (context, index) { + if (!readMemberIsFinished && index == readMemberList.length - 5) { + _getReadMemberList(); + } + return _memberItemBuilder(readMemberList[index], theme); + }, + ), + ListView.builder( + shrinkWrap: false, + itemCount: unreadMemberList.length, + itemBuilder: (context, index) { + if (!unreadMemberIsFinished && index == unreadMemberList.length - 5) { + _getUnreadMemberList(); + } + return _memberItemBuilder(unreadMemberList[index], theme); + }, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + desktopWidget: pageBody(), + defaultWidget: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text( + TIM_t("消息详情"), + style: TextStyle(color: theme.appbarTextColor, fontSize: 17), + ), + shadowColor: theme.weakDividerColor, + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + )), + body: pageBody()), + )); + } +} diff --git a/lib/ui/widgets/radio_button.dart b/lib/ui/widgets/radio_button.dart new file mode 100644 index 0000000..276699c --- /dev/null +++ b/lib/ui/widgets/radio_button.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class CheckBoxButton extends TIMUIKitStatelessWidget { + final bool isChecked; + final Function(bool isChecked)? onChanged; + final bool disabled; + final bool onlyShow; + final double? size; + + CheckBoxButton( + {this.disabled = false, + Key? key, + this.size, + this.onlyShow = false, + required this.isChecked, + this.onChanged}) + : super(key: key); + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + BoxDecoration boxDecoration = !isChecked + ? BoxDecoration( + border: Border.all(color: hexToColor("888888")), + shape: BoxShape.circle, + color: Colors.white) + : BoxDecoration(shape: BoxShape.circle, color: theme.primaryColor); + + if (disabled) { + boxDecoration = + const BoxDecoration(shape: BoxShape.circle, color: Colors.grey); + } + return Center( + child: onlyShow + ? Container( + height: size ?? 22, + width: size ?? 22, + decoration: boxDecoration, + child: Icon( + Icons.check, + size: size != null ? (size! / 2) : 11, + color: Colors.white, + ), + ) + : InkWell( + onTap: () { + if (onChanged != null && !disabled) { + onChanged!(!isChecked); + } + }, + child: Container( + height: size ?? 22, + width: size ?? 22, + decoration: boxDecoration, + child: Icon( + Icons.check, + size: size != null ? (size! / 2) : 11, + color: Colors.white, + ), + ), + )); + } +} diff --git a/lib/ui/widgets/recent_conversation_list.dart b/lib/ui/widgets/recent_conversation_list.dart new file mode 100644 index 0000000..8c39c88 --- /dev/null +++ b/lib/ui/widgets/recent_conversation_list.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class RecentForwardList extends StatefulWidget { + final bool isMultiSelect; + final Function(List conversationList)? onChanged; + + const RecentForwardList({ + Key? key, + this.isMultiSelect = true, + this.onChanged, + }) : super(key: key); + + @override + State createState() => _RecentForwardListState(); +} + +class _RecentForwardListState extends TIMUIKitState { + final TUIConversationViewModel _conversationViewModel = serviceLocator(); + final List _selectedConversation = []; + + List> _buildMemberList(List conversationList) { + final List> showList = List.empty(growable: true); + for (var i = 0; i < conversationList.length; i++) { + final item = conversationList[i]; + showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#")); + } + return showList; + } + + Widget _buildItem(V2TimConversation conversation) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + final faceUrl = conversation.faceUrl ?? ""; + final showName = conversation.showName ?? ""; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.isMultiSelect) + Container( + padding: EdgeInsets.only(left: isDesktopScreen ? 8 : 16.0, top: isDesktopScreen ? 10 : 0), + child: CheckBoxButton( + isChecked: _selectedConversation.contains(conversation), + onChanged: (value) { + if (value) { + _selectedConversation.add(conversation); + } else { + _selectedConversation.remove(conversation); + } + setState(() {}); + if (widget.onChanged != null) { + widget.onChanged!(_selectedConversation); + } + }, + ), + ), + Expanded( + child: InkWell( + onTap: () { + if (widget.isMultiSelect) { + final isSelected = _selectedConversation.contains(conversation); + if (isSelected) { + _selectedConversation.remove(conversation); + } else { + _selectedConversation.add(conversation); + } + if (widget.onChanged != null) { + widget.onChanged!(_selectedConversation); + } + setState(() {}); + } else { + if (widget.onChanged != null) { + widget.onChanged!([conversation]); + } + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10, left: 16), + child: Row( + children: [ + Container( + height: isDesktopScreen ? 30 : 40, + width: isDesktopScreen ? 30 : 40, + margin: const EdgeInsets.only(right: 12), + child: Avatar( + faceUrl: faceUrl, + showName: showName, + type: conversation.type, + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.only(top: 10, bottom: isDesktopScreen ? 12 : 19), + decoration: isDesktopScreen + ? null + : const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFDBDBDB)))), + child: Text( + showName, + // textAlign: TextAlign.center, + style: TextStyle(color: const Color(0xFF111111), fontSize: isDesktopScreen ? 16 : 18), + ), + )) + ], + ), + ), + )) + ], + ); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + if (!widget.isMultiSelect) { + _selectedConversation.clear(); + } + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _conversationViewModel), + ], + builder: (context, w) { + final recentConvList = serviceLocator().conversationList; + final showList = _buildMemberList(recentConvList); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + + return AZListViewContainer( + memberList: showList, + isShowIndexBar: false, + susItemBuilder: (context, index) { + return isDesktopScreen + ? Container() + : Container( + height: 40, + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.only(left: 16.0), + color: theme.weakDividerColor, + alignment: Alignment.centerLeft, + child: Text( + TIM_t("最近联系人"), + softWrap: true, + style: TextStyle( + fontSize: 14.0, + color: theme.weakTextColor, + ), + ), + ); + }, + itemBuilder: (context, index) { + final conversation = showList[index].memberInfo; + if (conversation != null) { + return _buildItem(conversation); + } else { + return Container(); + } + }, + ); + }, + ); + } +} diff --git a/lib/ui/widgets/textSize.dart b/lib/ui/widgets/textSize.dart new file mode 100644 index 0000000..f19a951 --- /dev/null +++ b/lib/ui/widgets/textSize.dart @@ -0,0 +1,126 @@ + +// ignore_for_file: file_names + +import 'package:flutter/material.dart'; + +class TextSize { + static Size boundingTextSize(String text, TextStyle style, + {int maxLines = 2 ^ 31, double maxWidth = double.infinity}) { + if (text.isEmpty) { + return const Size(0, 0); + } + final TextPainter textPainter = TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan(text: text, style: style), + maxLines: maxLines) + ..layout(maxWidth: maxWidth); + return textPainter.size; + } +} + +class CustomText extends StatefulWidget { + const CustomText( + this.text, { + Key? key, + required this.width, + this.offset, + this.overflowtext, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + this.textScaleFactor, + this.maxLines, + this.semanticsLabel, + this.textWidthBasis, + this.textHeightBehavior, + }) : super(key: key); + final String text; + final double width; + final String? overflowtext; + final int? offset; + final TextStyle? style; + final StrutStyle? strutStyle; + final TextAlign? textAlign; + final TextDirection? textDirection; + final Locale? locale; + final bool? softWrap; + final TextOverflow? overflow; + final double? textScaleFactor; + final int? maxLines; + final String? semanticsLabel; + final TextWidthBasis? textWidthBasis; + final TextHeightBehavior? textHeightBehavior; + + @override + State createState() => _ExtendTextState(); +} + +class _ExtendTextState extends State { + String? text; + countTextSize() { + TextStyle style = widget.style ?? const TextStyle(fontSize: 14); + double textwidth = TextSize.boundingTextSize(widget.text, style).width; + int offset = widget.offset ?? 3; + if (textwidth > widget.width) { + int position = widget.text.lastIndexOf('.'); + if(position < 1){ + int numberOfCharsToRemove = ((textwidth - widget.width) / (style.fontSize ?? 14)).floor(); + String overflowText = widget.overflowtext ?? '...'; + text = widget.text.replaceRange(widget.text.length - numberOfCharsToRemove, widget.text.length, '') + overflowText; + }else{ + String overflowtext = widget.overflowtext ?? '...'; + int overflowtextLength = overflowtext.length; + double singTextSize = textwidth / widget.text.length; + String newtext = + '${widget.text.substring(0, position - offset)}$overflowtext${widget.text.substring(position - offset, widget.text.length)}'; + position += overflowtextLength; + int number = ((textwidth - widget.width) / singTextSize).ceil(); + do { + int a = position - offset - overflowtextLength - number; + newtext = newtext.substring(0, a < 1 ? 1 : a) + + newtext.substring( + position - offset - overflowtextLength, newtext.length); + position -= number; + number = + ((TextSize.boundingTextSize(newtext, style).width - widget.width) / + singTextSize) + .ceil(); + if (a < 1 || number < 1) { + break; + } + } while ( + TextSize.boundingTextSize(newtext, style).width > widget.width - 20); + text = newtext; + } + } + } + + @override + void initState() { + super.initState(); + countTextSize(); + } + + @override + Widget build(BuildContext context) { + return Text( + text ?? widget.text, + style: widget.style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + locale: widget.locale, + softWrap: widget.softWrap, + overflow: widget.overflow, + textScaleFactor: widget.textScaleFactor, + maxLines: widget.maxLines, + semanticsLabel: widget.semanticsLabel, + textWidthBasis: widget.textWidthBasis, + textHeightBehavior: widget.textHeightBehavior, + ); + } +} diff --git a/lib/ui/widgets/text_input_bottom_sheet.dart b/lib/ui/widgets/text_input_bottom_sheet.dart new file mode 100644 index 0000000..20638e0 --- /dev/null +++ b/lib/ui/widgets/text_input_bottom_sheet.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/drag_widget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class TextInputBottomSheet { + static OverlayEntry? entry; + + static Widget inputBoxContent( + {required BuildContext context, + required String title, + String? tips, + required Function(String text) onSubmitted, + required TUITheme theme, + bool isShowCancel = false, + Offset? initOffset, + String? initText, + required TextEditingController selectionController}) { + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + selectionController.text = initText ?? ""; + return SingleChildScrollView( + child: Container( + padding: EdgeInsets.only( + top: 16, + left: 16, + right: 16, + bottom: + isDesktopScreen ? 16 : MediaQuery.of(context).viewInsets.bottom + 30, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(title, + style: + const TextStyle(fontWeight: FontWeight.w500, fontSize: 16)), + ), + Divider(height: 2, color: theme.weakDividerColor), + TextField( + + onSubmitted: (text) { + onSubmitted(text); + if (entry != null) { + entry?.remove(); + entry = null; + } else { + Navigator.pop(context); + } + }, + autofocus: true, + controller: selectionController, + ), + if(tips != null) Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 5), + height: 40, + child: Text( + tips, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ) + ], + ), + if (isDesktopScreen) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isShowCancel) + Container( + margin: const EdgeInsets.only(right: 20), + child: SizedBox( + width: 84, + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + theme.wideBackgroundColor), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5))), + ), + onPressed: () { + if (entry != null) { + entry?.remove(); + entry = null; + } else { + Navigator.pop(context); + } + }, + child: Text( + TIM_t("取消"), + style: TextStyle(color: theme.darkTextColor), + )), + )), + SizedBox( + width: 84, + child: ElevatedButton( + style: ButtonStyle( + shape: MaterialStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5))), + ), + onPressed: () { + String text = selectionController.text; + onSubmitted(text); + if (entry != null) { + entry?.remove(); + entry = null; + } else { + Navigator.pop(context); + } + }, + child: Text(TIM_t("保存"))), + ), + ], + ), + if (!isDesktopScreen) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isShowCancel) + Expanded( + child: Container( + margin: const EdgeInsets.only(right: 20), + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + theme.wideBackgroundColor), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5))), + ), + onPressed: () { + if (entry != null) { + entry?.remove(); + entry = null; + } else { + Navigator.pop(context); + } + }, + child: Text( + TIM_t("取消"), + style: TextStyle(color: theme.darkTextColor), + )), + )), + Expanded( + child: SizedBox( + child: ElevatedButton( + style: ButtonStyle( + shape: MaterialStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5))), + ), + onPressed: () { + String text = selectionController.text; + onSubmitted(text); + if (entry != null) { + entry?.remove(); + entry = null; + } else { + Navigator.pop(context); + } + }, + child: Text(TIM_t("确定"))), + )), + ], + ), + ], + ), + )); + } + + static showTextInputBottomSheet({ + required BuildContext context, + required String title, + String? tips, + required Function(String text) onSubmitted, + required TUITheme theme, + Offset? initOffset, + String? initText, + }) { + TextEditingController _selectionController = TextEditingController(); + final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop; + if (isDesktopScreen) { + if (entry != null) { + return; + } + entry = OverlayEntry(builder: (BuildContext context) { + return TUIKitDragArea( + closeFun: (){ + if(entry != null){ + entry?.remove(); + entry = null; + } + }, + initOffset: initOffset ?? + Offset(MediaQuery.of(context).size.height * 0.5 + 20, + MediaQuery.of(context).size.height * 0.5 - 100), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + color: theme.wideBackgroundColor, + border: Border.all( + width: 2, + color: theme.weakBackgroundColor ?? const Color(0xFFbebebe), + ), + boxShadow: const [ + BoxShadow( + color: Color(0xFFbebebe), + offset: Offset(5, 5), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: SizedBox( + width: 350, + child: inputBoxContent( + context: context, + isShowCancel: true, + title: title, + tips: tips, + onSubmitted: onSubmitted, + theme: theme, + initText: initText, + selectionController: _selectionController), + ), + )); + }); + Overlay.of(context).insert(entry!); + } else { + showModalBottomSheet( + isScrollControlled: true, // !important + context: context, + builder: (BuildContext context) { + return inputBoxContent( + context: context, + title: title, + tips: tips, + initText: initText, + onSubmitted: onSubmitted, + theme: theme, + selectionController: _selectionController); + }); + } + } +} diff --git a/lib/ui/widgets/transimit_group_owner_select.dart b/lib/ui/widgets/transimit_group_owner_select.dart new file mode 100644 index 0000000..b6bacaa --- /dev/null +++ b/lib/ui/widgets/transimit_group_owner_select.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +GlobalKey<_SelectNewGroupOwner> selectNewGroupOwnerKey = GlobalKey(); + +class SelectNewGroupOwner extends StatefulWidget { + final String? groupID; + final TUIGroupProfileModel model; + final ValueChanged>? onSelectedMember; + + const SelectNewGroupOwner({ + this.groupID, + Key? key, + required this.model, + this.onSelectedMember, + }) : super(key: key); + + @override + State createState() => _SelectNewGroupOwner(); +} + +class _SelectNewGroupOwner extends TIMUIKitState { + final CoreServicesImpl _coreServicesImpl = serviceLocator(); + List selectedMember = []; + List? searchMemberList; + String? searchText; + + @override + void dispose() { + super.dispose(); + } + + bool isSearchTextExist(String? searchText) { + return searchText != null && searchText != ""; + } + + handleSearchGroupMembers(String searchText, context) async { + searchText = searchText; + List currentGroupMember = + widget.model.groupMemberList.where((element) => element?.userID != _coreServicesImpl.loginInfo.userID).toList(); + final res = await widget.model.searchGroupMember(V2TimGroupMemberSearchParam( + keywordList: [searchText], + groupIDList: [widget.model.groupInfo!.groupID], + isSearchMemberNameCard: true, + isSearchMemberUserID: true, + isSearchMemberNickName: true, + isSearchMemberRemark: true, + )); + + if (res.code == 0) { + List list = []; + final searchResult = res.data!.groupMemberSearchResultItems!; + searchResult.forEach((key, value) { + if (value is List) { + for (V2TimGroupMemberFullInfo item in value) { + list.add(item); + } + } + }); + + currentGroupMember = list; + } else { + currentGroupMember = []; + } + setState(() { + searchMemberList = isSearchTextExist(searchText) ? currentGroupMember : null; + }); + } + + onSubmit() { + if (widget.onSelectedMember != null) { + widget.onSelectedMember!(selectedMember); + } + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + Widget memberBody() { + return GroupProfileMemberList( + customTopArea: PlatformUtils().isWeb + ? null + : GroupMemberSearchTextField( + onTextChange: (text) => handleSearchGroupMembers(text, context), + ), + memberList: (searchMemberList ?? widget.model.groupMemberList) + .where((element) => element?.userID != _coreServicesImpl.loginInfo.userID) + .toList(), + canSlideDelete: false, + canSelectMember: true, + maxSelectNum: 1, + onSelectedMemberChange: (member) { + selectedMember = member; + setState(() {}); + }, + touchBottomCallBack: () {}, + ); + } + + return TUIKitScreenUtils.getDeviceWidget( + context: context, + defaultWidget: Scaffold( + appBar: AppBar( + shadowColor: theme.weakBackgroundColor, + iconTheme: IconThemeData( + color: theme.appbarTextColor, + ), + backgroundColor: theme.appbarBgColor ?? theme.primaryColor, + leading: TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + TIM_t("取消"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 16, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + if (selectedMember.isNotEmpty) { + Navigator.pop(context, selectedMember); + } + }, + child: Text( + TIM_t("完成"), + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 16, + ), + ), + ) + ], + centerTitle: true, + leadingWidth: 100, + title: Text( + "转让群主", + style: TextStyle( + color: theme.appbarTextColor, + fontSize: 16, + ), + ), + ), + body: memberBody()), + desktopWidget: memberBody()); + } +} diff --git a/lib/ui/widgets/unread_message.dart b/lib/ui/widgets/unread_message.dart new file mode 100644 index 0000000..cd13f04 --- /dev/null +++ b/lib/ui/widgets/unread_message.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; + +class UnreadMessage extends TIMUIKitStatelessWidget { + final int unreadCount; + final double? width; + final double? height; + UnreadMessage( + {Key? key, + required this.unreadCount, + this.width = 22.0, + this.height = 22.0}) + : super(key: key); + + String generateUnreadText() => + unreadCount > 99 ? '99+' : unreadCount.toString(); + double generateFontSize(String text) => text.length * -2 + 13; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final unreadText = generateUnreadText(); + final fontSize = generateFontSize(unreadText); + return Container( + width: width, + height: height, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.conversationItemUnreadCountBgColor ?? + CommonColor.cautionColor, + ), + child: unreadText != "0" + ? Center( + child: Text( + unreadText, + style: TextStyle( + color: theme.conversationItemUnreadCountTextColor, + fontSize: fontSize, + ), + ), + ) + : null, + ); + } +} diff --git a/lib/ui/widgets/video_custom_control.dart b/lib/ui/widgets/video_custom_control.dart new file mode 100644 index 0000000..12e6d9b --- /dev/null +++ b/lib/ui/widgets/video_custom_control.dart @@ -0,0 +1,437 @@ +// ignore_for_file: implementation_imports, unused_element + +import 'dart:async'; + +import 'package:chewie/chewie.dart'; +import 'package:chewie/src/animated_play_pause.dart'; +import 'package:chewie/src/helpers/utils.dart'; +import 'package:chewie/src/material/material_progress_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:loading_animation_widget/loading_animation_widget.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:video_player/video_player.dart'; +import 'center_play_button.dart'; + +class VideoCustomControls extends StatefulWidget { + const VideoCustomControls({required this.downloadFn, Key? key}) : super(key: key); + final Future Function() downloadFn; + + @override + State createState() { + return _VideoCustomControlsState(); + } +} + +class _VideoCustomControlsState extends TIMUIKitState with SingleTickerProviderStateMixin { + late VideoPlayerValue _latestValue; + bool _hideStuff = true; + Timer? _hideTimer; + Timer? _initTimer; + Timer? _showAfterExpandCollapseTimer; + bool _dragging = false; + bool _displayTapped = false; + bool isLoading = false; + + final barHeight = 48.0; + final marginSize = 5.0; + + late VideoPlayerController controller; + ChewieController? _chewieController; + + // We know that _chewieController is set in didChangeDependencies + ChewieController get chewieController => _chewieController!; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + if (_latestValue.hasError) { + return Container( + color: Colors.transparent, + child: chewieController.errorBuilder?.call( + context, + chewieController.videoPlayerController.value.errorDescription!, + ) ?? + const Center( + child: Icon( + Icons.error, + color: Colors.white, + size: 42, + ), + ), + ); + } + + return MouseRegion( + onHover: (_) { + _cancelAndRestartTimer(); + }, + child: GestureDetector( + onTap: () => _cancelAndRestartTimer(), + child: AbsorbPointer( + absorbing: _hideStuff, + child: Stack( + alignment: Alignment.center, + children: [ + if (_latestValue.isBuffering) const Center(child: CircularProgressIndicator(color: Colors.white)) else _buildHitArea(), + Positioned( + bottom: 0, + width: MediaQuery.of(context).size.width, + child: Column(children: [_buildVideoControlBar(context), _buildBottomBar()]), + ), + if (isLoading) + Container( + child: LoadingAnimationWidget.staggeredDotsWave( + size: 35, + color: Colors.white, + ), + padding: const EdgeInsets.all(30), + decoration: const BoxDecoration( + color: Color(0xB22b2b2b), + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + ], + ), + ), + ), + ); + } + + @override + void dispose() { + _dispose(); + super.dispose(); + } + + void _dispose() { + controller.removeListener(_updateState); + _hideTimer?.cancel(); + _initTimer?.cancel(); + _showAfterExpandCollapseTimer?.cancel(); + } + + @override + void didChangeDependencies() { + final _oldController = _chewieController; + _chewieController = ChewieController.of(context); + controller = chewieController.videoPlayerController; + + if (_oldController != chewieController) { + _dispose(); + _initialize(); + } + + super.didChangeDependencies(); + } + + Widget _buildBottomBar() { + return Material( + color: Colors.transparent, + child: Container( + height: barHeight, + margin: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Row( + children: [ + SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Image.asset( + 'images/close.png', + package: 'tencent_cloud_chat_uikit', + ), + iconSize: 30, + onPressed: () { + if (_latestValue.isPlaying) { + _playPause(); + } + Navigator.of(context).pop(); + }, + ), + ), + Expanded(child: Container()), + SizedBox( + width: 48, + height: 48, + child: IconButton( + icon: Image.asset( + 'images/download.png', + package: 'tencent_cloud_chat_uikit', + ), + iconSize: 30, + onPressed: () async { + setState(() { + isLoading = true; + }); + await widget.downloadFn(); + Future.delayed( + const Duration(milliseconds: 200), + () { + setState( + () { + isLoading = false; + }, + ); + }, + ); + }, + ), + ) + ], + ), + ), + ); + } + + AnimatedOpacity _buildVideoControlBar( + BuildContext context, + ) { + const iconColor = Colors.white; + + return AnimatedOpacity( + opacity: _hideStuff ? 0.0 : 1.0, + duration: const Duration(milliseconds: 300), + child: SizedBox( + height: barHeight, + child: Row( + children: [ + _buildPlayPause(controller, iconColor), + if (chewieController.isLive) const Expanded(child: Text('LIVE')) else _buildPositionStart(iconColor), + if (chewieController.isLive) const SizedBox() else _buildProgressBar(), + if (!chewieController.isLive) _buildPositionEnd(iconColor), + ], + ), + ), + ); + } + + Widget _buildHitArea() { + // final bool isFinished = _latestValue.position >= _latestValue.duration; + + return GestureDetector( + onTap: () { + if (_latestValue.isPlaying) { + if (_displayTapped) { + setState(() { + _hideStuff = true; + }); + } else { + _cancelAndRestartTimer(); + } + } else { + _playPause(); + + setState(() { + _hideStuff = true; + }); + } + }, + child: CenterPlayButton( + isPlaying: controller.value.isPlaying, + show: !_latestValue.isPlaying && !_dragging, + onPressed: _playPause, + )); + } + + GestureDetector _buildPlayPause(VideoPlayerController controller, Color color) { + return GestureDetector( + onTap: _playPause, + child: Container( + height: barHeight, + color: Colors.transparent, + margin: const EdgeInsets.only(left: 8.0, right: 4.0), + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + ), + child: AnimatedPlayPause( + playing: controller.value.isPlaying, + color: color, + ), + ), + ); + } + + Widget _buildPositionStart(Color? iconColor) { + final position = _latestValue.position; + + return Padding( + padding: const EdgeInsets.only(right: 24.0), + child: Text( + formatDuration(position), + style: TextStyle(fontSize: 14.0, color: iconColor), + ), + ); + } + + Widget _buildPositionEnd(Color? iconColor) { + final duration = _latestValue.duration; + + return Padding( + padding: const EdgeInsets.only(right: 24.0), + child: Text( + formatDuration(duration), + style: TextStyle(fontSize: 14.0, color: iconColor), + ), + ); + } + + void _cancelAndRestartTimer() { + _hideTimer?.cancel(); + _startHideTimer(); + + setState(() { + _hideStuff = false; + _displayTapped = true; + }); + } + + Future _initialize() async { + controller.addListener(_updateState); + + _updateState(); + + if (controller.value.isPlaying || chewieController.autoPlay) { + _startHideTimer(); + } + + if (chewieController.showControlsOnInitialize) { + _initTimer = Timer(const Duration(milliseconds: 200), () { + setState(() { + _hideStuff = false; + }); + }); + } + } + + void _onExpandCollapse() { + setState(() { + _hideStuff = true; + + chewieController.toggleFullScreen(); + _showAfterExpandCollapseTimer = Timer(const Duration(milliseconds: 300), () { + setState(() { + _cancelAndRestartTimer(); + }); + }); + }); + } + + void _playPause() { + final isFinished = _latestValue.position >= _latestValue.duration; + + setState(() { + if (controller.value.isPlaying) { + _hideStuff = false; + _hideTimer?.cancel(); + controller.pause(); + } else { + _cancelAndRestartTimer(); + + if (!controller.value.isInitialized) { + controller.initialize().then((_) { + controller.play(); + }); + } else { + if (isFinished) { + controller.seekTo(const Duration()); + } + Timer(const Duration(milliseconds: 100), () => controller.play()); + } + } + }); + } + + void _startHideTimer() { + _hideTimer = Timer(const Duration(seconds: 3), () { + setState(() { + _hideStuff = true; + }); + }); + } + + void _updateState() { + if (!mounted) return; + setState(() { + _latestValue = controller.value; + }); + } + + Widget _buildProgressBar() { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 20.0), + child: MaterialVideoProgressBar( + controller, + onDragStart: () { + setState(() { + _dragging = true; + }); + + _hideTimer?.cancel(); + }, + onDragEnd: () { + setState(() { + _dragging = false; + }); + + _startHideTimer(); + }, + colors: chewieController.materialProgressColors ?? ChewieProgressColors(playedColor: Colors.white, handleColor: Colors.white, bufferedColor: Colors.white38, backgroundColor: Colors.white24), + ), + ), + ); + } +} + +class _PlaybackSpeedDialog extends TIMUIKitStatelessWidget { + _PlaybackSpeedDialog({ + Key? key, + required List speeds, + required double selected, + }) : _speeds = speeds, + _selected = selected, + super(key: key); + + final List _speeds; + final double _selected; + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + final TUITheme theme = value.theme; + + final Color selectedColor = theme.primaryColor ?? Theme.of(context).primaryColor; + + return ListView.builder( + shrinkWrap: true, + physics: const ScrollPhysics(), + itemBuilder: (context, index) { + final _speed = _speeds[index]; + return ListTile( + dense: true, + title: Row( + children: [ + if (_speed == _selected) + Icon( + Icons.check, + size: 20.0, + color: selectedColor, + ) + else + Container(width: 20.0), + const SizedBox(width: 16.0), + Text(_speed.toString()), + ], + ), + selected: _speed == _selected, + onTap: () { + Navigator.of(context).pop(_speed); + }, + ); + }, + itemCount: _speeds.length, + ); + } +} diff --git a/lib/ui/widgets/video_screen.dart b/lib/ui/widgets/video_screen.dart new file mode 100644 index 0000000..75e9891 --- /dev/null +++ b/lib/ui/widgets/video_screen.dart @@ -0,0 +1,302 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_sdk/enum/message_status.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_video_elem.dart' + if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_video_elem.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart'; +import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKITMessageItem/tim_uikit_chat_videoplayer.dart'; +import 'package:universal_html/html.dart' as html; + +class VideoScreen extends StatefulWidget { + const VideoScreen({required this.message, required this.heroTag, required this.videoElement, Key? key}) + : super(key: key); + + final V2TimMessage message; + final dynamic heroTag; + final V2TimVideoElem videoElement; + + @override + State createState() => _VideoScreenState(); +} + +class _VideoScreenState extends TIMUIKitState { + GlobalKey slidePagekey = GlobalKey(); + final TUIChatGlobalModel model = serviceLocator(); + + @override + initState() { + super.initState(); + // 允许横屏 + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + //保存网络视频到本地 + Future _saveNetworkVideo( + context, + String videoUrl, { + bool isAsset = true, + }) async { + if (PlatformUtils().isWeb) { + RegExp exp = RegExp(r"((\.){1}[^?]{2,4})"); + String? suffix = exp.allMatches(videoUrl).last.group(0); + var xhr = html.HttpRequest(); + xhr.open('get', videoUrl); + xhr.responseType = 'arraybuffer'; + xhr.onLoad.listen((event) { + final a = html.AnchorElement(href: html.Url.createObjectUrl(html.Blob([xhr.response]))); + a.download = '${md5.convert(utf8.encode(videoUrl)).toString()}$suffix'; + a.click(); + a.remove(); + }); + xhr.send(); + return; + } + if (PlatformUtils().isMobile) { + if (PlatformUtils().isIOS) { + if (!await Permissions.checkPermission( + context, + Permission.photosAddOnly.value, + )) { + return; + } + } else { + final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + if ((androidInfo.version.sdkInt) >= 33) { + final videos = await Permissions.checkPermission( + context, + Permission.videos.value, + ); + + if (!videos) { + return; + } + } else { + final storage = await Permissions.checkPermission( + context, + Permission.storage.value, + ); + if (!storage) { + return; + } + } + } + } + String savePath = videoUrl; + if (!isAsset) { + if (widget.message.msgID == null || widget.message.msgID!.isEmpty) { + return; + } + if (model.getMessageProgress(widget.message.msgID) == 100) { + String savePath; + if (widget.message.videoElem!.localVideoUrl != null && widget.message.videoElem!.localVideoUrl != '') { + savePath = widget.message.videoElem!.localVideoUrl!; + } else { + savePath = model.getFileMessageLocation(widget.message.msgID); + } + File f = File(savePath); + if (f.existsSync()) { + var result = await ImageGallerySaverPlus.saveFile(savePath); + if (PlatformUtils().isIOS) { + if (result['isSuccess']) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402)); + } else { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403)); + } + } else { + if (result != null) { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402)); + } else { + onTIMCallback( + TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403)); + } + } + } + } else { + onTIMCallback(TIMCallback( + type: TIMCallbackType.INFO, infoRecommendText: TIM_t("the message is downloading"), infoCode: -1)); + } + return; + } + var result = await ImageGallerySaverPlus.saveFile(savePath); + if (PlatformUtils().isIOS) { + if (result['isSuccess']) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402)); + } else { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403)); + } + } else { + if (result != null) { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402)); + } else { + onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403)); + } + } + return; + } + + Future _saveVideo() async { + if (PlatformUtils().isWeb) { + return await _saveNetworkVideo( + context, + widget.videoElement.videoPath!, + isAsset: true, + ); + } + if (widget.videoElement.videoPath != '' && + widget.videoElement.videoPath != null && + File(widget.videoElement.videoPath!).existsSync()) { + File f = File(widget.videoElement.videoPath!); + if (f.existsSync()) { + return await _saveNetworkVideo( + context, + widget.videoElement.videoPath!, + isAsset: true, + ); + } + } + if (widget.videoElement.localVideoUrl != '' && widget.videoElement.localVideoUrl != null) { + File f = File(widget.videoElement.localVideoUrl!); + if (f.existsSync()) { + return await _saveNetworkVideo( + context, + widget.videoElement.localVideoUrl!, + isAsset: true, + ); + } + } + return await _saveNetworkVideo( + context, + widget.videoElement.videoUrl!, + isAsset: false, + ); + } + + double getVideoHeight() { + double height = widget.videoElement.snapshotHeight!.toDouble(); + double width = widget.videoElement.snapshotWidth!.toDouble(); + // 横图 + if (width > height) { + return height * 1.3; + } + return height; + } + + double getVideoWidth() { + double height = widget.videoElement.snapshotHeight!.toDouble(); + double width = widget.videoElement.snapshotWidth!.toDouble(); + // 横图 + if (width > height) { + return width * 1.3; + } + return width; + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + super.dispose(); + } + + @override + Widget tuiBuild(BuildContext context, TUIKitBuildValue value) { + return OrientationBuilder(builder: ((context, orientation) { + return Material( + color: Colors.transparent, + child: Stack( + children: [ + Container( + color: Colors.transparent, + constraints: BoxConstraints.expand( + height: MediaQuery.of(context).size.height, + ), + child: ExtendedImageSlidePage( + key: slidePagekey, + slidePageBackgroundHandler: (Offset offset, Size size) { + if (orientation == Orientation.landscape) { + return Colors.black; + } + double opacity = 0.0; + opacity = offset.distance / (Offset(size.width, size.height).distance / 2.0); + return Colors.black.withOpacity(min(1.0, max(1.0 - opacity, 0.0))); + }, + slideType: SlideType.onlyImage, + slideEndHandler: ( + Offset offset, { + ExtendedImageSlidePageState? state, + ScaleEndDetails? details, + }) { + final vy = details?.velocity.pixelsPerSecond.dy ?? 0; + final oy = offset.dy; + if (vy > 300 || oy > 100) { + return true; + } + return null; + }, + child: TIMUIKitVideoPlayer( + message: widget.message, + controller: true, + isSending: widget.message.status == MessageStatus.V2TIM_MSG_STATUS_SENDING, + ), + ), + ), + Positioned( + bottom: 20, + left: 20, + child: IconButton( + icon: Image.asset( + 'images/close.png', + package: 'tencent_cloud_chat_uikit', + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + Positioned( + bottom: 20, + right: 20, + child: IconButton( + icon: Image.asset( + 'images/download.png', + package: 'tencent_cloud_chat_uikit', + ), + onPressed: () async { + await _saveVideo(); + }, + ), + ), + ], + ), + ); + })); + } +} diff --git a/lib/ui/widgets/wide_popup.dart b/lib/ui/widgets/wide_popup.dart new file mode 100644 index 0000000..5496f3f --- /dev/null +++ b/lib/ui/widgets/wide_popup.dart @@ -0,0 +1,399 @@ +// ignore_for_file: unused_import + +import 'dart:io'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart'; +import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart'; +import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart'; +import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart'; +import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart'; +import 'package:tencent_cloud_chat_uikit/ui/widgets/drag_widget.dart'; +import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart'; +import 'package:tencent_cloud_chat_uikit/theme/color.dart'; +import 'package:video_player/video_player.dart'; + +typedef BuildContentFunction = Widget Function(BuildContext context); + +class TUIKitWidePopup { + static OverlayEntry? entry; + static bool isShow = false; + + static showSecondaryConfirmDialog({ + required TUIKitWideModalOperationKey operationKey, + required BuildContext context, + required String text, + required TUITheme theme, + VoidCallback? onConfirm, + VoidCallback? onCancel, + }) { + return TUIKitWidePopup.showPopupWindow( + operationKey: operationKey, + context: context, + isDarkBackground: false, + onCancel: onCancel, + onConfirm: onConfirm, + width: 350, + height: 120, + child: (onClose) => Container( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Row( + children: [ + Icon(Icons.info, color: theme.primaryColor), + const SizedBox( + width: 8, + ), + Expanded(child: Text(text)) + ], + ), + )); + } + + static showPopupWindow({ + /// You could determine this field as `TUIKitWideModalOperationKey.custom` for your own business needs. + required TUIKitWideModalOperationKey operationKey, + required BuildContext context, + required Widget Function(VoidCallback closeFunc) child, + TUITheme? theme, + double? width, + double? height, + Offset? offset, + String? initText, + BorderRadius? borderRadius, + bool isDarkBackground = true, + String? title, + VoidCallback? onSubmit, + Widget? submitWidget, + VoidCallback? onConfirm, + VoidCallback? onCancel, + }) async { + if (isShow) { + return; + } + isShow = true; + + final TUISelfInfoViewModel selfInfoViewModel = serviceLocator(); + + if (selfInfoViewModel.globalConfig?.showDesktopModalFunc != null) { + final res = await selfInfoViewModel.globalConfig!.showDesktopModalFunc!(operationKey, context, child, theme, width, height, offset, initText, borderRadius, isDarkBackground, title, onSubmit, submitWidget, onConfirm, onCancel); + + if (res == true) { + return; + } + } + + final isUseMaterialAlert = (offset == null); + + // ignore: prefer_function_declarations_over_variables + final BuildContentFunction buildContent = (BuildContext contentContext) => Container( + key:UniqueKey(), + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: borderRadius ?? const BorderRadius.all(Radius.circular(16)), + color: theme?.wideBackgroundColor ?? const Color(0xFFffffff), + border: isDarkBackground + ? Border.all( + width: 2, + color: theme?.weakBackgroundColor ?? const Color(0xFFbebebe), + ) + : null, + boxShadow: (isDarkBackground || isUseMaterialAlert) + ? null + : const [ + BoxShadow( + color: Color(0xFFbebebe), + offset: Offset(3, 3), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: Column( + children: [ + if (title != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: hexToColor("f5f6f7"), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + title, + style: TextStyle(fontSize: 18, color: theme?.darkTextColor ?? const Color(0xFF444444)), + ), + InkWell( + onTap: () { + if (onSubmit != null) { + onSubmit(); + } + isShow = false; + if (offset == null) { + if (contentContext.mounted) { + Navigator.pop(contentContext); + } + } else { + entry?.remove(); + entry = null; + } + }, + child: onSubmit != null ? (submitWidget ?? const Icon(Icons.check)) : const Icon(Icons.close), + ) + ], + ), + ), + if (title != null) + SizedBox( + height: 1, + child: Container( + color: theme?.weakDividerColor ?? const Color(0xFFE5E6E9), + ), + ), + if (height != null && width != null) + Expanded(child: child(() { + isShow = false; + if (isUseMaterialAlert) { + Navigator.pop(contentContext); + } else { + entry?.remove(); + entry = null; + } + })), + if (height == null || width == null) + child(() { + isShow = false; + if (isUseMaterialAlert) { + Navigator.pop(contentContext); + } else { + entry?.remove(); + entry = null; + } + }), + if (onCancel != null || onConfirm != null) + Container( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (onCancel != null) + Container( + margin: const EdgeInsets.only(right: 16), + child: OutlinedButton( + onPressed: () { + isShow = false; + if (isUseMaterialAlert) { + Navigator.pop(contentContext); + } else { + entry?.remove(); + entry = null; + } + onCancel(); + }, + child: Text( + TIM_t("取消"), + style: TextStyle(color: theme?.weakTextColor ?? Colors.black), + )), + ), + if (onConfirm != null) + Container( + margin: const EdgeInsets.only(right: 16), + child: ElevatedButton( + onPressed: () { + isShow = false; + if (isUseMaterialAlert) { + Navigator.pop(contentContext); + } else { + entry?.remove(); + entry = null; + } + onConfirm(); + }, + child: Text( + TIM_t("确定"), + style: TextStyle(color: theme?.primaryColor), + )), + ), + ], + ), + ) + ], + ), + ); + + if (isUseMaterialAlert) { + return showDialog( + barrierDismissible: true, + context: context, + builder: (dialogContext) { + return WillPopScope( + child: AlertDialog( + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + backgroundColor: Colors.transparent, + titlePadding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0), + contentPadding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0), + content: buildContent(dialogContext), + ), + onWillPop: () { + isShow = false; + return Future.value(true); + }); + }); + } + + if (entry != null) { + return; + } + + entry = OverlayEntry(builder: (BuildContext overlayContext) { + return Material( + color: Colors.transparent, + child: TUIKitDragArea( + backgroundColor: isDarkBackground ? const Color(0x7F000000) : null, + closeFun: () { + isShow = false; + if (entry != null) { + entry?.remove(); + entry = null; + } + }, + initOffset: offset, + child: buildContent(overlayContext)), + ); + }); + Overlay.of(context).insert(entry!); + } + + static void showMedia({ + String? mediaLocalPath, + String? mediaURL, + required BuildContext context, + required VoidCallback onClickOrigin, + double? aspectRatio, + }) async { + assert((mediaLocalPath != null) || (mediaURL != null), "At least one of mediaLocalPath or mediaURL must be provided."); + + String _removeQueryString(String urlString) { + Uri uri = Uri.parse(urlString); + Uri cleanUri = Uri( + scheme: uri.scheme, + host: uri.host, + port: uri.port, + path: uri.path, + ); + return cleanUri.toString(); + } + + final String mediaPath = mediaLocalPath ?? mediaURL ?? ""; + final isLocalResource = mediaLocalPath != null; + + String fileExtension = p.extension(isLocalResource ? mediaPath : _removeQueryString(mediaPath)); + bool isVideo = ['.mp4', '.avi', '.mov', '.flv', '.wmv'].contains(fileExtension); + + VideoPlayerController? videoController; + ChewieController? chewieController; + Widget mediaWidget; + double? aspectRatioFinal = aspectRatio; + + if (isVideo) { + if (isLocalResource) { + videoController = VideoPlayerController.file(File(mediaPath)); + } else { + videoController = VideoPlayerController.networkUrl(Uri.parse(mediaPath)); + } + + await videoController.initialize(); + aspectRatioFinal = videoController.value.aspectRatio; + + chewieController = ChewieController( + allowFullScreen: false, + videoPlayerController: videoController, + aspectRatio: aspectRatioFinal, + autoPlay: true, + looping: false, + autoInitialize: true, + ); + + mediaWidget = Chewie(controller: chewieController); + } else { + mediaWidget = isLocalResource ? Image.file(File(mediaPath), fit: BoxFit.contain) : Image.network(mediaPath, fit: BoxFit.contain); + } + + showDialog( + barrierDismissible: true, + context: context, + builder: (context) { + return WillPopScope( + child: AlertDialog( + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + backgroundColor: Colors.transparent, + titlePadding: const EdgeInsets.all(0), + contentPadding: const EdgeInsets.all(0), + content: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + child: aspectRatioFinal != null ? AspectRatio(aspectRatio: aspectRatioFinal, child: mediaWidget) : mediaWidget, + ), + const SizedBox(height: 10), + InkWell( + onTap: onClickOrigin, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 0), + child: Icon( + Icons.open_in_new, + size: 14, + color: Colors.grey.shade200, + ), + ), + const SizedBox( + width: 8, + ), + // Custom Text Widget with designer baseline + Text( + TIM_t("在新窗口中打开"), + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade200, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + onWillPop: () { + if (isVideo) videoController?.dispose(); + return Future.value(true); + }, + ); + }); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..586b438 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1666 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0816708f5fbcacca324d811297153fe3c8e047beb5c6752e12292d2974c17045" + url: "https://pub.dev" + source: hosted + version: "62.0.0" + adaptive_action_sheet: + dependency: "direct main" + description: + name: adaptive_action_sheet + sha256: "2cf53889102f2f476d03da30ef4219a3199f1d9b9f7bf063e2b23cd5aa88ea02" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "21862995c9932cd082f89d72ae5f5e2c110d1a0204ad06e4ebaee8307b76b834" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "8a2bc5e30520e18f3fb0e366793d78057fb64cd5287862c76af0c8771f2a52ad" + url: "https://pub.dev" + source: hosted + version: "0.1.16" + azlistview_all_platforms: + dependency: "direct main" + description: + name: azlistview_all_platforms + sha256: "47ce2204863e0c3e481ca2a3813096d9818b153f1f677e839503e33d36e97993" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + better_player_plus: + dependency: "direct main" + description: + name: better_player_plus + sha256: fc8804f837b450b1b614d5e624a9312894d3089c9194330542ab04a4afa82127 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + url: "https://pub.dev" + source: hosted + version: "2.4.6" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" + url: "https://pub.dev" + source: hosted + version: "7.2.10" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "598a2a682e2a7a90f08ba39c0aaa9374c5112340f0a2e275f61b59389543d166" + url: "https://pub.dev" + source: hosted + version: "8.6.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + url: "https://pub.dev" + source: hosted + version: "3.3.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "335df378c025588aef400c704bd71f0daea479d4cd57c471c88c056c1144e7cd" + url: "https://pub.dev" + source: hosted + version: "1.8.5" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "4ad01d6e56db961d29661561effde45e519939fdaeb46c351275b182eac70189" + url: "https://pub.dev" + source: hosted + version: "4.5.0" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + csslib: + dependency: "direct main" + description: + name: csslib + sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745 + url: "https://pub.dev" + source: hosted + version: "0.17.2" + csv: + dependency: transitive + description: + name: csv + sha256: "016b31a51a913744a0a1655c74ff13c9379e1200e246a03d96c81c5d9ed297b5" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + url: "https://pub.dev" + source: hosted + version: "1.0.5" + dart_internal: + dependency: transitive + description: + name: dart_internal + sha256: "93ddc8b258a4fd3487d846af38676f87159d7a2ecec0e13d691bbe6ab2c4e10a" + url: "https://pub.dev" + source: hosted + version: "0.2.14" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + url: "https://pub.dev" + source: hosted + version: "0.4.4" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + diff_match_patch: + dependency: "direct main" + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + sha256: "07a5c5e8d4e6e992279e190e0352be8faa5b8f96d81c77a78b2d42f060279840" + url: "https://pub.dev" + source: hosted + version: "2.0.0+3" + extended_image: + dependency: "direct main" + description: + name: extended_image + sha256: "85199f9233e03abc2ce2e68cbb2991648666af4a527ae4e6250935be8edfddae" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + extended_image_library: + dependency: transitive + description: + name: extended_image_library + sha256: e61dafd94400fff6ef7ed1523d445ff3af137f198f3228e4a3107bc5b4bec5d1 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + extended_text: + dependency: "direct main" + description: + name: extended_text + sha256: d8f4a6e2676505b54dc0d5f5e8de9020667b402e9c1b3a8b030a83e568c99654 + url: "https://pub.dev" + source: hosted + version: "15.0.2" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" + url: "https://pub.dev" + source: hosted + version: "16.0.2" + extended_text_library: + dependency: transitive + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + fast_i18n: + dependency: transitive + description: + name: fast_i18n + sha256: f0039a3c1f5f3b7deafefdbb5222d7eb1ee9c2c2fe1222b648b285711b2c7570 + url: "https://pub.dev" + source: hosted + version: "5.12.6" + fc_native_video_thumbnail: + dependency: "direct main" + description: + name: fc_native_video_thumbnail + sha256: "61836a6fd34bb0cbda48d7ba7cd7a23242468886d4c68017ae59b9791fb42d2a" + url: "https://pub.dev" + source: hosted + version: "0.16.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a + url: "https://pub.dev" + source: hosted + version: "10.2.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "770eb1ab057b5ae4326d1c24cc57710758b9a46026349d021d6311bd27580046" + url: "https://pub.dev" + source: hosted + version: "0.9.2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "7a6f1ae6107265664f3f7f89a66074882c4d506aef1441c9af313c1f7e6f41ce" + url: "https://pub.dev" + source: hosted + version: "0.9.3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "412705a646a0ae90f33f37acfae6a0f7cbc02222d6cd34e479421c3e74d3853c" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "1372760c6b389842b77156203308940558a2817360154084368608413835fc26" + url: "https://pub.dev" + source: hosted + version: "0.9.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_easyrefresh: + dependency: "direct main" + description: + name: flutter_easyrefresh + sha256: "5d161ee5dcac34da9065116568147d742dd25fb9bff3b10024d9054b195087ad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.dev" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "4b1bfbb802d76320a1a46d9ce984106135093efd9d969765d07c2125af107bdf" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_plugin_record_plus: + dependency: "direct main" + description: + name: flutter_plugin_record_plus + sha256: "9b8f5013dcdf68423f34200986563a9083eea3ea4dbcd7cd84456c4fbc6b0fb7" + url: "https://pub.dev" + source: hosted + version: "0.0.21" + flutter_slidable_plus_plus: + dependency: "direct main" + description: + name: flutter_slidable_plus_plus + sha256: d2ceb3a0b2079e8faa23630c3faeee3055024ba0ab303462007f950803c3c29a + url: "https://pub.dev" + source: hosted + version: "0.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "8c5d68a82add3ca76d792f058b186a0599414f279f00ece4830b9b231b570338" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_widget_from_html_core: + dependency: transitive + description: + name: flutter_widget_from_html_core + sha256: b1048fd119a14762e2361bd057da608148a895477846d6149109b2151d2f7abf + url: "https://pub.dev" + source: hosted + version: "0.15.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: "529de303c739fca98cd7ece5fca500d8ff89649f1bb4b4e94fb20954abcd7468" + url: "https://pub.dev" + source: hosted + version: "7.6.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_client_helper: + dependency: transitive + description: + name: http_client_helper + sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image_gallery_saver_plus: + dependency: "direct main" + description: + name: image_gallery_saver_plus + sha256: "199b9e24f8d85e98f11e3d35571ab68ae50626ad40e2bb85c84383f69a6950ad" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d2bab152deb2547ea6f53d82ebca9b7e77386bb706e5789e815d37e08ea475bb + url: "https://pub.dev" + source: hosted + version: "0.8.7+3" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "869fe8a64771b7afbc99fc433a5f7be2fea4d1cb3d7c11a48b6b579eb9c797f0" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b3e2f21feb28b24dd73a35d7ad6e83f568337c70afab5eabac876e23803f264b + url: "https://pub.dev" + source: hosted + version: "0.8.8" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "02cbc21fe1706b97942b575966e5fbbeaac535e76deef70d3a242e4afb857831" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: cee2aa86c56780c13af2c77b5f2f72973464db204569e1ba2dd744459a065af4 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: c3066601ea42113922232c7b7b3330a2d86f029f685bba99d82c30e799914952 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "890cd0fc41a1a4530c171e375a2a3fb6a09d84e9d508c5195f40bcff54330327" + url: "https://pub.dev" + source: hosted + version: "0.9.34" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: d8409da198bbc59426cd45d4c92fca522a2ec269b576ce29459d6d6fcaeb44df + url: "https://pub.dev" + source: hosted + version: "4.2.1" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: ff62f733f437b25a0ff590f0e295fa5441dcb465f1edbdb33b3dea264705bc13 + url: "https://pub.dev" + source: hosted + version: "0.4.8" + link_preview_generator_for_us: + dependency: "direct main" + description: + name: link_preview_generator_for_us + sha256: "22871934571062577b0ed0ef00e8e0eefc044be58b5dc8a99102adeccd206075" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c + url: "https://pub.dev" + source: hosted + version: "1.0.1" + loading_animation_widget: + dependency: "direct main" + description: + name: loading_animation_widget + sha256: "1901682600273a966c34cf44a85fc5355da92a8d08a8a43c11adc4e471993e3a" + url: "https://pub.dev" + source: hosted + version: "1.2.0+4" + logger: + dependency: "direct main" + description: + name: logger + sha256: "66cb048220ca51cf9011da69fa581e4ee2bed4be6e82870d9e9baae75739da49" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + lpinyin: + dependency: "direct main" + description: + name: lpinyin + sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: "8e332924094383133cee218b676871f42db2514f1f6ac617b6cf6152a7faab8e" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + mime_type: + dependency: transitive + description: + name: mime_type + sha256: "2ad6e67d3d2de9ac0f8ef5352d998fd103cb21351ae8c02fb0c78b079b37d275" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: a5a32d44acb7c899987d0999e1e3cbb0a0f1adebbf41ac813ec6d2d8faa0af20 + url: "https://pub.dev" + source: hosted + version: "3.3.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + pasteboard: + dependency: "direct main" + description: + name: pasteboard + sha256: "1c8b6a8b3f1d12e55d4e9404433cda1b4abe66db6b17bc2d2fb5965772c04674" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + photo_manager: + dependency: transitive + description: + name: photo_manager + sha256: "0bc7548fd3111eb93a3b0abf1c57364e40aeda32512c100085a48dade60e574f" + url: "https://pub.dev" + source: hosted + version: "3.6.4" + photo_manager_image_provider: + dependency: transitive + description: + name: photo_manager_image_provider + sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + platform: + dependency: transitive + description: + name: platform + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + provider: + dependency: "direct main" + description: + name: provider + sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + sha256: "5df1597b5bfa1703c02962a7478a187a8c9dedaeb871aedbf7874ee0d58a0bba" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + scrollable_positioned_list_for_us: + dependency: transitive + description: + name: scrollable_positioned_list_for_us + sha256: b5bcbb35114902c004a4f98f2dbd5b0a5a7f80a0144a8b46297601e38fa5383d + url: "https://pub.dev" + source: hosted + version: "0.4.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: fe8401ec5b6dcd739a0fe9588802069e608c3fdbfd3c3c93e546cf2f90438076 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: b046999bf0ff58f04c364491bb803dcfa8f42e47b19c75478f53d323684a8cc1 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "7347b194fb0bbeb4058e6a4e87ee70350b6b2b90f8ac5f8bd5b3a01548f6d33a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: b4d6710e1200e96845747e37338ea8a819a12b51689a3bcf31eff0003b37a0b9 + url: "https://pub.dev" + source: hosted + version: "2.2.8+4" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "8f7603f3f8f126740bc55c4ca2d1027aab4b74a1267a3e31ce51fe40e3b65b8f" + url: "https://pub.dev" + source: hosted + version: "2.4.5+1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + tencent_chat_i18n_tool: + dependency: "direct main" + description: + name: tencent_chat_i18n_tool + sha256: e963cd88b432c18e89e1711171f1c0220d008ae66d8fc81c2cd8be17dbaa891a + url: "https://pub.dev" + source: hosted + version: "2.3.8" + tencent_cloud_chat_sdk: + dependency: "direct main" + description: + name: tencent_cloud_chat_sdk + sha256: "6ffebc22de1eadd6034a82f7602f9b814c3831a43c0fb6e7e4112e8732867e62" + url: "https://pub.dev" + source: hosted + version: "8.6.7019+6" + tencent_cloud_uikit_core: + dependency: "direct main" + description: + name: tencent_cloud_uikit_core + sha256: "987e266dec10801510ada00411ddb063de8129bf550d2d25ba32d88afa272a67" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + tencent_keyboard_visibility: + dependency: "direct main" + description: + name: tencent_keyboard_visibility + sha256: f90bc6671bc85d641b5d8dc1ed9e473de8288c477299290da9c535d7b19ff0e8 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + tencent_super_tooltip: + dependency: "direct main" + description: + name: tencent_super_tooltip + sha256: "94bce1bd23212741d800ed4436b95437438ec27ba1fbadfc1051542e480f7c6a" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + tim_ui_kit_sticker_plugin: + dependency: "direct main" + description: + name: tim_ui_kit_sticker_plugin + sha256: "58d602ad1b65a0ebc11988c323f1a64f75e7b2a2cef0963b1d74b4cdf5e33f4c" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" + tuple: + dependency: "direct main" + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + universal_html: + dependency: "direct main" + description: + name: universal_html + sha256: f2e0ff0c4af8e4bbda4d273ca4a11be4055414f478fad5c161609b74790ff696 + url: "https://pub.dev" + source: hosted + version: "2.2.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" + url: "https://pub.dev" + source: hosted + version: "6.1.12" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "15f5acbf0dce90146a0f5a2c4a002b1814a6303c4c5c075aa2623b2d16156f03" + url: "https://pub.dev" + source: hosted + version: "6.0.36" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "207f4ddda99b95b4d4868320a352d374b0b7e05eefad95a4a26f57da413443f5" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: bfdfa402f1f3298637d71ca8ecfe840b4696698213d5346e9d12d4ab647ee2ea + url: "https://pub.dev" + source: hosted + version: "2.1.3" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: cc26720eefe98c1b71d85f9dc7ef0cada5132617046369d9dc296b3ecaa5cbb4 + url: "https://pub.dev" + source: hosted + version: "2.0.18" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "7967065dd2b5fccc18c653b97958fdf839c5478c28e767c61ee879f4e7882422" + url: "https://pub.dev" + source: hosted + version: "3.0.7" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://pub.dev" + source: hosted + version: "3.0.7" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "670f6e07aca990b4a2bcdc08a784193c4ccdd1932620244c3a86bb72a0eac67f" + url: "https://pub.dev" + source: hosted + version: "1.1.7" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "7451721781d967db9933b63f5733b1c4533022c0ba373a01bdd79d1a5457f69f" + url: "https://pub.dev" + source: hosted + version: "1.1.7" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "80a13c613c8bde758b1464a1755a7b3a8f2b6cec61fbf0f5a53c94c30f03ba2e" + url: "https://pub.dev" + source: hosted + version: "1.1.7" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: f338a5a396c845f4632959511cad3542cdf3167e1b2a1a948ef07f7123c03608 + url: "https://pub.dev" + source: hosted + version: "2.4.9" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" + url: "https://pub.dev" + source: hosted + version: "2.7.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + visibility_detector: + dependency: "direct main" + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: b6962cd9fc15e4843b573ba7b53bc46dd8a787594cf9ed5c5182581924656a58 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + wechat_assets_picker: + dependency: "direct main" + description: + name: wechat_assets_picker + sha256: "65104fff598394fcf1c9a75a8a65a7aa9687485534b44d6e85275774d015df45" + url: "https://pub.dev" + source: hosted + version: "9.5.0" + wechat_picker_library: + dependency: transitive + description: + name: wechat_picker_library + sha256: a42e09cb85b15fc9410f6a69671371cc60aa99c4a1f7967f6593a7f665f6f47a + url: "https://pub.dev" + source: hosted + version: "1.0.5" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + xml: + dependency: transitive + description: + name: xml + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.7.0 <3.9.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1a7fe8b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,133 @@ +name: tencent_cloud_chat_uikit +description: A powerful chat UI component library and business logic for Tencent Cloud Chat, creating seamless in-app chat modules for delightful user experiences. +version: 5.0.3 +homepage: https://trtc.io/products/chat?utm_source=gfs&utm_medium=link&utm_campaign=%E6%B8%A0%E9%81%93&_channel_track_key=k6WgfCKn +repository: https://github.com/TencentCloud/chat-uikit-flutter +documentation: https://comm.qq.com/im/doc/flutter/en/TUIKit/readme.html + +platforms: + android: + ios: + macos: + web: + windows: + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + tencent_cloud_chat_sdk: ^8.6.7019+2 + tim_ui_kit_sticker_plugin: ^4.0.2 + tencent_chat_i18n_tool: ^2.3.8 + adaptive_action_sheet: ^2.0.1 + provider: ^6.0.1 + intl: ^0.20.2 + get_it: ^7.2.0 + dotted_border: ^2.0.0+2 + flutter_svg: ^2.0.6 + image_picker: ^1.0.8 + file_picker: ^10.1.2 + tencent_super_tooltip: ^0.0.1 + better_player_plus: 1.0.8 + video_player: ^2.9.2 + chewie: '>=1.8.4 <=1.8.5' + flutter_slidable_plus_plus: ^0.1.0 + flutter_plugin_record_plus: ^0.0.21 + azlistview_all_platforms: ^2.1.2 + lpinyin: ^2.0.3 + transparent_image: ^2.0.0 + image_gallery_saver_plus: ^4.0.0 + path_provider: ^2.0.8 + cached_network_image: ^3.3.0 + shared_preferences: ^2.0.13 + scroll_to_index: ^2.1.1 + wechat_assets_picker: ^9.3.3 + flutter_easyrefresh: ^2.2.1 + extended_image: ^9.0.0 + extended_text_field: ^16.0.0 + extended_text: ^15.0.0 + package_info_plus: ^8.0.0 + loading_animation_widget: ^1.1.0+3 + permission_handler: ^12.0.0+1 + tuple: ^2.0.0 + flutter_markdown: ^0.6.15 + url_launcher: ^6.1.4 + universal_html: ^2.2.2 + link_preview_generator_for_us: ^2.0.0 + http: ^1.0.0 + crypto: ^3.0.2 + collection: ^1.15.0 + flutter_image_compress: ^2.3.0 + uuid: ^3.0.0 + open_file: ^3.3.2 + tencent_keyboard_visibility: ^1.0.1 + fc_native_video_thumbnail: ^0.16.0 + path: ^1.8.1 + tencent_cloud_uikit_core: ^1.6.0 + pasteboard: ^0.2.0 + desktop_drop: ^0.4.4 + device_info_plus: ^10.1.2 + cross_file: ^0.3.3+4 + csslib: ^0.17.2 + diff_match_patch: ^0.4.1 + just_audio: ^0.9.34 + markdown: ^7.1.0 + logger: ^2.0.1 + visibility_detector: ^0.4.0+2 + +dev_dependencies: + flutter_lints: ^1.0.0 + build_runner: any + lints: ^1.0.1 + +dependency_overrides: +# tencent_chat_i18n_tool: +# path: ../../../tencent_chat_i18n_tool + +# tencent_cloud_chat_sdk: +# path: ../../../../imsdk/imsdk/src/platform/flutter/tencent_cloud_chat_sdk/ + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + uses-material-design: true + assets: + - images/ + - images/svg/ + # - assets/custom_face_resource/4349/ + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/scan.js b/scan.js new file mode 100644 index 0000000..5537906 --- /dev/null +++ b/scan.js @@ -0,0 +1,242 @@ +const fs = require("fs"); +const path = require("path"); +const readline = require("readline").createInterface({ + input: process.stdin, + output: process.stdout, +}); + +let pathList = []; +const keyMap = new Map(); +const keyMapRevert = new Map(); +const dirPaths = ["lib/business_logic", "lib/data_services", "lib/ui"]; + +const singleREP = /'([^']*[^\x00-\xff]{0,200}[^']*)'/g; +const doubleREP = /"([^"]*[^\x00-\xff]{0,200}[^"]*)"/g; +const parameterREP = /(?<=\{{)[^}]*(?=\}})/g; +const dartParameterREP = /(?<=\${)[^}]*(?=\})/g; +const extractDartParaOutREP = /\${[^}]+}/g; + +const readPath = (currentDirPath) => { + fs.readdirSync(currentDirPath).forEach(function (name) { + var filePath = path.join(currentDirPath, name); + var stat = fs.statSync(filePath); + if (stat.isFile()) { + pathList.push(filePath); + } else if (stat.isDirectory()) { + readPath(filePath); + } + }); +}; + +const detectChinese = (filePath, resolve, reject) => { + fs.readFile(filePath, "utf8", (err, data) => { + const temp = []; + if (err) { + console.error(err); + reject(err); + } + const resultSingle = data.match(singleREP); + if (Array.isArray(resultSingle) && resultSingle.length > 0) { + temp.push(...resultSingle.map((item) => item.split("'")[1])); + } + const resultDouble = data.match(doubleREP); + if (Array.isArray(resultDouble) && resultDouble.length > 0) { + temp.push(...resultDouble.map((item) => item.split('"')[1])); + } + resolve(temp); + }); +}; + +const flatArray = (arr) => { + if (Object.prototype.toString.call(arr) != "[object Array]") { + return false; + } + let res = []; + arr.map((item) => { + if (item instanceof Array) { + res.push(...item); + } else { + res.push(item); + } + }); + return res; +}; + +const dealWithResult = (originResult) => { + const arr = flatArray(originResult).filter((item) => item); + const noRepeatArr = Array.from(new Set(arr)); + const chineseArr = noRepeatArr.filter((item) => /[\u4e00-\u9fa5]/.test(item)); + const finalArr = chineseArr.filter((item) => !parameterREP.test(item)); + return finalArr; +}; + +const hashStr = (text) => { + "use strict"; + + let hash = 5381, + index = text.length; + + while (index) { + hash = (hash * 33) ^ text.charCodeAt(--index); + } + + return hash >>> 0; +}; + +const hashKey = (value, context, onError) => { + const key = + "k_" + ("0000" + hashStr(value.replace(/\s+/g, "")).toString(36)).slice(-7); + const existedValue = keyMap.get(context ? `${key}_${context}` : key); + if (existedValue && existedValue !== value) { + onError && + onError((filepath) => { + console.error(""); + console.error(filepath); + console.error("Same sentence in different forms found:"); + console.error(` "${existedValue}"`); + console.error(` "${value}"`); + }); + } else { + keyMap.set(context ? `${key}_${context}` : key, value); + keyMapRevert.set(value, context ? `${key}_${context}` : key); + } + return key; +}; + +const replace = () => { + const getReplaceValue = (origin, quotation) => { + const val = origin.split(quotation == 1 ? "'" : '"')[1]; + const ifReplace = val && keyMapRevert.get(val) && origin.indexOf("imt") === -1; + if(origin.indexOf("${") === -1 && ifReplace){ + return ifReplace ? `ttBuild.imt("${val}")` : origin; + }else if(ifReplace){ + const parameter = val.match(dartParameterREP)[0]; + const template = val.replace(extractDartParaOutREP, `{{${parameter}}}`); + return parameter; + }else{ + return origin; + } + }; + + pathList.forEach((path) => { + if (path.indexOf("i18n") > -1) { + return; + } + let data; + try { + data = fs.readFileSync(path, "utf8"); + } catch (err) { + console.error(err); + return; + } + const newData = data + .replace(doubleREP, (val) => getReplaceValue(val, 2)) + .replace(singleREP, (val) => getReplaceValue(val, 1)); + if (newData != data) { + fs.writeFile(path, newData, (err) => { + if (err) { + console.error(err); + return; + } + }); + } + }); +}; + +dirPaths.forEach((item) => readPath(item)); +pathList = Array.from(new Set(pathList)); + +Promise.all( + pathList.map((item) => { + return new Promise((resolve, reject) => { + return detectChinese(item, resolve, reject); + }); + }) +).then((res) => { + const resultArray = dealWithResult(res); + const chineseDict = {}; + resultArray.forEach((item) => (chineseDict[hashKey(item)] = item)); + + try{ + const data = fs.readFileSync('lib/i18n/i18n_utils.dart', 'utf8').split('\n'); + const lineIndex = data.findIndex(item => item.indexOf("final zhJson =") > -1); + data[lineIndex] = ` final zhJson = `; + data[lineIndex + 1] = ` '''${JSON.stringify(chineseDict).replace(/\$/g, "\\$")}''';`; + // data.splice(lineIndex + 1, 0, ` '''${JSON.stringify(chineseDict).replace(/\$/g, "\\$")}''';`); + fs.writeFileSync('lib/i18n/i18n_utils.dart', data.join('\n'), 'utf8'); + }catch(err){ + console.log(`替换dart文件失败, ${err}`); + } + + try{ + // 增补英文JSON + const enDataFile = fs.readFileSync('lib/i18n/strings.i18n.json'); + const enData = JSON.parse(enDataFile); + for(const item in chineseDict){ + if(!enData.hasOwnProperty(item)){ + enData[item] = chineseDict[item]; + } + } + fs.writeFile( + "lib/i18n/strings.i18n.json", + JSON.stringify(enData), + (err) => { + if (err) { + console.error(err); + return; + } + } + ); + }catch(err){ + console.error(err); + } + + try{ + // 增补繁体中文JSON + const hantDataFile = fs.readFileSync('lib/i18n/strings_zh-Hant.i18n.json'); + const hantData = JSON.parse(hantDataFile); + + for(const item in chineseDict){ + if(!hantData.hasOwnProperty(item)){ + hantData[item] = chineseDict[item]; + } + } + fs.writeFile( + "lib/i18n/strings_zh-Hant.i18n.json", + JSON.stringify(hantData), + (err) => { + if (err) { + console.error(err); + return; + } + } + ); + }catch(err){ + console.error(err); + } + + try{ + // 增补简体中文JSON + const zhDataFile = fs.readFileSync('lib/i18n/strings_zh-Hans.i18n.json'); + const zhData = JSON.parse(zhDataFile); + for(const item in chineseDict){ + if(!zhData.hasOwnProperty(item)){ + zhData[item] = chineseDict[item]; + } + } + fs.writeFile( + "lib/i18n/strings_zh-Hans.i18n.json", + JSON.stringify(zhData), + (err) => { + if (err) { + console.error(err); + return; + } + } + ); + }catch(err){ + console.error(err); + } + + readline.close(); +}); \ No newline at end of file diff --git a/test/tencent_cloud_chat_uikit_test.dart b/test/tencent_cloud_chat_uikit_test.dart new file mode 100644 index 0000000..a5eb090 --- /dev/null +++ b/test/tencent_cloud_chat_uikit_test.dart @@ -0,0 +1,4 @@ + +void main() { + +}