diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 33e8c25b..61fcf7d3 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -321,6 +321,9 @@ 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784707 /* CancelAccountView.swift */; }; 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784709 /* MemberInfoVC.swift */; }; 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A0010078470B /* MemberInfoView.swift */; }; + 55B220013031000100784801 /* FeedbackVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220003031000100784800 /* FeedbackVC.swift */; }; + 55B220033031000100784803 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220023031000100784802 /* FeedbackView.swift */; }; + 55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220043031000100784804 /* FeedbackSuccessView.swift */; }; 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; }; 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; }; 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219A1302A000100784750 /* UnlockRequestPopView.swift */; }; @@ -731,6 +734,9 @@ 55B218A13024A00100784707 /* CancelAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountView.swift; sourceTree = ""; }; 55B218A13024A00100784709 /* MemberInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoVC.swift; sourceTree = ""; }; 55B218A13024A0010078470B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.swift; sourceTree = ""; }; + 55B220003031000100784800 /* FeedbackVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackVC.swift; sourceTree = ""; }; + 55B220023031000100784802 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = ""; }; + 55B220043031000100784804 /* FeedbackSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackSuccessView.swift; sourceTree = ""; }; 55B218B13024B00100784721 /* FeatureIntroVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroVC.swift; sourceTree = ""; }; 55B218B13024B00100784723 /* FeatureIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroView.swift; sourceTree = ""; }; 55B219A1302A000100784750 /* UnlockRequestPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockRequestPopView.swift; sourceTree = ""; }; @@ -1285,6 +1291,7 @@ 30EFF3CB2FDA667A00EB35D4 /* MyProfile */, 30EFF3BC2FD9585200EB35D4 /* Account */, 55B218A13024A00100784710 /* About */, + 55B220063031000100784806 /* Feedback */, 55B218A13024A00100784711 /* CancelAccount */, 30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */, 30EFF3E32FDAA92200EB35D4 /* PrivacyPolicy */, @@ -1973,6 +1980,16 @@ path = MemberInfo; sourceTree = ""; }; + 55B220063031000100784806 /* Feedback */ = { + isa = PBXGroup; + children = ( + 55B220003031000100784800 /* FeedbackVC.swift */, + 55B220023031000100784802 /* FeedbackView.swift */, + 55B220043031000100784804 /* FeedbackSuccessView.swift */, + ); + path = Feedback; + sourceTree = ""; + }; 55B218B13024B00100784720 /* Explore */ = { isa = PBXGroup; children = ( @@ -2390,6 +2407,9 @@ 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */, 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */, 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */, + 55B220013031000100784801 /* FeedbackVC.swift in Sources */, + 55B220033031000100784803 /* FeedbackView.swift in Sources */, + 55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, 30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */, 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/Contents.json new file mode 100644 index 00000000..f0dc1121 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "remove_member_search@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "remove_member_search@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@2x.png b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@2x.png new file mode 100644 index 00000000..1c1151a7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@3x.png b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@3x.png new file mode 100644 index 00000000..fe064d08 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/Contents.json new file mode 100644 index 00000000..4cd3c3a8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "remove_member_selected@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "remove_member_selected@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@2x.png b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@2x.png new file mode 100644 index 00000000..7ce66406 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@3x.png b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@3x.png new file mode 100644 index 00000000..4535c3f7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/Contents.json new file mode 100644 index 00000000..72b22100 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "remove_member_unselected@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "remove_member_unselected@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@2x.png b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@2x.png new file mode 100644 index 00000000..9640f4ec Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@3x.png b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@3x.png new file mode 100644 index 00000000..19eb5c68 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/remove_member_unselected@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/at.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/at.imageset/Contents.json new file mode 100644 index 00000000..04ec9aef --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/at.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "at@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "at@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/at.imageset/at@2x.png b/QuickLocation/Assets.xcassets/IM/at.imageset/at@2x.png new file mode 100644 index 00000000..51d41bd9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/at.imageset/at@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/at.imageset/at@3x.png b/QuickLocation/Assets.xcassets/IM/at.imageset/at@3x.png new file mode 100644 index 00000000..f1543c29 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/at.imageset/at@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/camera.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/camera.imageset/Contents.json new file mode 100644 index 00000000..33f5c00c --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/camera.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "camera@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "camera@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@2x.png b/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@2x.png new file mode 100644 index 00000000..cb00ca47 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@3x.png b/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@3x.png new file mode 100644 index 00000000..8e562a11 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/camera.imageset/camera@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/Contents.json new file mode 100644 index 00000000..e21a556a --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "emoji@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "emoji@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@2x.png b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@2x.png new file mode 100644 index 00000000..293206bc Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@3x.png b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@3x.png new file mode 100644 index 00000000..523d7e88 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/emoji_input.imageset/emoji@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/gallery.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/gallery.imageset/Contents.json new file mode 100644 index 00000000..649532b9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/gallery.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "gallery@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "gallery@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@2x.png b/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@2x.png new file mode 100644 index 00000000..6ad2d6ec Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@3x.png b/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@3x.png new file mode 100644 index 00000000..d8dc80a5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/gallery.imageset/gallery@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Contents.json index 3ba55c1b..21c87db1 100644 --- a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Contents.json +++ b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "Icons_Photo@2x.png", + "filename" : "keyboard@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Icons_Photo@3x.png", + "filename" : "keyboard@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@2x.png b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@2x.png deleted file mode 100644 index ab78db90..00000000 Binary files a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@2x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@3x.png b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@3x.png deleted file mode 100644 index 1e87dcd1..00000000 Binary files a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/Icons_Photo@3x.png and /dev/null differ diff --git a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@2x.png b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@2x.png new file mode 100644 index 00000000..5a6531b9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@3x.png b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@3x.png new file mode 100644 index 00000000..c0d052f3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/keyboard.imageset/keyboard@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/location.imageset/Contents.json new file mode 100644 index 00000000..ea76a428 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/location.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "location@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "location@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/location.imageset/location@2x.png b/QuickLocation/Assets.xcassets/IM/location.imageset/location@2x.png new file mode 100644 index 00000000..21867da8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location.imageset/location@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location.imageset/location@3x.png b/QuickLocation/Assets.xcassets/IM/location.imageset/location@3x.png new file mode 100644 index 00000000..579f1039 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location.imageset/location@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location_pin.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/Contents.json new file mode 100644 index 00000000..7ac2b095 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "location_pin@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "location_pin@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@2x.png b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@2x.png new file mode 100644 index 00000000..c5262f79 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@3x.png b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@3x.png new file mode 100644 index 00000000..3a83f0f1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location_pin.imageset/location_pin@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/Contents.json new file mode 100644 index 00000000..c3710d91 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "location_recenter@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "location_recenter@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@2x.png b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@2x.png new file mode 100644 index 00000000..5c9b82c0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@3x.png b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@3x.png new file mode 100644 index 00000000..ddb3d4cf Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@2x.png b/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@2x.png index 6396614f..4f58035d 100644 Binary files a/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@2x.png and b/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@3x.png b/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@3x.png index 7cbba75d..02fabeb6 100644 Binary files a/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@3x.png and b/QuickLocation/Assets.xcassets/IM/member.imageset/Group_1613@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/reply.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/reply.imageset/Contents.json new file mode 100644 index 00000000..59d21f57 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/reply.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "reply@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "reply@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@2x.png b/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@2x.png new file mode 100644 index 00000000..974e3660 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@3x.png b/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@3x.png new file mode 100644 index 00000000..295336d4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/reply.imageset/reply@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/send.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/send.imageset/Contents.json new file mode 100644 index 00000000..2e0394e3 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/send.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "send-2@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "send-2@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@2x.png b/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@2x.png new file mode 100644 index 00000000..4548c7a8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@3x.png b/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@3x.png new file mode 100644 index 00000000..574604c3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/send.imageset/send-2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@2x.png b/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@2x.png index 30e5d7ca..047f032f 100644 Binary files a/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@2x.png and b/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@3x.png b/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@3x.png index de9dc0e4..63881e65 100644 Binary files a/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@3x.png and b/QuickLocation/Assets.xcassets/IM/setting.imageset/Group_1614@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/voice_input.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/Contents.json new file mode 100644 index 00000000..9832aa90 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "voice@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "voice@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@2x.png b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@2x.png new file mode 100644 index 00000000..9a8a3c3d Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@3x.png b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@3x.png new file mode 100644 index 00000000..1748e133 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/voice_input.imageset/voice@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/Contents.json new file mode 100644 index 00000000..9d7523d6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "add@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "add@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@2x.png b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@2x.png new file mode 100644 index 00000000..9ff1fec2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@3x.png b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@3x.png new file mode 100644 index 00000000..caada6eb Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_add.imageset/add@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/Contents.json new file mode 100644 index 00000000..e00c23d6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "header@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "header@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@2x.png b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@2x.png new file mode 100644 index 00000000..b7d298f6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@3x.png b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@3x.png new file mode 100644 index 00000000..714b126e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@2x.png new file mode 100644 index 00000000..024d5ce0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@3x.png new file mode 100644 index 00000000..ee75d7f0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@3x.png differ diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 501db0fe..702ec6c1 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -81,6 +81,8 @@ enum Route: String { case phoneReportDetail = "phoneReportDetail" /// 关于我们 case about = "about" + /// 意见反馈 + case feedback = "feedback" /// 成员信息 case memberInfo = "memberInfo" /// 注销账号 @@ -384,6 +386,11 @@ extension AppRouter: AppRouterProtocol { AboutVC() } + // MARK: - 意见反馈 + AppRouter.register(Route.feedback) { _, _ in + FeedbackVC() + } + // MARK: - 成员信息 AppRouter.register(Route.memberInfo) { _, parameters in MemberInfoVC(userInfo: parameters) @@ -430,4 +437,3 @@ extension AppRouter: AppRouterProtocol { } } - diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 987bfeb2..0074b314 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -15,6 +15,12 @@ import AudioToolbox import HXPHPicker import IQKeyboardManagerSwift import MJRefresh +import CoreLocation +import SwiftyUserDefaults +#if !targetEnvironment(simulator) +import AMapNaviKit +import AMapSearchKit +#endif final class GroupChatVC: BaseViewController { @@ -26,6 +32,7 @@ final class GroupChatVC: BaseViewController { /// mention 列表:首行 @所有人 + 成员 private var mentionRows: [(userId: String?, nickname: String)] = [] private var isMentionPickerVisible = false + private var cameraPicker: ImagePicker? /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? @@ -76,8 +83,11 @@ final class GroupChatVC: BaseViewController { IQKeyboardManager.shared.resignOnTouchOutside = false // 非圈主 会员权益拦截 - guard viewModel.groupId.contains(AppContextManager.shared.userId) == false else { return } - rootView.disableIMView.isHidden = AppContextManager.shared.vip > 1 + let isCircleOwner = viewModel.groupId.contains(AppContextManager.shared.userId) + let canSend = isCircleOwner || AppContextManager.shared.vip > 1 + rootView.disableIMView.isHidden = canSend + rootView.quickActionBar.isUserInteractionEnabled = canSend + rootView.quickActionBar.alpha = canSend ? 1 : 0.35 } override func viewWillDisappear(_ animated: Bool) { @@ -121,6 +131,7 @@ final class GroupChatVC: BaseViewController { .filter { $0.state == .began } .subscribe(onNext: { [weak self] _ in self?.rootView.dismissAllPanels() + self?.bubbleMenu?.dismiss() }) .disposed(by: disposeBag) @@ -128,6 +139,7 @@ final class GroupChatVC: BaseViewController { rootView.tableView.rx.itemSelected .subscribe(onNext: { [weak self] _ in self?.rootView.dismissAllPanels() + self?.bubbleMenu?.dismiss() }) .disposed(by: disposeBag) } @@ -339,6 +351,8 @@ final class GroupChatVC: BaseViewController { if let c = cell as? VoiceReceivedMsgCell { return c.menuAnchorView } if let c = cell as? ImageSendMsgCell { return c.menuAnchorView } if let c = cell as? ImageReceivedMsgCell { return c.menuAnchorView } + if let c = cell as? LocationSendMsgCell { return c.menuAnchorView } + if let c = cell as? LocationReceivedMsgCell { return c.menuAnchorView } return cell.contentView } @@ -350,7 +364,11 @@ final class GroupChatVC: BaseViewController { let menu = ChatBubbleActionMenu() bubbleMenu = menu - menu.onQuote = { [weak self] in + menu.onMention = { [weak self] in + guard let self else { return } + self.insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false) + } + menu.onReply = { [weak self] in guard let self = self, let oim = self.viewModel.oimMessage(forClientMsgID: msg.id) else { DLToast.showError(text: "无法引用该消息") @@ -359,11 +377,9 @@ final class GroupChatVC: BaseViewController { self.viewModel.setPendingQuote(oim) self.rootView.textField.becomeFirstResponder() } - menu.onRevoke = { [weak self] in - self?.viewModel.revokeMessage(clientMsgID: msg.id) - } + menu.onDismiss = { [weak self] in self?.bubbleMenu = nil } let anchor = menuAnchorView(in: cell) -// menu.show(in: rootView, anchor: anchor, showRevoke: msg.isSelf) + menu.show(in: rootView, anchor: anchor, canMention: !msg.isSelf) } // MARK: - Mention @@ -510,9 +526,8 @@ final class GroupChatVC: BaseViewController { }) .disposed(by: disposeBag) - // ➕按钮 Observable.merge( - rootView.addBtn.rx.tap.asObservable(), + rootView.galleryBtn.rx.tap.asObservable(), rootView.voiceRecordView.addBtn.rx.tap.asObservable() ) .subscribe(onNext: { [weak self] in @@ -521,6 +536,20 @@ final class GroupChatVC: BaseViewController { self.showAlbum() }) .disposed(by: disposeBag) + + rootView.cameraBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.rootView.dismissAllPanels() + self?.showCamera() + }) + .disposed(by: disposeBag) + + rootView.locationBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.rootView.dismissAllPanels() + self?.showLocationPicker() + }) + .disposed(by: disposeBag) rootView.sendBtn.rx.tap .subscribe(onNext: { [weak self] in @@ -548,7 +577,7 @@ final class GroupChatVC: BaseViewController { rootView.voiceRecordView.isHidden = !show let offset: CGFloat = show ? 252 : 0 UIView.animate(withDuration: 0.25) { - self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin + 20) + self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin) self.rootView.voiceRecordView.layoutChain.bottom(offset - 252) } scrollToBottom() @@ -599,6 +628,37 @@ final class GroupChatVC: BaseViewController { self.present(pickerController, animated: true, completion: nil) } + private func showCamera() { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + DLToast.showError(text: "当前设备不支持拍照") + return + } + let picker = ImagePicker() + picker.imageDidPicked = { [weak self] image in + DispatchQueue.main.async { + self?.sendImageMessage(image) + } + } + cameraPicker = picker + picker.showCamera() + } + + private func showLocationPicker() { + let picker = ChatLocationPickerVC() + picker.onPickedLocation = { [weak self] location in + self?.viewModel.sendLocation(location) + } + navigationController?.pushViewController(picker, animated: true) + } + + private func showLocationDetail(_ location: ChatLocationPayload?) { + guard let location else { + DLToast.showError(text: "无法读取位置信息") + return + } + navigationController?.pushViewController(ChatLocationDetailVC(location: location), animated: true) + } + // MARK: - Voice Recording private var audioRecorder: AVAudioRecorder? private var recordFileURL: URL? @@ -701,11 +761,12 @@ final class GroupChatVC: BaseViewController { private func requestGroupInfoByKey() { GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in guard let model = response.model else { return } + self.viewModel.groupModel = model self.viewModel.memberList = response.list - self.rootView.groupNameLabel.text = model.name + self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))" self.rootView.groupAvatarView.image = model.groupIcon self.rootView.reviewBtn.isHidden = !model.is_owner - self.rootView.reviewDotView.isHidden = response.reviewCount == 0 +// self.rootView.reviewDotView.isHidden = response.reviewCount == 0 }.disposed(by: disposeBag) } @@ -759,6 +820,20 @@ final class GroupChatVC: BaseViewController { self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } return cell + case let .locationSend(msg): + let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + cell.onLocationTap = { [weak self] in + self?.showLocationDetail(msg.location) + } + return cell + case let .locationReceived(msg): + let cell: LocationReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + cell.onLocationTap = { [weak self] in + self?.showLocationDetail(msg.location) + } + return cell case let .notification(text, showTime, timestamp), let .revoked(text, showTime, timestamp): let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -809,10 +884,12 @@ extension GroupChatVC: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard tableView === rootView.mentionTableView else { return } - tableView.deselectRow(at: indexPath, animated: true) - let row = mentionRows[indexPath.row] - insertMention(userId: row.userId, nickname: row.nickname) + if tableView === rootView.mentionTableView { + tableView.deselectRow(at: indexPath, animated: true) + let row = mentionRows[indexPath.row] + insertMention(userId: row.userId, nickname: row.nickname) + return + } } } @@ -931,3 +1008,811 @@ extension GroupChatVC: PhotoPickerControllerDelegate { } } + +// MARK: - 群聊位置选择 +private struct ChatLocationSuggestion { + let name: String + let address: String + let coordinate: CLLocationCoordinate2D +} + +#if !targetEnvironment(simulator) +private final class ChatCurrentLocationAnnotation: NSObject, MAAnnotation { + var coordinate: CLLocationCoordinate2D + + init(coordinate: CLLocationCoordinate2D) { + self.coordinate = coordinate + super.init() + } +} + +private final class ChatCurrentLocationAnnotationView: MAAnnotationView { + private let avatarContainer: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 14 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOffset = CGSize(width: 0, height: 2) + view.layer.shadowRadius = 5 + view.layer.shadowOpacity = 0.16 + return view + }() + private let avatarImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.layer.cornerRadius = 10 + return view + }() + private let statusOuterView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 8 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOffset = CGSize(width: 0, height: 1) + view.layer.shadowRadius = 2 + view.layer.shadowOpacity = 0.16 + return view + }() + private let statusInnerView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#56DC42") + view.layer.cornerRadius = 5 + return view + }() + + override init!(annotation: MAAnnotation!, reuseIdentifier: String!) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + canShowCallout = false + isUserInteractionEnabled = false + bounds = CGRect(x: 0, y: 0, width: 56, height: 72) + centerOffset = CGPoint(x: 0, y: -29) + + addSubview(avatarContainer) + avatarContainer.addSubview(avatarImageView) + addSubview(statusOuterView) + statusOuterView.addSubview(statusInnerView) + + avatarContainer.frame = CGRect(x: 0, y: 0, width: 56, height: 56) + avatarImageView.frame = avatarContainer.bounds.insetBy(dx: 4, dy: 4) + statusOuterView.frame = CGRect(x: 20, y: 57, width: 16, height: 16) + statusInnerView.frame = statusOuterView.bounds.insetBy(dx: 3, dy: 3) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure() { + let avatar = AppContextManager.shared.avaterIcon + avatarImageView.image = avatar.size == .zero ? UIImage(named: "Common/default_avatar") : avatar + } +} +#endif + +final class ChatLocationPickerVC: BaseViewController { + override var isNavigationBarHidden: Bool { true } + + var onPickedLocation: ((ChatLocationPayload) -> Void)? + private var rootView: ChatLocationPickerView! + private var currentCoordinate: CLLocationCoordinate2D? + private var selectedLocation: ChatLocationSuggestion? + private var suggestions: [ChatLocationSuggestion] = [] + private var searchWorkItem: DispatchWorkItem? + + #if !targetEnvironment(simulator) + private let searchAPI = AMapSearchAPI() + private var currentLocationAnnotation: ChatCurrentLocationAnnotation? + #endif + + override func loadView() { + rootView = ChatLocationPickerView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.resultTableView.dataSource = self + rootView.resultTableView.delegate = self + rootView.backBtn.addTarget(self, action: #selector(handleBack), for: .touchUpInside) + rootView.confirmBtn.addTarget(self, action: #selector(confirm), for: .touchUpInside) + rootView.recenterBtn.addTarget(self, action: #selector(recenterToCurrentLocation), for: .touchUpInside) + rootView.searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged) + rootView.searchField.addTarget(self, action: #selector(search), for: .editingDidEndOnExit) + setupMap() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + #if !targetEnvironment(simulator) + rootView.cleanupMap() + #endif + } + } + + @objc private func handleBack() { + navigationController?.popViewController(animated: true) + } + + @objc private func confirm() { + guard let selectedLocation else { + DLToast.showError(text: "请选择位置") + return + } + onPickedLocation?(ChatLocationPayload(name: selectedLocation.name, + address: selectedLocation.address, + latitude: selectedLocation.coordinate.latitude, + longitude: selectedLocation.coordinate.longitude)) + navigationController?.popViewController(animated: true) + } + + @objc private func searchChanged() { + searchWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in self?.search() } + searchWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem) + } + + @objc private func search() { + let keyword = rootView.searchField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !keyword.isEmpty else { + loadNearbyPOIs() + return + } + #if !targetEnvironment(simulator) + let request = AMapPOIKeywordsSearchRequest() + request.keywords = keyword + request.city = "" + request.offset = 20 + if let currentCoordinate { + request.location = AMapGeoPoint.location(withLatitude: CGFloat(currentCoordinate.latitude), + longitude: CGFloat(currentCoordinate.longitude)) + } + searchAPI?.aMapPOIKeywordsSearch(request) + #else + rootView.emptyLabel.isHidden = false + rootView.emptyLabel.text = "模拟器无法搜索地图地点" + #endif + } + + @objc private func recenterToCurrentLocation() { + guard let currentCoordinate else { + DLToast.showError(text: "暂无当前位置") + return + } + #if !targetEnvironment(simulator) + rootView.mapView.setCenter(currentCoordinate, animated: true) + #endif + selectMapCenterLocation(currentCoordinate, placeholderName: "当前位置") + } + + private func setupMap() { + if let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + if CLLocationCoordinate2DIsValid(coordinate) { + currentCoordinate = coordinate + } + } + #if !targetEnvironment(simulator) + searchAPI?.delegate = self + rootView.mapView.delegate = self + rootView.mapView.showsUserLocation = false + let coordinate = currentCoordinate ?? CLLocationCoordinate2D(latitude: 39.9042, longitude: 116.4074) + rootView.mapView.setCenter(coordinate, animated: false) + rootView.mapView.setZoomLevel(15, animated: false) + if let currentCoordinate { + addCurrentLocationAnnotation(at: currentCoordinate) + selectMapCenterLocation(currentCoordinate, placeholderName: "当前位置") + } + #else + rootView.emptyLabel.isHidden = false + rootView.emptyLabel.text = "请在真机上选择地图位置" + #endif + loadNearbyPOIs() + } + + private func loadNearbyPOIs() { + #if !targetEnvironment(simulator) + guard let currentCoordinate else { + rootView.emptyLabel.isHidden = false + rootView.emptyLabel.text = "暂无当前位置,可直接在地图上选择" + return + } + let request = AMapPOIAroundSearchRequest() + request.location = AMapGeoPoint.location(withLatitude: CGFloat(currentCoordinate.latitude), + longitude: CGFloat(currentCoordinate.longitude)) + request.radius = 1500 + request.offset = 20 + searchAPI?.aMapPOIAroundSearch(request) + #endif + } + + private func updateSuggestions(_ values: [ChatLocationSuggestion]) { + suggestions = values + keepSelectedLocationInSuggestions() + rootView.resultTableView.reloadData() + rootView.emptyLabel.isHidden = !suggestions.isEmpty + } + + private func select(_ location: ChatLocationSuggestion, recenterMap: Bool) { + selectedLocation = location + keepSelectedLocationInSuggestions() + rootView.selectedNameLabel.text = location.name.isEmpty ? "已选位置" : location.name + rootView.selectedAddressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address + rootView.selectedPanel.isHidden = false + rootView.confirmBtn.isEnabled = true + rootView.emptyLabel.isHidden = true + rootView.resultTableView.reloadData() + #if !targetEnvironment(simulator) + if recenterMap { + rootView.mapView.setCenter(location.coordinate, animated: true) + } + #endif + } + + private func selectMapCenterLocation(_ coordinate: CLLocationCoordinate2D, placeholderName: String = "正在获取地点...") { + guard CLLocationCoordinate2DIsValid(coordinate) else { return } + let placeholder = ChatLocationSuggestion(name: placeholderName, + address: "正在获取地址...", + coordinate: coordinate) + select(placeholder, recenterMap: false) + requestReverseGeocode(for: coordinate) + } + + private func keepSelectedLocationInSuggestions() { + guard let selectedLocation else { return } + if let index = suggestions.firstIndex(where: { coordinatesMatch($0.coordinate, selectedLocation.coordinate) }) { + suggestions[index] = selectedLocation + } else { + suggestions.insert(selectedLocation, at: 0) + } + } + + private func coordinatesMatch(_ lhs: CLLocationCoordinate2D, _ rhs: CLLocationCoordinate2D) -> Bool { + abs(lhs.latitude - rhs.latitude) < 0.000_001 && abs(lhs.longitude - rhs.longitude) < 0.000_001 + } + + #if !targetEnvironment(simulator) + private func requestReverseGeocode(for coordinate: CLLocationCoordinate2D) { + let request = AMapReGeocodeSearchRequest() + request.location = AMapGeoPoint.location(withLatitude: CGFloat(coordinate.latitude), longitude: CGFloat(coordinate.longitude)) + request.requireExtension = true + searchAPI?.aMapReGoecodeSearch(request) + } + + private func addCurrentLocationAnnotation(at coordinate: CLLocationCoordinate2D) { + if let currentLocationAnnotation { + currentLocationAnnotation.coordinate = coordinate + return + } + let annotation = ChatCurrentLocationAnnotation(coordinate: coordinate) + currentLocationAnnotation = annotation + rootView.mapView.addAnnotation(annotation) + } + #endif +} + +extension ChatLocationPickerVC: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { suggestions.count } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "ChatLocationSuggestionCell", for: indexPath) + let value = suggestions[indexPath.row] + var content = cell.defaultContentConfiguration() + content.text = value.name + content.secondaryText = value.address + content.textProperties.font = .systemFont(ofSize: 15, weight: .medium) + content.secondaryTextProperties.font = .systemFont(ofSize: 12) + content.secondaryTextProperties.color = UIColor(hexStr: "#8A8A8A") + cell.contentConfiguration = content + cell.accessoryType = selectedLocation?.coordinate.latitude == value.coordinate.latitude + && selectedLocation?.coordinate.longitude == value.coordinate.longitude ? .checkmark : .none + cell.tintColor = UIColor(hexStr: "#16B3FF") + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + rootView.searchField.resignFirstResponder() + select(suggestions[indexPath.row], recenterMap: true) + } +} + +#if !targetEnvironment(simulator) +extension ChatLocationPickerVC: AMapSearchDelegate, MAMapViewDelegate { + func onPOISearchDone(_ request: AMapPOISearchBaseRequest!, response: AMapPOISearchResponse!) { + let values = response.pois.compactMap { poi -> ChatLocationSuggestion? in + guard let point = poi.location else { return nil } + let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude) + guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } + return ChatLocationSuggestion(name: poi.name ?? "", + address: poi.address ?? poi.district ?? "", + coordinate: coordinate) + } + updateSuggestions(values) + } + + func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { + guard let point = request.location else { return } + let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude) + guard let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) else { return } + let poi = response.regeocode?.pois?.first + let location = ChatLocationSuggestion(name: poi?.name ?? response.regeocode?.formattedAddress ?? "已选位置", + address: poi?.address ?? response.regeocode?.formattedAddress ?? "", + coordinate: coordinate) + select(location, recenterMap: false) + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + if let reGeocodeRequest = request as? AMapReGeocodeSearchRequest, + let point = reGeocodeRequest.location { + let coordinate = CLLocationCoordinate2D(latitude: point.latitude, longitude: point.longitude) + if let selectedLocation, coordinatesMatch(selectedLocation.coordinate, coordinate) { + select(ChatLocationSuggestion(name: "已选位置", address: "", coordinate: coordinate), recenterMap: false) + } + return + } + rootView.emptyLabel.isHidden = false + rootView.emptyLabel.text = "地点加载失败,请重试或在地图上选择" + } + + func mapView(_ mapView: MAMapView!, regionDidChangeAnimated animated: Bool, wasUserAction: Bool) { + guard wasUserAction else { return } + let center = CGPoint(x: mapView.bounds.midX, y: mapView.bounds.midY) + let coordinate = mapView.convert(center, toCoordinateFrom: mapView) + selectMapCenterLocation(coordinate) + } + + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + guard annotation is ChatCurrentLocationAnnotation else { return nil } + let identifier = "ChatCurrentLocation" + guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? ChatCurrentLocationAnnotationView) + ?? ChatCurrentLocationAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil } + view.annotation = annotation + view.zIndex = 10 + view.configure() + return view + } +} +#endif + +private final class ChatLocationPickerView: UIView { + #if !targetEnvironment(simulator) + lazy var mapView: MAMapView! = { + let map = MAMapView() + map.showsCompass = false + map.showsScale = false + map.zoomLevel = 15 + return map + }() + #endif + + let backBtn: UIButton = { + let button = UIButton(type: .custom) + button.setBackgroundImage(UIImage(named: "Common/back"), for: .normal) + button.backgroundColor = .clear + button.extendEdgeInsets = UIEdgeInsets(top: 20, left: 18, bottom: 20, right: 20) + return button + }() + let recenterBtn: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "IM/location_recenter"), for: .normal) + button.backgroundColor = .white + button.layer.cornerRadius = 10 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 6 + button.layer.shadowOpacity = 0.12 + return button + }() + let centerPinView: UIImageView = { + let view = UIImageView(image: UIImage(named: "IM/location_pin")) + view.contentMode = .scaleAspectFit + view.isUserInteractionEnabled = false + return view + }() + let searchField: UITextField = { + let field = UITextField() + field.placeholder = "搜索地点、商圈或道路" + field.font = .systemFont(ofSize: 14) + field.clearButtonMode = .whileEditing + field.returnKeyType = .search + field.backgroundColor = UIColor(hexStr: "#F3F3F3") + field.cornerRadius = 11 + field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 36)) + field.leftViewMode = .always + return field + }() + let resultTableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.backgroundColor = .white + table.separatorColor = UIColor(hexStr: "#EEEEEE") + table.rowHeight = 74 + table.register(UITableViewCell.self, forCellReuseIdentifier: "ChatLocationSuggestionCell") + table.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: kSafeBottomMargin, right: 0) + return table + }() + let emptyLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hexStr: "#8A8A8A") + label.textAlignment = .center + label.numberOfLines = 0 + label.isHidden = true + return label + }() + let selectedPanel: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 24 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + let selectedNameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.textColor = UIColor(hexStr: "#282828") + return label + }() + let selectedAddressLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hexStr: "#8A8A8A") + label.lineBreakMode = .byTruncatingTail + return label + }() + let confirmBtn: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("确认", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + button.cornerRadius = 8 + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#EFF7F8") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + #if !targetEnvironment(simulator) + addSubview(mapView) + mapView.layoutChain.edges(excludingEdge: .bottom).bottom(250 + kSafeBottomMargin) + addSubview(centerPinView) + #endif + addSubview(backBtn) + addSubview(recenterBtn) + addSubview(confirmBtn) + addSubview(selectedPanel) + selectedPanel.addSubview(searchField) + selectedPanel.addSubview(resultTableView) + selectedPanel.addSubview(emptyLabel) + selectedPanel.addSubview(selectedNameLabel) + selectedPanel.addSubview(selectedAddressLabel) + + backBtn.layoutChain.top(kStatusBarHeight + 12).left(18).width(32).height(32) + recenterBtn.layoutChain.left(13).bottomToTopOfView(selectedPanel, offset: -24).width(38).height(38) + confirmBtn.layoutChain.right(14).width(50).height(30).centerY(backBtn) + selectedPanel.layoutChain.edgesHorzontal().bottom().height(290) + searchField.layoutChain.top(13).edgesHorzontal(15).height(38) + resultTableView.layoutChain.topToBottomOfView(searchField, offset: 13).edgesHorzontal(0).bottom() + emptyLabel.layoutChain.centerX().centerY().edgesHorzontal(40) + selectedNameLabel.isHidden = true + selectedAddressLabel.isHidden = true + #if !targetEnvironment(simulator) + centerPinView.layoutChain.centerX(mapView).centerY(mapView, offset: -28).width(34).height(56) + #endif + } + + #if !targetEnvironment(simulator) + func cleanupMap() { + mapView?.delegate = nil + mapView?.removeFromSuperview() + mapView = nil + } + #endif +} + +// MARK: - 群聊位置详情 +final class ChatLocationDetailVC: BaseViewController { + override var isNavigationBarHidden: Bool { true } + + private let location: ChatLocationPayload + private var rootView: ChatLocationDetailView! + private var currentCoordinate: CLLocationCoordinate2D? + #if !targetEnvironment(simulator) + private let routeSearch = AMapSearchAPI() + private var routeOverlays: [MAPolyline] = [] + #endif + + init(location: ChatLocationPayload) { + self.location = location + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + rootView = ChatLocationDetailView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.backBtn.addTarget(self, action: #selector(handleBack), for: .touchUpInside) + rootView.nameLabel.text = location.name.isEmpty ? "位置" : location.name + rootView.addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address + setupLocation() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + #if !targetEnvironment(simulator) + rootView.cleanupMap() + #endif + } + } + + @objc private func handleBack() { navigationController?.popViewController(animated: true) } + + private func setupLocation() { + if let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + if CLLocationCoordinate2DIsValid(coordinate) { + currentCoordinate = coordinate + let distance = CLLocation(latitude: latitude, longitude: longitude) + .distance(from: CLLocation(latitude: location.latitude, longitude: location.longitude)) + rootView.distanceLabel.text = "距离你约 \(chatDistanceText(distance))" + } + } + if currentCoordinate == nil { + rootView.distanceLabel.text = "暂未获取当前位置" + } + #if !targetEnvironment(simulator) + rootView.mapView.delegate = self + rootView.mapView.showsUserLocation = false + routeSearch?.delegate = self + rootView.mapView.setCenter(location.coordinate, animated: false) + rootView.mapView.setZoomLevel(15, animated: false) + addAnnotations() + requestRoutes() + #else + rootView.mapHintLabel.isHidden = false + #endif + } + + #if !targetEnvironment(simulator) + private func addAnnotations() { + let target = MAPointAnnotation() + target.coordinate = location.coordinate + target.title = "target" + rootView.mapView.addAnnotation(target) + if let currentCoordinate { + let current = ChatCurrentLocationAnnotation(coordinate: currentCoordinate) + rootView.mapView.addAnnotation(current) + rootView.mapView.showAnnotations([target, current], animated: true) + } + } + + private func requestRoutes() { + guard let currentCoordinate else { + rootView.drivingLabel.text = "驾车 暂无当前位置" + rootView.walkingLabel.text = "步行 暂无当前位置" + return + } + let origin = AMapGeoPoint.location(withLatitude: CGFloat(currentCoordinate.latitude), longitude: CGFloat(currentCoordinate.longitude)) + let destination = AMapGeoPoint.location(withLatitude: CGFloat(location.latitude), longitude: CGFloat(location.longitude)) + let driving = AMapDrivingRouteSearchRequest() + driving.origin = origin + driving.destination = destination + driving.strategy = 0 + routeSearch?.aMapDrivingRouteSearch(driving) + let walking = AMapWalkingRouteSearchRequest() + walking.origin = origin + walking.destination = destination + routeSearch?.aMapWalkingRouteSearch(walking) + } + #endif +} + +#if !targetEnvironment(simulator) +extension ChatLocationDetailVC: AMapSearchDelegate, MAMapViewDelegate { + func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) { + guard let path = response.route?.paths?.first as? AMapPath else { return } + let text = chatDurationText(TimeInterval(path.duration)) + if request is AMapDrivingRouteSearchRequest { + rootView.drivingLabel.text = "驾车 约\(text)" + drawDrivingRoute(path) + } else if request is AMapWalkingRouteSearchRequest { + rootView.walkingLabel.text = "步行 约\(text)" + } + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + if request is AMapDrivingRouteSearchRequest { + rootView.drivingLabel.text = "驾车 路线加载失败" + } else if request is AMapWalkingRouteSearchRequest { + rootView.walkingLabel.text = "步行 路线加载失败" + } + } + + private func drawDrivingRoute(_ path: AMapPath) { + routeOverlays.forEach { rootView.mapView.remove($0) } + routeOverlays.removeAll() + var coordinates: [CLLocationCoordinate2D] = [] + for step in path.steps { + guard let polyline = step.polyline else { continue } + for point in polyline.split(separator: ";") { + let values = point.split(separator: ",") + guard values.count == 2, let longitude = Double(values[0]), let latitude = Double(values[1]) else { continue } + coordinates.append(CLLocationCoordinate2D(latitude: latitude, longitude: longitude)) + } + } + guard coordinates.count > 1 else { return } + var mutableCoordinates = coordinates + if let overlay = MAPolyline(coordinates: &mutableCoordinates, count: UInt(coordinates.count)) { + rootView.mapView.add(overlay) + routeOverlays.append(overlay) + } + } + + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + if annotation is ChatCurrentLocationAnnotation { + let identifier = "ChatLocationDetailCurrent" + guard let view = (mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? ChatCurrentLocationAnnotationView) + ?? ChatCurrentLocationAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil } + view.annotation = annotation + view.zIndex = 10 + view.configure() + return view + } + guard annotation is MAPointAnnotation else { return nil } + let identifier = "ChatLocationDetailPin" + guard let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + ?? MAAnnotationView(annotation: annotation, reuseIdentifier: identifier) else { return nil } + view.annotation = annotation + view.image = UIImage(named: "IM/location_pin") + view.centerOffset = CGPoint(x: 0, y: -28) + return view + } + + func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { + guard let polyline = overlay as? MAPolyline else { return nil } + let renderer = MAPolylineRenderer(polyline: polyline) + renderer?.strokeColor = UIColor(hexStr: "#16B3FF") + renderer?.lineWidth = 4 + return renderer + } +} +#endif + +private final class ChatLocationDetailView: UIView { + #if !targetEnvironment(simulator) + lazy var mapView: MAMapView! = { + let map = MAMapView() + map.showsCompass = false + map.showsScale = false + map.zoomLevel = 15 + return map + }() + #endif + + let backBtn: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "Common/back"), for: .normal) + button.backgroundColor = .white + button.cornerRadius = 18 + return button + }() + let mapHintLabel: UILabel = { + let label = UILabel() + label.text = "请在真机查看地图" + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hexStr: "#7F8794") + label.textAlignment = .center + label.isHidden = true + return label + }() + let nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 19, weight: .semibold) + label.textColor = UIColor(hexStr: "#262626") + return label + }() + let addressLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hexStr: "#858585") + label.numberOfLines = 2 + return label + }() + let distanceLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hexStr: "#16B3FF") + label.textAlignment = .center + label.backgroundColor = UIColor(hexStr: "#F4F5F6") + label.cornerRadius = 8 + return label + }() + let drivingLabel: UILabel = routeLabel(icon: "驾", color: UIColor(hexStr: "#16B3FF")) + let walkingLabel: UILabel = routeLabel(icon: "步", color: UIColor(hexStr: "#21B573")) + private let routeStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .fill + stack.distribution = .fillEqually + stack.spacing = 8 + return stack + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#EAF2F3") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + #if !targetEnvironment(simulator) + addSubview(mapView) + mapView.layoutChain.edges() + #endif + addSubview(mapHintLabel) + addSubview(backBtn) + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 20 + card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + addSubview(card) + card.addSubview(nameLabel) + card.addSubview(addressLabel) + card.addSubview(routeStack) + routeStack.addArrangedSubview(distanceLabel) + routeStack.addArrangedSubview(drivingLabel) + routeStack.addArrangedSubview(walkingLabel) + + backBtn.layoutChain.top(kStatusBarHeight + 10).left(15).width(36).height(36) + mapHintLabel.layoutChain.centerX().centerY().edgesHorzontal(40) + card.layoutChain.edgesHorzontal().bottom().height(210 + kSafeBottomMargin) + nameLabel.layoutChain.top(22).left(20).right(20) + addressLabel.layoutChain.topToBottomOfView(nameLabel, offset: 8).left(20).right(20) + routeStack.layoutChain.topToBottomOfView(addressLabel, offset: 18).edgesHorzontal(20).height(32) + } + + #if !targetEnvironment(simulator) + func cleanupMap() { + mapView?.delegate = nil + mapView?.removeFromSuperview() + mapView = nil + } + #endif + + private static func routeLabel(icon: String, color: UIColor) -> UILabel { + let label = UILabel() + label.text = "\(icon) 路线计算中" + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textAlignment = .center + label.textColor = color + label.backgroundColor = color.withAlphaComponent(0.1) + label.cornerRadius = 8 + return label + } +} + +private func chatDistanceText(_ distance: CLLocationDistance) -> String { + distance >= 1000 ? String(format: "%.1fkm", distance / 1000) : "\(Int(distance))m" +} + +private func chatDurationText(_ duration: TimeInterval) -> String { + let minutes = max(1, Int(ceil(duration / 60))) + return minutes >= 60 ? "\(minutes / 60)小时\(minutes % 60)分钟" : "\(minutes)分钟" +} diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index cc231cc4..e0641823 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -11,6 +11,7 @@ import RxCocoa import Lottie import AVFoundation import SwiftDate +import MapKit enum VoiceRecordState { case began @@ -37,12 +38,16 @@ struct ChatMessage { var atUserIDs: [String] = [] var atNicknames: [String] = [] var isAtAll: Bool = false + var isCircleOwner: Bool = false + var location: ChatLocationPayload? = nil func with(avatar: UIImage? = nil, showTime: Bool? = nil, isUploading: Bool? = nil, imageUrl: String? = nil, - quotePreview: QuotePreview? = nil) -> ChatMessage { + quotePreview: QuotePreview? = nil, + isCircleOwner: Bool? = nil, + location: ChatLocationPayload? = nil) -> ChatMessage { ChatMessage( id: id, isSelf: isSelf, @@ -60,14 +65,16 @@ struct ChatMessage { quotePreview: quotePreview ?? self.quotePreview, atUserIDs: atUserIDs, atNicknames: atNicknames, - isAtAll: isAtAll + isAtAll: isAtAll, + isCircleOwner: isCircleOwner ?? self.isCircleOwner, + location: location ?? self.location ) } /// 正文 attributed(含 @ 高亮) func attributedContent(isOutgoing: Bool) -> NSAttributedString { - let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333") - let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF") + let baseColor: UIColor = isOutgoing ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#293445") + let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#0086C4") : UIColor(hexStr: "#0086C4") let result = NSMutableAttributedString( string: content, attributes: [ @@ -102,6 +109,7 @@ class GroupChatView: UIView { var bottomBarBottomConstraint: NSLayoutConstraint? /// 语音录制回调 var onVoiceRecordState: ((VoiceRecordState) -> Void)? + var onLocationTap: (() -> Void)? private var emojiPlaybackGeneration = 0 private var quoteBarHeightConstraint: NSLayoutConstraint? @@ -130,7 +138,7 @@ class GroupChatView: UIView { mentionPickerView.isHidden = true if !excludeTextField { textField.resignFirstResponder() } UIView.animate(withDuration: 0.25) { - self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20) + self.bottomBar.layoutChain.bottom(kSafeBottomMargin) self.voiceRecordView.layoutChain.bottom(-252) } } @@ -175,24 +183,24 @@ class GroupChatView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = UIColor(hexStr: "#F5FBFB") addSubview(navBgView) addSubview(navBarView) navBarView.addSubview(backBtn) navBarView.addSubview(groupAvatarView) navBarView.addSubview(groupNameLabel) - navBarView.addSubview(onlineStatusLabel) +// navBarView.addSubview(onlineStatusLabel) navBarView.addSubview(rightIconsView) - rightIconsView.addSubview(reviewBtn) +// rightIconsView.addSubview(reviewBtn) rightIconsView.addSubview(memberBtn) rightIconsView.addSubview(settingBtn) - rightIconsView.addSubview(reviewDotView) +// rightIconsView.addSubview(reviewDotView) addSubview(tableView) addSubview(chatWarningView) addSubview(quoteBar) addSubview(mentionPickerView) + addSubview(quickActionBar) addSubview(bottomBar) addSubview(disableIMView) bottomBar.addSubview(bottomBarCornerView) @@ -203,8 +211,10 @@ class GroupChatView: UIView { bottomBar.addSubview(voiceBtn) bottomBar.addSubview(textField) bottomBar.addSubview(emojiBtn) - bottomBar.addSubview(addBtn) bottomBar.addSubview(sendBtn) + quickActionBar.addSubview(galleryBtn) + quickActionBar.addSubview(cameraBtn) + quickActionBar.addSubview(locationBtn) quoteBar.addSubview(quoteBarLabel) quoteBar.addSubview(quoteBarCloseBtn) @@ -220,53 +230,53 @@ class GroupChatView: UIView { .height(kNaviHeight) backBtn.layoutChain - .top(kStatusBarHeight + 12) + .top(kStatusBarHeight + 8) .left(7) - .width(24).height(24) + .width(32).height(32) groupAvatarView.layoutChain .centerY(backBtn) .leftToRightOfView(backBtn, offset: 5) - .width(32).height(32) + .width(30).height(30) groupNameLabel.layoutChain - .topToView(groupAvatarView) .leftToRightOfView(groupAvatarView, offset: 8) + .centerY(groupAvatarView) - onlineStatusLabel.layoutChain - .topToBottomOfView(groupNameLabel, offset: 2) - .leftToView(groupNameLabel) +// onlineStatusLabel.layoutChain +// .topToBottomOfView(groupNameLabel, offset: 2) +// .leftToView(groupNameLabel) rightIconsView.layoutChain .centerY(backBtn) - .right(15) - .height(24) + .right() + .height(32) - reviewBtn.layoutChain - .left().centerY() - .width(24).height(24) +// reviewBtn.layoutChain +// .left().centerY() +// .width(24).height(24) - reviewDotView.layoutChain - .topToView(reviewBtn, offset: -2) - .leftToRightOfView(reviewBtn, offset: -6) - .width(8) - .height(8) - - memberBtn.layoutChain - .leftToRightOfView(reviewBtn, offset: 20) - .centerY() - .width(24).height(24) +// reviewDotView.layoutChain +// .topToView(reviewBtn, offset: -2) +// .leftToRightOfView(reviewBtn, offset: -6) +// .width(8) +// .height(8) settingBtn.layoutChain - .leftToRightOfView(memberBtn, offset: 20) + .right(14) .centerY() - .width(24).height(24) - .right() + .width(32).height(32) + + memberBtn.layoutChain + .left() + .centerY() + .width(32).height(32) + .rightToLeftOfView(settingBtn, offset: -10) tableView.layoutChain .topToBottomOfView(navBarView) .edgesHorzontal() - .bottomToTopOfView(bottomBar) + .bottomToTopOfView(quickActionBar) chatWarningView.layoutChain .topToBottomOfView(navBarView) @@ -274,7 +284,7 @@ class GroupChatView: UIView { quoteBar.layoutChain .edgesHorzontal(15) - .bottomToTopOfView(bottomBar, offset: -6) + .bottomToTopOfView(quickActionBar, offset: -6) quoteBarHeightConstraint = quoteBar.heightAnchor.constraint(equalToConstant: 0) quoteBarHeightConstraint?.isActive = true @@ -293,16 +303,39 @@ class GroupChatView: UIView { mentionPickerContentView.layoutChain.edges() mentionTableView.layoutChain.edges() + + quickActionBar.translatesAutoresizingMaskIntoConstraints = false + galleryBtn.translatesAutoresizingMaskIntoConstraints = false + cameraBtn.translatesAutoresizingMaskIntoConstraints = false + locationBtn.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + quickActionBar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + quickActionBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + quickActionBar.bottomAnchor.constraint(equalTo: bottomBar.topAnchor, constant: -10), + quickActionBar.heightAnchor.constraint(equalToConstant: 40), + galleryBtn.leadingAnchor.constraint(equalTo: quickActionBar.leadingAnchor), + galleryBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor), + galleryBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor), + cameraBtn.leadingAnchor.constraint(equalTo: galleryBtn.trailingAnchor, constant: 11), + cameraBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor), + cameraBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor), + locationBtn.leadingAnchor.constraint(equalTo: cameraBtn.trailingAnchor, constant: 11), + locationBtn.trailingAnchor.constraint(equalTo: quickActionBar.trailingAnchor), + locationBtn.topAnchor.constraint(equalTo: quickActionBar.topAnchor), + locationBtn.bottomAnchor.constraint(equalTo: quickActionBar.bottomAnchor), + galleryBtn.widthAnchor.constraint(equalTo: cameraBtn.widthAnchor), + cameraBtn.widthAnchor.constraint(equalTo: locationBtn.widthAnchor) + ]) bottomBar.layoutChain - .edgesHorzontal(15) + .edgesHorzontal(10) .height(50) - .bottom(kSafeBottomMargin + 20) + .bottom(kSafeBottomMargin) disableIMView.layoutChain .edgesHorzontal(15) .height(50) - .bottom(kSafeBottomMargin + 20) + .bottom(kSafeBottomMargin) bottomBarBottomConstraint = bottomBar.jh_constraint(.bottom, toAttribute: .bottom, otherView: bottomBar.superview, relation: .equal) @@ -313,18 +346,13 @@ class GroupChatView: UIView { .width(28).height(28) sendBtn.layoutChain - .right(19).centerY() - .width(76).height(34) - - addBtn.layoutChain - .rightToLeftOfView(sendBtn, offset: -10) - .centerY() - .width(28).height(28) + .right(10).centerY() + .width(56).height(40) emojiBtn.layoutChain - .rightToLeftOfView(addBtn, offset: -10) + .rightToLeftOfView(sendBtn, offset: -8) .centerY() - .width(28).height(28) + .width(30).height(30) textField.layoutChain .leftToRightOfView(voiceBtn, offset: 10) @@ -358,12 +386,22 @@ class GroupChatView: UIView { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill + iv.isHidden = true return iv }() lazy var navBarView: UIView = { let view = UIView() - view.backgroundColor = .clear + view.backgroundColor = UIColor(hexStr: "#F5F5F5") + + let line = UIView() + line.backgroundColor = UIColor(hexStr: "#E6E6E6") + view.addSubview(line) + line.layoutChain + .edgesHorzontal() + .height(0.5) + .bottom() + return view }() @@ -377,7 +415,7 @@ class GroupChatView: UIView { lazy var groupAvatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 16 + iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") return iv @@ -422,12 +460,16 @@ class GroupChatView: UIView { lazy var memberBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/member"), for: .normal) + btn.backgroundColor = .white + btn.cornerRadius = 11 return btn }() lazy var settingBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/setting"), for: .normal) + btn.backgroundColor = .white + btn.cornerRadius = 11 return btn }() @@ -486,6 +528,8 @@ class GroupChatView: UIView { tv.register(VoiceReceivedMsgCell.self) tv.register(ImageSendMsgCell.self) tv.register(ImageReceivedMsgCell.self) + tv.register(LocationSendMsgCell.self) + tv.register(LocationReceivedMsgCell.self) tv.register(NotificationMsgCell.self) tv.rowHeight = UITableView.automaticDimension tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) @@ -550,6 +594,27 @@ class GroupChatView: UIView { }() // MARK: - Bottom Bar + lazy var quickActionBar: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + lazy var galleryBtn: UIButton = makeQuickActionButton(title: " 照片", imageName: "IM/gallery") + lazy var cameraBtn: UIButton = makeQuickActionButton(title: " 拍照", imageName: "IM/camera") + lazy var locationBtn: UIButton = makeQuickActionButton(title: " 位置", imageName: "IM/location") + + private func makeQuickActionButton(title: String, imageName: String) -> UIButton { + let btn = UIButton(type: .custom) + btn.setTitle(title, for: .normal) + btn.setTitleColor(UIColor(hexStr: "#7F8794"), for: .normal) + btn.setImage(UIImage(named: imageName), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 13) + btn.backgroundColor = .white + btn.cornerRadius = 10 + return btn + } + lazy var bottomBar: UIView = { let view = UIView() view.backgroundColor = .clear @@ -563,13 +628,13 @@ class GroupChatView: UIView { lazy var bottomBarCornerView: UIView = { let view = UIView() view.backgroundColor = .white - view.cornerRadius = 25 + view.cornerRadius = 13 return view }() lazy var voiceBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/voice"), for: .normal) + btn.setImage(UIImage(named: "IM/voice_input"), for: .normal) return btn }() @@ -587,7 +652,7 @@ class GroupChatView: UIView { lazy var emojiBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/emoji"), for: .normal) + btn.setImage(UIImage(named: "IM/emoji_input"), for: .normal) return btn }() @@ -599,13 +664,9 @@ class GroupChatView: UIView { lazy var sendBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setTitle("发送", for: .normal) - btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) - btn.backgroundColor = .white - btn.borderWidth = 1 - btn.borderColor = UIColor(hexStr: "#16B3FF") - btn.cornerRadius = 17 + btn.setImage(UIImage(named: "IM/send"), for: .normal) + btn.backgroundColor = UIColor(hexStr: "#18B5F4") + btn.cornerRadius = 14 return btn }() @@ -683,7 +744,7 @@ class GroupChatView: UIView { override init(frame: CGRect) { super.init(frame: .zero) - backgroundColor = .clear + backgroundColor = UIColor(hexStr: "#F5F5F5") setupUI() setupRx() } @@ -745,10 +806,78 @@ final class ChatQuoteBlockView: UIControl { } } +final class ChatSenderNameView: UIView { + private let ownerLabel: ChatPaddingLabel = { + let label = ChatPaddingLabel() + label.text = "圈主" + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = UIColor(hexStr: "#16B3FF") + label.backgroundColor = UIColor(hexStr: "#DFF6FF") + label.cornerRadius = 6 + label.clipsToBounds = true + label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + return label + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hexStr: "#7B7B7B") + label.lineBreakMode = .byTruncatingTail + return label + }() + + private var ownerWidthConstraint: NSLayoutConstraint! + private var spacingConstraint: NSLayoutConstraint! + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(ownerLabel) + addSubview(nameLabel) + ownerLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + ownerWidthConstraint = ownerLabel.widthAnchor.constraint(equalToConstant: 0) + spacingConstraint = nameLabel.leadingAnchor.constraint(equalTo: ownerLabel.trailingAnchor) + NSLayoutConstraint.activate([ + ownerLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + ownerLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ownerLabel.heightAnchor.constraint(equalToConstant: 20), + ownerWidthConstraint, + spacingConstraint, + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + heightAnchor.constraint(equalToConstant: 20) + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(name: String, isOwner: Bool) { + nameLabel.text = name + ownerLabel.isHidden = !isOwner + ownerWidthConstraint.constant = isOwner ? ownerLabel.intrinsicContentSize.width : 0 + spacingConstraint.constant = isOwner ? 6 : 0 + } +} + +private final class ChatPaddingLabel: UILabel { + var insets = UIEdgeInsets.zero + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + insets.left + insets.right, + height: size.height + insets.top + insets.bottom) + } +} + // MARK: - 气泡上方操作菜单 final class ChatBubbleActionMenu: UIView { - var onQuote: (() -> Void)? - var onRevoke: (() -> Void)? + var onMention: (() -> Void)? + var onReply: (() -> Void)? var onDismiss: (() -> Void)? private let panel = UIView() @@ -787,7 +916,7 @@ final class ChatBubbleActionMenu: UIView { stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor), stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor), stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor), - panel.heightAnchor.constraint(equalToConstant: 44) + panel.heightAnchor.constraint(equalToConstant: 68) ]) arrow.backgroundColor = UIColor(hexStr: "#333333") @@ -796,7 +925,7 @@ final class ChatBubbleActionMenu: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func show(in host: UIView, anchor: UIView, showRevoke: Bool) { + func show(in host: UIView, anchor: UIView, canMention: Bool) { removeFromSuperview() host.addSubview(self) frame = host.bounds @@ -806,22 +935,22 @@ final class ChatBubbleActionMenu: UIView { stack.removeArrangedSubview($0) $0.removeFromSuperview() } - let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote)) - quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true - stack.addArrangedSubview(quoteBtn) - if showRevoke { + if canMention { + let mentionBtn = makeButton(title: "@TA", imageName: "IM/at", action: #selector(handleMention)) + mentionBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true + stack.addArrangedSubview(mentionBtn) let sep = UIView() sep.backgroundColor = UIColor.white.withAlphaComponent(0.2) sep.translatesAutoresizingMaskIntoConstraints = false sep.widthAnchor.constraint(equalToConstant: 1).isActive = true stack.addArrangedSubview(sep) - let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke)) - revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true - stack.addArrangedSubview(revokeBtn) } + let replyBtn = makeButton(title: "回复", imageName: "IM/reply", action: #selector(handleReply)) + replyBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true + stack.addArrangedSubview(replyBtn) - let panelW: CGFloat = showRevoke ? 145 : 72 - let panelH: CGFloat = 44 + let panelW: CGFloat = canMention ? 145 : 72 + let panelH: CGFloat = 68 let arrowSize: CGFloat = 8 let gap: CGFloat = 6 @@ -850,23 +979,29 @@ final class ChatBubbleActionMenu: UIView { UIView.animate(withDuration: 0.15) { self.alpha = 1 } } - private func makeButton(title: String, action: Selector) -> UIButton { + private func makeButton(title: String, imageName: String, action: Selector) -> UIButton { let btn = UIButton(type: .system) btn.setTitle(title, for: .normal) + btn.setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysOriginal), for: .normal) btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + btn.imageView?.contentMode = .scaleAspectFit + btn.contentHorizontalAlignment = .center + btn.contentVerticalAlignment = .center + btn.imageEdgeInsets = UIEdgeInsets(top: -18, left: 17, bottom: 0, right: -17) + btn.titleEdgeInsets = UIEdgeInsets(top: 30, left: -14, bottom: 0, right: 14) btn.addTarget(self, action: action, for: .touchUpInside) btn.translatesAutoresizingMaskIntoConstraints = false return btn } - @objc private func handleQuote() { - onQuote?() + @objc private func handleMention() { + onMention?() dismiss() } - @objc private func handleRevoke() { - onRevoke?() + @objc private func handleReply() { + onReply?() dismiss() } @@ -902,6 +1037,7 @@ class TextSendMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) contentLabel.attributedText = msg.attributedContent(isOutgoing: true) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) @@ -928,23 +1064,28 @@ class TextSendMsgCell: UITableViewCell { let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() private let bubbleView: UIView = { let v = UIView() - v.backgroundColor = UIColor(hexStr: "#16B3FF") + v.backgroundColor = UIColor(hexStr: "#C9EEFF") + v.cornerRadius = 10 + v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() + private let senderNameView = ChatSenderNameView() + private let contentLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14) + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") label.numberOfLines = 0 return label }() @@ -963,27 +1104,37 @@ class TextSendMsgCell: UITableViewCell { bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) + contentView.addSubview(senderNameView) timeLabel.layoutChain.top().centerX() + avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) - .right(12).width(30).height(30) + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) +// NSLayoutConstraint.activate([ +// senderNameView.trailingAnchor.constraint(equalTo: avatarView.leadingAnchor, constant: -5), +// senderNameView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), +// senderNameView.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 80) +// ]) + bubbleView.layoutChain - .topToBottomOfView(avatarView, offset: -15) - .rightToView(avatarView, offset: -13) + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView) .left(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.translatesAutoresizingMaskIntoConstraints = false + contentLabel.layoutChain + .edgesVertical(13) + .edgesHorzontal(12) + quoteBlock.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), - contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), - contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), - contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10) - ]) - let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop @@ -1005,10 +1156,6 @@ class TextSendMsgCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() - bubbleView.setNeedsLayout() - bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight], - withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } @@ -1028,7 +1175,7 @@ class TextReceivedMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - nameLabel.text = msg.senderName + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) contentLabel.attributedText = msg.attributedContent(isOutgoing: false) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) @@ -1052,31 +1199,28 @@ class TextReceivedMsgCell: UITableViewCell { return label }() - private let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#666666") - return label - }() - let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() private let bubbleView: UIView = { let v = UIView() - v.backgroundColor = UIColor(hexStr: "#CCEAFF") + v.backgroundColor = UIColor(hexStr: "#FFFFFF") + v.cornerRadius = 10 + v.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() + private let senderNameView = ChatSenderNameView() + private let contentLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14) @@ -1098,42 +1242,40 @@ class TextReceivedMsgCell: UITableViewCell { bubbleView.addSubview(contentLabel) contentView.addSubview(quoteBlock) contentView.addSubview(avatarView) - contentView.addSubview(nameLabel) + contentView.addSubview(senderNameView) timeLabel.layoutChain.top().centerX() + + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + bubbleView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) - .left(30) + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) .right(60, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) contentLabel.translatesAutoresizingMaskIntoConstraints = false + contentLabel.layoutChain + .edgesVertical(13) + .edgesHorzontal(12) + quoteBlock.translatesAutoresizingMaskIntoConstraints = false - avatarView.translatesAutoresizingMaskIntoConstraints = false - nameLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10), - contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20), - contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20), - contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10), - // 头像 + 昵称贴在气泡下方 - avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15), - avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), - avatarView.widthAnchor.constraint(equalToConstant: 30), - avatarView.heightAnchor.constraint(equalToConstant: 30), - nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), - nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor) - ]) - - // 引用在 senderName 下方 - let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4) + // 引用在 bubbleView 下方 + let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop quoteHeightConstraint = qH NSLayoutConstraint.activate([ qTop, - quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), + quoteBlock.leadingAnchor.constraint(equalTo: senderNameView.leadingAnchor), quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60), quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), @@ -1148,10 +1290,6 @@ class TextReceivedMsgCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() - bubbleView.setNeedsLayout() - bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight], - withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } @@ -1216,17 +1354,19 @@ final class EmojiSendMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() + private let senderNameView = ChatSenderNameView() + private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit @@ -1243,17 +1383,27 @@ final class EmojiSendMsgCell: UITableViewCell { backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) + contentView.addSubview(senderNameView) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) + avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .right(12) .width(30).height(30) lottieView.layoutChain - .topToBottomOfView(avatarView, offset: -15) + .topToBottomOfView(senderNameView, offset: 5) .right(60) .width(60).height(60) .bottom(10) @@ -1267,6 +1417,7 @@ final class EmojiSendMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil @@ -1323,21 +1474,16 @@ final class EmojiReceivedMsgCell: UITableViewCell { let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white iv.isUserInteractionEnabled = true return iv }() - private let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#666666") - return label - }() + private let senderNameView = ChatSenderNameView() private let lottieView: LottieAnimationView = { let v = LottieAnimationView() @@ -1355,25 +1501,25 @@ final class EmojiReceivedMsgCell: UITableViewCell { backgroundColor = .clear contentView.addSubview(timeLabel) contentView.addSubview(avatarView) - contentView.addSubview(nameLabel) + contentView.addSubview(senderNameView) contentView.addSubview(lottieView) timeLabel.layoutChain.top().centerX() + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + lottieView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) + .topToBottomOfView(senderNameView, offset: 5) .left(60) .width(60).height(60) - - avatarView.layoutChain - .topToBottomOfView(lottieView, offset: -15) - .left(12) - .width(30).height(30) .bottom(10) - - nameLabel.layoutChain - .leftToRightOfView(avatarView, offset: 5) - .bottomToView(avatarView) } required init?(coder: NSCoder) { @@ -1384,7 +1530,7 @@ final class EmojiReceivedMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - nameLabel.text = msg.senderName + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil @@ -1473,20 +1619,24 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() + private let senderNameView = ChatSenderNameView() + private let bubbleView: UIView = { let v = UIView() - v.backgroundColor = UIColor(hexStr: "#16B3FF") + v.backgroundColor = UIColor(hexStr: "#C9EEFF") + v.cornerRadius = 10 + v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() @@ -1516,18 +1666,25 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) + contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() + avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) - .right(12).width(30).height(30) + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) bubbleView.layoutChain - .topToBottomOfView(avatarView, offset: -15) - .rightToView(avatarView, offset: -13) + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView) .width(105).height(39).bottom(10) playAnimation.layoutChain @@ -1548,6 +1705,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl @@ -1580,10 +1738,6 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { override func layoutSubviews() { super.layoutSubviews() - bubbleView.setNeedsLayout() - bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft , .bottomRight, .bottomLeft], - withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } @@ -1595,7 +1749,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - nameLabel.text = msg.senderName + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl @@ -1632,25 +1786,21 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white - iv.isUserInteractionEnabled = true +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() - private let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#666666") - return label - }() + private let senderNameView = ChatSenderNameView() private let bubbleView: UIView = { let v = UIView() - v.backgroundColor = UIColor(hexStr: "#CCEAFF") + v.backgroundColor = UIColor(hexStr: "#FFFFFF") + v.cornerRadius = 10 + v.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] return v }() @@ -1681,29 +1831,28 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { bubbleView.addSubview(playAnimation) bubbleView.addSubview(durationLabel) contentView.addSubview(avatarView) - contentView.addSubview(nameLabel) + contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(togglePlay)) bubbleView.addGestureRecognizer(tap) timeLabel.layoutChain.top().centerX() + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + bubbleView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) .width(105) .height(39) - - avatarView.layoutChain - .topToBottomOfView(bubbleView, offset: -15) - .left(12) - .width(30).height(30) .bottom(10) - bubbleView.layoutChain.leftToView(avatarView, offset: 13) - - nameLabel.layoutChain - .leftToRightOfView(avatarView, offset: 5) - .bottomToView(avatarView) - playAnimation.layoutChain .left(36) .centerY() @@ -1725,10 +1874,6 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { override func layoutSubviews() { super.layoutSubviews() - bubbleView.setNeedsLayout() - bubbleView.layoutIfNeeded() - bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight], - withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) } } @@ -1742,6 +1887,7 @@ final class ImageSendMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) if !msg.imageUrl.isEmpty { if let localImage = UIImage(contentsOfFile: msg.imageUrl) { photoView.image = localImage @@ -1771,17 +1917,19 @@ final class ImageSendMsgCell: UITableViewCell { return label }() - private let avatarView: UIImageView = { + let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() + private let senderNameView = ChatSenderNameView() + lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill @@ -1799,6 +1947,7 @@ final class ImageSendMsgCell: UITableViewCell { contentView.addSubview(timeLabel) contentView.addSubview(photoView) contentView.addSubview(avatarView) + contentView.addSubview(senderNameView) let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) photoView.addGestureRecognizer(tap) @@ -1806,13 +1955,19 @@ final class ImageSendMsgCell: UITableViewCell { loadingView.layoutChain.centerX().centerY() timeLabel.layoutChain.top().centerX() + avatarView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) - .right(12).width(30).height(30) + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) photoView.layoutChain - .topToBottomOfView(avatarView, offset: -15) - .rightToView(avatarView, offset: -13) + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView, offset: 0) .bottom(10) } @@ -1830,7 +1985,7 @@ final class ImageReceivedMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - nameLabel.text = msg.senderName + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) if !msg.imageUrl.isEmpty { photoView.dl.setImage(with: msg.imageUrl) } @@ -1850,21 +2005,15 @@ final class ImageReceivedMsgCell: UITableViewCell { let avatarView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 15 + iv.cornerRadius = 10 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.borderWidth = 2 - iv.borderColor = .white - iv.isUserInteractionEnabled = true +// iv.borderWidth = 2 +// iv.borderColor = .white return iv }() - private let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .regular) - label.textColor = UIColor(hexStr: "#666666") - return label - }() + private let senderNameView = ChatSenderNameView() lazy var photoView: UIImageView = { let iv = UIImageView() @@ -1881,7 +2030,7 @@ final class ImageReceivedMsgCell: UITableViewCell { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) - contentView.addSubview(nameLabel) + contentView.addSubview(senderNameView) contentView.addSubview(photoView) contentView.addSubview(avatarView) @@ -1890,24 +2039,229 @@ final class ImageReceivedMsgCell: UITableViewCell { timeLabel.layoutChain.top().centerX() - photoView.layoutChain - .topToBottomOfView(timeLabel, offset: 14) - .width(160).height(160) - avatarView.layoutChain - .topToBottomOfView(photoView, offset: -15) - .left(12) - .width(30).height(30) + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + + senderNameView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + + photoView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) + .width(160).height(160) .bottom(10) - - photoView.layoutChain.leftToView(avatarView, offset: 13) - - nameLabel.layoutChain - .leftToRightOfView(avatarView, offset: 5) - .bottomToView(avatarView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } + +// MARK: - 位置消息 +final class LocationSendMsgCell: ChatLocationMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: true) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +final class LocationReceivedMsgCell: ChatLocationMsgCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier, isOutgoing: false) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +class ChatLocationMsgCell: UITableViewCell { + var onLocationTap: (() -> Void)? + var menuAnchorView: UIView { cardView } + + private let isOutgoing: Bool + private let timeLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hexStr: "#999999") + label.textAlignment = .center + return label + }() + private let avatarView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.cornerRadius = 10 + iv.clipsToBounds = true + iv.backgroundColor = UIColor(hexStr: "#E0E0E0") +// iv.borderWidth = 2 +// iv.borderColor = .white + return iv + }() + private let senderNameView = ChatSenderNameView() + private let cardView: UIControl = { + let view = UIControl() + view.backgroundColor = .white + view.cornerRadius = 10 + view.clipsToBounds = true + return view + }() + private let mapPreview = ChatLocationMapPreview() + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .bold) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + private let addressLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = UIColor(hexStr: "#AAAAAA") + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + self.isOutgoing = false + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + init(style: UITableViewCell.CellStyle, reuseIdentifier: String?, isOutgoing: Bool) { + self.isOutgoing = isOutgoing + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(_ msg: ChatMessage) { + timeLabel.isHidden = !msg.showTime + timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil + avatarView.image = msg.avatar + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + let location = msg.location ?? ChatLocationPayload(name: msg.content, address: "", latitude: 0, longitude: 0) + titleLabel.text = location.name.isEmpty ? "位置" : location.name + addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address + mapPreview.configure(location: location) + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + contentView.addSubview(timeLabel) + contentView.addSubview(cardView) + contentView.addSubview(avatarView) + contentView.addSubview(senderNameView) + cardView.addSubview(titleLabel) + cardView.addSubview(addressLabel) + cardView.addTarget(self, action: #selector(handleLocationTap), for: .touchUpInside) + + timeLabel.layoutChain.top().centerX() + cardView.translatesAutoresizingMaskIntoConstraints = false + avatarView.translatesAutoresizingMaskIntoConstraints = false + senderNameView.translatesAutoresizingMaskIntoConstraints = false + + titleLabel.layoutChain + .top(11) + .left(10) + + addressLabel.layoutChain + .topToBottomOfView(titleLabel, offset: 8) + .edgesHorzontal(10) + + if isOutgoing { + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .right(12).width(38).height(38) + + senderNameView.layoutChain + .topToView(avatarView) + .rightToLeftOfView(avatarView, offset: -9) + + cardView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .rightToView(senderNameView) + .width(250) + .height(148) + .bottom(10) + } else { + avatarView.layoutChain + .topToBottomOfView(timeLabel, offset: 10) + .left(12).width(38).height(38) + + senderNameView.layoutChain + .topToView(avatarView) + .leftToRightOfView(avatarView, offset: 9) + + cardView.layoutChain + .topToBottomOfView(senderNameView, offset: 5) + .leftToView(senderNameView) + .width(250) + .height(148) + .bottom(10) + } + } + + @objc private func handleLocationTap() { onLocationTap?() } +} + +private final class ChatLocationMapPreview: UIView { + private let imageView = UIImageView() + private let pinView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#16B3FF") + view.cornerRadius = 10 + view.borderWidth = 3 + view.borderColor = .white + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.18 + view.layer.shadowRadius = 3 + view.layer.shadowOffset = CGSize(width: 0, height: 1) + return view + }() + private var requestID = UUID() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#E7F0E9") + clipsToBounds = true + imageView.contentMode = .scaleAspectFill + addSubview(imageView) + addSubview(pinView) + imageView.translatesAutoresizingMaskIntoConstraints = false + pinView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + pinView.centerXAnchor.constraint(equalTo: centerXAnchor), + pinView.centerYAnchor.constraint(equalTo: centerYAnchor), + pinView.widthAnchor.constraint(equalToConstant: 20), + pinView.heightAnchor.constraint(equalToConstant: 20) + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(location: ChatLocationPayload) { + let identifier = UUID() + requestID = identifier + imageView.image = nil + guard CLLocationCoordinate2DIsValid(location.coordinate), + abs(location.latitude) > 0.0001 || abs(location.longitude) > 0.0001 else { return } + let options = MKMapSnapshotter.Options() + options.region = MKCoordinateRegion(center: location.coordinate, + latitudinalMeters: 700, + longitudinalMeters: 700) + options.size = CGSize(width: 460, height: 168) + options.scale = UIScreen.main.scale + MKMapSnapshotter(options: options).start(with: .main) { [weak self] snapshot, _ in + guard let self, self.requestID == identifier else { return } + self.imageView.image = snapshot?.image + } + } +} diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index 0de543fe..69697150 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -6,6 +6,7 @@ // import UIKit +import CoreLocation import RxSwift import RxCocoa import OpenIMSDK @@ -22,6 +23,40 @@ struct QuotePreview { let quotedClientMsgID: String? } +struct ChatLocationPayload: Codable, Equatable { + let name: String + let address: String + let latitude: Double + let longitude: Double + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + var encodedDescription: String { + guard let data = try? JSONEncoder().encode(self), + let value = String(data: data, encoding: .utf8) else { + return [name, address].filter { !$0.isEmpty }.joined(separator: "\n") + } + return value + } + + static func decode(description: String?, latitude: Double, longitude: Double) -> ChatLocationPayload { + if let description, + let data = description.data(using: .utf8), + let payload = try? JSONDecoder().decode(ChatLocationPayload.self, from: data) { + return payload + } + let parts = (description ?? "").components(separatedBy: "\n") + return ChatLocationPayload( + name: parts.first ?? "", + address: parts.dropFirst().joined(separator: "\n"), + latitude: latitude, + longitude: longitude + ) + } +} + enum ChatSectionItem { case send(ChatMessage) case received(ChatMessage) @@ -31,6 +66,8 @@ enum ChatSectionItem { case voiceReceived(ChatMessage) case imageSend(ChatMessage) case imageReceived(ChatMessage) + case locationSend(ChatMessage) + case locationReceived(ChatMessage) case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) } @@ -57,7 +94,9 @@ final class GroupChatViewModel { private var lastTimeGap: TimeInterval = 0 private let timeGapThreshold: TimeInterval = 300 - var groupModel: GroupInfoModel? + var groupModel: GroupInfoModel? { + didSet { refreshMessageMetadata() } + } var memberList: [GroupMemberModel] = [] { didSet { buildAvatarCache() } } @@ -110,16 +149,23 @@ final class GroupChatViewModel { cache[member.user_id] = member.userIcon } avatarCache = cache - refreshMessageAvatars() + refreshMessageMetadata() } - private func refreshMessageAvatars() { + private func refreshMessageMetadata() { var items = messagesSubject.value var didChange = false items = items.map { item in - guard var m = item.chatMessage, updateAvatar(&m) else { return item } + guard var message = item.chatMessage else { return item } + let avatarUpdated = updateAvatar(&message) + let isOwner = isCircleOwner(id: message.senderId) + let ownerUpdated = message.isCircleOwner != isOwner + if ownerUpdated { + message = message.with(isCircleOwner: isOwner) + } + guard avatarUpdated || ownerUpdated else { return item } didChange = true - return ChatSectionItem.with(m) + return ChatSectionItem.with(message) } if didChange { messagesSubject.accept(items) } } @@ -144,6 +190,11 @@ final class GroupChatViewModel { memberList.first { id == $0.user_id }?.nick_name ?? "" } + func isCircleOwner(id: String) -> Bool { + guard !id.isEmpty, let groupModel else { return false } + return groupModel.group_key.contains(id) + } + // MARK: - Quote / Mention state func setPendingQuote(_ msg: OIMMessageInfo?) { pendingQuote = msg @@ -239,6 +290,26 @@ final class GroupChatViewModel { }) } + func sendLocation(_ location: ChatLocationPayload) { + let msg = OIMMessageInfo.createLocationMessage(location.encodedDescription, + latitude: location.latitude, + longitude: location.longitude) + OIMManager.manager.sendMessage(msg, + recvID: "", + groupID: groupId, + offlinePushInfo: nil, + onSuccess: { [weak self] _ in + self?.cacheMessage(msg) + self?.appendMessage(msg) + }, + onProgress: nil as OIMNumberCallback?, + onFailure: { code, errMsg in + DispatchQueue.main.async { + DLToast.showError(text: errMsg ?? "位置发送失败(\(code))") + } + }) + } + // MARK: - Revoke func revokeMessage(clientMsgID: String) { OIMManager.manager.revokeMessage(conversationID, @@ -276,9 +347,10 @@ final class GroupChatViewModel { } let ts = timestampFrom(item: items[idx]) let showTime: Bool = { - switch items[idx] { - case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), - let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): + switch items[idx] { + case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), + let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m), + let .locationSend(m), let .locationReceived(m): return m.showTime case let .notification(_, s, _), let .revoked(_, s, _): return s @@ -602,7 +674,8 @@ final class GroupChatViewModel { private func timestampFrom(item: ChatSectionItem) -> TimeInterval { switch item { case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), - let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): + let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m), + let .locationSend(m), let .locationReceived(m): return m.timestamp case let .notification(_, _, ts), let .revoked(_, _, ts): return ts @@ -619,6 +692,8 @@ final class GroupChatViewModel { case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m) case var .imageSend(m): m.showTime = show; return .imageSend(m) case var .imageReceived(m): m.showTime = show; return .imageReceived(m) + case var .locationSend(m): m.showTime = show; return .locationSend(m) + case var .locationReceived(m): m.showTime = show; return .locationReceived(m) case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts) case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts) } @@ -636,6 +711,7 @@ final class GroupChatViewModel { var atUserIDs: [String] = [] var atNicknames: [String] = [] var isAtAll = false + var location: ChatLocationPayload? let maxWH: CGFloat = 200 var msgImageW: CGFloat = 0 @@ -659,6 +735,11 @@ final class GroupChatViewModel { imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" imageW = msgImageW imageH = msgImageH + } else if type == 109, let elem = msg.locationElem { + location = ChatLocationPayload.decode(description: elem.desc, + latitude: elem.latitude, + longitude: elem.longitude) + content = location?.name ?? "" } else if type == 106, let at = msg.atTextElem { content = at.text ?? "" atUserIDs = at.atUserList ?? [] @@ -684,12 +765,15 @@ final class GroupChatViewModel { } let sendID = msg.sendID ?? "" + let senderName = msg.senderNickname?.isEmpty == false + ? msg.senderNickname! + : getUserNickName(id: sendID) return ChatMessage( id: msg.clientMsgID ?? UUID().uuidString, isSelf: isSelf, senderId: sendID, avatar: getUserAvatar(id: sendID), - senderName: msg.senderNickname ?? "", + senderName: senderName, content: content, voiceUrl: voiceUrl, imageUrl: imageUrl, @@ -700,7 +784,9 @@ final class GroupChatViewModel { quotePreview: quotePreview, atUserIDs: atUserIDs, atNicknames: atNicknames, - isAtAll: isAtAll + isAtAll: isAtAll, + isCircleOwner: isCircleOwner(id: sendID), + location: location ) } @@ -719,6 +805,9 @@ final class GroupChatViewModel { let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]" return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id) } + if type == 109 { + return QuotePreview(senderName: name, kind: .unknown, summary: "[位置]", quotedClientMsgID: id) + } var text = "" if type == 106 { text = msg.atTextElem?.text ?? "" @@ -764,6 +853,11 @@ final class GroupChatViewModel { return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg) } + if msg.contentType.rawValue == 109 { + let chatMsg = convert(msg) + return chatMsg.isSelf ? .locationSend(chatMsg) : .locationReceived(chatMsg) + } + // 文本 / @ / 引用 let chatMsg = convert(msg) if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil } @@ -815,7 +909,8 @@ extension ChatSectionItem { var chatMessage: ChatMessage? { switch self { case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), - let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): + let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m), + let .locationSend(m), let .locationReceived(m): return m case .notification, .revoked: return nil @@ -826,6 +921,9 @@ extension ChatSectionItem { var revokedClientMsgID: String? { nil } static func with(_ msg: ChatMessage) -> ChatSectionItem { + if msg.location != nil { + return msg.isSelf ? .locationSend(msg) : .locationReceived(msg) + } if !msg.imageUrl.isEmpty { return msg.isSelf ? .imageSend(msg) : .imageReceived(msg) } diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift index ecb99360..84fc0c03 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift @@ -41,12 +41,26 @@ class RemoveMemberVC: BaseViewController { self?.rootView.updateHeight(itemCount: count) }) .disposed(by: disposeBag) + + rootView.searchField.rx.text.orEmpty + .distinctUntilChanged() + .subscribe(onNext: { [weak self] keyword in + self?.rootView.updateSearchPlaceholder(isHidden: !keyword.isEmpty) + self?.viewModel.updateSearchKeyword(keyword) + }) + .disposed(by: disposeBag) + + rootView.searchField.rx.controlEvent(.editingDidEndOnExit) + .subscribe(onNext: { [weak self] in + self?.rootView.searchField.resignFirstResponder() + }) + .disposed(by: disposeBag) rootView.tableView.rx.modelSelected(GroupMemberModel.self) .subscribe(viewModel.cellAction.inputs) .disposed(by: disposeBag) - // selectedMembers 驱动删除按钮状态和文字 + // selectedMembers 驱动移除按钮状态 let selectedCount = viewModel.selectedMembers .map { $0.count } .share(replay: 1) @@ -55,21 +69,19 @@ class RemoveMemberVC: BaseViewController { .map { $0 > 0 } .bind(to: rootView.deleteBtn.rx.isEnabled) .disposed(by: disposeBag) - - selectedCount - .map { "删除(\($0))" } - .bind(to: rootView.deleteBtn.rx.title()) - .disposed(by: disposeBag) - rootView.deleteBtn.rx.tap.subscribe(onNext: { _ in - self.showConfirmPop(showCloseBtn: true, - title: "确定要移除成员吗?", - message: "成员将从当前圈子中移除", - confirmText: "否", - cancelText: "是") { - self.requestRemoveMember() - } - }).disposed(by: disposeBag) + rootView.deleteBtn.rx.tap + .subscribe(onNext: { [weak self] in + guard let self = self else { return } + self.showConfirmPop(showCloseBtn: true, + title: "确定要移除成员吗?", + message: "成员将从当前圈子中移除", + confirmText: "否", + cancelText: "是") { + self.requestRemoveMember() + } + }) + .disposed(by: disposeBag) } // MARK: - UITableViewDataSource @@ -79,7 +91,8 @@ class RemoveMemberVC: BaseViewController { let cell: RemoveMemberCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(model, isOwn: self.viewModel.groupId.contains(model.user_id), - isSelected: self.viewModel.isSelected(id: model.user_id)) + isSelected: self.viewModel.isSelected(id: model.user_id), + showsSeparator: indexPath.row < tableView.numberOfRows(inSection: indexPath.section) - 1) return cell }) }() @@ -91,6 +104,7 @@ class RemoveMemberVC: BaseViewController { DLToast.dismiss() guard let model = response.model else { return } self.viewModel.groupModel = model + self.rootView.updateMemberCount(response.list.count) self.viewModel.loadData(response.list) }.disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift index b1fc9339..e00c1fb8 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberView.swift @@ -6,261 +6,261 @@ // import UIKit -import RxSwift -import RxCocoa -class RemoveMemberView: UIView { +final class RemoveMemberView: UIView { - var disposeBag = DisposeBag() - /// 单行高度 - static let rowHeight: CGFloat = 85 - - private func setupRx() { - } - - private func setupUI() { - addSubview(navBgView) - addSubview(navView) - - addSubview(titleLab) - addSubview(cornerBgView) - addSubview(tableView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - titleLab.layoutChain - .topToBottomOfView(navView, offset: 8) - .left(15) - - cornerBgView.layoutChain - .topToBottomOfView(titleLab, offset: 16) - .edgesHorzontal(15) - - tableView.layoutChain - .topToBottomOfView(titleLab, offset: 16) - .edgesHorzontal(15) - } - - /// 根据数据源数量更新高度,上限到 bottom = kSafeBottomMargin + 10 - func updateHeight(itemCount: Int) { - let contentH = CGFloat(itemCount) * Self.rowHeight - let maxH = dl.height - kNaviHeight - kSafeBottomMargin - 10 - 16 - let targetH = min(contentH, maxH) - - tableView.layoutChain.height(targetH) - cornerBgView.layoutChain.height(targetH) - tableView.isScrollEnabled = targetH > maxH - } + static let rowHeight: CGFloat = 52 private var listHeightConstraint: NSLayoutConstraint? - - lazy var navBgView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") - iv.contentMode = .scaleAspectFill - return iv - }() - - lazy var navView: BaseNavigationView = { - let nav = BaseNavigationView(title: "移除圈子成员") - nav.addRightButton(deleteBtn) - return nav - }() - - lazy var deleteBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setTitle("删除(0)", for: .normal) - btn.setTitleColor(UIColor(hexStr: "#FF383C"), for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) - btn.contentHorizontalAlignment = .right - return btn - }() - - lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "圈子成员" - label.font = .systemFont(ofSize: 16, weight: .semibold) - label.textColor = ThemeManager.shared.color.titleAuxColor - return label - }() - - lazy var cornerBgView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFF") - view.cornerRadius = 10 - return view - }() - - lazy var tableView: UITableView = { - let tableView = UITableView(frame: .zero, style: .plain) - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.estimatedRowHeight = 85 - tableView.isScrollEnabled = false - tableView.register(RemoveMemberCell.self) - return tableView - }() - + override init(frame: CGRect) { - super.init(frame: .zero) - backgroundColor = .white + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F7F7F7") setupUI() - setupRx() } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} - - -// MARK: - RemoveMemberCell -class RemoveMemberCell: UITableViewCell { - - var disposeBag = DisposeBag() - - func configure(_ model: GroupMemberModel, isOwn: Bool, isSelected: Bool) { - avaterImgView.image = model.userIcon - nameLab.text = model.nick_name - ownView.isHidden = !isOwn - selectedBtn.isHidden = isOwn - selectedBtn.isSelected = isSelected - } - - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = .clear - setupSubviews() - } - - private func setupSubviews() { - contentView.addSubview(bgView) - bgView.addSubview(avaterImgView) - bgView.addSubview(nameLab) - bgView.addSubview(stackView) - bgView.addSubview(lineView) - - bgView.layoutChain - .edgesHorzontal() - .edgesVertical() - - avaterImgView.layoutChain - .edgesVertical(20) - .left(15) - .height(45) - .widthToHeight(1) - - nameLab.layoutChain - .leftToRightOfView(avaterImgView, offset: 10) - .centerY(avaterImgView) - - stackView.layoutChain - .right(15) - .centerY() - - ownView.layoutChain.width(25).height(35) - selectedBtn.layoutChain.width(20).height(20) - - lineView.layoutChain - .height(0.5) - .edgesHorzontal(15) - .bottom() - } - - lazy var bgView: UIView = { - let view = UIView() - view.backgroundColor = .clear//UIColor(hexStr: "#F5FBFF") - return view - }() - - lazy var avaterImgView: UIImageView = { - let view = UIImageView() - view.backgroundColor = .clear - view.cornerRadius = 22.5 - return view - }() - - lazy var nameLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#0F2846") - label.font = .systemFont(ofSize: 14, weight: .medium) - return label - }() - - lazy var stackView: UIStackView = { - let view = UIStackView(arrangedSubviews: [ownView, selectedBtn]) - view.axis = .horizontal - view.alignment = .trailing - view.spacing = 0 - view.backgroundColor = .clear - return view - }() - - lazy var ownView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - - let icon = UIImageView(image: UIImage(named: "Common/owner")) - view.addSubview(icon) - icon.layoutChain - .top() - .centerX() - .width(18) - .height(18) - - let label = UILabel() - label.text = "圈主" - label.textColor = ThemeManager.shared.color.titleAuxColor - label.font = .systemFont(ofSize: 10, weight: .medium) - view.addSubview(label) - label.layoutChain - .topToBottomOfView(icon) - .centerX() - - return view - }() - - lazy var selectedBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Common/checkbox_20x20"), for: .normal) - btn.setImage(UIImage(named: "Common/delete_check"), for: .selected) - btn.isUserInteractionEnabled = false - btn.isHidden = true - return btn - }() - - lazy var lineView: UIView = { - let view = UIView() - view.backgroundColor = ThemeManager.shared.color.lineColor - return view - }() - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code + + private func setupUI() { + addSubview(navView) + addSubview(searchField) + addSubview(titleLab) + addSubview(listContainerView) + listContainerView.addSubview(tableView) + + navView.layoutChain + .top() + .edgesHorzontal() + .height(kNaviHeight) + + searchField.layoutChain + .topToBottomOfView(navView, offset: 20) + .edgesHorzontal(15) + .height(38) + + searchField.addSubview(searchPlaceholderView) + searchPlaceholderView.layoutChain + .center() + .height(20) + searchPlaceholderView.layoutChain.width(52) + + titleLab.layoutChain + .topToBottomOfView(searchField, offset: 19) + .left(16) + .height(19) + + listContainerView.layoutChain + .topToBottomOfView(titleLab, offset: 6) + .edgesHorzontal(15) + + tableView.layoutChain.edges() + listHeightConstraint = listContainerView.jh_setDimension(.height, toSize: 0) } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) + func updateMemberCount(_ count: Int) { + let prefix = "圈成员" + let countText = "(\(count)人)" + let text = prefix + countText + let attributedText = NSMutableAttributedString(string: text) + attributedText.addAttributes([ + .font: UIFont.systemFont(ofSize: 14, weight: .medium), + .foregroundColor: UIColor(hexStr: "#6E6E73") + ], range: NSRange(location: 0, length: text.count)) + attributedText.addAttributes([ + .font: UIFont.systemFont(ofSize: 13, weight: .regular), + .foregroundColor: UIColor(hexStr: "#8E8E93") + ], range: NSRange(location: prefix.count, length: countText.count)) + titleLab.attributedText = attributedText + } - // Configure the view for the selected state + func updateHeight(itemCount: Int) { + let contentHeight = CGFloat(itemCount) * Self.rowHeight + layoutIfNeeded() + + let bottomInset = max(kSafeBottomMargin, safeAreaInsets.bottom) + let maxHeight = max(0, bounds.height - listContainerView.frame.minY - bottomInset) + listHeightConstraint?.constant = min(contentHeight, maxHeight) + tableView.isScrollEnabled = contentHeight >= maxHeight && itemCount > 0 + listContainerView.isHidden = itemCount == 0 + layoutIfNeeded() } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() + + func updateSearchPlaceholder(isHidden: Bool) { + searchPlaceholderView.isHidden = isHidden } + + lazy var navView: BaseNavigationView = { + let nav = BaseNavigationView(title: "圈子成员") + nav.addRightButton(deleteBtn) + return nav + }() + + lazy var deleteBtn: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("移除", for: .normal) + button.setTitleColor(UIColor(hexStr: "#FF4D53"), for: .normal) + button.setTitleColor(UIColor(hexStr: "#FF4D53", alpha: 0.35), for: .disabled) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.contentHorizontalAlignment = .right + button.isEnabled = false + return button + }() + + lazy var searchField: UISearchTextField = { + let textField = UISearchTextField() + textField.backgroundColor = .white + textField.borderStyle = .none + textField.cornerRadius = 11 + textField.clipsToBounds = true + textField.font = .systemFont(ofSize: 14) + textField.textColor = UIColor(hexStr: "#293445") + textField.tintColor = UIColor(hexStr: "#293445") + textField.returnKeyType = .search + textField.clearButtonMode = .whileEditing + textField.textAlignment = .left + textField.leftView = UIView(frame: .zero) + textField.leftViewMode = .never + return textField + }() + + private lazy var searchPlaceholderView: UIView = { + let view = UIView() + view.isUserInteractionEnabled = false + + let imageView = UIImageView(image: UIImage(named: "Group/remove_member_search")) + imageView.contentMode = .scaleAspectFit + view.addSubview(imageView) + imageView.layoutChain + .left() + .centerY() + .width(16) + .height(16) + + let label = UILabel() + label.text = "搜索" + label.textColor = UIColor(hexStr: "#B0B0B5") + label.font = .systemFont(ofSize: 14) + view.addSubview(label) + label.layoutChain + .leftToRightOfView(imageView, offset: 6) + .right() + .centerY() + + return view + }() + + private lazy var titleLab = UILabel() + + private lazy var listContainerView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.cornerRadius = 10 + view.clipsToBounds = true + return view + }() + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.rowHeight = Self.rowHeight + tableView.estimatedRowHeight = Self.rowHeight + tableView.isScrollEnabled = false + tableView.showsVerticalScrollIndicator = false + tableView.keyboardDismissMode = .onDrag + tableView.register(RemoveMemberCell.self) + return tableView + }() +} + +// MARK: - RemoveMemberCell +final class RemoveMemberCell: UITableViewCell { + + func configure( + _ model: GroupMemberModel, + isOwn: Bool, + isSelected: Bool, + showsSeparator: Bool + ) { + avatarImageView.image = model.userIcon + nameLab.text = model.nick_name + selectedBtn.isHidden = false + selectedBtn.isSelected = isOwn ? false : isSelected + selectedBtn.alpha = isOwn ? 0.45 : 1 + lineView.isHidden = !showsSeparator + } + + override init(style: CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + setupSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + contentView.addSubview(avatarImageView) + contentView.addSubview(nameLab) + contentView.addSubview(selectedBtn) + contentView.addSubview(lineView) + + avatarImageView.layoutChain + .left(16) + .centerY() + .width(38) + .height(38) + + nameLab.layoutChain + .leftToRightOfView(avatarImageView, offset: 12) + .centerY() + + selectedBtn.layoutChain + .right(14) + .centerY() + .width(24) + .height(24) + + lineView.layoutChain + .left(20) + .right(13) + .bottom() + .height(0.5) + } + + private lazy var avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.backgroundColor = UIColor(hexStr: "#F2F2F2") + imageView.contentMode = .scaleAspectFill + imageView.cornerRadius = 9 + imageView.clipsToBounds = true + return imageView + }() + + private lazy var nameLab: UILabel = { + let label = UILabel() + label.textColor = UIColor(hexStr: "#111111") + label.font = .systemFont(ofSize: 15, weight: .regular) + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var selectedBtn: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "Group/remove_member_unselected"), for: .normal) + button.setImage(UIImage(named: "Group/remove_member_selected"), for: .selected) + button.isUserInteractionEnabled = false + return button + }() + + private lazy var lineView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#EEEEF0") + return view + }() } diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift index 53358606..a822d560 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift @@ -27,6 +27,7 @@ class RemoveMemberViewModel { private var disposeBag = DisposeBag() private let sectionedItems = PublishSubject<[ReviewMemberListSectionModel]>() private var list: [GroupMemberModel] = [] + private var searchKeyword = "" var selectedMembers = BehaviorRelay<[String]>(value: []) @@ -42,7 +43,7 @@ class RemoveMemberViewModel { current.append(memberId) } this.selectedMembers.accept(current) - self.sectionedItems.onNext(self.list.mapSection()) + self.emitItems() return .empty() } }(self) @@ -53,7 +54,27 @@ class RemoveMemberViewModel { func loadData(_ list: [GroupMemberModel]) { self.list = list - sectionedItems.onNext(list.mapSection()) + selectedMembers.accept(selectedMembers.value.filter { selectedId in + list.contains(where: { $0.user_id == selectedId }) + }) + emitItems() + } + + func updateSearchKeyword(_ keyword: String) { + searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + emitItems() + } + + private func emitItems() { + let items: [GroupMemberModel] + if searchKeyword.isEmpty { + items = list + } else { + items = list.filter { + $0.nick_name.localizedCaseInsensitiveContains(searchKeyword) + } + } + sectionedItems.onNext(items.mapSection()) } init(groupId: String) { diff --git a/QuickLocation/Section/Mine/About/AboutVC.swift b/QuickLocation/Section/Mine/About/AboutVC.swift index e9455c35..0e3cb438 100644 --- a/QuickLocation/Section/Mine/About/AboutVC.swift +++ b/QuickLocation/Section/Mine/About/AboutVC.swift @@ -28,7 +28,7 @@ final class AboutVC: BaseViewController { rootView.feedbackBtn.rx.controlEvent(.touchUpInside) .subscribe(onNext: { _ in - DLToast.show(text: "敬请期待") + AppRouter.push(Route.feedback) }) .disposed(by: disposeBag) diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackSuccessView.swift b/QuickLocation/Section/Mine/Feedback/FeedbackSuccessView.swift new file mode 100644 index 00000000..bc043f55 --- /dev/null +++ b/QuickLocation/Section/Mine/Feedback/FeedbackSuccessView.swift @@ -0,0 +1,88 @@ +// +// FeedbackSuccessView.swift +// QuickLocation +// + +import UIKit + +final class FeedbackSuccessView: UIView { + + private let cardView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = UIColor.black.withAlphaComponent(0.66) + + let pointerView = UIView() + pointerView.backgroundColor = .white + pointerView.transform = CGAffineTransform(rotationAngle: .pi / 4) + addSubview(pointerView) + pointerView.layoutChain.centerX().centerY(self, offset: 94).width(28).height(28) + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 36 + addSubview(cardView) + cardView.layoutChain.centerX().centerY(self, offset: -8).width(280).height(208) + + let headerImageView = UIImageView(image: UIImage(named: "Mine/feedback_success_header")) + headerImageView.contentMode = .scaleToFill + headerImageView.layer.cornerRadius = 36 + headerImageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + headerImageView.clipsToBounds = true + cardView.addSubview(headerImageView) + headerImageView.layoutChain.top().left().right().height(100) + + let iconView = UIImageView(image: UIImage(named: "Mine/feedback_success_icon")) + iconView.contentMode = .scaleAspectFit + cardView.addSubview(iconView) + iconView.layoutChain.top(-60).centerX().width(120).height(120) + + let titleLabel = UILabel() + titleLabel.text = "反馈提交成功" + titleLabel.font = .systemFont(ofSize: 24, weight: .semibold) + titleLabel.textColor = UIColor(hexStr: "#1D1D1D") + titleLabel.textAlignment = .center + cardView.addSubview(titleLabel) + titleLabel.layoutChain.top(78).left(16).right(16) + + let detailLabel = UILabel() + detailLabel.text = "感谢您的反馈,我们会及时查看并妥\n善处理相关问题" + detailLabel.font = .systemFont(ofSize: 15, weight: .medium) + detailLabel.textColor = UIColor(hexStr: "#767676") + detailLabel.textAlignment = .center + detailLabel.numberOfLines = 2 + detailLabel.lineBreakMode = .byWordWrapping + cardView.addSubview(detailLabel) + detailLabel.layoutChain.topToBottomOfView(titleLabel, offset: 22).left(24).right(24) + } + + func show(in superview: UIView) { + frame = superview.bounds + autoresizingMask = [.flexibleWidth, .flexibleHeight] + alpha = 0 + cardView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + superview.addSubview(self) + UIView.animate(withDuration: 0.24, delay: 0, options: .curveEaseOut) { + self.alpha = 1 + self.cardView.transform = .identity + } + } + + func dismiss(completion: @escaping () -> Void) { + UIView.animate(withDuration: 0.2, animations: { + self.alpha = 0 + self.cardView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) + }, completion: { _ in + self.removeFromSuperview() + completion() + }) + } +} diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift new file mode 100644 index 00000000..458eba10 --- /dev/null +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -0,0 +1,168 @@ +// +// FeedbackVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import HXPHPicker + +final class FeedbackVC: BaseViewController { + + private enum FeedbackCategory: String, CaseIterable { + case feature = "功能问题" + case suggestion = "优化建议" + case other = "其他" + } + + private struct FeedbackDraft { + let category: FeedbackCategory + let detail: String + let images: [UIImage] + let contact: String + } + + private var rootView: FeedbackView! + private var selectedCategory: FeedbackCategory = .feature + private var selectedImages: [UIImage] = [] + private var pendingDraft: FeedbackDraft? + private var isSubmitting = false + + override func loadView() { + rootView = FeedbackView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + bind() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + guard self?.isSubmitting == false else { return } + self?.navigationController?.popViewController(animated: true) + } + + rootView.categoryButtons.forEach { button in + button.addTarget(self, action: #selector(categoryTapped(_:)), for: .touchUpInside) + } + + rootView.detailTextView.rx.text.orEmpty + .subscribe(onNext: { [weak self] text in + self?.handleDetailChange(text) + }) + .disposed(by: disposeBag) + + rootView.contactTextField.rx.controlEvent(.editingDidEndOnExit) + .subscribe(onNext: { [weak self] in + self?.rootView.contactTextField.resignFirstResponder() + }) + .disposed(by: disposeBag) + + rootView.onAddImage = { [weak self] in + self?.showAlbum() + } + + rootView.onDeleteImage = { [weak self] index in + guard let self, self.selectedImages.indices.contains(index) else { return } + self.selectedImages.remove(at: index) + self.rootView.updateImages(self.selectedImages) + } + + rootView.saveBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.submit() + }) + .disposed(by: disposeBag) + } + + @objc private func categoryTapped(_ sender: UIButton) { + let categories = FeedbackCategory.allCases + guard categories.indices.contains(sender.tag) else { return } + let category = categories[sender.tag] + selectedCategory = category + rootView.updateCategory(selectedIndex: sender.tag) + } + + private func handleDetailChange(_ text: String) { + let limitedText = text.count > 200 ? String(text.prefix(200)) : text + if limitedText != text { + rootView.detailTextView.text = limitedText + rootView.detailTextView.selectedRange = NSRange(location: limitedText.count, length: 0) + } + rootView.updateDetail(text: limitedText) + } + + private func showAlbum() { + let remainingCount = 3 - selectedImages.count + guard remainingCount > 0, !isSubmitting else { return } + + let config = PhotoTools.getWXPickerConfig() + config.selectOptions = [.photo] + config.selectMode = .multiple + config.maximumSelectedCount = remainingCount + config.allowSyncICloudWhenSelectPhoto = false + config.previewView.bottomView.editButtonHidden = true + config.photoList.allowAddCamera = false + + let pickerController = PhotoPickerController(picker: config) + pickerController.pickerDelegate = self + pickerController.modalPresentationStyle = .fullScreen + present(pickerController, animated: true) + } + + private func submit() { + guard !isSubmitting else { return } + view.endEditing(true) + + let detail = (rootView.detailTextView.text ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !detail.isEmpty else { + DLToast.show(text: "请补充详细问题或意见") + return + } + + pendingDraft = FeedbackDraft( + category: selectedCategory, + detail: detail, + images: selectedImages, + contact: rootView.contactTextField.text ?? "" + ) + isSubmitting = true + rootView.setSubmitting(true) + + let successView = FeedbackSuccessView() + successView.show(in: view) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak successView] in + guard let self, let successView else { return } + successView.dismiss { + AppRouter.shared.popOrDismiss() + } + } + } +} + +extension FeedbackVC: PhotoPickerControllerDelegate { + + func pickerController( + _ pickerController: PhotoPickerController, + didFinishSelection result: PickerResult + ) { + let remainingCount = max(0, 3 - selectedImages.count) + guard remainingCount > 0 else { return } + + result.getImage { _, _, _ in + } completionHandler: { [weak self] images in + DispatchQueue.main.async { + guard let self else { return } + self.selectedImages.append(contentsOf: images.prefix(remainingCount)) + self.selectedImages = Array(self.selectedImages.prefix(3)) + self.rootView.updateImages(self.selectedImages) + } + } + } + + func pickerController(didCancel pickerController: PhotoPickerController) {} +} diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackView.swift b/QuickLocation/Section/Mine/Feedback/FeedbackView.swift new file mode 100644 index 00000000..6d484b2f --- /dev/null +++ b/QuickLocation/Section/Mine/Feedback/FeedbackView.swift @@ -0,0 +1,398 @@ +// +// FeedbackView.swift +// QuickLocation +// + +import UIKit + +final class FeedbackView: UIView { + + let navView = BaseNavigationView(title: "问题反馈") + let detailTextView = UITextView() + let contactTextField = UITextField() + let saveBtn = UIButton(type: .custom) + private(set) var categoryButtons: [UIButton] = [] + + var onAddImage: (() -> Void)? + var onDeleteImage: ((Int) -> Void)? + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let detailPlaceholderLabel = UILabel() + private let countLabel = UILabel() + private let imagesCollectionView: UICollectionView + private var images: [UIImage] = [] + + override init(frame: CGRect) { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 8 + layout.minimumInteritemSpacing = 8 + layout.itemSize = CGSize(width: 100, height: 100) + imagesCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F8F8F8") + setupUI() + updateCategory(selectedIndex: 0) + updateDetail(text: "") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(navView) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + + saveBtn.setTitle("保存", for: .normal) + saveBtn.setTitleColor(.white, for: .normal) + saveBtn.titleLabel?.font = FontManager.boboBold(18) + saveBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + saveBtn.layer.cornerRadius = 24 + saveBtn.clipsToBounds = true + addSubview(saveBtn) + saveBtn.layoutChain + .left(28) + .right(28) + .bottom(kSafeBottomMargin + 22) + .height(56) + + scrollView.alwaysBounceVertical = false + scrollView.keyboardDismissMode = .onDrag + scrollView.showsVerticalScrollIndicator = false + addSubview(scrollView) + scrollView.layoutChain + .topToBottomOfView(navView) + .edgesHorzontal() + .bottomToTopOfView(saveBtn, offset: -12) + + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + let typeTitle = makeSectionTitle(required: true, title: "您想反馈的功能类型") + contentView.addSubview(typeTitle) + typeTitle.layoutChain.top(14).left(16).right(16) + + let titles = ["功能问题", "优化建议", "其他"] + categoryButtons = titles.enumerated().map { index, title in + let button = UIButton(type: .custom) + button.tag = index + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.layer.cornerRadius = 12 + button.layer.borderWidth = 1 + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + return button + } + + let firstButton = categoryButtons[0] + let secondButton = categoryButtons[1] + let thirdButton = categoryButtons[2] + NSLayoutConstraint.activate([ + firstButton.topAnchor.constraint(equalTo: typeTitle.bottomAnchor, constant: 12), + firstButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + firstButton.heightAnchor.constraint(equalToConstant: 34), + secondButton.topAnchor.constraint(equalTo: firstButton.topAnchor), + secondButton.leadingAnchor.constraint(equalTo: firstButton.trailingAnchor, constant: 16), + secondButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor), + secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor), + thirdButton.topAnchor.constraint(equalTo: firstButton.topAnchor), + thirdButton.leadingAnchor.constraint(equalTo: secondButton.trailingAnchor, constant: 16), + thirdButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + thirdButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor), + thirdButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor) + ]) + + let detailTitle = makeSectionTitle(required: true, title: "请补充详细问题或意见") + contentView.addSubview(detailTitle) + detailTitle.layoutChain + .topToBottomOfView(firstButton, offset: 20) + .left(16) + .right(16) + + let detailContainer = UIView() + detailContainer.backgroundColor = .white + detailContainer.layer.cornerRadius = 16 + contentView.addSubview(detailContainer) + detailContainer.layoutChain + .topToBottomOfView(detailTitle, offset: 12) + .left(16) + .right(16) + .height(150) + + detailTextView.backgroundColor = .clear + detailTextView.font = .systemFont(ofSize: 14, weight: .medium) + detailTextView.textColor = UIColor(hexStr: "#293445") + detailTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 28, right: 10) + detailTextView.textContainer.lineFragmentPadding = 0 + detailTextView.isScrollEnabled = true + detailTextView.returnKeyType = .default + detailContainer.addSubview(detailTextView) + detailTextView.layoutChain.edges() + + detailPlaceholderLabel.text = "请在这里输入内容" + detailPlaceholderLabel.font = .systemFont(ofSize: 14, weight: .medium) + detailPlaceholderLabel.textColor = UIColor(hexStr: "#B5B5B5") + detailPlaceholderLabel.isUserInteractionEnabled = false + detailContainer.addSubview(detailPlaceholderLabel) + detailPlaceholderLabel.layoutChain.top(12).left(14).right(14) + + countLabel.font = .systemFont(ofSize: 13, weight: .medium) + countLabel.textColor = UIColor(hexStr: "#767676") + countLabel.textAlignment = .right + detailContainer.addSubview(countLabel) + countLabel.layoutChain.right(14).bottom(10) + + let imageTitle = makeSectionTitle( + required: false, + title: "请提供相关问题的图片", + detail: "(选填,最多可上传三张)" + ) + contentView.addSubview(imageTitle) + imageTitle.layoutChain + .topToBottomOfView(detailContainer, offset: 18) + .left(16) + .right(16) + + imagesCollectionView.backgroundColor = .clear + imagesCollectionView.isScrollEnabled = false + imagesCollectionView.showsHorizontalScrollIndicator = false + imagesCollectionView.dataSource = self + imagesCollectionView.delegate = self + imagesCollectionView.register( + FeedbackImageCell.self, + forCellWithReuseIdentifier: FeedbackImageCell.reuseIdentifier + ) + imagesCollectionView.register( + FeedbackAddImageCell.self, + forCellWithReuseIdentifier: FeedbackAddImageCell.reuseIdentifier + ) + contentView.addSubview(imagesCollectionView) + imagesCollectionView.layoutChain + .topToBottomOfView(imageTitle, offset: 12) + .left(16) + .right(16) + .height(100) + + let contactTitle = makeSectionTitle( + required: false, + title: "联系方式", + detail: "(选填,可以留下您的手机号、微信、邮箱)" + ) + contentView.addSubview(contactTitle) + contactTitle.layoutChain + .topToBottomOfView(imagesCollectionView, offset: 18) + .left(16) + .right(16) + + contactTextField.backgroundColor = .white + contactTextField.layer.cornerRadius = 20 + contactTextField.font = .systemFont(ofSize: 14, weight: .medium) + contactTextField.textColor = UIColor(hexStr: "#293445") + contactTextField.returnKeyType = .done + contactTextField.clearButtonMode = .whileEditing + contactTextField.attributedPlaceholder = NSAttributedString( + string: "请输入您的联系方式", + attributes: [ + .foregroundColor: UIColor(hexStr: "#B5B5B5"), + .font: UIFont.systemFont(ofSize: 14, weight: .medium) + ] + ) + contactTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + contactTextField.leftViewMode = .always + contactTextField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + contactTextField.rightViewMode = .unlessEditing + contentView.addSubview(contactTextField) + contactTextField.layoutChain + .topToBottomOfView(contactTitle, offset: 12) + .left(16) + .right(16) + .height(40) + .bottom(24) + } + + private func makeSectionTitle(required: Bool, title: String, detail: String? = nil) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + + let text = NSMutableAttributedString() + if required { + text.append(NSAttributedString( + string: "*", + attributes: [ + .font: UIFont.systemFont(ofSize: 17, weight: .medium), + .foregroundColor: UIColor(hexStr: "#F64545") + ] + )) + } + text.append(NSAttributedString( + string: title, + attributes: [ + .font: UIFont.systemFont(ofSize: 16, weight: .medium), + .foregroundColor: UIColor(hexStr: "#293445") + ] + )) + if let detail { + text.append(NSAttributedString( + string: detail, + attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: UIColor(hexStr: "#B5B5B5") + ] + )) + } + label.attributedText = text + return label + } + + func updateCategory(selectedIndex: Int) { + for (index, button) in categoryButtons.enumerated() { + let isSelected = index == selectedIndex + button.backgroundColor = isSelected ? UIColor(hexStr: "#08B2F5") : .clear + button.layer.borderColor = UIColor( + hexStr: isSelected ? "#08B2F5" : "#D6D6D6" + ).cgColor + button.setTitleColor( + isSelected ? .white : UIColor(hexStr: "#293445"), + for: .normal + ) + } + } + + func updateDetail(text: String) { + detailPlaceholderLabel.isHidden = !text.isEmpty + countLabel.text = "\(text.count)/200" + } + + func updateImages(_ images: [UIImage]) { + self.images = Array(images.prefix(3)) + imagesCollectionView.reloadData() + } + + func setSubmitting(_ submitting: Bool) { + saveBtn.isEnabled = !submitting + saveBtn.alpha = submitting ? 0.65 : 1 + } +} + +extension FeedbackView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = min(100, floor((collectionView.bounds.width - 16) / 3)) + return CGSize(width: width, height: 100) + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + images.count + (images.count < 3 ? 1 : 0) + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + if indexPath.item < images.count { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: FeedbackImageCell.reuseIdentifier, + for: indexPath + ) as? FeedbackImageCell else { + return UICollectionViewCell() + } + cell.configure(image: images[indexPath.item]) + cell.onDelete = { [weak self, weak cell] in + guard let self, let cell, + let currentIndexPath = collectionView.indexPath(for: cell) else { return } + self.onDeleteImage?(currentIndexPath.item) + } + return cell + } + + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: FeedbackAddImageCell.reuseIdentifier, + for: indexPath + ) as? FeedbackAddImageCell else { + return UICollectionViewCell() + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.item == images.count, images.count < 3 else { return } + onAddImage?() + } +} + +private final class FeedbackImageCell: UICollectionViewCell { + static let reuseIdentifier = "FeedbackImageCell" + + var onDelete: (() -> Void)? + + private let imageView = UIImageView() + private let deleteButton = UIButton(type: .custom) + + override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 16 + imageView.clipsToBounds = true + contentView.addSubview(imageView) + imageView.layoutChain.edges() + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) + deleteButton.setImage(UIImage(systemName: "xmark", withConfiguration: symbolConfig), for: .normal) + deleteButton.tintColor = UIColor(hexStr: "#293445") + deleteButton.backgroundColor = UIColor.white.withAlphaComponent(0.92) + deleteButton.layer.cornerRadius = 11 + deleteButton.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + deleteButton.layer.shadowOpacity = 1 + deleteButton.layer.shadowRadius = 3 + deleteButton.layer.shadowOffset = CGSize(width: 0, height: 1) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + contentView.addSubview(deleteButton) + deleteButton.layoutChain.top(6).right(6).width(22).height(22) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + onDelete = nil + } + + func configure(image: UIImage) { + imageView.image = image + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +private final class FeedbackAddImageCell: UICollectionViewCell { + static let reuseIdentifier = "FeedbackAddImageCell" + + private let addImageView = UIImageView(image: UIImage(named: "Mine/feedback_add")) + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 16 + addImageView.contentMode = .scaleAspectFit + contentView.addSubview(addImageView) + addImageView.layoutChain.centerX().centerY().width(48).height(48) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +}