diff --git a/QuickLocation/API/GroupAPI.swift b/QuickLocation/API/GroupAPI.swift index fae813cf..aea25a87 100644 --- a/QuickLocation/API/GroupAPI.swift +++ b/QuickLocation/API/GroupAPI.swift @@ -13,6 +13,10 @@ internal import Alamofire enum GroupAPI { /// 圈子信息 case groupInfo + /// 圈子类型模板 + case groupTemplates + /// 指定圈子的资料及成员 + case groupUsers(groupKey: String) /// 圈子操作(创建、解散、设为默认等) /// - Parameters: /// - opType: create、dismiss、setdefault @@ -26,6 +30,10 @@ extension GroupAPI: MultiTargetProtocol { switch self { case .groupInfo: return "mapi/user/fullinfo" + case .groupTemplates: + return "mapi/group/template" + case .groupUsers: + return "mapi/group/users" case .operate: return "mapi/group/operate" } @@ -33,7 +41,7 @@ extension GroupAPI: MultiTargetProtocol { var method: Moya.Method { switch self { - case .groupInfo: + case .groupInfo, .groupTemplates, .groupUsers: return .get default: return .post @@ -44,6 +52,12 @@ extension GroupAPI: MultiTargetProtocol { switch self { case .groupInfo: return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) + + case .groupTemplates: + return .requestPlain + + case let .groupUsers(groupKey): + return .requestParameters(parameters: ["group_key": groupKey], encoding: URLEncoding()) case let .operate(opType, requestData): var params = Parameters() diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index fd78c9b7..f2405ec7 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -21,6 +21,9 @@ enum UserAPI { /// 用户信息 case userInfo + + /// 用户当前状态 + case userStatus /// 用户IM Token case imToken @@ -90,6 +93,8 @@ extension UserAPI: MultiTargetProtocol { return "api/user/login" case .userInfo: return "api/user" + case .userStatus: + return "mapi/user/status" case .imToken: return "mapi/openim/user/token/get" case .signInInfo: @@ -125,7 +130,7 @@ extension UserAPI: MultiTargetProtocol { var method: Moya.Method { switch self { - case .userInfo, .signInInfo, .notice, .followList: + case .userInfo, .userStatus, .signInInfo, .notice, .followList: return .get case .changePhone, .setGender: return .put @@ -147,6 +152,9 @@ extension UserAPI: MultiTargetProtocol { case .userInfo: return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) + + case .userStatus: + return .requestPlain case .imToken: var params = Parameters() diff --git a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png index b2d831c9..3bdab339 100644 Binary files a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png and b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png index 0f9bdc11..243eb36d 100644 Binary files a/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png and b/QuickLocation/Assets.xcassets/Bubble/clock.imageset/clock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/Contents.json new file mode 100644 index 00000000..177bc2bb --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deco_flower@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deco_flower@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@2x.png b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@2x.png new file mode 100644 index 00000000..c832de79 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@3x.png b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@3x.png new file mode 100644 index 00000000..81b063bd Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/Contents.json new file mode 100644 index 00000000..6f7995f2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deco_ghost@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deco_ghost@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@2x.png b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@2x.png new file mode 100644 index 00000000..24819d8d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@3x.png b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@3x.png new file mode 100644 index 00000000..a3cfe81b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_ghost.imageset/deco_ghost@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/Contents.json new file mode 100644 index 00000000..5e6dad2e --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deco_pencil@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deco_pencil@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@2x.png b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@2x.png new file mode 100644 index 00000000..78c033f8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@3x.png b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@3x.png new file mode 100644 index 00000000..bbfa79de Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/Contents.json new file mode 100644 index 00000000..82df9f00 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deco_phone@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deco_phone@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@2x.png b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@2x.png new file mode 100644 index 00000000..9d391c25 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@3x.png b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@3x.png new file mode 100644 index 00000000..21177f17 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_phone.imageset/deco_phone@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/Contents.json new file mode 100644 index 00000000..dfd5c453 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deco_pin@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deco_pin@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@2x.png b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@2x.png new file mode 100644 index 00000000..1d830a97 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@3x.png b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@3x.png new file mode 100644 index 00000000..f777a43e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/deco_pin.imageset/deco_pin@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/Contents.json new file mode 100644 index 00000000..c2c58123 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "notification_megaphone@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "notification_megaphone@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@2x.png b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@2x.png new file mode 100644 index 00000000..d8706be0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@3x.png b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@3x.png new file mode 100644 index 00000000..1b17be14 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/Contents.json b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/Contents.json new file mode 100644 index 00000000..a75013b8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "notification_sparkle@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "notification_sparkle@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@2x.png b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@2x.png new file mode 100644 index 00000000..4dfdc7e0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@3x.png b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@3x.png new file mode 100644 index 00000000..868f6508 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/Contents.json new file mode 100644 index 00000000..ca4061bb --- /dev/null +++ b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "popup_header_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "popup_header_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@2x.png b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@2x.png new file mode 100644 index 00000000..f6eaec24 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@3x.png b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@3x.png new file mode 100644 index 00000000..54153545 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/Contents.json b/QuickLocation/Assets.xcassets/GroupSetting/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/GroupSetting/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/Contents.json b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/Contents.json new file mode 100644 index 00000000..f5c718fd --- /dev/null +++ b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "arrow-right@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "arrow-right@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@2x.png b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@2x.png new file mode 100644 index 00000000..705ecb65 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@2x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@3x.png b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@3x.png new file mode 100644 index 00000000..ed772e88 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@3x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/Contents.json b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/Contents.json new file mode 100644 index 00000000..6dcf2989 --- /dev/null +++ b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "invite-code@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "invite-code@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@2x.png b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@2x.png new file mode 100644 index 00000000..bd1f8ce9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@2x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@3x.png b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@3x.png new file mode 100644 index 00000000..c69de097 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@3x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/Contents.json b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/Contents.json new file mode 100644 index 00000000..81a4e06e --- /dev/null +++ b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "remove-member@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "remove-member@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@2x.png b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@2x.png new file mode 100644 index 00000000..a8ef2858 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@2x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@3x.png b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@3x.png new file mode 100644 index 00000000..9d448cb1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@3x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/Contents.json b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/Contents.json new file mode 100644 index 00000000..b2fd27a4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@2x.png b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@2x.png new file mode 100644 index 00000000..7d0d2b43 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@2x.png differ diff --git a/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@3x.png b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@3x.png new file mode 100644 index 00000000..efff9654 Binary files /dev/null and b/QuickLocation/Assets.xcassets/GroupSetting/share.imageset/share@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@2x.png b/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@2x.png index de9cc1b7..a68a13bc 100644 Binary files a/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@2x.png and b/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@3x.png b/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@3x.png index 606804e7..9f665758 100644 Binary files a/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@3x.png and b/QuickLocation/Assets.xcassets/Popup/header_bg.imageset/header_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/Contents.json b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/Contents.json new file mode 100644 index 00000000..cf6560d8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pigeon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pigeon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@2x.png b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@2x.png new file mode 100644 index 00000000..2cf79358 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@3x.png b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@3x.png new file mode 100644 index 00000000..df539623 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Popup/pigeon.imageset/pigeon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/Contents.json new file mode 100644 index 00000000..b98e231f --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_contact_alert@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_contact_alert@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@2x.png new file mode 100644 index 00000000..86fed87e Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@3x.png new file mode 100644 index 00000000..2bdcaab1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png index df757ae1..0081740a 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png index fc4bfe7b..e5a90576 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_1.imageset/sos_guide_3@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png index 58b46578..35e69f87 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png index a876fb1a..0603e841 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_2.imageset/sos_guide_2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png index f48b6c86..8d5d75bf 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png index 2e0fc613..cbf0a051 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png and b/QuickLocation/Assets.xcassets/SOS/sos_guide_3.imageset/sos_guide_1@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/Contents.json b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/Contents.json new file mode 100644 index 00000000..bc18bae3 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_info_alert@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_info_alert@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@2x.png new file mode 100644 index 00000000..048a0cea Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@3x.png new file mode 100644 index 00000000..d6889d17 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png index 219c352f..13414957 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png and b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png index f9df9522..3ca9850d 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png and b/QuickLocation/Assets.xcassets/SOS/sos_phone_badge.imageset/sos_phone_badge@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png index ee66bfc0..50d75150 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png and b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png index 5badcffb..8f6c40c8 100644 Binary files a/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png and b/QuickLocation/Assets.xcassets/SOS/sos_practice_success.imageset/sos_practice_success@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@2x.png b/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@2x.png index 7d95d3fe..00da141e 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@2x.png and b/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@3x.png b/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@3x.png index f2510bfb..7f968e76 100644 Binary files a/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@3x.png and b/QuickLocation/Assets.xcassets/Schedule/create.imageset/Group_2250@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/Contents.json b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/Contents.json new file mode 100644 index 00000000..d789f58c --- /dev/null +++ b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "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/Schedule/title_location.imageset/location@2x.png b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@2x.png new file mode 100644 index 00000000..f49485f7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@3x.png b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@3x.png new file mode 100644 index 00000000..a7c8fc52 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/Contents.json b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/Contents.json new file mode 100644 index 00000000..980c0fb4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "组 48245@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "组 48245@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@2x.png b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@2x.png new file mode 100644 index 00000000..3f9956e2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@3x.png b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@3x.png new file mode 100644 index 00000000..6a14ae9d Binary files /dev/null and b/QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/Contents.json new file mode 100644 index 00000000..3c4bace7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "header_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "header_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@2x.png b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@2x.png new file mode 100644 index 00000000..bceda7d8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@3x.png b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@3x.png new file mode 100644 index 00000000..6a8dc7ad Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/header_bg.imageset/header_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/Contents.json b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/Contents.json new file mode 100644 index 00000000..22b0410b --- /dev/null +++ b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hero_bg_missed_one_day@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "hero_bg_missed_one_day@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@2x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@2x.png new file mode 100644 index 00000000..a4c8fd06 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@3x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@3x.png new file mode 100644 index 00000000..28e1c89e Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/Contents.json b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/Contents.json new file mode 100644 index 00000000..5a95c9e6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hero_bg_missed_two_days@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "hero_bg_missed_two_days@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@2x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@2x.png new file mode 100644 index 00000000..7bb052c6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@3x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@3x.png new file mode 100644 index 00000000..de31c307 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/Contents.json b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/Contents.json new file mode 100644 index 00000000..c80f6b8e --- /dev/null +++ b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hero_bg_signed@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "hero_bg_signed@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@2x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@2x.png new file mode 100644 index 00000000..17b690d6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@3x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@3x.png new file mode 100644 index 00000000..5e9c19ab Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/Contents.json b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/Contents.json new file mode 100644 index 00000000..4edf7393 --- /dev/null +++ b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hero_bg_unsigned@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "hero_bg_unsigned@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@2x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@2x.png new file mode 100644 index 00000000..c37b3d3d Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@3x.png b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@3x.png new file mode 100644 index 00000000..c03871ff Binary files /dev/null and b/QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@3x.png differ diff --git a/QuickLocation/Assets.xcassets/empty_data.imageset/Contents.json b/QuickLocation/Assets.xcassets/empty_data.imageset/Contents.json new file mode 100644 index 00000000..f09750d8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/empty_data.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "empty_data@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "empty_data@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@2x.png b/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@2x.png new file mode 100644 index 00000000..4a759bc6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@2x.png differ diff --git a/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@3x.png b/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@3x.png new file mode 100644 index 00000000..89ba2262 Binary files /dev/null and b/QuickLocation/Assets.xcassets/empty_data.imageset/empty_data@3x.png differ diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index e8b811ff..77691eea 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -41,6 +41,8 @@ extension DefaultsKeys { var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) } /// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计) var screenSessionStartAt: DefaultsKey { .init("screenSessionStartAt") } + /// 手机报告:最后一次确认亮屏的时间(锁屏结算用,避免用 Date() 把灭屏空档算进去) + var screenLastOnAt: DefaultsKey { .init("screenLastOnAt") } /// 手机报告:本 App 今日进入前台次数 var appUsageToday: DefaultsKey { .init("appUsageToday", defaultValue: 0) } } diff --git a/QuickLocation/Manager/Account/AppContextManager.swift b/QuickLocation/Manager/Account/AppContextManager.swift index b02120a2..425c4538 100644 --- a/QuickLocation/Manager/Account/AppContextManager.swift +++ b/QuickLocation/Manager/Account/AppContextManager.swift @@ -106,6 +106,14 @@ class AppContextManager: NSObject { saveAccount(account) } + /// 同步服务端返回的用户状态和心情。 + public func updateAccountState(status: Int, mood: Int) { + guard var account = self.account else { return } + account.status = status + account.mood = mood + saveAccount(account) + } + public func deleteAccount() { Defaults[\.loginToken] = nil Defaults[\.userConfig] = nil diff --git a/QuickLocation/Manager/Account/UserConfigResponse.swift b/QuickLocation/Manager/Account/UserConfigResponse.swift index ea501885..28b2a335 100644 --- a/QuickLocation/Manager/Account/UserConfigResponse.swift +++ b/QuickLocation/Manager/Account/UserConfigResponse.swift @@ -5,8 +5,26 @@ // Created by 八条 on 2026/5/26. // +import Foundation import ObjectMapper +private let kUserStatusTransformInt64 = TransformOf(fromJSON: { value in + switch value { + case let value as Int64: + return value + case let value as Int: + return Int64(value) + case let value as Double: + return Int64(value) + case let value as String: + return Int64(value) + default: + return nil + } +}, toJSON: { value in + value +}) + class UserConfigResponse: BaseModelProtocol { var code: String? @@ -42,3 +60,39 @@ class UserLoginRespons: BaseModelProtocol { model <- map["data"] } } + +struct UserStatusResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: UserStatusModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + model <- map["data"] + } +} + +struct UserStatusModel: Mappable { + var status: Int = 0 + var mood: Int = 0 + var bubbleTime: Int64 = 0 + + init?(map: Map) {} + + mutating func mapping(map: Map) { + status <- (map["status"], kStrTransformInt) + mood <- (map["mood"], kStrTransformInt) + bubbleTime <- (map["bubble_time"], kUserStatusTransformInt64) + } + + var bubbleEndDate: Date? { + guard bubbleTime > 0 else { return nil } + let seconds = bubbleTime >= 100_000_000_000 + ? TimeInterval(bubbleTime) / 1000 + : TimeInterval(bubbleTime) + return Date(timeIntervalSince1970: seconds) + } +} diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 2e982352..13f61e64 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -322,7 +322,9 @@ extension AppRouter: AppRouterProtocol { // MARK: - 行程路线 AppRouter.register(Route.itineraryDetail) { url, parameters in - return ItineraryDetailVC(scheduleJson: parameters["scheduleJson"].safeDictionary as! [String : Any]) + let routeId = parameters["routeId"].safeString + return ScheduleDetailVC(routeId: routeId, + scheduleJson: parameters["scheduleJson"].safeDictionary as! [String : Any]) } // MARK: - 历史行程 @@ -445,4 +447,3 @@ extension AppRouter: AppRouterProtocol { } } } - diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 3f9bec0f..1d2aabf0 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -48,7 +48,7 @@ extension DefaultsKeys { func setupNetworkMode() { #if DEBUG - setupApiEnv(1) + setupApiEnv(0) openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002 openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010 #elseif AdHoc @@ -86,7 +86,7 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "https://jsapi.zuom8.cn/" + return "http://172.16.10.24:9243/" } } diff --git a/QuickLocation/Manager/Unlock/UnlockCountManager.swift b/QuickLocation/Manager/Unlock/UnlockCountManager.swift index 818f46f8..e18be8e5 100644 --- a/QuickLocation/Manager/Unlock/UnlockCountManager.swift +++ b/QuickLocation/Manager/Unlock/UnlockCountManager.swift @@ -31,8 +31,13 @@ final class UnlockCountManager: NSObject { private var lastGeofenceCenter: CLLocationCoordinate2D? private var lastKnownLocation: CLLocation? private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid + private var backgroundPollTask: UIBackgroundTaskIdentifier = .invalid /// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程) private var screenSessionStart: Date? + /// 最后一次确认亮屏(锁屏结算用,不用 Date() 把灭屏空档算进去) + private var lastScreenOnAt: Date? + /// 亮屏心跳超过该间隔视为观察中断(可能错过锁屏) + private let screenOnStaleInterval: TimeInterval = 10 /// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算 private var hasUserActivatedApp = false @@ -84,7 +89,7 @@ final class UnlockCountManager: NSObject { let today = todayString() var total = Defaults[\.screenTimeByDay][today] ?? 0 if let start = screenSessionStart { - total += screenTimeSeconds(from: start, to: Date(), onDay: today) + total += screenTimeSeconds(from: start, to: resolvedScreenSessionEnd(), onDay: today) } return total } @@ -115,6 +120,7 @@ final class UnlockCountManager: NSObject { return } didStart = true + lastScreenOnAt = Defaults[\.screenLastOnAt] closeOrphanScreenSessionIfNeeded() rollDayIfNeeded() @@ -140,6 +146,12 @@ final class UnlockCountManager: NSObject { name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(appDidEnterBackground), @@ -162,7 +174,11 @@ final class UnlockCountManager: NSObject { restartLocationMonitoringIfPossible(reassertContinuous: true) handleNonActiveLaunchIfNeeded(launchOptions) reconcileUnlockIfNeeded(reason: "coldStartAfterLock") + if UIApplication.shared.isProtectedDataAvailable { + noteScreenOn() + } Defaults[\.unlockLastHeartbeatAt] = Date() + startProtectedDataPolling() } func refreshAuthorizationAndMonitoring() { @@ -270,37 +286,84 @@ final class UnlockCountManager: NSObject { return formatter.date(from: key) } - /// 上一进程留下的 session 只结算到最后一次存活心跳,死掉的空档不再计入 + /// 上一进程留下的 session 只结算到最后一次确认亮屏,死掉/锁屏的空档不再计入 private func closeOrphanScreenSessionIfNeeded() { guard screenSessionStart == nil else { return } guard let start = Defaults[\.screenSessionStartAt] else { return } - let heartbeat = Defaults[\.unlockLastHeartbeatAt] - if let heartbeat, heartbeat > start { - addScreenTime(from: start, to: heartbeat) + let end = Defaults[\.screenLastOnAt] ?? Defaults[\.unlockLastHeartbeatAt] + if let end, end > start { + addScreenTime(from: start, to: end) } Defaults[\.screenSessionStartAt] = nil - debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: heartbeat))") + debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: end))") notifyChange() } + private var shouldUseLiveScreenClock: Bool { + UIApplication.shared.applicationState == .active + && UIApplication.shared.isProtectedDataAvailable + && !isSessionLocked + } + + private func resolvedScreenSessionEnd() -> Date { + if shouldUseLiveScreenClock { + let now = Date() + noteScreenOn(at: now) + return now + } + return lastScreenOnAt ?? screenSessionStart ?? Date() + } + + private func noteScreenOn(at date: Date = Date()) { + guard UIApplication.shared.isProtectedDataAvailable else { return } + lastScreenOnAt = date + Defaults[\.screenLastOnAt] = date + } + private func startScreenSession() { guard UIApplication.shared.isProtectedDataAvailable else { return } guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return } rollDayIfNeeded() - guard screenSessionStart == nil else { return } - screenSessionStart = Date() - Defaults[\.screenSessionStartAt] = screenSessionStart - Defaults[\.unlockLastHeartbeatAt] = screenSessionStart + let now = Date() + if screenSessionStart != nil { + noteScreenOn(at: now) + return + } + screenSessionStart = now + Defaults[\.screenSessionStartAt] = now + noteScreenOn(at: now) + Defaults[\.unlockLastHeartbeatAt] = now + debugLog("startScreenSession at=\(now)") } - private func flushScreenSession(end: Date = Date()) { + private func flushScreenSession(end: Date? = nil) { guard let start = screenSessionStart else { return } - addScreenTime(from: start, to: end) + let resolvedEnd = end ?? resolvedScreenSessionEnd() + addScreenTime(from: start, to: max(start, resolvedEnd)) screenSessionStart = nil Defaults[\.screenSessionStartAt] = nil + debugLog("flushScreenSession start=\(start) end=\(resolvedEnd)") notifyChange() } + /// 观察中断后按最后亮屏结算,避免锁屏空档计入 + private func repairOpenScreenSessionIfStale() { + guard let start = screenSessionStart else { return } + guard UIApplication.shared.isProtectedDataAvailable else { + markLocked() + return + } + let lastOn = lastScreenOnAt ?? start + let gap = Date().timeIntervalSince(lastOn) + if gap > screenOnStaleInterval { + debugLog("repairStaleScreenSession gap=\(Int(gap)) lastOn=\(lastOn)") + flushScreenSession(end: lastOn) + startScreenSession() + } else { + noteScreenOn() + } + } + private func recordAppUsage() { rollDayIfNeeded() Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1 @@ -324,13 +387,23 @@ final class UnlockCountManager: NSObject { Defaults[\.unlockLastHeartbeatAt] = now stopProtectedDataPolling() startScreenSession() + if UIApplication.shared.applicationState != .active { + startProtectedDataPolling() + } notifyChange() print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") } private func markLocked() { - flushScreenSession() + if isSessionLocked, screenSessionStart == nil { + Defaults[\.unlockWasLocked] = true + hasSeenLockInSession = true + startProtectedDataPolling() + return + } + let end = lastScreenOnAt ?? screenSessionStart + flushScreenSession(end: end) isSessionLocked = true hasSeenLockInSession = true Defaults[\.unlockWasLocked] = true @@ -341,10 +414,10 @@ final class UnlockCountManager: NSObject { private func persistLifecycleHeartbeat() { Defaults[\.unlockLastHeartbeatAt] = Date() if !UIApplication.shared.isProtectedDataAvailable { - Defaults[\.unlockWasLocked] = true - isSessionLocked = true - hasSeenLockInSession = true + markLocked() + return } + noteScreenOn() } private func notifyChange() { @@ -370,9 +443,15 @@ final class UnlockCountManager: NSObject { logs.suffix(50).forEach { NSLog("%@ stored %@", logPrefix, $0) } } + private var shouldPollProtectedData: Bool { + let needsUnlockDetect = Defaults[\.unlockWasLocked] || isSessionLocked + let needsLockDetect = screenSessionStart != nil && UIApplication.shared.applicationState != .active + return needsUnlockDetect || needsLockDetect + } + private func startProtectedDataPolling() { + guard shouldPollProtectedData else { return } guard protectedDataPollTimer == nil else { return } - guard Defaults[\.unlockWasLocked] || isSessionLocked else { return } debugLog("startProtectedDataPolling") let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in @@ -393,13 +472,20 @@ final class UnlockCountManager: NSObject { private func pollProtectedDataAvailability() { let available = UIApplication.shared.isProtectedDataAvailable - debugLog("protectedDataPoll protected=\(available) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") - guard available else { return } - guard Defaults[\.unlockWasLocked] || isSessionLocked else { - stopProtectedDataPolling() + if !available { + debugLog("protectedDataPoll protected=0 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") + markLocked() return } - recordUnlock(reason: "protectedDataPoll") + if Defaults[\.unlockWasLocked] || isSessionLocked { + debugLog("protectedDataPoll protected=1 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") + recordUnlock(reason: "protectedDataPoll") + return + } + noteScreenOn() + if !shouldPollProtectedData { + stopProtectedDataPolling() + } } /// 对照 protectedData 纠正锁态(定位回调补强,避免挂起丢通知) @@ -410,7 +496,9 @@ final class UnlockCountManager: NSObject { } if Defaults[\.unlockWasLocked] || isSessionLocked { recordUnlock(reason: reason) + return } + repairOpenScreenSessionIfStale() } /// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言 @@ -455,6 +543,25 @@ final class UnlockCountManager: NSObject { debugLog("bootTask end id=\(id.rawValue)") } + /// 进后台后再给 poll 一段系统宽限,覆盖「回桌面马上锁屏」 + private func beginBackgroundPollTask() { + endBackgroundPollTask() + let app = UIApplication.shared + backgroundPollTask = app.beginBackgroundTask(withName: "unlock.screenOnPoll") { [weak self] in + self?.debugLog("screenOnPollTask expired") + self?.endBackgroundPollTask() + } + debugLog("screenOnPollTask begin id=\(backgroundPollTask.rawValue)") + } + + private func endBackgroundPollTask() { + guard backgroundPollTask != .invalid else { return } + let id = backgroundPollTask + backgroundPollTask = .invalid + UIApplication.shared.endBackgroundTask(id) + debugLog("screenOnPollTask end id=\(id.rawValue)") + } + // MARK: - protectedData / lifecycle @objc private func protectedDataWillBecomeUnavailable() { @@ -469,8 +576,20 @@ final class UnlockCountManager: NSObject { recordUnlock(reason: "protectedData") } + @objc private func appWillResignActive() { + if UIApplication.shared.isProtectedDataAvailable { + noteScreenOn() + Defaults[\.unlockLastHeartbeatAt] = Date() + } else { + markLocked() + } + debugLog("willResignActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])") + } + @objc private func appDidEnterBackground() { persistLifecycleHeartbeat() + startProtectedDataPolling() + beginBackgroundPollTask() restartLocationMonitoringIfPossible(reassertContinuous: true) forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground") debugLog("didEnterBackground auth=\(authorizationStatus().rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) regions=\(locationManager.monitoredRegions.count)") @@ -487,9 +606,19 @@ final class UnlockCountManager: NSObject { debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") hasUserActivatedApp = true endBootBackgroundTask() - reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") - if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked { - startScreenSession() + endBackgroundPollTask() + if UIApplication.shared.isProtectedDataAvailable { + if isSessionLocked || Defaults[\.unlockWasLocked] { + reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") + } else { + repairOpenScreenSessionIfStale() + startScreenSession() + } + if !isSessionLocked { + stopProtectedDataPolling() + } + } else { + markLocked() } recordAppUsage() restartLocationMonitoringIfPossible(reassertContinuous: true) @@ -679,6 +808,7 @@ final class UnlockCountManager: NSObject { reassertContinuousLocationUpdates(forceStopFirst: true) forceRefreshGeofenceFromLastKnown(reason: reason) performLocationTick(reason: reason) + startProtectedDataPolling() } } diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index 9f5a0b5f..e00cd763 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -7,6 +7,24 @@ import ObjectMapper import RxDataSources +import Kingfisher + +private let kGroupTransformInt64 = TransformOf(fromJSON: { value in + switch value { + case let value as Int64: + return value + case let value as Int: + return Int64(value) + case let value as Double: + return Int64(value) + case let value as String: + return Int64(value) + default: + return nil + } +}, toJSON: { value in + value +}) /// 用户圈子 struct UserGroupResponse: BaseModelProtocol { @@ -68,6 +86,58 @@ struct GroupInfoResponse: BaseModelProtocol { } } +/// 指定圈子的资料及成员列表 +struct GroupUsersResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: GroupInfoModel? + var list: [GroupMemberModel] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + model <- map["data.group"] + list <- map["data.mates"] + } + + func isValid(for groupKey: String) -> Bool { + code == "0" && model?.group_key == groupKey + } +} + +/// 圈子类型模板列表 +struct GroupTemplateResponse: BaseModelProtocol { + var code: String? + var message: String? + var list: [GroupTemplateModel] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + list <- map["data"] + } +} + +struct GroupTemplateModel: Mappable, Equatable { + var name: String = "" + var type: Int = 0 + var limit: Int = -1 + var icon: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + name <- map["name"] + type <- (map["type"], kStrTransformInt) + limit <- (map["limit"], kStrTransformInt) + icon <- map["icon"] + } +} + struct ReviewMemberListResponse: BaseModelProtocol { // 状态码 var code: String? @@ -125,6 +195,21 @@ struct GroupInfoModel: Mappable, Equatable { var groupIcon: UIImage { UIImage(named: "GroupIcon/\(icon_index)") ?? UIImage() } + /// 新版圈子类型及模板;icon_index 仅保留给旧数据兼容。 + var group_type: Int = 0 + var group_template: GroupTemplateModel? + var resolvedGroupType: Int { + if group_type > 0 { return group_type } + if let type = group_template?.type, type > 0 { return type } + return icon_index + } + var groupTemplateName: String { + let name = group_template?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return name.isEmpty ? "未知类型" : name + } + var groupIconURL: String { + group_template?.icon.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } /// 标签 var labels: [String] = [] /// 审核开关 @@ -147,8 +232,10 @@ struct GroupInfoModel: Mappable, Equatable { is_owner <- map["is_owner"] name <- map["name"] icon_index <- map["icon_index"] + group_type <- (map["group_type"], kStrTransformInt) + group_template <- map["group_template"] labels <- map["labels"] - level <- map["level"] + level <- (map["level"], kIntTransformStr) review <- map["review"] description <- map["description"] share_code <- map["share_code"] @@ -156,6 +243,25 @@ struct GroupInfoModel: Mappable, Equatable { } } +extension UIImageView { + @MainActor + func setGroupIcon(_ model: GroupInfoModel) { + setGroupIcon(url: model.groupIconURL, fallback: model.groupIcon) + } + + @MainActor + func setGroupIcon(url: String, fallback: UIImage?) { + kf.cancelDownloadTask() + image = fallback + guard let imageURL = URL(string: url), !url.isEmpty else { return } + kf.setImage(with: imageURL, placeholder: fallback) { [weak self] result in + if case .failure = result { + self?.image = fallback + } + } + } +} + extension GroupInfoModel: IdentifiableType { public typealias Identity = String @@ -164,6 +270,34 @@ extension GroupInfoModel: IdentifiableType { } } +struct CommonGroupModel: Mappable, Equatable { + var group_key: String = "" + var group_name: String = "" + + init(groupKey: String, groupName: String) { + group_key = groupKey + group_name = groupName + } + + init?(map: Map) {} + + mutating func mapping(map: Map) { + group_key <- map["group_key"] + group_name <- map["group_name"] + } + + static func deduplicated(_ groups: [CommonGroupModel]) -> [CommonGroupModel] { + var seenKeys = Set() + var result: [CommonGroupModel] = [] + for group in groups { + let key = group.group_key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, seenKeys.insert(key).inserted else { continue } + result.append(group) + } + return result + } +} + struct GroupMemberModel: Mappable, Equatable { /// var user_id: String = "" @@ -171,7 +305,12 @@ struct GroupMemberModel: Mappable, Equatable { var head_pic: String = "1" /// 用户头像 var userIcon: UIImage { - UIImage(named: "UserIcon/\(head_pic)") ?? UIImage() + let iconName = head_pic.trimmingCharacters(in: .whitespacesAndNewlines) + guard !iconName.isEmpty, + let image = UIImage(named: "UserIcon/\(iconName)") else { + return UIImage(named: "UserIcon/1") ?? UIImage() + } + return image } /// 昵称 var nick_name: String = "" @@ -191,7 +330,7 @@ struct GroupMemberModel: Mappable, Equatable { var status: Int = 0 /// 入圈天数(若服务端下发) var join_days: Int = 0 - /// 入圈时间戳毫秒(若服务端下发) + /// 入圈时间戳。新接口使用秒,历史数据可能使用毫秒。 var join_time: Int64 = 0 /// 是否在线 var is_online: Bool = false @@ -221,6 +360,8 @@ struct GroupMemberModel: Mappable, Equatable { } /// 最后位置 var last_position: String = "" + /// 当前用户与该成员共同加入的圈子 + var common_groups: [CommonGroupModel] = [] var lastLocation: String { guard last_position.contains(":") else { return "" } let arr = last_position.components(separatedBy: ":") @@ -243,7 +384,55 @@ struct GroupMemberModel: Mappable, Equatable { status <- map["status"] mood <- (map["mood"], kStrTransformInt) join_days <- (map["join_days"], kStrTransformInt) - join_time <- map["join_time"] + join_time <- (map["join_time"], kGroupTransformInt64) + common_groups <- map["common_groups"] + common_groups = CommonGroupModel.deduplicated(common_groups) + } +} + +extension GroupMemberModel { + /// New responses use seconds; older payloads may still contain milliseconds. + var joinDate: Date? { + guard join_time > 0 else { return nil } + let seconds = join_time >= 100_000_000_000 + ? TimeInterval(join_time) / 1000 + : TimeInterval(join_time) + return Date(timeIntervalSince1970: seconds) + } +} + +enum GroupOwnerMatcher { + static func matches(userId: String, groupKey: String) -> Bool { + !userId.isEmpty && !groupKey.isEmpty && groupKey.contains(userId) + } +} + +enum GroupMemberSorter { + /// 圈主 -> 当前用户 -> 在线成员 -> 其他成员,同优先级保持接口顺序。 + static func sorted(_ members: [GroupMemberModel], + groupKey: String, + currentUserId: String) -> [GroupMemberModel] { + members.enumerated() + .sorted { lhs, rhs in + let lhsPriority = priority(lhs.element, + groupKey: groupKey, + currentUserId: currentUserId) + let rhsPriority = priority(rhs.element, + groupKey: groupKey, + currentUserId: currentUserId) + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.offset < rhs.offset + } + .map(\.element) + } + + private static func priority(_ member: GroupMemberModel, + groupKey: String, + currentUserId: String) -> Int { + if GroupOwnerMatcher.matches(userId: member.user_id, groupKey: groupKey) { return 0 } + if !currentUserId.isEmpty, member.user_id == currentUserId { return 1 } + if member.is_online { return 2 } + return 3 } } diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift index 175688eb..210e94e3 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupVC.swift @@ -29,11 +29,13 @@ class CreateGroupVC: BaseViewController { reactiveAction() viewModel.loadData() + viewModel.requestGroupTemplates() } private func reactiveAction() { - rootView.iconCarousel.onSelectIndex = { [weak self] index in - self?.viewModel.iconIndex = index + rootView.iconCarousel.onSelectTemplate = { [weak self] template in + self?.viewModel.groupType = template.type + self?.rootView.typeNameLab.text = template.name } rootView.submitBtn.rx.tap.subscribe(onNext: { [weak self] _ in @@ -65,6 +67,13 @@ class CreateGroupVC: BaseViewController { viewModel.output.sectionedItems .bind(to: rootView.tagView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) + + viewModel.output.groupTemplates + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] templates in + self?.rootView.configureGroupTemplates(templates) + }) + .disposed(by: disposeBag) viewModel.output.sectionedItems .observe(on: MainScheduler.instance) diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift index 131ec48b..6d1f2b50 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupView.swift @@ -17,6 +17,14 @@ class CreateGroupView: UIView { private let tagMaxLength = 10 var onConfirmAddTag: ((String) -> Void)? + + func configureGroupTemplates(_ templates: [GroupTemplateModel]) { + guard let first = templates.first else { return } + typeNameLab.text = first.name + iconCarousel.configure(templates: templates) + submitBtn.isEnabled = true + submitBtn.alpha = 1 + } private func setupRx() { groupNameTF.rx.text.orEmpty @@ -465,6 +473,8 @@ class CreateGroupView: UIView { btn.titleLabel?.font = FontManager.boboBold(18) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) btn.cornerRadius = 20 + btn.isEnabled = false + btn.alpha = 0.5 return btn }() @@ -537,17 +547,23 @@ class CreateGroupView: UIView { final class CreateGroupIconCarouselView: UIView { - var onSelectIndex: ((Int) -> Void)? + var onSelectTemplate: ((GroupTemplateModel) -> Void)? - private let iconCount = 11 + private var templates: [GroupTemplateModel] = [] private let itemSize: CGFloat = 80 private var lastWidth: CGFloat = 0 - - var selectedIndex: Int = 1 { - didSet { - guard oldValue != selectedIndex else { return } - onSelectIndex?(selectedIndex) + private var selectedItem = 0 + + func configure(templates: [GroupTemplateModel]) { + self.templates = templates + selectedItem = 0 + collectionView.reloadData() + collectionView.layoutIfNeeded() + scrollToItem(selectedItem, animated: false) + if let first = templates.first { + onSelectTemplate?(first) } + applyTransforms() } override init(frame: CGRect) { @@ -569,23 +585,29 @@ final class CreateGroupIconCarouselView: UIView { let inset = max(0, (bounds.width - itemSize) / 2) flowLayout.sectionInset = UIEdgeInsets(top: 25, left: inset, bottom: 25, right: inset) collectionView.layoutIfNeeded() - scrollToIndex(selectedIndex, animated: false) + scrollToItem(selectedItem, animated: false) } applyTransforms() } - private func scrollToIndex(_ index: Int, animated: Bool) { - let item = max(0, min(iconCount - 1, index - 1)) + private func scrollToItem(_ item: Int, animated: Bool) { + guard templates.indices.contains(item) else { return } collectionView.scrollToItem(at: IndexPath(item: item, section: 0), at: .centeredHorizontally, animated: animated) } + + private func selectItem(_ item: Int) { + guard templates.indices.contains(item) else { return } + selectedItem = item + onSelectTemplate?(templates[item]) + } private func nearestIndex() -> Int { let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2 var nearest = 0 var minDist = CGFloat.greatestFiniteMagnitude - for i in 0.. Int { - iconCount + templates.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(for: indexPath) as CreateGroupIconCell - cell.configure(UIImage(named: "GroupIcon/\(indexPath.item + 1)")) + cell.configure(templates[indexPath.item]) return cell } @@ -646,7 +668,7 @@ extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionV } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedIndex = indexPath.item + 1 + selectItem(indexPath.item) collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) } @@ -658,7 +680,7 @@ extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionV let proposedCenter = targetContentOffset.pointee.x + scrollView.bounds.width / 2 var nearest = 0 var minDist = CGFloat.greatestFiniteMagnitude - for i in 0.. + var groupTemplates: Observable<[GroupTemplateModel]> } let input: Input @@ -34,6 +35,7 @@ class CreateGroupViewModel { var disposeBag = DisposeBag() private let sectionedItems = PublishSubject<[GroupTagListSectionModel]>() + private let groupTemplates = PublishSubject<[GroupTemplateModel]>() private var tagList = ["私密", "游戏", "运动", "美食", "自驾", "聚会", "旅行", "学习"] @@ -44,7 +46,7 @@ class CreateGroupViewModel { } } - var iconIndex = 1 + var groupType = 0 let groupName = BehaviorRelay(value: "") let groupDesc = BehaviorRelay(value: "") @@ -81,6 +83,21 @@ class CreateGroupViewModel { func loadData() { sectionedItems.onNext(tagList.mapSection()) } + + func requestGroupTemplates() { + GroupService.groupTemplates() + .subscribe(onNext: { [weak self] response in + guard response.code == "0", !response.list.isEmpty else { + DLToast.showError(text: response.message ?? "获取圈子类型失败") + return + } + self?.groupType = response.list[0].type + self?.groupTemplates.onNext(response.list) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } // MARK: - Request func requestCreateGroup() { @@ -95,11 +112,16 @@ class CreateGroupViewModel { DLToast.show(text: "请输入圈子描述") return } + + guard groupType > 0 else { + DLToast.showError(text: "圈子类型尚未加载") + return + } DLToast.showLoading() GroupService.operate(opType: "create", requestData: ["group_name": name, - "icon_index": iconIndex, + "group_type": groupType, "description": desc, "labels": selectedTagList]).subscribe(onNext: { response in DLToast.showSuccess(text: "创建成功") { @@ -123,7 +145,8 @@ class CreateGroupViewModel { init() { input = Input() output = Output( - sectionedItems: sectionedItems.asObservable() + sectionedItems: sectionedItems.asObservable(), + groupTemplates: groupTemplates.asObservable() ) } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index bec549b8..185ad119 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -37,6 +37,8 @@ final class GroupChatVC: BaseViewController { /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? private var didSetupKeyboard = false + /// 首屏等行高落地后再贴底;用户手滑列表后取消 + private var needsInitialBottomScroll = true // MARK: - Init init(groupId: String) { @@ -85,7 +87,10 @@ final class GroupChatVC: BaseViewController { IQKeyboardManager.shared.resignOnTouchOutside = false // 非圈主 会员权益拦截 - let isCircleOwner = viewModel.groupId.contains(AppContextManager.shared.userId) + let isCircleOwner = GroupOwnerMatcher.matches( + userId: AppContextManager.shared.userId, + groupKey: viewModel.groupId + ) let canSend = isCircleOwner || AppContextManager.shared.vip > 1 rootView.disableIMView.isHidden = canSend rootView.quickActionBar.isUserInteractionEnabled = canSend @@ -156,6 +161,7 @@ final class GroupChatVC: BaseViewController { guard let self = self, self.rootView.voiceRecordView.isHidden else { return } self.rootView.dismissAllPanels() self.bubbleMenu?.dismiss() + self.needsInitialBottomScroll = false }) .disposed(by: disposeBag) @@ -193,6 +199,14 @@ final class GroupChatVC: BaseViewController { } self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory self.scrollToBottom() + if self.needsInitialBottomScroll { + DispatchQueue.main.async { [weak self] in + self?.scrollToBottom() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.scrollToBottom() + } + } }) .disposed(by: disposeBag) @@ -276,12 +290,24 @@ final class GroupChatVC: BaseViewController { } private func scrollToBottom() { - let count = dataSource.sectionModels.first?.items.count ?? 0 - guard count > 0 else { return } - DispatchQueue.main.async { - self.rootView.tableView.layoutIfNeeded() - let indexPath = IndexPath(row: count - 1, section: 0) - self.rootView.tableView.scrollToRow(at: indexPath, at: .bottom, animated: false) + let tableView = rootView.tableView + tableView.layoutIfNeeded() + let inset = tableView.adjustedContentInset + let y = tableView.contentSize.height - tableView.bounds.height + inset.bottom + guard y > -inset.top else { return } + tableView.setContentOffset(CGPoint(x: 0, y: max(-inset.top, y)), animated: false) + } + + private func isNearBottom(threshold: CGFloat = 40) -> Bool { + let tableView = rootView.tableView + let inset = tableView.adjustedContentInset + let maxY = tableView.contentSize.height - tableView.bounds.height + inset.bottom + return tableView.contentOffset.y >= maxY - threshold + } + + private func scrollToBottomIfPinned() { + if needsInitialBottomScroll || isNearBottom() { + scrollToBottom() } } @@ -837,15 +863,28 @@ final class GroupChatVC: BaseViewController { // MARK: - API 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)(\(response.list.count))" - self.rootView.groupAvatarView.image = model.groupIcon - self.rootView.reviewBtn.isHidden = !model.is_owner + GroupService.groupUsers(groupKey: viewModel.groupId) + .subscribe(onNext: { [weak self] response in + guard let self else { return } + guard response.isValid(for: self.viewModel.groupId), + let model = response.model else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self.viewModel.groupModel = model + self.viewModel.memberList = GroupMemberSorter.sorted( + response.list, + groupKey: model.group_key, + currentUserId: AppContextManager.shared.userId + ) + self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))" + self.rootView.groupAvatarView.setGroupIcon(model) + self.rootView.reviewBtn.isHidden = !model.is_owner // self.rootView.reviewDotView.isHidden = response.reviewCount == 0 - }.disposed(by: disposeBag) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } // MARK: - dataSource @@ -890,11 +929,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } - cell.onNeedRelayout = { [weak tableView] in + cell.onNeedRelayout = { [weak self, weak tableView] in UIView.performWithoutAnimation { tableView?.beginUpdates() tableView?.endUpdates() } + self?.scrollToBottomIfPinned() } return cell case let .imageReceived(msg): @@ -903,11 +943,12 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } - cell.onNeedRelayout = { [weak tableView] in + cell.onNeedRelayout = { [weak self, weak tableView] in UIView.performWithoutAnimation { tableView?.beginUpdates() tableView?.endUpdates() } + self?.scrollToBottomIfPinned() } return cell case let .locationSend(msg): diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index 35cd92b1..3f41b22b 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -406,7 +406,7 @@ class GroupChatView: UIView { keyboardBtn.layoutChain .right(12).centerY() - .width(28).height(28) + .width(30).height(30) sendBtn.layoutChain .right(8).centerY() @@ -414,7 +414,7 @@ class GroupChatView: UIView { voiceBtn.layoutChain .right(12).centerY() - .width(28).height(28) + .width(30).height(30) emojiBtn.layoutChain .rightToLeftOfView(sendBtn, offset: -8) diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index bc7985bd..d3eae5f0 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -191,8 +191,8 @@ final class GroupChatViewModel { } func isCircleOwner(id: String) -> Bool { - guard !id.isEmpty, let groupModel else { return false } - return groupModel.group_key.contains(id) + guard let groupModel else { return false } + return GroupOwnerMatcher.matches(userId: id, groupKey: groupModel.group_key) } // MARK: - Quote / Mention state diff --git a/QuickLocation/Section/Group/GroupInfo/GroupInfoVC.swift b/QuickLocation/Section/Group/GroupInfo/GroupInfoVC.swift index 95366cc3..9ab5edbf 100644 --- a/QuickLocation/Section/Group/GroupInfo/GroupInfoVC.swift +++ b/QuickLocation/Section/Group/GroupInfo/GroupInfoVC.swift @@ -28,7 +28,6 @@ class GroupInfoVC: BaseViewController { guard let model = groupInModel else { return } - rootView.groupIcon.image = model.groupIcon rootView.groupNameLab.text = model.name rootView.setupData(model) rootView.applyBtn.rx.tap.subscribe(onNext: { _ in diff --git a/QuickLocation/Section/Group/GroupInfo/GroupInfoView.swift b/QuickLocation/Section/Group/GroupInfo/GroupInfoView.swift index 2f3f602f..e1c1e10a 100644 --- a/QuickLocation/Section/Group/GroupInfo/GroupInfoView.swift +++ b/QuickLocation/Section/Group/GroupInfo/GroupInfoView.swift @@ -15,6 +15,7 @@ class GroupInfoView: UIView { var disposeBag = DisposeBag() func setupData(_ model: GroupInfoModel) { + groupIcon.setGroupIcon(model) groupDescLab.text = model.description.isEmpty ? "暂无描述" : model.description tagListView.addTags(model.labels) tagListView.tagViews.forEach { diff --git a/QuickLocation/Section/Group/GroupItineraryView.swift b/QuickLocation/Section/Group/GroupItineraryView.swift index 7870d53b..840814a7 100644 --- a/QuickLocation/Section/Group/GroupItineraryView.swift +++ b/QuickLocation/Section/Group/GroupItineraryView.swift @@ -24,7 +24,7 @@ final class GroupItineraryView: UIView { func reloadSchedules(_ list: [ScheduleModel]) { scheduleSections = Self.groupByDay(list) tableView.reloadData() - emptyLab.isHidden = !list.isEmpty + tableView.refresh(status: .noMoreData, isEmpty: list.isEmpty) } private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = [] @@ -35,7 +35,6 @@ final class GroupItineraryView: UIView { headerRow.addSubview(groupNameLab) headerRow.addSubview(changeGroupBtn) addSubview(tableView) - addSubview(emptyLab) addSubview(createBtn) headerRow.layoutChain @@ -63,15 +62,11 @@ final class GroupItineraryView: UIView { .topToBottomOfView(headerRow, offset: 12) .edgesHorzontal() .bottom() - - emptyLab.layoutChain - .centerX() - .centerY(tableView) createBtn.layoutChain .bottom(kSafeBottomMargin + 102) - .right(10) - .width(60) + .right(20) + .width(54) .heightToWidth(1) } @@ -165,17 +160,15 @@ final class GroupItineraryView: UIView { if #available(iOS 15.0, *) { tv.sectionHeaderTopPadding = 0 } + tv.dl.addEmptyDataSet { emptyDataSet in + emptyDataSet.emptyNoDataTitle = "这里怎么什么都没有~" + emptyDataSet.emptyNoDataDetail = "" + emptyDataSet.customButtonTitle = "" + emptyDataSet.backgroundColor = .clear + emptyDataSet.offsetY = -100 + } return tv }() - - lazy var emptyLab: UILabel = { - let label = UILabel() - label.text = "暂无行程" - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hexStr: "#999999") - label.isHidden = true - return label - }() lazy var createBtn: UIButton = { let btn = UIButton() @@ -252,17 +245,6 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { onSelectSchedule?(scheduleSections[indexPath.section].items[indexPath.row]) } - func tableView(_ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let model = scheduleSections[indexPath.section].items[indexPath.row] - let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, done in - self?.onDeleteSchedule?(model) - done(true) - } - delete.image = UIImage(systemName: "trash") - delete.backgroundColor = UIColor(hexStr: "#FF5A5F") - return UISwipeActionsConfiguration(actions: [delete]) - } } // MARK: - GroupItineraryCell @@ -270,21 +252,14 @@ final class GroupItineraryCell: UITableViewCell { func configure(_ model: ScheduleModel) { avatarImg.image = model.userIcon - nameLab.text = model.nick_name - let count = max(model.points.count, 0) - placeCountLab.text = "\(count)" - let date = Date(timeIntervalSince1970: TimeInterval(model.timestamp) / 1000) - let ongoing = Calendar.current.isDateInToday(date) || date > Date() - statusLab.text = ongoing ? "进行中" : "已完成" - statusLab.textColor = ongoing ? UIColor(hexStr: "#00B0C6") : UIColor(hexStr: "#999999") - statusLab.backgroundColor = ongoing - ? UIColor(hexStr: "#DCFCFF") - : UIColor(hexStr: "#F0F0F0") + nameLab.text = model.nick_name.isEmpty ? "未命名用户" : model.nick_name + followIcon.isHidden = !model.is_follow - let tag = model.groups.first?.group_name ?? "" - tagLab.text = tag.isEmpty ? nil : " \(tag) " - tagLab.isHidden = tag.isEmpty - setNeedsLayout() + let points = Self.orderedPoints(model.points) + startLocationLab.text = Self.locationName(points.first) + endLocationLab.text = Self.locationName(points.last, maximumCharacters: 4) + durationLab.text = Self.durationText(from: points.first, to: points.last) + configureTags(model.groups.map(\.group_name).filter { !$0.isEmpty }) } override init(style: CellStyle, reuseIdentifier: String?) { @@ -296,50 +271,90 @@ final class GroupItineraryCell: UITableViewCell { contentView.addSubview(cardView) cardView.addSubview(avatarImg) cardView.addSubview(nameLab) - cardView.addSubview(statusLab) - cardView.addSubview(summaryLab) - cardView.addSubview(placeCountLab) - cardView.addSubview(summaryTailLab) - cardView.addSubview(tagLab) + cardView.addSubview(followIcon) + cardView.addSubview(startMarkerLab) + cardView.addSubview(startLocationLab) + cardView.addSubview(routeLineView) + cardView.addSubview(durationLab) + cardView.addSubview(endMarkerLab) + cardView.addSubview(endLocationLab) + cardView.addSubview(tagScrollView) + tagScrollView.addSubview(tagStackView) cardView.layoutChain .edgesVertical(6) - .edgesHorzontal(16) + .edgesHorzontal(15) avatarImg.layoutChain - .top(14) + .top(12) .left(14) - .width(36) - .height(36) + .width(28) + .height(28) nameLab.layoutChain - .leftToRightOfView(avatarImg, offset: 10) + .leftToRightOfView(avatarImg, offset: 8) .centerY(avatarImg) - statusLab.layoutChain - .right(14) - .centerY(avatarImg) - .height(22) - - summaryLab.layoutChain - .topToBottomOfView(avatarImg, offset: 12) - .left(14) - - placeCountLab.layoutChain - .leftToRightOfView(summaryLab, offset: 4) - .centerY(summaryLab) - .width(22) - .height(22) - - summaryTailLab.layoutChain - .leftToRightOfView(placeCountLab, offset: 4) - .centerY(summaryLab) - - tagLab.layoutChain - .topToBottomOfView(summaryLab, offset: 12) - .right(14) - .bottom(14) + followIcon.layoutChain + .leftToRightOfView(nameLab, offset: 7) + .centerY(nameLab) + .width(20) .height(20) + + followIcon.translatesAutoresizingMaskIntoConstraints = false + followIcon.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -14).isActive = true + + startMarkerLab.translatesAutoresizingMaskIntoConstraints = false + startLocationLab.translatesAutoresizingMaskIntoConstraints = false + routeLineView.translatesAutoresizingMaskIntoConstraints = false + durationLab.translatesAutoresizingMaskIntoConstraints = false + endMarkerLab.translatesAutoresizingMaskIntoConstraints = false + endLocationLab.translatesAutoresizingMaskIntoConstraints = false + tagScrollView.translatesAutoresizingMaskIntoConstraints = false + tagStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + startMarkerLab.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14), + startMarkerLab.topAnchor.constraint(equalTo: avatarImg.bottomAnchor, constant: 10), + startMarkerLab.widthAnchor.constraint(equalToConstant: 20), + startMarkerLab.heightAnchor.constraint(equalToConstant: 20), + + startLocationLab.leadingAnchor.constraint(equalTo: startMarkerLab.trailingAnchor, constant: 7), + startLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), + startLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82), + + endLocationLab.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14), + endLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), + endLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82), + + endMarkerLab.trailingAnchor.constraint(equalTo: endLocationLab.leadingAnchor, constant: -7), + endMarkerLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor), + endMarkerLab.widthAnchor.constraint(equalToConstant: 20), + endMarkerLab.heightAnchor.constraint(equalToConstant: 20), + + routeLineView.leadingAnchor.constraint(equalTo: startLocationLab.trailingAnchor, constant: 8), + routeLineView.trailingAnchor.constraint(equalTo: endMarkerLab.leadingAnchor, constant: -8), + routeLineView.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor, constant: 4), + routeLineView.heightAnchor.constraint(equalToConstant: 1), + routeLineView.widthAnchor.constraint(greaterThanOrEqualToConstant: 28), + + durationLab.centerXAnchor.constraint(equalTo: routeLineView.centerXAnchor), + durationLab.bottomAnchor.constraint(equalTo: routeLineView.topAnchor, constant: -3), + durationLab.widthAnchor.constraint(lessThanOrEqualTo: routeLineView.widthAnchor, constant: 24), + + tagScrollView.topAnchor.constraint(equalTo: startMarkerLab.bottomAnchor, constant: 8), + tagScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 90), + tagScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14), + tagScrollView.heightAnchor.constraint(equalToConstant: 18), + tagScrollView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10), + + tagStackView.leadingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.leadingAnchor), + tagStackView.trailingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.trailingAnchor), + tagStackView.topAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.topAnchor), + tagStackView.bottomAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.bottomAnchor), + tagStackView.heightAnchor.constraint(equalTo: tagScrollView.frameLayoutGuide.heightAnchor), + tagStackView.widthAnchor.constraint(greaterThanOrEqualTo: tagScrollView.frameLayoutGuide.widthAnchor) + ]) } required init?(coder: NSCoder) { @@ -363,60 +378,162 @@ final class GroupItineraryCell: UITableViewCell { private lazy var nameLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .bold) + label.font = .systemFont(ofSize: 15, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label }() - private lazy var statusLab: PaddingLabel = { - let label = PaddingLabel() - label.font = .systemFont(ofSize: 12, weight: .medium) - label.textAlignment = .center - label.cornerRadius = 4 - label.clipsToBounds = true - label.insets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8) - label.borderWidth = 1 - label.borderColor = UIColor(hexStr: "#84E2ED") - return label + private lazy var followIcon: UIImageView = { + let view = UIImageView(image: Self.followBadgeImage()) + view.contentMode = .scaleAspectFit + return view }() - private lazy var summaryLab: UILabel = { + private static func followBadgeImage() -> UIImage? { + let size = CGSize(width: 22, height: 18) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + defer { UIGraphicsEndImageContext() } + let configuration = UIImage.SymbolConfiguration(pointSize: 13, weight: .bold) + UIImage(systemName: "heart.fill", withConfiguration: configuration)? + .withTintColor(UIColor(hexStr: "#FF9AC1"), renderingMode: .alwaysOriginal) + .draw(in: CGRect(x: 0, y: 1, width: 15, height: 15)) + UIImage(systemName: "heart.fill", withConfiguration: configuration)? + .withTintColor(UIColor(hexStr: "#FF78AC"), renderingMode: .alwaysOriginal) + .draw(in: CGRect(x: 8, y: 3, width: 14, height: 14)) + return UIGraphicsGetImageFromCurrentImageContext() + } + + private lazy var startMarkerLab: UILabel = makeMarkerLabel(text: "始") + private lazy var endMarkerLab: UILabel = makeMarkerLabel(text: "终") + + private func makeMarkerLabel(text: String) -> UILabel { let label = UILabel() - label.text = "今天停留了" - label.font = .systemFont(ofSize: 16, weight: .bold) - label.textColor = UIColor(hexStr: "#353B4F") - return label - }() - - private lazy var placeCountLab: UILabel = { - let label = UILabel() - label.font = FontManager.youSheBiaoTiHei(14) + label.text = text + label.font = FontManager.youSheBiaoTiHei(11) label.textColor = UIColor(hexStr: "#58EDFF") label.textAlignment = .center label.backgroundColor = UIColor(hexStr: "#293445") - label.cornerRadius = 6 + label.cornerRadius = 7 label.clipsToBounds = true return label - }() + } - private lazy var summaryTailLab: UILabel = { + private lazy var startLocationLab: UILabel = makeLocationLabel() + private lazy var endLocationLab: UILabel = makeLocationLabel() + + private func makeLocationLabel() -> UILabel { let label = UILabel() - label.text = "个地方" - label.font = .systemFont(ofSize: 16, weight: .bold) + label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#353B4F") + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + private lazy var durationLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = UIColor(hexStr: "#888888") + label.textAlignment = .center + label.backgroundColor = .white + label.lineBreakMode = .byTruncatingTail return label }() - private lazy var tagLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 10, weight: .medium) - label.textColor = UIColor(hexStr: "#FF8A3D") - label.backgroundColor = UIColor(hexStr: "#FFE8D6") - label.cornerRadius = 4 - label.clipsToBounds = true - label.textAlignment = .center - return label + private lazy var routeLineView: GroupItineraryDashLineView = { + GroupItineraryDashLineView() }() + + private lazy var tagScrollView: UIScrollView = { + let view = UIScrollView() + view.showsHorizontalScrollIndicator = false + view.alwaysBounceHorizontal = false + return view + }() + + private lazy var tagStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .fill + view.spacing = 6 + view.addArrangedSubview(tagSpacerView) + return view + }() + + private lazy var tagSpacerView: UIView = { + let view = UIView() + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return view + }() + + private func configureTags(_ names: [String]) { + tagStackView.arrangedSubviews.filter { $0 !== tagSpacerView }.forEach { + tagStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + for name in names { + let label = PaddingLabel() + label.text = name + label.insets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = UIColor(hexStr: "#D5923F") + label.backgroundColor = UIColor(hexStr: "#FFF9DD") + label.cornerRadius = 4 + label.clipsToBounds = true + label.setContentCompressionResistancePriority(.required, for: .horizontal) + tagStackView.addArrangedSubview(label) + } + tagScrollView.isHidden = names.isEmpty + } + + private static func orderedPoints(_ points: [SchedulePointModel]) -> [SchedulePointModel] { + guard points.count > 1 else { return points } + let sequences = points.map(\.sequence) + guard Set(sequences).count == points.count else { return points } + return points.sorted { $0.sequence < $1.sequence } + } + + private static func locationName(_ point: SchedulePointModel?, maximumCharacters: Int? = nil) -> String { + let name: String + if let point, !point.street.isEmpty { + name = point.street + } else if let point, !point.formatted_address.isEmpty { + name = point.formatted_address + } else { + name = "暂无地点" + } + guard let maximumCharacters, name.count > maximumCharacters else { return name } + return String(name.prefix(maximumCharacters)) + "..." + } + + private static func durationText(from start: SchedulePointModel?, to end: SchedulePointModel?) -> String { + guard let start, let end, + start.id != end.id, + end.expected_timestamp > start.expected_timestamp else { return "--" } + let minutes = max(1, (end.expected_timestamp - start.expected_timestamp) / 60_000) + let hours = minutes / 60 + let remainder = minutes % 60 + return hours > 0 ? "\(hours)小时\(remainder)分" : "\(minutes)分钟" + } +} + +private final class GroupItineraryDashLineView: UIView { + override func layoutSubviews() { + super.layoutSubviews() + layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } + + let line = CAShapeLayer() + line.strokeColor = UIColor(hexStr: "#D6D6D6").cgColor + line.lineWidth = 1.5 + line.lineDashPattern = [6, 6] + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: bounds.midY)) + path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY)) + line.path = path.cgPath + layer.addSublayer(line) + } } private final class PaddingLabel: UILabel { diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift index cf099da8..abaf5928 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift @@ -9,190 +9,88 @@ import UIKit import RxSwift import RxCocoa import RxDataSources -import ObjectMapper -class GroupMemberListVC: BaseViewController { +final class GroupMemberListVC: BaseViewController { fileprivate var rootView: GroupMemberListView! + private let viewModel: GroupMemberListVM override func loadView() { rootView = GroupMemberListView(frame: UIScreen.main.bounds) view = rootView } - private var viewModel: GroupMemberListVM - override func viewDidLoad() { super.viewDidLoad() - bindViewModel() - reactiveAction() - requestGroupInfo() - - guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { return } - rootView.unlockVipMaskView.isHidden = true - if AppContextManager.shared.vip == 1 { - rootView.tipsImg.image = UIImage(named: "GroupMemberList/mask_tips") - rootView.unlockVipMaskView.isHidden = false - } - - NotificationCenter.default.rx.notification(.RequestOrderPayStatusNotification) - .subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - self.rootView.unlockVipMaskView.isHidden = true - if AppContextManager.shared.vip == 1 { - self.rootView.tipsImg.image = UIImage(named: "GroupMemberList/mask_tips") - self.rootView.unlockVipMaskView.isHidden = false - } else { - self.rootView.refreshVipState() - } - }).disposed(by: disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - -// rootView.selectedDate.accept(Date()) -// rootView.scrollToToday() - } - - private func reactiveAction() { - } private func bindViewModel() { - viewModel.output.sectionedItems - .bind(to: rootView.collectionView.rx.items(dataSource: memberDataSource)) + rootView.tableView.rx.setDelegate(rootView) .disposed(by: disposeBag) - viewModel.output.drivingSectionedItems - .bind(to: rootView.drivingEventCV.rx.items(dataSource: drivingDataSource)) + viewModel.output.directoryItems + .subscribe(onNext: { [weak self] sections in + self?.rootView.updateSectionTitles(sections.map(\.title)) + }) .disposed(by: disposeBag) - viewModel.output.scheduleSectionedItems - .bind(to: rootView.tableView.rx.items(dataSource: scheduleDataSource)) + viewModel.output.directoryItems + .bind(to: rootView.tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) - viewModel.output.scheduleSectionedItems - .subscribe(onNext: { [weak self] items in - self?.rootView.updateTableViewHeight(count: items.first?.items.count ?? 0) - }).disposed(by: disposeBag) - - rootView.tableView.rx.modelSelected(ScheduleRecordModel.self) - .subscribe(viewModel.scheduleCellRecordAction.inputs) + 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.selectedDate - .skip(1) - .subscribe(onNext: { [weak self] date in - guard let self = self else { return } - self.rootView.scrollReportToTop() - guard !self.rootView.isSelectedDateLocked else { return } - self.requestDrivingEvents(for: date) - }).disposed(by: disposeBag) - // 成员点击 → 重置日期到当天(selectedDate 发射 → 自动触发 API 请求) - rootView.collectionView.rx.modelSelected(GroupMemberModel.self) - .subscribe(onNext: { [weak self] model in - guard let self = self else { return } - self.viewModel.memberId = model.user_id - self.rootView.selectedMemberIsSelf = self.viewModel.isCurrentUser(id: model.user_id) - self.rootView.unlockVipMaskView.isHidden = true - self.rootView.selectedDate.accept(Date()) - self.rootView.scrollToToday() - self.rootView.scrollReportToTop() - self.rootView.collectionView.reloadData() - }).disposed(by: disposeBag) - } - - /// 根据日期生成起止时间戳并请求驾驶数据 - private func requestDrivingEvents(for date: Date) { - guard !viewModel.memberId.isEmpty else { return } - let calendar = Calendar.current - let start = calendar.startOfDay(for: date) - let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date -// let end: Date -// if calendar.isDateInToday(date) { -// end = Date() // 当天以当前时间为结束 -// } else { -// end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date -// } - requestDrivingEvents(start_time: "\(Int64(start.timeIntervalSince1970 * 1000))", - end_time: "\(Int64(end.timeIntervalSince1970 * 1000))") - - requestPlayback(date: "\(Int64(start.timeIntervalSince1970 * 1000))") + rootView.searchField.rx.controlEvent(.editingDidEndOnExit) + .subscribe(onNext: { [weak self] in + self?.rootView.searchField.resignFirstResponder() + }) + .disposed(by: disposeBag) } - // MARK: - dataSource - private lazy var scheduleDataSource: RxTableViewSectionedReloadDataSource = { - RxTableViewSectionedReloadDataSource { _, tableView, indexPath, model in - let cell: ScheduleRecordCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(model) - return cell - } + private lazy var dataSource: RxTableViewSectionedReloadDataSource = { + RxTableViewSectionedReloadDataSource( + configureCell: { dataSource, tableView, indexPath, member in + let cell: GroupMemberDirectoryCell = tableView.dequeueReusableCell(for: indexPath) + let count = dataSource.sectionModels[indexPath.section].items.count + cell.configure(member, + showsSeparator: indexPath.row < count - 1, + roundsTopCorners: indexPath.row == 0, + roundsBottomCorners: indexPath.row == count - 1) + return cell + }, + titleForHeaderInSection: { dataSource, section in + dataSource.sectionModels[section].title + } + ) }() - private lazy var memberDataSource: RxCollectionViewSectionedReloadDataSource = { - RxCollectionViewSectionedReloadDataSource { [weak self] datasource, collectionView, indexPath, model in - let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) - cell.configure(model: model, - isCurrentUser: self?.viewModel.isCurrentUser(id: model.user_id) ?? false, - isSelected: model.user_id == self?.viewModel.memberId) - return cell - } - }() - - private lazy var drivingDataSource: RxCollectionViewSectionedReloadDataSource = { - RxCollectionViewSectionedReloadDataSource { _, collectionView, indexPath, item in - let cell: DrivingEventCell = collectionView.dequeueReusableCell(for: indexPath) - cell.configure(item) - return cell - } - }() - - // MARK: - API private func requestGroupInfo() { DLToast.showLoading() - GroupService.groupInfoByKey(viewModel.groupKey).subscribe { response in - DLToast.dismiss() - guard let model = response.model else { return } - self.viewModel.groupModel = model - self.viewModel.loadData(response.list) - self.rootView.selectedMemberIsSelf = self.viewModel.isCurrentUser(id: self.viewModel.memberId) - // 首次加载后请求今日驾驶数据 - if !self.viewModel.memberId.isEmpty { - self.requestDrivingEvents(for: Date()) - } - }.disposed(by: disposeBag) + GroupService.groupUsers(groupKey: viewModel.groupKey) + .subscribe(onNext: { [weak self] response in + guard let self else { return } + DLToast.dismiss() + guard response.isValid(for: self.viewModel.groupKey), + let group = response.model else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self.viewModel.loadData(group: group, members: response.list) + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } - // 驾驶事件 - private func requestDrivingEvents(start_time: String, end_time: String) { - DrivingService.drivingEvents(user_id: viewModel.memberId, start_time: start_time, end_time: end_time).subscribe { response in - guard let model = response.model else { return } - self.rootView.mileageLab.text = String(format: "%.1fkm", model.distance_km) - self.rootView.speedLab.text = String(format: "%.0fkm/h", model.max_speed) - self.viewModel.updateDrivingStats(model) - }.disposed(by: disposeBag) - } - - // 行程记录 - private func requestPlayback(date: String) { - let start = CFAbsoluteTimeGetCurrent() - dl.showLoading(text: "行程记录获取中...") - DrivingService.playback(user_id: viewModel.memberId, date: date).subscribe(onNext: { [weak self] response in - let elapsed = CFAbsoluteTimeGetCurrent() - start - print("[playback] total: \(String(format: "%.1f", elapsed))s, items: \(response.list.count)") - self?.dl.dismiss() - self?.viewModel.loadScheduleRecords(response.list) - }, onError: { _ in - self.dl.dismiss() - }).disposed(by: disposeBag) - } - - // MARK: - Init init(groupKey: String) { viewModel = GroupMemberListVM(groupKey: groupKey) super.init(nibName: nil, bundle: nil) diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift index 0e13adc5..1f7014bc 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVM.swift @@ -8,10 +8,11 @@ import RxSwift import RxRelay import RxDataSources -import ObjectMapper +// Used by the itinerary detail page in this module. struct DrivingEventItem: IdentifiableType, Equatable { typealias Identity = String + let identity: String let title: String let iconName: String @@ -25,107 +26,78 @@ struct DrivingEventItem: IdentifiableType, Equatable { } } -typealias DrivingEventSection = SectionModel -typealias ScheduleRecordSection = SectionModel +struct GroupMemberDirectorySection { + let title: String + let members: [GroupMemberModel] +} -class GroupMemberListVM { +extension GroupMemberDirectorySection: SectionModelType { + typealias Item = GroupMemberModel - let groupKey: String - var groupModel: GroupInfoModel? + init(original: GroupMemberDirectorySection, items: [GroupMemberModel]) { + self.init(title: original.title, members: items) + } + + var items: [GroupMemberModel] { members } +} + +final class GroupMemberListVM { struct Output { - var sectionedItems: Observable<[GroupMemberListSectionModel]> - var drivingSectionedItems: Observable<[DrivingEventSection]> - var scheduleSectionedItems: Observable<[ScheduleRecordSection]> + let directoryItems: Observable<[GroupMemberDirectorySection]> } + let groupKey: String let output: Output - var memberId: String = "" - - private let sectionedItems = PublishSubject<[GroupMemberListSectionModel]>() - private let drivingItemsRelay = BehaviorRelay<[DrivingEventItem]>(value: GroupMemberListVM.defaultDrivingEvents) - private let scheduleItemsRelay = BehaviorRelay<[ScheduleRecordModel]>(value: []) - - private var memberList: [GroupMemberModel] = [] - - private static let defaultDrivingEvents: [DrivingEventItem] = [ - DrivingEventItem(title: "急加速", iconName: "GroupMemberList/1"), - DrivingEventItem(title: "急转向", iconName: "GroupMemberList/3"), - DrivingEventItem(title: "急刹", iconName: "GroupMemberList/5"), - DrivingEventItem(title: "手机干扰", iconName: "GroupMemberList/7"), - DrivingEventItem(title: "超速", iconName: "GroupMemberList/2"), - DrivingEventItem(title: "低速", iconName: "GroupMemberList/4"), - DrivingEventItem(title: "频繁变道", iconName: "GroupMemberList/6"), - DrivingEventItem(title: "长时间驾驶", iconName: "GroupMemberList/8") - ] - - lazy var scheduleCellRecordAction: Action = { this in - return Action { model in - let memberInfo = self.memberList.first(where: { $0.user_id == self.memberId }) - AppRouter.push(Route.itineraryTrace, userInfo: ["model": model.toJSON(), - "memberInfo": memberInfo?.toJSON() ?? [:]]) - return .empty() - } - }(self) - - // 是否当前用户 - func isCurrentUser(id: String) -> Bool { - id == AppContextManager.shared.userId - } - - // 是否圈主 - func isGroupOwn(id: String) -> Bool { - guard let model = groupModel else { return false } - return model.group_key.contains(id) - } - - func loadData(_ list: [GroupMemberModel]) { - var tempmemberList = list - tempmemberList.moveToFirst { $0.is_online == true } - tempmemberList.moveToFirst { $0.user_id == AppContextManager.shared.userId } - tempmemberList.moveToFirst { isGroupOwn(id: $0.user_id) } - memberList = tempmemberList - memberId = memberList.first?.user_id ?? "" - sectionedItems.onNext(memberList.mapSection()) - } - - /// 获取成员在列表中的行号 - func rowOf(userId: String) -> Int? { - memberList.firstIndex(where: { $0.user_id == userId }) - } - - /// 加载行程记录 - func loadScheduleRecords(_ list: [ScheduleRecordModel]) { - scheduleItemsRelay.accept(list) - } - - /// 更新驾驶事件统计 - func updateDrivingStats(_ stats: DrivingStatsModel) { - let items = Self.defaultDrivingEvents.enumerated().map { i, item in - let count: Int - switch i { - case 0: count = stats.hard_acceleration - case 1: count = stats.sharp_turn - case 2: count = stats.hard_braking - case 3: count = stats.signal_loss - case 4: count = stats.speeding - case 5: count = stats.low_speeding - case 6: count = stats.frequent_lane_change - case 7: count = stats.long_driving - default: count = 0 - } - return DrivingEventItem(title: item.title, iconName: item.iconName, count: count) - } - drivingItemsRelay.accept(items) - } + private let directoryItemsRelay = BehaviorRelay<[GroupMemberDirectorySection]>(value: []) + private var owner: GroupMemberModel? + private var members: [GroupMemberModel] = [] + private var searchKeyword = "" init(groupKey: String) { self.groupKey = groupKey - output = Output( - sectionedItems: sectionedItems.asObservable(), - drivingSectionedItems: drivingItemsRelay.map { $0.mapSection() }.asObservable(), - scheduleSectionedItems: scheduleItemsRelay.map { $0.mapSection() }.asObservable() + output = Output(directoryItems: directoryItemsRelay.asObservable()) + } + + func loadData(group: GroupInfoModel, members: [GroupMemberModel]) { + let sortedMembers = GroupMemberSorter.sorted( + members, + groupKey: group.group_key, + currentUserId: AppContextManager.shared.userId ) + let owner = resolvedOwner(group: group, members: sortedMembers) + self.owner = owner + self.members = sortedMembers.filter { $0.user_id != owner?.user_id } + publishDirectory() + } + + func updateSearchKeyword(_ keyword: String) { + searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + publishDirectory() + } + + private func resolvedOwner(group: GroupInfoModel, members: [GroupMemberModel]) -> GroupMemberModel? { + members.first { + GroupOwnerMatcher.matches(userId: $0.user_id, groupKey: group.group_key) + } + } + + private func publishDirectory() { + let matches: (GroupMemberModel) -> Bool = { [searchKeyword] member in + searchKeyword.isEmpty || member.nick_name.localizedCaseInsensitiveContains(searchKeyword) + } + + var sections: [GroupMemberDirectorySection] = [] + if let owner, matches(owner) { + sections.append(GroupMemberDirectorySection(title: "圈主(1人)", members: [owner])) + } + + let visibleMembers = members.filter(matches) + if !visibleMembers.isEmpty { + sections.append(GroupMemberDirectorySection(title: "圈成员(\(visibleMembers.count)人)", members: visibleMembers)) + } + + directoryItemsRelay.accept(sections) } } diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift index e858303d..36fbf462 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift @@ -6,1178 +6,367 @@ // import UIKit -import RxSwift -import RxCocoa -import SwiftDate -class GroupMemberListView: UIView { - - var disposeBag = DisposeBag() - - func updateArrowVisibility() { - let offsetX = collectionView.contentOffset.x - let contentW = collectionView.contentSize.width - let viewW = collectionView.bounds.width - memberArrowLeft.isHidden = offsetX <= 0 - memberArrowRight.isHidden = offsetX >= contentW - viewW - } - - private func setupRx() { - collectionView.rx.contentOffset - .subscribe(onNext: { [weak self] _ in - self?.updateArrowVisibility() - }) - .disposed(by: disposeBag) +final class GroupMemberListView: UIView { - // 选中日期变化时更新月份 - selectedDate.subscribe(onNext: { [weak self] date in - guard let self = self else { return } - self.monthLab.text = date.toFormat("MM月") - }).disposed(by: disposeBag) - - // 往前/往后翻 7 天 - datePreviousBtn.rx.tap.subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - let target = self.dateCollectionView.contentOffset.x - self.dateItemWidth * CGFloat(self.daysPerPage) - self.dateCollectionView.setContentOffset(CGPoint(x: max(0, target), y: 0), animated: true) - }).disposed(by: disposeBag) - - dateNextBtn.rx.tap.subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - var target = self.dateCollectionView.contentOffset.x + self.dateItemWidth * CGFloat(self.daysPerPage) - let maxOffset = CGFloat(self.dateItems.count) * self.dateItemWidth - self.dateCollectionView.bounds.width - target = min(max(0, maxOffset), target) - self.dateCollectionView.setContentOffset(CGPoint(x: target, y: 0), animated: true) - }).disposed(by: disposeBag) - } - - private func setupUI() { - addSubview(navBgView) - addSubview(navView) - - addSubview(memberArrowLeft) - addSubview(collectionView) - addSubview(memberArrowRight) - addSubview(reportView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - collectionView.layoutChain - .topToBottomOfView(navView, offset: 0) - .height(90) - .edgesHorzontal(28) - - memberArrowLeft.layoutChain - .rightToLeftOfView(collectionView, offset: -8) - .height(14) - .width(5) - .centerY(collectionView) - - memberArrowRight.layoutChain - .leftToRightOfView(collectionView, offset: 8) - .height(14) - .width(5) - .centerY(collectionView) - - reportView.layoutChain - .topToBottomOfView(collectionView, offset: 10) - .edges(excludingEdge: .top) - } - - 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: "圈子成员") - return nav - }() - - // MARK: - 成员列表 - lazy var memberArrowLeft: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupMemberList/member_left") - view.isHidden = true - return view - }() - - lazy var memberArrowRight: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupMemberList/member_right") - view.isHidden = true - return view - }() - - lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - let cvWidth = kScreenWidth - 56 - layout.itemSize = CGSize(width: 65, height: 90) - layout.minimumLineSpacing = 4 - layout.scrollDirection = .horizontal - - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.register(GroupMemberListCell.self) - return cv - }() - - // MARK: - 未解锁会员遮罩 - var tipsImg: UIImageView! - lazy var unlockVipMaskView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - - let maskBg = UIImageView(image: UIImage(named: "GroupMemberList/mask_bg")) - view.addSubview(maskBg) - maskBg.layoutChain.edges() - - tipsImg = UIImageView(image: UIImage(named: "GroupMemberList/mask_tips")) - view.addSubview(tipsImg) - tipsImg.layoutChain - .top(83) - .centerX() - .width(283) - .height(194) - - let btn = UIButton() - btn.setTitle("立即开通", for: .normal) - btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - btn.setBackgroundColor(UIColor(hexStr: "#FF7B24"), for: .normal) - btn.cornerRadius = 25 - btn.rx.tap.subscribe(onNext: { _ in - AppRouter.push(Route.vipRecharge) - }).disposed(by: disposeBag) - view.addSubview(btn) - btn.layoutChain - .topToBottomOfView(tipsImg, offset: 23) - .centerX() - .width(224) - .height(50) - - return view - }() - - // MARK: - 报告 - lazy var reportView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFF") - view.layer.cornerRadius = 20 - view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - - let monthView = UIView() - monthView.backgroundColor = UIColor(hexStr: "#57C7FF") - monthView.cornerRadius = 6 - monthView.addSubview(monthLab) - monthLab.layoutChain - .edgesVertical(2) - .edgesHorzontal(12) - view.addSubview(monthView) - monthView.layoutChain - .top(15) - .left(15) - - let totalLab = UILabel() - totalLab.text = "本周总计里程" - totalLab.font = .systemFont(ofSize: 10, weight: .medium) - totalLab.textColor = UIColor(hexStr: "#333333") - view.addSubview(totalLab) - totalLab.layoutChain - .leftToRightOfView(monthView, offset: 21) - .centerY(monthView) - view.addSubview(mileageLab) - mileageLab.layoutChain - .leftToRightOfView(totalLab, offset: 5) - .centerY(monthView) - - let highLab = UILabel() - highLab.text = "本周最高时速" - highLab.font = .systemFont(ofSize: 10, weight: .medium) - highLab.textColor = UIColor(hexStr: "#333333") - view.addSubview(highLab) - highLab.layoutChain - .leftToRightOfView(mileageLab, offset: 23) - .centerY(monthView) - view.addSubview(speedLab) - speedLab.layoutChain - .leftToRightOfView(highLab, offset: 5) - .centerY(monthView) - - view.addSubview(dateView) - dateView.layoutChain - .topToBottomOfView(monthView, offset: 10) - .edgesHorzontal() - - view.addSubview(scrollView) - scrollView.layoutChain - .topToBottomOfView(dateView) - .edges(excludingEdge: .top) - - view.addSubview(unlockVipMaskView) - unlockVipMaskView.layoutChain - .topToBottomOfView(dateView) - .edges(excludingEdge: .top) - - return view - }() - - lazy var monthLab: UILabel = { - let label = UILabel() - label.text = " " - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hexStr: "#0F2846") - label.textAlignment = .center - return label - }() - - /// 里程 - lazy var mileageLab: UILabel = { - let label = UILabel() - label.text = "0km" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#16B3FF") - return label - }() - - /// 速度 - lazy var speedLab: UILabel = { - let label = UILabel() - label.text = "0km/h" - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#16B3FF") - return label - }() - - // MARK: - 日期 - private var dateItems: [DateItem] = [] - let selectedDate = BehaviorRelay(value: Date()) - var isSelectedDateLocked: Bool { - let today = Date() - let calendar = Calendar.current - let daysAgo = calendar.dateComponents([.day], from: selectedDate.value, to: today).day ?? 0 - return maxSelectableDays > 0 && daysAgo > maxSelectableDays - 1 - } - private let daysPerPage = 7 - - /// 当前选中的成员是否是自己 - var selectedMemberIsSelf = true { - didSet { updateDateSelectability() } - } - - struct DateItem { - let date: Date - let day: Int - let isToday: Bool - let isFuture: Bool - let isSelectable: Bool - let isLocked: Bool - } - - /// 根据 VIP 和成员关系计算最大可查询天数 - private var maxSelectableDays: Int { - switch AppContextManager.shared.vip { - case 1: return 0 // 非会员不可点 - case 2: return selectedMemberIsSelf ? 7 : 1 - case 3: return selectedMemberIsSelf ? 30 : 14 - default: return 0 - } - } - - /// 重新设置 dateItems 的 isSelectable - private func updateDateSelectability() { - let calendar = Calendar.current - let today = Date() - let maxDays = maxSelectableDays - let hasAccess = maxDays > 0 - dateItems = dateItems.map { item in - let daysAgo = calendar.dateComponents([.day], from: item.date, to: today).day ?? 0 - let inFreeRange = daysAgo <= maxDays - 1 - return DateItem(date: item.date, day: item.day, - isToday: item.isToday, - isFuture: item.isFuture, - isSelectable: hasAccess && !item.isFuture && inFreeRange, - isLocked: hasAccess && !item.isFuture && !inFreeRange) - } - dateCollectionView.reloadData() - } - - func refreshVipState() { - updateDateSelectability() - selectedDate.accept(Date()) - } - - private func generateDateItems() { - let calendar = Calendar.current - let today = Date() - // 31 天前 → 今天 → 之后 3 天 = 35 天,正好 5 页 × 7 天 - let pastDays = 31 - let futureDays = 3 - let totalDays = pastDays + 1 + futureDays // 35 - - dateItems = (0.. today - let daysAgo = calendar.dateComponents([.day], from: date, to: today).day ?? 0 - let hasAccess = maxSelectableDays > 0 - let inFreeRange = hasAccess && daysAgo <= maxSelectableDays - 1 - return DateItem(date: date, day: day, - isToday: calendar.isDateInToday(date), - isFuture: isFuture, - isSelectable: hasAccess && !isFuture && inFreeRange, - isLocked: hasAccess && !isFuture && !inFreeRange) - } - } - - /// 日期 - lazy var dateView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - generateDateItems() - - view.addSubview(datePreviousBtn) - view.addSubview(dateNextBtn) - view.addSubview(dateCollectionView) - - datePreviousBtn.layoutChain - .left(15) - .edgesVertical(10) - .width(20) - .height(20) - - dateNextBtn.layoutChain - .right(15) - .centerY() - .width(20) - .height(20) - - dateCollectionView.layoutChain - .leftToRightOfView(datePreviousBtn, offset: 5) - .rightToLeftOfView(dateNextBtn, offset: -5) - .edgesVertical() - - DispatchQueue.main.async { self.scrollToToday() } - - return view - }() - - private var dateItemWidth: CGFloat { - let available = kScreenWidth - 15 - 20 - 5 - 5 - 20 - 15 - return floor(available / CGFloat(daysPerPage)) - } - - lazy var dateCollectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.itemSize = CGSize(width: dateItemWidth, height: 40) - layout.minimumLineSpacing = 0 - layout.scrollDirection = .horizontal - layout.sectionInset = .zero - - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.isPagingEnabled = true - cv.register(DateCell.self) - cv.dataSource = self - cv.delegate = self - return cv - }() - - lazy var datePreviousBtn: UIButton = { - let btn = UIButton() - btn.setImage(UIImage(named: "GroupMemberList/date_left"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 0) - return btn - }() - - lazy var dateNextBtn: UIButton = { - let btn = UIButton() - btn.setImage(UIImage(named: "GroupMemberList/date_right"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 0, bottom: 15, right: 15) - return btn - }() - - // MARK: - 驾驶分析 - lazy var scrollView: UIScrollView = { - let view = UIScrollView() - view.backgroundColor = .clear - view.showsVerticalScrollIndicator = false - view.bounces = false - view.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: kSafeBottomMargin, right: 0) - - let contentView = UIView() - contentView.backgroundColor = .clear - view.addSubview(contentView) - contentView.layoutChain.edges().widthToView(view) - - contentView.addSubview(drivingAnalysisView) - drivingAnalysisView.layoutChain - .top(5) - .edgesHorzontal(15) - .height(249) - - contentView.addSubview(recordView) - recordView.layoutChain - .topToBottomOfView(drivingAnalysisView, offset: 15) - .edgesHorzontal(15) - .bottom(20) - - return view - }() - - lazy var drivingAnalysisView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.cornerRadius = 10 - - let titleBg = UIImageView(image: UIImage(named: "GroupMemberList/title_bg")) - view.addSubview(titleBg) - - let titleLab = UILabel() - titleLab.text = "驾驶分析" - titleLab.font = .systemFont(ofSize: 16, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .top(15) - .centerX() - - titleBg.layoutChain - .centerX() - .bottomToView(titleLab, offset: 5) - - view.addSubview(drivingEventCV) - drivingEventCV.layoutChain - .topToBottomOfView(titleBg, offset: 20) - .edgesHorzontal() - .bottom(20) - - return view - }() - - lazy var drivingEventCV: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.itemSize = CGSize(width: 66, height: 75) - layout.minimumLineSpacing = 15 - layout.scrollDirection = .vertical - layout.sectionInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) - - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.isScrollEnabled = false - cv.register(DrivingEventCell.self) - return cv - }() - - // MARK: - 行程记录 - lazy var recordView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.cornerRadius = 10 - - let titleBg = UIImageView(image: UIImage(named: "GroupMemberList/title_bg")) - view.addSubview(titleBg) - - let titleLab = UILabel() - titleLab.text = "行程记录" - titleLab.font = .systemFont(ofSize: 16, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .top(15) - .centerX() - - titleBg.layoutChain - .centerX() - .bottomToView(titleLab, offset: 5) - - view.addSubview(tableView) - tableView.layoutChain - .topToBottomOfView(titleBg, offset: 10) - .edgesHorzontal() - .bottom() - - view.addSubview(nodataLab) - nodataLab.layoutChain.centerX().centerY() - - return view - }() - - lazy var tableView: UITableView = { - let tableView = UITableView(frame: .zero, style: .plain) - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.estimatedRowHeight = 177 - tableView.showsVerticalScrollIndicator = false - tableView.isScrollEnabled = false - tableView.register(ScheduleRecordCell.self) - return tableView - }() - - lazy var nodataLab: UILabel = { - let l = UILabel() - l.text = "暂无行程记录" - l.font = .systemFont(ofSize: 12, weight: .regular) - l.textColor = UIColor(hexStr: "#999999") - return l - }() - - /// 滚动报告区域到顶部 - func scrollReportToTop() { - scrollView.setContentOffset(.zero, animated: false) - } - - /// 滚到今天所在页(第 5 页,items 28-34,今天在第 3 位) - func scrollToToday() { - let page: CGFloat = 4 - let offset = page * CGFloat(daysPerPage) * dateItemWidth - dateCollectionView.contentOffset.x = offset - } - - func updateTableViewHeight(count: Int) { - let h = CGFloat(count) * 179 - tableView.layoutChain.height(h > 0 ? h : 179) - nodataLab.isHidden = h > 0 - } - - override func layoutSubviews() { - super.layoutSubviews() - updateArrowVisibility() - } + private var sectionTitles: [String] = [] override init(frame: CGRect) { - super.init(frame: .zero) - backgroundColor = .white + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F7F7F8") setupUI() - setupRx() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - UICollectionViewDataSource & Delegate (日期) -extension GroupMemberListView: UICollectionViewDataSource, UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - dateItems.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell: DateCell = collectionView.dequeueReusableCell(for: indexPath) - let item = dateItems[indexPath.row] - let isSel = Calendar.current.isDate(item.date, inSameDayAs: selectedDate.value) - cell.configure(item: item, isSelected: isSel) - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard collectionView == dateCollectionView else { return } - let item = dateItems[indexPath.row] - guard item.isSelectable else { return } - - unlockVipMaskView.isHidden = true - - let oldDate = selectedDate.value - selectedDate.accept(item.date) - if let oldRow = dateItems.firstIndex(where: { Calendar.current.isDate($0.date, inSameDayAs: oldDate) }), - let oldCell = dateCollectionView.cellForItem(at: IndexPath(row: oldRow, section: 0)) as? DateCell { - oldCell.configure(item: dateItems[oldRow], isSelected: false) - } - if let newCell = dateCollectionView.cellForItem(at: indexPath) as? DateCell { - newCell.configure(item: item, isSelected: true) - } - } - - func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - guard scrollView == dateCollectionView else { return } - let targetX = targetContentOffset.pointee.x - let idx = round(targetX / dateItemWidth) - targetContentOffset.pointee.x = idx * dateItemWidth - } -} - -// MARK: - DateCell -class DateCell: UICollectionViewCell { - - private let bgView: UIView = { - let v = UIView() - v.backgroundColor = UIColor(hexStr: "#16B3FF") - v.cornerRadius = 11 - v.isHidden = true - return v - }() - - private let dayLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 14, weight: .medium) - l.textAlignment = .center - return l - }() - - override init(frame: CGRect) { - super.init(frame: frame) - contentView.addSubview(bgView) - contentView.addSubview(dayLab) - bgView.layoutChain.centerX().centerY().width(36).height(22) - dayLab.layoutChain.centerX().centerY() - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func configure(item: GroupMemberListView.DateItem, isSelected: Bool) { - dayLab.text = "\(item.day)" - let disabled = item.isFuture || !item.isSelectable - bgView.isHidden = !isSelected || disabled - dayLab.textColor = disabled ? UIColor(hexStr: "#D1D1D6") - : isSelected ? .white - : UIColor(hexStr: "#333333") - } -} - -// MARK: - GroupMemberListCell 成员 -class GroupMemberListCell: UICollectionViewCell { - - private static let sosBlinkKey = "sosBlink" - - func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) { - avaterImgView.image = model.userIcon -// vipIcon.image = model.vipIcon - vipBgImgView.isHidden = model.level == 1 - nameLab.text = model.nick_name - nameLab.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846") - selectedBgView.isHidden = !isSelected - let isSOS = model.status == 1 - updateSOSBlink(isSOS) - bubbleImgView.isHidden = isSOS || model.status != 2 - // 会员权益 -// if AppContextManager.shared.vip > 1, model.is_online { -// batteryInfoView.isHidden = model.battery.int == 0 -// // 电量 16是电池图标宽度,右边有电池造型需要减去 -// let batteryPercent = min(CGFloat(model.battery.int), 100) -// batteryView.layoutChain.width(CGFloat(16 - 1) * batteryPercent / 100.0) -// batteryLab.text = "\(model.battery)%" -// } - } - - private func updateSOSBlink(_ blinking: Bool) { - sosImgView.layer.removeAnimation(forKey: Self.sosBlinkKey) - if blinking { - sosImgView.isHidden = false - sosImgView.alpha = 1 - let anim = CABasicAnimation(keyPath: "opacity") - anim.fromValue = 1.0 - anim.toValue = 0.2 - anim.duration = 0.6 - anim.autoreverses = true - anim.repeatCount = .infinity - anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - sosImgView.layer.add(anim, forKey: Self.sosBlinkKey) - } else { - sosImgView.isHidden = true - sosImgView.alpha = 1 - } - } - - override func prepareForReuse() { - super.prepareForReuse() - updateSOSBlink(false) - bubbleImgView.isHidden = true - } - - private func setupSubviews() { - contentView.addSubview(selectedBgView) - contentView.addSubview(avaterImgView) - contentView.addSubview(bubbleImgView) - contentView.addSubview(sosImgView) - contentView.addSubview(vipBgImgView) -// contentView.addSubview(vipIcon) - contentView.addSubview(batteryInfoView) - batteryInfoView.addSubview(cornerView) - cornerView.addSubview(batteryView) - cornerView.addSubview(batteryIcon) - cornerView.addSubview(batteryLab) - contentView.addSubview(nameLab) - - setupLayout() - } - - private func setupLayout() { - selectedBgView.layoutChain.edges() - - avaterImgView.layoutChain - .top(9) - .edgesHorzontal(5.5) - .heightToWidth(1) - - bubbleImgView.layoutChain - .topToView(avaterImgView) - .leftToView(avaterImgView) - .rightToView(avaterImgView) - .bottomToView(avaterImgView) - - sosImgView.layoutChain - .topToView(avaterImgView) - .leftToView(avaterImgView) - .rightToView(avaterImgView) - .bottomToView(avaterImgView) - - vipBgImgView.layoutChain - .top(7) - .centerX() - - batteryInfoView.layoutChain - .leftToView(avaterImgView) - .rightToView(avaterImgView) - .bottomToView(avaterImgView) - .height(12) - - cornerView.layoutChain.edges() - - batteryIcon.layoutChain - .left(7) - .centerY() - .width(16) - .height(8) - - batteryView.layoutChain - .topToView(batteryIcon) - .leftToView(batteryIcon, offset: -1) - .bottomToView(batteryIcon) - - batteryLab.layoutChain - .leftToRightOfView(batteryIcon, offset: 4) - .right(5) - .centerY() - -// vipIcon.layoutChain -// .topToView(avaterImgView, offset: -8) -// .leftToView(avaterImgView, offset: -6) -// .width(25) -// .height(21) - - nameLab.layoutChain - .topToBottomOfView(batteryInfoView, offset: 8) - .edgesHorzontal() - - - } - - lazy var selectedBgView: UIImageView = { - let view = UIImageView(image: UIImage(named: "GroupMemberList/selected_bg")) - view.contentMode = .scaleAspectFill - view.isHidden = true - return view - }() - - lazy var vipBgImgView: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupMemberList/vip") - view.isHidden = true - return view - }() - - lazy var avaterImgView: UIImageView = { - let view = UIImageView() - view.backgroundColor = .lightGray - view.contentMode = .scaleAspectFill - view.cornerRadius = 25 - return view - }() - - lazy var bubbleImgView: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupMemberList/bubble") - view.contentMode = .scaleAspectFit - view.isHidden = true - return view - }() - - lazy var sosImgView: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "GroupMemberList/sos") - view.isHidden = true - return view - }() - - lazy var batteryInfoView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.1).cgColor - view.layer.shadowOffset = CGSize(width: 0, height: 2) - view.layer.shadowOpacity = 1 - view.layer.shadowRadius = 6 - view.isHidden = true - return view - }() - - lazy var cornerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.cornerRadius = 6 - return view - }() - - lazy var batteryView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#75E582") - return view - }() - - lazy var batteryIcon: UIImageView = { - let view = UIImageView() - view.backgroundColor = .clear - view.image = UIImage(named: "Home/battery") - return view - }() - - lazy var batteryLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#D4D4D4") - label.font = .systemFont(ofSize: 6, weight: .medium) - return label - }() - - lazy var vipIcon: UIImageView = { - let view = UIImageView() - return view - }() - - lazy var nameLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#0F2846") - label.font = .systemFont(ofSize: 12, weight: .medium) - label.textAlignment = .center - return label - }() - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override init(frame: CGRect) { - super.init(frame: frame) - setupSubviews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func updateSectionTitles(_ titles: [String]) { + sectionTitles = titles + } + + func updateSearchPlaceholder(isHidden: Bool) { + searchPlaceholderStack.isHidden = isHidden + } + + private func setupUI() { + addSubview(navView) + addSubview(searchField) + addSubview(tableView) + searchField.addSubview(searchPlaceholderStack) + + [navView, searchField, tableView, searchPlaceholderStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + navView.topAnchor.constraint(equalTo: topAnchor), + navView.leadingAnchor.constraint(equalTo: leadingAnchor), + navView.trailingAnchor.constraint(equalTo: trailingAnchor), + navView.heightAnchor.constraint(equalToConstant: kNaviHeight), + + searchField.topAnchor.constraint(equalTo: navView.bottomAnchor, constant: 20), + searchField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15), + searchField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15), + searchField.heightAnchor.constraint(equalToConstant: 38), + + searchPlaceholderStack.centerXAnchor.constraint(equalTo: searchField.centerXAnchor), + searchPlaceholderStack.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), + + tableView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 20), + tableView.leadingAnchor.constraint(equalTo: leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + lazy var navView: BaseNavigationView = BaseNavigationView(title: "圈子成员") + + lazy var searchField: UISearchTextField = { + let textField = UISearchTextField() + textField.backgroundColor = .white + textField.borderStyle = .none + textField.layer.cornerRadius = 12 + textField.clipsToBounds = true + textField.font = .systemFont(ofSize: 15, weight: .regular) + textField.textColor = UIColor(hexStr: "#293445") + textField.tintColor = UIColor(hexStr: "#293445") + textField.returnKeyType = .search + textField.clearButtonMode = .whileEditing + textField.leftView = UIView(frame: .zero) + textField.leftViewMode = .never + return textField + }() + + private lazy var searchPlaceholderStack: UIStackView = { + let imageView = UIImageView(image: UIImage(named: "Group/remove_member_search")) + imageView.contentMode = .scaleAspectFit + imageView.widthAnchor.constraint(equalToConstant: 16).isActive = true + imageView.heightAnchor.constraint(equalToConstant: 16).isActive = true + + let label = UILabel() + label.text = "搜索" + label.textColor = UIColor(hexStr: "#B0B0B5") + label.font = .systemFont(ofSize: 15, weight: .regular) + + let stack = UIStackView(arrangedSubviews: [imageView, label]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 6 + stack.isUserInteractionEnabled = false + return stack + }() + + lazy var tableView: UITableView = { + let view = UITableView(frame: .zero, style: .plain) + view.backgroundColor = .clear + view.separatorStyle = .none + view.rowHeight = GroupMemberDirectoryCell.rowHeight + view.estimatedRowHeight = GroupMemberDirectoryCell.rowHeight + view.sectionHeaderHeight = 42 + view.estimatedSectionHeaderHeight = 42 + view.showsVerticalScrollIndicator = false + view.keyboardDismissMode = .onDrag + view.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: kSafeBottomMargin + 24, right: 0) + if #available(iOS 15.0, *) { + view.sectionHeaderTopPadding = 0 + } + view.register(GroupMemberDirectoryCell.self) + return view + }() } -// MARK: - DrivingEventCell 驾驶事件 -class DrivingEventCell: UICollectionViewCell { +extension GroupMemberListView: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard section < sectionTitles.count else { return nil } - func configure(_ item: DrivingEventItem) { - iconView.image = UIImage(named: item.iconName) - nameLab.text = "\(item.title)\n(\(item.count))" + let container = UIView() + let label = UILabel() + label.text = sectionTitles[section] + label.textColor = UIColor(hexStr: "#777A80") + label.font = .systemFont(ofSize: 14, weight: .regular) + container.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8) + ]) + return container } - private let bgView: UIView = { - let v = UIView() - v.backgroundColor = UIColor(hexStr: "#F5FBFF") - v.cornerRadius = 8 - return v - }() - - lazy var iconView: UIImageView = { - let view = UIImageView() - view.contentMode = .scaleAspectFill - return view - }() - - lazy var nameLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .regular) - l.textAlignment = .center - l.numberOfLines = 0 - return l - }() - - override init(frame: CGRect) { - super.init(frame: frame) - contentView.addSubview(bgView) - contentView.addSubview(iconView) - contentView.addSubview(nameLab) - - bgView.layoutChain - .edges(excludingEdge: .top) - .height(60) - - iconView.layoutChain - .top() - .centerX() - .width(32) - .heightToWidth(1) - - nameLab.layoutChain - .topToBottomOfView(iconView, offset: 8) - .edgesHorzontal() + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + 42 } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } -// MARK: - ScheduleRecordCells -class ScheduleRecordCell: UITableViewCell { +final class GroupMemberDirectoryCell: UITableViewCell { - func configure(_ model: ScheduleRecordModel) { - distanceLab.text = String(format: "%.1fkm", model.distance_km) - speedLab.text = String(format: "最高时速 %.0fkm/h", model.max_speed) - scoreLab.text = model.score.string - - dayLab.text = model.start_time.isoStringToCustom(model.start_time, format: "dd") ?? "" - yearMonthLab.text = model.start_time.isoStringToCustom(model.start_time, format: "yyyy.MM") ?? "" - - let startTime = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm") ?? "" - let endTime = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm") ?? "" - durationLab.text = "\(startTime)-\(endTime)(\(model.duration_minutes)分钟)" - - guard let start_address = model.start_address, - let end_address = model.end_address else { return } - - startLab.text = start_address.street - endLab.text = end_address.street - } - - private func setupViews() { - contentView.addSubview(dotView) - contentView.addSubview(dayLab) - contentView.addSubview(yearMonthLab) - contentView.addSubview(distanceLab) - contentView.addSubview(durationLab) - contentView.addSubview(detailView) - detailView.addSubview(headerBgView) - detailView.addSubview(speedLab) - detailView.addSubview(scroreTitleLab) - detailView.addSubview(scoreLab) - detailView.addSubview(unitLab) - detailView.addSubview(startTitleLab) - detailView.addSubview(startLab) - detailView.addSubview(endTitleLab) - detailView.addSubview(endLab) - contentView.addSubview(lineView) - - dotView.layoutChain - .left(15) - .width(8) - .heightToWidth(1) - - dayLab.layoutChain - .top(15) - .leftToRightOfView(dotView, offset: 8) - - dotView.layoutChain.centerY(dayLab) - - yearMonthLab.layoutChain - .leftToRightOfView(dayLab, offset: 4) - .bottomToView(dayLab, offset: -2) - - distanceLab.layoutChain - .leftToRightOfView(yearMonthLab, offset: 8) - .bottomToView(yearMonthLab) - - durationLab.layoutChain - .centerY(distanceLab) - .leftToRightOfView(distanceLab, offset: 6) - - detailView.layoutChain - .topToBottomOfView(dayLab, offset: 15) - .left(28) - .right(15) - .height(110) - .bottom(15) - - headerBgView.layoutChain - .edges(excludingEdge: .bottom) - .height(38) - - speedLab.layoutChain - .top(11) - .left(15) - - unitLab.layoutChain - .right(10) - .centerY(speedLab) - - scoreLab.layoutChain - .rightToLeftOfView(unitLab) - .centerY(speedLab) - - scroreTitleLab.layoutChain - .rightToLeftOfView(scoreLab, offset: -3) - .centerY(speedLab) - - startTitleLab.layoutChain - .topToBottomOfView(headerBgView, offset: 12) - .left(15) - - startLab.layoutChain - .leftToRightOfView(startTitleLab, offset: 2) - .centerY(startTitleLab) - - endTitleLab.layoutChain - .topToBottomOfView(startTitleLab, offset: 11) - .leftToView(startTitleLab) - - endLab.layoutChain - .leftToRightOfView(endTitleLab, offset: 2) - .centerY(endTitleLab) - - lineView.layoutChain - .height(0.5) - .edgesHorzontal(15) - .bottom() - } - - lazy var dotView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#16B3FF") - view.cornerRadius = 4 - return view - }() - - lazy var dayLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 20, weight: .bold) - l.textColor = UIColor(hexStr: "#16B3FF") - return l - }() - - lazy var yearMonthLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .bold) - l.textColor = UIColor(hexStr: "#66CDFF") - return l - }() - - lazy var distanceLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var durationLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .regular) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var detailView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFF") - view.cornerRadius = 10 - view.clipsToBounds = true - return view - }() - - lazy var headerBgView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#C4E8FF") - return view - }() - - lazy var speedLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var scroreTitleLab: UILabel = { - let l = UILabel() - l.text = "安全评分" - l.font = .systemFont(ofSize: 10, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var scoreLab: UILabel = { - let l = UILabel() - l.text = "0" - l.font = .systemFont(ofSize: 24, weight: .medium) - l.textColor = UIColor(hexStr: "#16B3FF") - return l - }() - - lazy var unitLab: UILabel = { - let l = UILabel() - l.text = "分" - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#16B3FF") - return l - }() - - lazy var startTitleLab: UILabel = { - let l = UILabel() - l.text = "起点:" - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#666666") - return l - }() - - lazy var startLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var endTitleLab: UILabel = { - let l = UILabel() - l.text = "终点:" - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#666666") - return l - }() - - lazy var endLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l - }() - - lazy var lineView: UIView = { - let view = UIView() - view.backgroundColor = ThemeManager.shared.color.lineColor - return view - }() + static let rowHeight: CGFloat = 52 override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear - setupViews() + contentView.backgroundColor = .clear + setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code + + func configure(_ member: GroupMemberModel, + showsSeparator: Bool, + roundsTopCorners: Bool, + roundsBottomCorners: Bool) { + avatarView.image = member.userIcon + nicknameLabel.text = member.nick_name.isEmpty ? "未知昵称" : member.nick_name + separatorView.isHidden = !showsSeparator + + var corners: CACornerMask = [] + if roundsTopCorners { + corners.formUnion([.layerMinXMinYCorner, .layerMaxXMinYCorner]) + } + if roundsBottomCorners { + corners.formUnion([.layerMinXMaxYCorner, .layerMaxXMaxYCorner]) + } + cardView.layer.maskedCorners = corners } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) + private func setupUI() { + contentView.addSubview(cardView) + cardView.addSubview(avatarView) + cardView.addSubview(nicknameLabel) + cardView.addSubview(separatorView) - // Configure the view for the selected state + [cardView, avatarView, nicknameLabel, separatorView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + cardView.topAnchor.constraint(equalTo: contentView.topAnchor), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), + cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + avatarView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + avatarView.centerYAnchor.constraint(equalTo: cardView.centerYAnchor), + avatarView.widthAnchor.constraint(equalToConstant: 38), + avatarView.heightAnchor.constraint(equalToConstant: 38), + + nicknameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 12), + nicknameLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + nicknameLabel.centerYAnchor.constraint(equalTo: cardView.centerYAnchor), + + separatorView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 20), + separatorView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -13), + separatorView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor), + separatorView.heightAnchor.constraint(equalToConstant: 0.5) + ]) } + + private lazy var cardView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + }() + + private lazy var avatarView: UIImageView = { + let view = UIImageView() + view.backgroundColor = UIColor(hexStr: "#F1F2F4") + view.contentMode = .scaleAspectFill + view.layer.cornerRadius = 10 + view.clipsToBounds = true + return view + }() + + private lazy var nicknameLabel: UILabel = { + let label = UILabel() + label.textColor = UIColor(hexStr: "#111111") + label.font = .systemFont(ofSize: 16, weight: .regular) + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#EDEEF0") + return view + }() +} + +// This compact cell is shared by the map and member information pages. +final class GroupMemberListCell: UICollectionViewCell { + + private static let sosBlinkKey = "sosBlink" + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + updateSOSBlink(false) + bubbleImageView.isHidden = true + } + + func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) { + avatarImageView.image = model.userIcon + nameLabel.text = model.nick_name + nameLabel.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846") + selectionImageView.isHidden = !isSelected + vipBackgroundView.isHidden = model.level == 1 + + let isSOS = model.status == 1 + updateSOSBlink(isSOS) + bubbleImageView.isHidden = isSOS || model.status != 2 + } + + private func updateSOSBlink(_ blinking: Bool) { + sosImageView.layer.removeAnimation(forKey: Self.sosBlinkKey) + sosImageView.isHidden = !blinking + sosImageView.alpha = 1 + guard blinking else { return } + + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 1 + animation.toValue = 0.2 + animation.duration = 0.6 + animation.autoreverses = true + animation.repeatCount = .infinity + sosImageView.layer.add(animation, forKey: Self.sosBlinkKey) + } + + private func setupUI() { + contentView.addSubview(selectionImageView) + contentView.addSubview(avatarImageView) + contentView.addSubview(bubbleImageView) + contentView.addSubview(sosImageView) + contentView.addSubview(vipBackgroundView) + contentView.addSubview(nameLabel) + + [selectionImageView, avatarImageView, bubbleImageView, sosImageView, vipBackgroundView, nameLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + selectionImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + selectionImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + selectionImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + selectionImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + avatarImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 9), + avatarImageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + avatarImageView.widthAnchor.constraint(equalToConstant: 50), + avatarImageView.heightAnchor.constraint(equalToConstant: 50), + + bubbleImageView.topAnchor.constraint(equalTo: avatarImageView.topAnchor), + bubbleImageView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), + bubbleImageView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + bubbleImageView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + + sosImageView.topAnchor.constraint(equalTo: avatarImageView.topAnchor), + sosImageView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), + sosImageView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + sosImageView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + + vipBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7), + vipBackgroundView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 7), + nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + ]) + } + + private lazy var selectionImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "GroupMemberList/selected_bg")) + view.contentMode = .scaleAspectFill + view.isHidden = true + return view + }() + + private lazy var avatarImageView: UIImageView = { + let view = UIImageView() + view.backgroundColor = UIColor(hexStr: "#F1F2F4") + view.contentMode = .scaleAspectFill + view.layer.cornerRadius = 25 + view.clipsToBounds = true + return view + }() + + private lazy var bubbleImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "GroupMemberList/bubble")) + view.contentMode = .scaleAspectFit + view.isHidden = true + return view + }() + + private lazy var sosImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "GroupMemberList/sos")) + view.contentMode = .scaleAspectFit + view.isHidden = true + return view + }() + + private lazy var vipBackgroundView: UIImageView = { + let view = UIImageView(image: UIImage(named: "GroupMemberList/vip")) + view.isHidden = true + return view + }() + + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + return label + }() } diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift index fb4f8656..99ee3ccb 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift @@ -10,6 +10,7 @@ import RxSwift import RxCocoa import RxDataSources import ObjectMapper +import OpenIMSDK class GroupSettingVC: BaseViewController { @@ -50,18 +51,9 @@ class GroupSettingVC: BaseViewController { self.present(vc, animated: true, completion: nil) }.disposed(by: disposeBag) - // 更换图标 - rootView.groupIconView.rx.tapGesture.subscribe { _ in - guard let model = self.viewModel.groupModel, model.is_owner else { return } - let vc = GroupIconListVC(iconIndex: "\(model.icon_index)") - vc.onSelectIcon = { index in - self.requestChangeIcon(index) - } - self.navigationController?.pushViewController(vc, animated: true) - }.disposed(by: disposeBag) - // 审核开关 rootView.switchBtn.rx.isOn + .skip(1) .subscribe(onNext: { isOn in guard let model = self.viewModel.groupModel, model.is_owner else { return } self.requestChangeReview(isOn) @@ -69,7 +61,7 @@ class GroupSettingVC: BaseViewController { .disposed(by: disposeBag) // 标签 - rootView.tagView.rx.tapGesture.subscribe { _ in + rootView.tagView.rx.tapGesture.subscribe(onNext: { _ in guard let model = self.viewModel.groupModel, model.is_owner else { return } GroupTagListView.show(selectedTagList: model.labels) { tagList in guard let model = self.viewModel.groupModel, @@ -77,13 +69,25 @@ class GroupSettingVC: BaseViewController { let list = tagList else { return } self.requestEditLabels(labels: list) } - }.disposed(by: disposeBag) + }).disposed(by: disposeBag) + + rootView.memberHeaderView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.showMemberList() + }).disposed(by: disposeBag) + + rootView.onMembersTapped = { [weak self] in + self?.showMemberList() + } - // 邀请成员 - rootView.inviteView.rx.tapGesture.subscribe(onNext: { _ in + // 邀请码由既有 InviteJoin 页面生成和展示。 + rootView.inviteCodeButton.rx.tap.subscribe(onNext: { _ in guard let model = self.viewModel.groupModel else { return } AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": model.toJSON()]) }).disposed(by: disposeBag) + + rootView.shareButton.rx.tap.subscribe(onNext: { [weak self] _ in + self?.shareGroup() + }).disposed(by: disposeBag) // 审核成员 rootView.auditMemberView.rx.tapGesture.subscribe(onNext: { _ in @@ -91,10 +95,17 @@ class GroupSettingVC: BaseViewController { AppRouter.push(Route.reviewMemberList, userInfo: ["groupId": model.group_key]) }).disposed(by: disposeBag) - // 移除圈子成员 - rootView.removeMemberView.rx.tapGesture.subscribe(onNext: { _ in - guard let model = self.viewModel.groupModel else { return } - AppRouter.push(Route.removeMember, userInfo: ["groupId": model.group_key]) + // 成员快捷入口进入待审核成员页。 + rootView.onRemoveMemberShortcutTapped = { [weak self] in + self?.showRemoveMember() + } + + rootView.groupNicknameView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.editGroupNickname() + }).disposed(by: disposeBag) + + rootView.clearHistoryView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.confirmClearHistory() }).disposed(by: disposeBag) // 解散圈子 @@ -119,20 +130,26 @@ class GroupSettingVC: BaseViewController { // MARK: - API private func requestGroupInfo() { DLToast.showLoading() - GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in - DLToast.dismiss() - guard let model = response.model else { return } - self.viewModel.groupModel = model - self.rootView.setupData(model) - }.disposed(by: disposeBag) - } - - private func requestChangeIcon(_ iconIndex: Int) { - DLToast.showLoading() - GroupService.changeIcon(requestData: ["group_key": viewModel.groupId, "icon_index": iconIndex]).subscribe { response in - DLToast.dismiss() - self.requestGroupInfo() - }.disposed(by: disposeBag) + GroupService.groupUsers(groupKey: viewModel.groupId) + .subscribe(onNext: { [weak self] response in + guard let self = self else { return } + DLToast.dismiss() + guard response.isValid(for: self.viewModel.groupId), + let model = response.model else { + DLToast.showError(text: response.message ?? "获取圈子信息失败") + return + } + self.viewModel.groupModel = model + self.viewModel.members = response.list + self.rootView.setupData(model, + members: response.list, + groupNickname: self.viewModel.displayGroupNickname) + self.requestGroupNickname() + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } private func requestChangeReview(_ review: Bool) { @@ -187,6 +204,97 @@ class GroupSettingVC: BaseViewController { }.disposed(by: disposeBag) } + // MARK: - Page actions + private func showMemberList() { + guard let model = viewModel.groupModel else { return } + AppRouter.push(Route.groupMemberList, userInfo: ["groupKey": model.group_key]) + } + + private func showRemoveMember() { + guard let model = viewModel.groupModel, model.is_owner else { return } + AppRouter.push(Route.reviewMemberList, userInfo: ["groupId": model.group_key]) + } + + private func shareGroup() { + SharePopView.show() + } + + private func confirmClearHistory() { + showConfirmPop(title: "清空聊天记录", + message: "清空后无法恢复,确定清空当前圈子的聊天记录吗?", + confirmText: "清空", + confirmBlock: { [weak self] in + self?.clearHistory() + }, + cancelText: "取消") + } + + private func clearHistory() { + let groupId = viewModel.groupModel?.group_key ?? viewModel.groupId + OIMManager.manager.clearConversationAndDeleteAllMsg("sg_\(groupId)", onSuccess: { _ in + DispatchQueue.main.async { + DLToast.show(text: "聊天记录已清空") + } + }, onFailure: { _, message in + DispatchQueue.main.async { + DLToast.showError(text: message ?? "清空聊天记录失败") + } + }) + } + + private func requestGroupNickname() { + let groupId = viewModel.groupModel?.group_key ?? viewModel.groupId + let userId = AppContextManager.shared.userId + guard !groupId.isEmpty, !userId.isEmpty else { return } + + OIMManager.manager.getSpecifiedGroupMembersInfo(groupId, usersID: [userId], onSuccess: { [weak self] members in + guard let self = self else { return } + let nickname = members?.first?.nickname?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + DispatchQueue.main.async { + self.viewModel.groupNickname = nickname + self.rootView.updateGroupNickname(self.viewModel.displayGroupNickname) + } + }, onFailure: { _, _ in + // The account nickname remains the visible fallback when OpenIM data is unavailable. + }) + } + + private func editGroupNickname() { + let vc = TextInputViewController(title: "我在本群昵称", + maxLength: 20, + initialText: viewModel.displayGroupNickname) { [weak self] nickname in + self?.updateGroupNickname(nickname) + } + present(vc, animated: true) + } + + private func updateGroupNickname(_ nickname: String) { + let trimmedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedNickname.isEmpty else { return } + let groupId = viewModel.groupModel?.group_key ?? viewModel.groupId + let userId = AppContextManager.shared.userId + guard !groupId.isEmpty, !userId.isEmpty else { + DLToast.showError(text: "修改群昵称失败") + return + } + + OIMManager.manager.setGroupMemberNickname(groupId, + userID: userId, + groupNickname: trimmedNickname, + onSuccess: { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.viewModel.groupNickname = trimmedNickname + self.rootView.updateGroupNickname(trimmedNickname) + DLToast.show(text: "修改成功") + } + }, onFailure: { _, message in + DispatchQueue.main.async { + DLToast.showError(text: message ?? "修改群昵称失败") + } + }) + } + // MARK: - Init init(groupId: String) { self.viewModel = GroupSettingViewModel(groupId: groupId) diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift index 662dbd08..4d9d3444 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift @@ -10,610 +10,841 @@ import RxSwift import RxCocoa import TagListView -class GroupSettingView: UIView { - - var disposeBag = DisposeBag() - - func setupData(_ model: GroupInfoModel) { - navView.titleLabel.text = model.name - groupNameLab.text = model.name - groupIcon.image = model.groupIcon - groupDescLab.text = model.description.isEmpty ? "暂无描述" : model.description +final class GroupSettingView: UIView { + + private let pageInset: CGFloat = 15 + var onRemoveMemberShortcutTapped: (() -> Void)? + var onMembersTapped: (() -> Void)? + + func setupData(_ model: GroupInfoModel, + members: [GroupMemberModel], + groupNickname: String) { + groupNameView.setValue(model.name) + groupTypeView.setValue(model.groupTemplateName) + groupDescView.setValue(model.description.isEmpty ? "暂无描述" : model.description) + groupNicknameView.setValue(groupNickname) + tagView.configure(tags: model.labels, editable: model.is_owner) + switchBtn.isOn = model.review - tagListView.removeAllTags() - tagListView.addTags(model.labels) - tagListView.tagViews.forEach { - $0.layer.cornerRadius = 4 - } - tagListView.invalidateIntrinsicContentSize() // 通知系统重新算高 - auditSwitchView.isHidden = !model.is_owner - groupNameEditIcon.isHidden = !model.is_owner - groupIconArrowIcon.isHidden = !model.is_owner - groupDescEditIcon.isHidden = !model.is_owner - groupTagArrowIcon.isHidden = !model.is_owner - auditMemberView.isHidden = !model.is_owner - removeMemberView.isHidden = !model.is_owner dismissGroupView.isHidden = !model.is_owner leaveGroupView.isHidden = model.is_owner + + groupNameView.setShowsArrow(model.is_owner) + groupTypeView.setShowsArrow(false) + groupDescView.setShowsArrow(model.is_owner) + groupNicknameView.setShowsArrow(true) + auditMemberView.setShowsArrow(model.is_owner) + + let total = model.people_no > 0 ? model.people_no : members.count + let online = members.filter(\.is_online).count + memberCountLab.text = "共\(total)人/\(online)人在线" + memberGridView.configure(members: members, showsRemoveShortcut: model.is_owner) + setNeedsLayout() } - - private func setupRx() { + + func updateGroupNickname(_ nickname: String) { + groupNicknameView.setValue(nickname) } - + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F7F8FA") + memberGridView.onRemoveMemberShortcutTapped = { [weak self] in + self?.onRemoveMemberShortcutTapped?() + } + memberGridView.onMembersTapped = { [weak self] in + self?.onMembersTapped?() + } + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + private func setupUI() { addSubview(navBgView) addSubview(navView) - addSubview(scrollView) + scrollView.addSubview(scrollContentView) - scrollContentView.addSubview(infoView) - infoView.addSubview(infoStackView) - - scrollContentView.addSubview(groupManagerTitleLab) - scrollContentView.addSubview(groupManagerView) - groupManagerView.addSubview(groupManagerStackView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - scrollView.layoutChain - .topToBottomOfView(navView) - .edges(excludingEdge: .top) - - scrollContentView.layoutChain - .edges() - .widthToView(scrollView) - - infoView.layoutChain - .top(10) - .edgesHorzontal(15) - - infoStackView.layoutChain - .edges() - - groupNameView.layoutChain - .height(60) - - groupIconView.layoutChain - .height(60) - - auditSwitchView.layoutChain - .height(57) - - groupManagerTitleLab.layoutChain - .topToBottomOfView(infoView, offset: 20) - .left(15) - - groupManagerView.layoutChain - .topToBottomOfView(groupManagerTitleLab, offset: 15) - .edgesHorzontal(15) - .bottom(30) - - groupManagerStackView.layoutChain - .edges() - - inviteView.layoutChain.height(57) - auditMemberView.layoutChain.height(57) - removeMemberView.layoutChain.height(57) - dismissGroupView.layoutChain.height(57) - leaveGroupView.layoutChain.height(57) - } - - 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: "") - return nav - }() - - lazy var scrollView: UIScrollView = { - let view = UIScrollView() - view.backgroundColor = .clear -// view.showsVerticalScrollIndicator = false -// view.bounces = false - return view - }() - - lazy var scrollContentView: UIView = { - let view = UIView() - view.backgroundColor = .clear - return view - }() - - // MARK: - 圈子信息 - lazy var infoView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFF") - view.cornerRadius = 10 - return view - }() - - lazy var infoStackView: UIStackView = { - let view = UIStackView(arrangedSubviews: [groupNameView, groupIconView, groupDescView, auditSwitchView, tagView]) - view.axis = .vertical - view.alignment = .fill - view.distribution = .fill - view.spacing = 0 - view.backgroundColor = .clear - return view - }() - - // 圈子名字 - lazy var groupNameView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "圈子名称" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - view.addSubview(groupNameEditIcon) - groupNameEditIcon.layoutChain - .right(15) - .width(20) - .height(20) - .centerY() - - view.addSubview(groupNameLab) - groupNameLab.layoutChain - .leftToRightOfView(titleLab, offset: 20) - .rightToLeftOfView(groupNameEditIcon, offset: -17, relation: .greaterThanOrEqual) - .centerY() - - return view - }() - - lazy var groupNameLab: UILabel = { - let label = UILabel() - label.textColor = ThemeManager.shared.color.titleAuxColor - label.font = .systemFont(ofSize: 14, weight: .medium) - return label - }() - - lazy var groupNameEditIcon: UIImageView = { - let view = UIImageView(image: UIImage(named: "Group/edit")) - view.isHidden = true - return view - }() - - // 圈子图标 - lazy var groupIconView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "圈子图标" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - view.addSubview(groupIconArrowIcon) - groupIconArrowIcon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - view.addSubview(groupIcon) - groupIcon.layoutChain - .leftToRightOfView(titleLab, offset: 20) - .centerY() - .width(40) - .heightToWidth(1) - - return view - }() - - lazy var groupIcon: UIImageView = { - let view = UIImageView() - view.backgroundColor = .clear - view.contentMode = .scaleAspectFill - view.cornerRadius = 10 - return view - }() - - lazy var groupIconArrowIcon: UIImageView = { - let view = UIImageView(image: UIImage(named: "Group/arrow")) - view.isHidden = true - return view - }() - - // 圈子描述 - lazy var groupDescView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "圈子描述" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(22) - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - view.addSubview(groupDescEditIcon) - groupDescEditIcon.layoutChain - .right(15) - .width(20) - .height(20) - .centerY(titleLab) - - view.addSubview(groupDescLab) - groupDescLab.layoutChain - .top(20) - .leftToRightOfView(titleLab, offset: 20) - .rightToLeftOfView(groupDescEditIcon, offset: -17) - .bottom(20) - - return view - }() - - lazy var groupDescLab: UILabel = { - let label = UILabel() - label.textColor = ThemeManager.shared.color.titleAuxColor - label.font = .systemFont(ofSize: 14, weight: .medium) - label.numberOfLines = 0 - return label - }() - - lazy var groupDescEditIcon: UIImageView = { - let view = UIImageView(image: UIImage(named: "Group/edit")) - view.isHidden = true - return view - }() - - // 审核开关 - lazy var auditSwitchView: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.clipsToBounds = true - - let titleLab = UILabel() - titleLab.text = "开启审核" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - view.addSubview(switchBtn) - switchBtn.layoutChain - .right(15) - .centerY() - .width(51) - .height(30) - - view.isHidden = true - - return view - }() - - lazy var switchBtn: UISwitch = { - let view = UISwitch() - view.isOn = false - return view - }() - - // 标签 - lazy var tagView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "圈子标签" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - view.addSubview(groupTagArrowIcon) - groupTagArrowIcon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY(titleLab) - - view.addSubview(tagListView) - tagListView.layoutChain - .top(15) - .leftToRightOfView(titleLab, offset: 23) - .height(27, relation: .greaterThanOrEqual) - .rightToLeftOfView(groupTagArrowIcon, offset: -17) - - let tipsLab = UILabel() - tipsLab.text = "如选择为私密圈子,将不能被分享到探索和被搜索。" - tipsLab.textColor = ThemeManager.shared.color.contentColor - tipsLab.font = .systemFont(ofSize: 10, weight: .regular) - view.addSubview(tipsLab) - tipsLab.layoutChain - .topToBottomOfView(tagListView, offset: 10) - .leftToView(titleLab) - .bottom(20) - - return view - }() - - lazy var tagListView: TagListView = { - let view = TagListView() - view.textFont = UIFont.systemFont(ofSize: 12, weight: .medium) - view.textColor = UIColor(hexStr: "#16B3FF") - view.tagBackgroundColor = UIColor(hexStr: "#E3F6FF") - view.paddingX = 20 // 水平内边距 - view.paddingY = 6 // 垂直内边距 - view.alignment = .left // 对齐 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var groupTagArrowIcon: UIImageView = { - let view = UIImageView(image: UIImage(named: "Group/arrow")) - view.isHidden = true - return view - }() - - // MARK: - 圈子管理 - lazy var groupManagerTitleLab: UILabel = { - let label = UILabel() - label.text = "圈子管理" - label.textColor = ThemeManager.shared.color.titleAuxColor - label.font = .systemFont(ofSize: 16, weight: .semibold) - return label - }() - - lazy var groupManagerView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFF") - view.cornerRadius = 10 - return view - }() - - lazy var groupManagerStackView: UIStackView = { - let view = UIStackView(arrangedSubviews: [inviteView, auditMemberView, removeMemberView, dismissGroupView, leaveGroupView]) - view.axis = .vertical - view.alignment = .fill - view.distribution = .fill - view.spacing = 0 - view.backgroundColor = .clear - return view - }() - - // 邀请成员 - lazy var inviteView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "邀请成员" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - let icon = UIImageView(image: UIImage(named: "Group/arrow")) - view.addSubview(icon) - icon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - return view - }() - - // 审核成员 - lazy var auditMemberView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - let titleLab = UILabel() - titleLab.text = "审核成员" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - let icon = UIImageView(image: UIImage(named: "Group/arrow")) - view.addSubview(icon) - icon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - return view - }() - - // 移除成员 - lazy var removeMemberView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - let titleLab = UILabel() - titleLab.text = "移除成员" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - let icon = UIImageView(image: UIImage(named: "Group/arrow")) - view.addSubview(icon) - icon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - return view - }() - - // 解散圈子 - lazy var dismissGroupView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - let titleLab = UILabel() - titleLab.text = "解散圈子" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - let icon = UIImageView(image: UIImage(named: "Group/arrow")) - view.addSubview(icon) - icon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - return view - }() - - // 退出圈子 - lazy var leaveGroupView = { - let view = UIView() - view.backgroundColor = .clear - view.isHidden = true - let titleLab = UILabel() - titleLab.text = "退出圈子" - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - titleLab.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(titleLab) - titleLab.layoutChain - .left(15) - .width(50) - .top(20) - - let icon = UIImageView(image: UIImage(named: "Group/arrow")) - view.addSubview(icon) - icon.layoutChain - .right(15) - .width(14) - .height(14) - .centerY() - - let line = UIView() - line.backgroundColor = ThemeManager.shared.color.lineColor - view.addSubview(line) - line.layoutChain - .bottom() - .height(0.5) - .edgesHorzontal(15) - - return view - }() - - override init(frame: CGRect) { - super.init(frame: .zero) - backgroundColor = .white - setupUI() - setupRx() + scrollContentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(memberCardView) + contentStackView.addArrangedSubview(infoCardView) + contentStackView.addArrangedSubview(utilityCardView) + contentStackView.addArrangedSubview(dangerCardView) + + setupMemberCard() + setupInfoCard() + setupUtilityCard() + setupDangerCard() + + navBgView.translatesAutoresizingMaskIntoConstraints = false + navView.translatesAutoresizingMaskIntoConstraints = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollContentView.translatesAutoresizingMaskIntoConstraints = false + contentStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + navBgView.topAnchor.constraint(equalTo: topAnchor), + navBgView.leadingAnchor.constraint(equalTo: leadingAnchor), + navBgView.trailingAnchor.constraint(equalTo: trailingAnchor), + navBgView.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 160.0 / 375.0), + + navView.topAnchor.constraint(equalTo: topAnchor), + navView.leadingAnchor.constraint(equalTo: leadingAnchor), + navView.trailingAnchor.constraint(equalTo: trailingAnchor), + navView.heightAnchor.constraint(equalToConstant: kNaviHeight), + + scrollView.topAnchor.constraint(equalTo: navView.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + scrollContentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + scrollContentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + scrollContentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + scrollContentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + scrollContentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + contentStackView.topAnchor.constraint(equalTo: scrollContentView.topAnchor, constant: 14), + contentStackView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor, constant: pageInset), + contentStackView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor, constant: -pageInset), + contentStackView.bottomAnchor.constraint(equalTo: scrollContentView.bottomAnchor, constant: -30) + ]) } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + private func setupMemberCard() { + memberCardView.addSubview(memberHeaderView) + memberCardView.addSubview(memberGridView) + memberCardView.addSubview(memberSeparatorView) + memberCardView.addSubview(memberActionsView) + + memberHeaderView.addSubview(memberTitleLab) + memberHeaderView.addSubview(memberCountLab) + memberHeaderView.addSubview(memberHeaderArrowView) + + memberActionsView.addSubview(inviteCodeButton) + memberActionsView.addSubview(actionDividerView) + memberActionsView.addSubview(shareButton) + + let views = [memberHeaderView, memberGridView, memberSeparatorView, memberActionsView, + memberTitleLab, memberCountLab, memberHeaderArrowView, + inviteCodeButton, actionDividerView, shareButton] + views.forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + memberHeaderView.topAnchor.constraint(equalTo: memberCardView.topAnchor), + memberHeaderView.leadingAnchor.constraint(equalTo: memberCardView.leadingAnchor), + memberHeaderView.trailingAnchor.constraint(equalTo: memberCardView.trailingAnchor), + memberHeaderView.heightAnchor.constraint(equalToConstant: 44), + + memberTitleLab.leadingAnchor.constraint(equalTo: memberHeaderView.leadingAnchor, constant: 16), + memberTitleLab.centerYAnchor.constraint(equalTo: memberHeaderView.centerYAnchor), + memberCountLab.trailingAnchor.constraint(equalTo: memberHeaderArrowView.leadingAnchor, constant: -8), + memberCountLab.centerYAnchor.constraint(equalTo: memberHeaderView.centerYAnchor), + memberHeaderArrowView.trailingAnchor.constraint(equalTo: memberHeaderView.trailingAnchor, constant: -16), + memberHeaderArrowView.centerYAnchor.constraint(equalTo: memberHeaderView.centerYAnchor), + memberHeaderArrowView.widthAnchor.constraint(equalToConstant: 16), + memberHeaderArrowView.heightAnchor.constraint(equalToConstant: 16), + + memberGridView.topAnchor.constraint(equalTo: memberHeaderView.bottomAnchor), + memberGridView.leadingAnchor.constraint(equalTo: memberCardView.leadingAnchor, constant: 20), + memberGridView.trailingAnchor.constraint(equalTo: memberCardView.trailingAnchor, constant: -20), + + memberSeparatorView.topAnchor.constraint(equalTo: memberGridView.bottomAnchor, constant: 6), + memberSeparatorView.leadingAnchor.constraint(equalTo: memberCardView.leadingAnchor, constant: 16), + memberSeparatorView.trailingAnchor.constraint(equalTo: memberCardView.trailingAnchor, constant: -16), + memberSeparatorView.heightAnchor.constraint(equalToConstant: 1), + + memberActionsView.topAnchor.constraint(equalTo: memberSeparatorView.bottomAnchor, constant: 4), + memberActionsView.leadingAnchor.constraint(equalTo: memberCardView.leadingAnchor), + memberActionsView.trailingAnchor.constraint(equalTo: memberCardView.trailingAnchor), + memberActionsView.bottomAnchor.constraint(equalTo: memberCardView.bottomAnchor, constant: -6), + memberActionsView.heightAnchor.constraint(equalToConstant: 40), + + inviteCodeButton.leadingAnchor.constraint(equalTo: memberActionsView.leadingAnchor), + inviteCodeButton.topAnchor.constraint(equalTo: memberActionsView.topAnchor), + inviteCodeButton.bottomAnchor.constraint(equalTo: memberActionsView.bottomAnchor), + inviteCodeButton.trailingAnchor.constraint(equalTo: actionDividerView.leadingAnchor), + + actionDividerView.centerXAnchor.constraint(equalTo: memberActionsView.centerXAnchor), + actionDividerView.centerYAnchor.constraint(equalTo: memberActionsView.centerYAnchor), + actionDividerView.widthAnchor.constraint(equalToConstant: 0.5), + actionDividerView.heightAnchor.constraint(equalToConstant: 20), + + shareButton.leadingAnchor.constraint(equalTo: actionDividerView.trailingAnchor), + shareButton.trailingAnchor.constraint(equalTo: memberActionsView.trailingAnchor), + shareButton.topAnchor.constraint(equalTo: memberActionsView.topAnchor), + shareButton.bottomAnchor.constraint(equalTo: memberActionsView.bottomAnchor) + ]) + } + + private func setupInfoCard() { + infoCardView.addSubview(infoStackView) + infoStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + infoStackView.topAnchor.constraint(equalTo: infoCardView.topAnchor), + infoStackView.leadingAnchor.constraint(equalTo: infoCardView.leadingAnchor), + infoStackView.trailingAnchor.constraint(equalTo: infoCardView.trailingAnchor), + infoStackView.bottomAnchor.constraint(equalTo: infoCardView.bottomAnchor) + ]) + + infoStackView.addArrangedSubview(groupNameView) + infoStackView.addArrangedSubview(groupTypeView) + infoStackView.addArrangedSubview(groupDescView) + infoStackView.addArrangedSubview(groupNicknameView) + infoStackView.addArrangedSubview(auditSwitchView) + infoStackView.addArrangedSubview(auditMemberView) + infoStackView.addArrangedSubview(tagView) + } + + private func setupUtilityCard() { + utilityCardView.addSubview(clearHistoryView) + clearHistoryView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate(clearHistoryView.edgesToSuperview()) + } + + private func setupDangerCard() { + dangerCardView.addSubview(dangerStackView) + dangerStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate(dangerStackView.edgesToSuperview()) + dangerStackView.addArrangedSubview(dismissGroupView) + dangerStackView.addArrangedSubview(leaveGroupView) + } + + private func makeMemberActionButton(title: String, imageName: String) -> UIButton { + let button = UIButton(type: .custom) + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 7 + stackView.isUserInteractionEnabled = false + + let imageView = UIImageView(image: UIImage(named: imageName)) + imageView.contentMode = .scaleAspectFit + imageView.widthAnchor.constraint(equalToConstant: 18).isActive = true + imageView.heightAnchor.constraint(equalToConstant: 18).isActive = true + + let titleLab = UILabel() + titleLab.text = title + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.font = .systemFont(ofSize: 16, weight: .medium) + + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLab) + button.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: button.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor) + ]) + return button + } + + lazy var navBgView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Common/navBar_bg_2")) + view.contentMode = .scaleAspectFill + return view + }() + + lazy var navView: BaseNavigationView = BaseNavigationView(title: "圈子详情") + + private lazy var scrollView: UIScrollView = { + let view = UIScrollView() + view.backgroundColor = .clear + view.showsVerticalScrollIndicator = false + return view + }() + + private lazy var scrollContentView = UIView() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 16 + view.alignment = .fill + view.distribution = .fill + return view + }() + + // MARK: - 圈子成员 + private lazy var memberCardView = makeCardView() + + let memberHeaderView = UIView() + + private lazy var memberTitleLab: UILabel = { + let label = UILabel() + label.text = "圈子成员" + label.textColor = UIColor(hexStr: "#293445") + label.font = .systemFont(ofSize: 17, weight: .semibold) + return label + }() + + private lazy var memberCountLab: UILabel = { + let label = UILabel() + label.textColor = UIColor(hexStr: "#A5A8AE") + label.font = .systemFont(ofSize: 13, weight: .regular) + return label + }() + + private lazy var memberHeaderArrowView: UIImageView = makeArrowView() + + private let memberGridView = GroupSettingMemberGridView() + private let memberSeparatorView = HorizontalDashLineView() + private let memberActionsView = UIView() + + lazy var inviteCodeButton: UIButton = makeMemberActionButton(title: "邀请码", imageName: "GroupSetting/invite_code") + lazy var shareButton: UIButton = makeMemberActionButton(title: "分享", imageName: "GroupSetting/share") + + private lazy var actionDividerView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#E7E8EA") + return view + }() + + // MARK: - 圈子信息 + private lazy var infoCardView = makeCardView() + + private lazy var infoStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 0 + view.alignment = .fill + view.distribution = .fill + return view + }() + + let groupNameView = GroupSettingInfoRow(title: "圈子昵称", style: .text) + let groupTypeView = GroupSettingInfoRow(title: "圈子类型", style: .text) + let groupDescView = GroupSettingInfoRow(title: "圈子描述", style: .multilineText) + let groupNicknameView = GroupSettingInfoRow(title: "我在本群昵称", style: .text) + let auditSwitchView = GroupSettingInfoRow(title: "审核开关", style: .toggle) + let auditMemberView = GroupSettingInfoRow(title: "审核成员", style: .text) + let tagView = GroupSettingTagRow() + + var switchBtn: UISwitch { auditSwitchView.switchButton } + + // MARK: - 操作 + private lazy var utilityCardView = makeCardView() + let clearHistoryView = GroupSettingActionRow(title: "清空聊天记录") + + private lazy var dangerCardView = makeCardView() + + private lazy var dangerStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 0 + view.alignment = .fill + view.distribution = .fill + return view + }() + + let dismissGroupView = GroupSettingActionRow(title: "解散圈子", titleColor: UIColor(hexStr: "#FF4D53"), hidesDivider: true) + let leaveGroupView = GroupSettingActionRow(title: "退出圈子", titleColor: UIColor(hexStr: "#FF4D53"), hidesDivider: true) + + private func makeCardView() -> UIView { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + } + + private func makeArrowView() -> UIImageView { + let view = UIImageView(image: UIImage(named: "GroupSetting/arrow_right")) + view.contentMode = .scaleAspectFit + return view + } +} + +private final class GroupSettingMemberGridView: UIView { + + private let itemWidth: CGFloat = 56 + private let itemHeight: CGFloat = 62 + var onRemoveMemberShortcutTapped: (() -> Void)? + var onMembersTapped: (() -> Void)? + private weak var removeMemberCell: GroupSettingRemoveMemberCell? + + private lazy var scrollView: UIScrollView = { + let view = UIScrollView() + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.alwaysBounceHorizontal = true + view.delaysContentTouches = false + return view + }() + + private lazy var itemsStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.spacing = 12 + view.alignment = .top + view.distribution = .fill + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(scrollView) + scrollView.addSubview(itemsStackView) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tap.cancelsTouchesInView = false + scrollView.addGestureRecognizer(tap) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + itemsStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + itemsStackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + itemsStackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + itemsStackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + itemsStackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + itemsStackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + heightAnchor.constraint(equalToConstant: itemHeight).isActive = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(members: [GroupMemberModel], showsRemoveShortcut: Bool) { + itemsStackView.arrangedSubviews.forEach { + itemsStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + removeMemberCell = nil + + members.forEach { member in + let cell = GroupSettingMemberCell(member: member) + cell.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true + itemsStackView.addArrangedSubview(cell) + } + + if showsRemoveShortcut { + let removeCell = GroupSettingRemoveMemberCell() + removeMemberCell = removeCell + removeCell.widthAnchor.constraint(equalToConstant: itemWidth).isActive = true + itemsStackView.addArrangedSubview(removeCell) + } + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + if let removeMemberCell { + let point = gesture.location(in: removeMemberCell) + if removeMemberCell.bounds.contains(point) { + onRemoveMemberShortcutTapped?() + return + } + } + onMembersTapped?() + } +} + +private final class GroupSettingMemberCell: UIView { + + init(member: GroupMemberModel) { + super.init(frame: .zero) + setupUI() + avatarView.image = member.userIcon + nicknameLab.text = member.nick_name.isEmpty ? "未知昵称" : member.nick_name + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(avatarView) + addSubview(nicknameLab) + avatarView.translatesAutoresizingMaskIntoConstraints = false + nicknameLab.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarView.topAnchor.constraint(equalTo: topAnchor), + avatarView.centerXAnchor.constraint(equalTo: centerXAnchor), + avatarView.widthAnchor.constraint(equalToConstant: 40), + avatarView.heightAnchor.constraint(equalToConstant: 40), + nicknameLab.topAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 5), + nicknameLab.leadingAnchor.constraint(equalTo: leadingAnchor), + nicknameLab.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + + private lazy var avatarView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.layer.cornerRadius = 11 + view.clipsToBounds = true + return view + }() + + private lazy var nicknameLab: UILabel = { + let label = UILabel() + label.textColor = UIColor(hexStr: "#81858C") + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + return label + }() +} + +private final class GroupSettingRemoveMemberCell: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(iconContainer) + addSubview(titleLab) + iconContainer.addSubview(iconView) + [iconContainer, titleLab, iconView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + NSLayoutConstraint.activate([ + iconContainer.topAnchor.constraint(equalTo: topAnchor), + iconContainer.centerXAnchor.constraint(equalTo: centerXAnchor), + iconContainer.widthAnchor.constraint(equalToConstant: 40), + iconContainer.heightAnchor.constraint(equalToConstant: 40), + iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 16), + iconView.heightAnchor.constraint(equalToConstant: 16), + titleLab.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 5), + titleLab.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLab.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var iconContainer: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#F6F4F5") + view.layer.cornerRadius = 12 + return view + }() + + private lazy var iconView: UIImageView = { + let view = UIImageView(image: UIImage(named: "GroupSetting/remove_member")) + view.contentMode = .scaleAspectFit + return view + }() + + private lazy var titleLab: UILabel = { + let label = UILabel() + label.text = "移除" + label.textColor = UIColor(hexStr: "#81858C") + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textAlignment = .center + return label + }() +} + +final class GroupSettingInfoRow: UIView { + + enum Style { + case text + case icon + case multilineText + case toggle + } + + let switchButton = UISwitch() + + private let style: Style + private let titleLab = UILabel() + private let valueLab = UILabel() + private let valueImageView = UIImageView() + private let arrowImageView = UIImageView(image: UIImage(named: "GroupSetting/arrow_right")) + private let dividerView = UIView() + + private var valueTrailingToArrow: NSLayoutConstraint? + private var valueTrailingToRow: NSLayoutConstraint? + private var imageTrailingToArrow: NSLayoutConstraint? + private var imageTrailingToRow: NSLayoutConstraint? + + init(title: String, style: Style) { + self.style = style + super.init(frame: .zero) + titleLab.text = title + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ value: String) { + valueLab.text = value + } + + func setImage(_ image: UIImage?) { + valueImageView.image = image + } + + func setShowsArrow(_ showsArrow: Bool) { + guard style != .toggle else { return } + arrowImageView.isHidden = !showsArrow + valueTrailingToArrow?.isActive = showsArrow + valueTrailingToRow?.isActive = !showsArrow + imageTrailingToArrow?.isActive = showsArrow + imageTrailingToRow?.isActive = !showsArrow + } + + private func setupUI() { + backgroundColor = .clear + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.font = .systemFont(ofSize: 16, weight: .regular) + titleLab.setContentHuggingPriority(.required, for: .horizontal) + titleLab.setContentCompressionResistancePriority(.required, for: .horizontal) + valueLab.textColor = UIColor(hexStr: "#4B5565") + valueLab.font = .systemFont(ofSize: 16, weight: .regular) + valueLab.textAlignment = .right + valueLab.lineBreakMode = .byTruncatingTail + valueLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + valueImageView.contentMode = .scaleAspectFill + valueImageView.layer.cornerRadius = 10 + valueImageView.clipsToBounds = true + arrowImageView.contentMode = .scaleAspectFit + dividerView.backgroundColor = UIColor(hexStr: "#EDEEF0") + switchButton.onTintColor = UIColor(hexStr: "#16B3FF") + + [titleLab, valueLab, valueImageView, arrowImageView, switchButton, dividerView].forEach { + addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + titleLab.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + arrowImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + arrowImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + arrowImageView.widthAnchor.constraint(equalToConstant: 16), + arrowImageView.heightAnchor.constraint(equalToConstant: 16), + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 0.5) + ]) + + switch style { + case .text: + setupTextLayout(multiline: false) + case .icon: + setupIconLayout() + case .multilineText: + setupTextLayout(multiline: true) + case .toggle: + setupToggleLayout() + } + } + + private func setupTextLayout(multiline: Bool) { + valueImageView.isHidden = true + switchButton.isHidden = true + valueTrailingToArrow = valueLab.trailingAnchor.constraint(equalTo: arrowImageView.leadingAnchor, constant: -10) + valueTrailingToRow = valueLab.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) + valueTrailingToRow?.isActive = true + arrowImageView.isHidden = true + + if multiline { + valueLab.numberOfLines = 2 + valueLab.textAlignment = .right + NSLayoutConstraint.activate([ + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + valueLab.leadingAnchor.constraint(equalTo: titleLab.trailingAnchor, constant: 20), + valueLab.centerYAnchor.constraint(equalTo: centerYAnchor), + valueLab.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 12), + valueLab.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -12), + heightAnchor.constraint(greaterThanOrEqualToConstant: 72) + ]) + } else { + NSLayoutConstraint.activate([ + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + valueLab.leadingAnchor.constraint(greaterThanOrEqualTo: titleLab.trailingAnchor, constant: 20), + valueLab.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 62) + ]) + } + } + + private func setupIconLayout() { + valueLab.isHidden = true + switchButton.isHidden = true + arrowImageView.isHidden = true + imageTrailingToArrow = valueImageView.trailingAnchor.constraint(equalTo: arrowImageView.leadingAnchor, constant: -12) + imageTrailingToRow = valueImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) + imageTrailingToRow?.isActive = true + NSLayoutConstraint.activate([ + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + valueImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + valueImageView.widthAnchor.constraint(equalToConstant: 40), + valueImageView.heightAnchor.constraint(equalToConstant: 40), + heightAnchor.constraint(equalToConstant: 64) + ]) + } + + private func setupToggleLayout() { + valueLab.isHidden = true + valueImageView.isHidden = true + arrowImageView.isHidden = true + NSLayoutConstraint.activate([ + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + switchButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + switchButton.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 62) + ]) + } +} + +final class GroupSettingTagRow: UIView { + + private let titleLab = UILabel() + private let arrowImageView = UIImageView(image: UIImage(named: "GroupSetting/arrow_right")) + private let tagListView = TagListView() + private let dividerView = UIView() + + private var tagTrailingToArrow: NSLayoutConstraint? + private var tagTrailingToRow: NSLayoutConstraint? + + init() { + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(tags: [String], editable: Bool) { + tagListView.removeAllTags() + tagListView.addTags(tags) + tagListView.tagViews.forEach { $0.layer.cornerRadius = 7 } + arrowImageView.isHidden = !editable + tagTrailingToArrow?.isActive = editable + tagTrailingToRow?.isActive = !editable + tagListView.invalidateIntrinsicContentSize() + } + + private func setupUI() { + titleLab.text = "圈子标签" + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.font = .systemFont(ofSize: 16, weight: .regular) + titleLab.setContentHuggingPriority(.required, for: .horizontal) + titleLab.setContentCompressionResistancePriority(.required, for: .horizontal) + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.isHidden = true + dividerView.backgroundColor = UIColor(hexStr: "#EDEEF0") + + tagListView.textFont = .systemFont(ofSize: 14, weight: .regular) + tagListView.textColor = UIColor(hexStr: "#16B3FF") + tagListView.tagBackgroundColor = UIColor(hexStr: "#E4F6FF") + tagListView.paddingX = 12 + tagListView.paddingY = 5 + tagListView.alignment = .right + tagListView.setContentHuggingPriority(.defaultLow, for: .horizontal) + tagListView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + [titleLab, arrowImageView, tagListView, dividerView].forEach { + addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + tagTrailingToArrow = tagListView.trailingAnchor.constraint(equalTo: arrowImageView.leadingAnchor, constant: -10) + tagTrailingToRow = tagListView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) + tagTrailingToRow?.isActive = true + + NSLayoutConstraint.activate([ + titleLab.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + titleLab.topAnchor.constraint(equalTo: topAnchor, constant: 18), + arrowImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + arrowImageView.centerYAnchor.constraint(equalTo: titleLab.centerYAnchor), + arrowImageView.widthAnchor.constraint(equalToConstant: 16), + arrowImageView.heightAnchor.constraint(equalToConstant: 16), + tagListView.leadingAnchor.constraint(equalTo: titleLab.trailingAnchor, constant: 20), + tagListView.topAnchor.constraint(equalTo: topAnchor, constant: 12), + tagListView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12), + tagListView.heightAnchor.constraint(greaterThanOrEqualToConstant: 28), + heightAnchor.constraint(greaterThanOrEqualToConstant: 62), + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 0.5) + ]) + } +} + +final class GroupSettingActionRow: UIView { + + init(title: String, + titleColor: UIColor = UIColor(hexStr: "#293445"), + hidesDivider: Bool = false) { + super.init(frame: .zero) + titleLab.text = title + titleLab.textColor = titleColor + dividerView.isHidden = hidesDivider + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + titleLab.font = .systemFont(ofSize: 16, weight: .regular) + arrowImageView.contentMode = .scaleAspectFit + dividerView.backgroundColor = UIColor(hexStr: "#EDEEF0") + [titleLab, arrowImageView, dividerView].forEach { + addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + titleLab.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + arrowImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + arrowImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + arrowImageView.widthAnchor.constraint(equalToConstant: 16), + arrowImageView.heightAnchor.constraint(equalToConstant: 16), + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 0.5), + heightAnchor.constraint(equalToConstant: 62) + ]) + } + + private let titleLab = UILabel() + private let arrowImageView = UIImageView(image: UIImage(named: "GroupSetting/arrow_right")) + private let dividerView = UIView() +} + +private final class GroupSettingDashedLineView: UIView { + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + context.setStrokeColor(UIColor(hexStr: "#D8D8D8").cgColor) + context.setLineWidth(1 / UIScreen.main.scale) + context.setLineDash(phase: 0, lengths: [4, 4]) + context.move(to: CGPoint(x: 0, y: rect.midY)) + context.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + context.strokePath() + } +} + +private extension UIView { + func edgesToSuperview() -> [NSLayoutConstraint] { + guard let superview = superview else { return [] } + return [ + topAnchor.constraint(equalTo: superview.topAnchor), + leadingAnchor.constraint(equalTo: superview.leadingAnchor), + trailingAnchor.constraint(equalTo: superview.trailingAnchor), + bottomAnchor.constraint(equalTo: superview.bottomAnchor) + ] } } diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift index 689b66ce..2b91d416 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift @@ -12,6 +12,12 @@ struct GroupSettingViewModel { let groupId: String var groupModel: GroupInfoModel? + var members: [GroupMemberModel] = [] + var groupNickname: String = "" + + var displayGroupNickname: String { + groupNickname.isEmpty ? AppContextManager.shared.name : groupNickname + } init(groupId: String) { self.groupId = groupId diff --git a/QuickLocation/Section/Group/GroupView.swift b/QuickLocation/Section/Group/GroupView.swift index 13dca9c3..0e10cf98 100644 --- a/QuickLocation/Section/Group/GroupView.swift +++ b/QuickLocation/Section/Group/GroupView.swift @@ -631,8 +631,13 @@ final class HotGroupCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + iconView.setGroupIcon(url: "", fallback: nil) + } + func configure(_ model: GroupInfoModel) { - iconView.image = model.groupIcon + iconView.setGroupIcon(model) nameLabel.text = model.name countLabel.text = "共\(model.people_no)个成员" } @@ -754,9 +759,14 @@ final class CircleGroupCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + iconView.setGroupIcon(url: "", fallback: nil) + } + func configure(_ model: GroupCellData) { let g = model.groupInfo - iconView.image = model.localIcon + iconView.setGroupIcon(url: model.iconURL, fallback: model.localIcon) let count = g.memberCount nameLabel.text = "\(g.groupName ?? "") (\(count))" previewLabel.text = model.lastMsgPreview diff --git a/QuickLocation/Section/Group/GroupViewModel.swift b/QuickLocation/Section/Group/GroupViewModel.swift index ac5affd1..ca191ac6 100644 --- a/QuickLocation/Section/Group/GroupViewModel.swift +++ b/QuickLocation/Section/Group/GroupViewModel.swift @@ -20,6 +20,7 @@ struct GroupCellData { let lastMsgTime: Int // ms timestamp let lastMsgPreview: String let tags: [String] + let iconURL: String let localIcon: UIImage } @@ -94,12 +95,14 @@ final class GroupViewModel { for g in cachedGroups { let conv = convMap[g.groupID ?? ""] + let businessGroup = groupList.first { $0.group_key == g.groupID } let data = GroupCellData( groupInfo: g, unreadCount: Int(conv?.unreadCount ?? 0), lastMsgTime: Int(conv?.latestMsgSendTime ?? 0), lastMsgPreview: Self.previewText(from: conv?.latestMsg), - tags: Array((groupList.first { $0.group_key == g.groupID }?.labels ?? []).prefix(2)), + tags: Array((businessGroup?.labels ?? []).prefix(2)), + iconURL: businessGroup?.groupIconURL ?? "", localIcon: getLocalIcon(groupId: g.groupID ?? "") ) all.append(data) diff --git a/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift b/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift index 5ba372dc..d469a915 100644 --- a/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift +++ b/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift @@ -14,7 +14,7 @@ class InviteJoinView: UIView { var disposeBag = DisposeBag() func configure(model: GroupInfoModel) { - groupIcon.image = model.groupIcon + groupIcon.setGroupIcon(model) groupNameLab.text = model.name groupDescLab.text = model.description groupDescLab.isHidden = model.description.isEmpty diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift index 73198a88..c66233ca 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -4,10 +4,7 @@ // import UIKit -import RxSwift -import RxCocoa import ObjectMapper -import Kingfisher final class MemberInfoVC: BaseViewController { @@ -59,42 +56,18 @@ final class MemberInfoVC: BaseViewController { rootView.navView.onBackTapped = { [weak self] in self?.navigationController?.popViewController(animated: true) } - rootView.switchGroupBtn.rx.tap - .subscribe(onNext: { [weak self] in - self?.switchGroup() - }) - .disposed(by: disposeBag) - } - - private func switchGroup() { - guard let groupModel else { return } - GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in - guard let self, let key = groupKey else { return } - GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) - .subscribe(onNext: { [weak self] _ in - NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) - self?.navigationController?.popViewController(animated: true) - }) - .disposed(by: self.disposeBag) - } } private func refreshDetail() { let member = members.first { $0.user_id == selectedUserId } let isSelf = selectedUserId == AppContextManager.shared.userId let joinText: String = { - guard let ts = member?.join_time, ts > 0 else { return "**" } - let date = Date(timeIntervalSince1970: TimeInterval(ts) / 1000) + guard let date = member?.joinDate else { return "**" } let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f.string(from: date) }() - let circleName: String = { - guard let gm = groupModel else { return "共同圈子" } - let name = gm.groups.first(where: { $0.group_key == gm.default_group_key })?.name - ?? gm.groups.first?.name - return (name?.isEmpty == false) ? name! : "共同圈子" - }() + let commonGroupsRow = makeCommonGroupsRow(member: member) if isSelf { let sexText: String = { @@ -116,7 +89,7 @@ final class MemberInfoVC: BaseViewController { AppRouter.push(Route.myProfile) }, MemberInfoRow(title: "添加时间", value: joinText, kind: .text), - MemberInfoRow(title: "共同圈子", value: circleName, kind: .tag) + commonGroupsRow ]) } else { rootView.renderRows([ @@ -129,10 +102,62 @@ final class MemberInfoVC: BaseViewController { }, MemberInfoRow(title: "性别", value: "保密", kind: .text), MemberInfoRow(title: "添加时间", value: joinText, kind: .text), - MemberInfoRow(title: "共同圈子", value: circleName, kind: .tag) + commonGroupsRow ]) } } + + private func makeCommonGroupsRow(member: GroupMemberModel?) -> MemberInfoRow { + let groups = resolvedCommonGroups(for: member) + let firstName = groups.first?.group_name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let visibleName = firstName.isEmpty ? "--" : firstName + let showsMore = groups.count > 1 + let action: (() -> Void)? = showsMore ? { [weak self] in + self?.showCommonGroups(groups) + } : nil + return MemberInfoRow( + title: "共同圈子", + value: visibleName, + kind: .tag, + showsMoreTags: showsMore, + action: action + ) + } + + private func resolvedCommonGroups(for member: GroupMemberModel?) -> [CommonGroupModel] { + var groups = CommonGroupModel.deduplicated(member?.common_groups ?? []) + let groupInfos = groupModel?.groups ?? [] + + for index in groups.indices where groups[index].group_name.isEmpty { + groups[index].group_name = groupInfos.first { + $0.group_key == groups[index].group_key + }?.name ?? "未知圈子" + } + + let currentGroup = groupInfos.first { $0.group_key == groupModel?.default_group_key } + ?? groupInfos.first + if let currentGroup, !currentGroup.group_key.isEmpty { + if let index = groups.firstIndex(where: { $0.group_key == currentGroup.group_key }) { + var group = groups.remove(at: index) + if group.group_name.isEmpty || group.group_name == "未知圈子" { + group.group_name = currentGroup.name + } + groups.insert(group, at: 0) + } else { + groups.insert( + CommonGroupModel(groupKey: currentGroup.group_key, groupName: currentGroup.name), + at: 0 + ) + } + } + return CommonGroupModel.deduplicated(groups) + } + + private func showCommonGroups(_ groups: [CommonGroupModel]) { + guard groups.count > 1 else { return } + let popup = CommonGroupsPopVC(groups: groups, groupInfos: groupModel?.groups ?? []) + present(popup, animated: true) + } } extension MemberInfoVC: UICollectionViewDataSource, UICollectionViewDelegate { diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift index 78c7ef37..d154c460 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift @@ -10,16 +10,6 @@ final class MemberInfoView: UIView { lazy var navView = BaseNavigationView(title: "成员信息") - let switchGroupBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.backgroundColor = .white.withAlphaComponent(0.8) - btn.setTitle(" 切换圈子 ", for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) - btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) - btn.cornerRadius = 8 - return btn - }() - lazy var memberCV: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal @@ -50,7 +40,6 @@ final class MemberInfoView: UIView { private func setupUI() { addSubview(navView) navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) - navView.addRightButton(switchGroupBtn) addSubview(scrollView) scrollView.layoutChain.topToBottomOfView(navView).edgesHorzontal().bottom() @@ -106,12 +95,19 @@ struct MemberInfoRow { let kind: MemberInfoRowKind let action: (() -> Void)? let avatarURL: String? + let showsMoreTags: Bool - init(title: String, value: String, kind: MemberInfoRowKind = .text, avatarURL: String? = nil, action: (() -> Void)? = nil) { + init(title: String, + value: String, + kind: MemberInfoRowKind = .text, + avatarURL: String? = nil, + showsMoreTags: Bool = false, + action: (() -> Void)? = nil) { self.title = title self.value = value self.kind = kind self.avatarURL = avatarURL + self.showsMoreTags = showsMoreTags self.action = action } } @@ -121,7 +117,6 @@ final class MemberInfoRowView: UIControl { private let valueLab = UILabel() private let arrow = UIImageView(image: UIImage(named: "Mine/arrow_right")) private let avatar = UIImageView() - private let tagLab = UILabel() private let actionHandler: (() -> Void)? init(row: MemberInfoRow) { @@ -145,14 +140,6 @@ final class MemberInfoRowView: UIControl { avatar.clipsToBounds = true avatar.layer.cornerRadius = 14 - tagLab.text = row.value - tagLab.font = .systemFont(ofSize: 12, weight: .medium) - tagLab.textColor = UIColor(hexStr: "#E8A045") - tagLab.backgroundColor = UIColor(hexStr: "#FFF3E0") - tagLab.textAlignment = .center - tagLab.layer.cornerRadius = 10 - tagLab.clipsToBounds = true - switch row.kind { case .text: addSubview(valueLab) @@ -161,12 +148,12 @@ final class MemberInfoRowView: UIControl { addSubview(arrow) arrow.layoutChain.right(15).centerY().width(12).height(12) addSubview(valueLab) - valueLab.layoutChain.rightToLeftOfView(arrow, offset: 6).centerY().leftToRightOfView(titleLab, offset: 12) + valueLab.layoutChain.rightToLeftOfView(arrow, offset: -8).centerY().leftToRightOfView(titleLab, offset: 12) case .avatar: addSubview(arrow) arrow.layoutChain.right(15).centerY().width(12).height(12) addSubview(avatar) - avatar.layoutChain.rightToLeftOfView(arrow, offset: 8).centerY().width(28).height(28) + avatar.layoutChain.rightToLeftOfView(arrow, offset: -8).centerY().width(28).height(28) if let pic = row.avatarURL, !pic.isEmpty, let img = UIImage(named: "UserIcon/\(pic)") { avatar.image = img } else if let urlStr = row.avatarURL, let u = URL(string: urlStr), urlStr.hasPrefix("http") { @@ -175,22 +162,227 @@ final class MemberInfoRowView: UIControl { avatar.image = UIImage(named: "Common/default_avatar") } case .tag: - addSubview(tagLab) - tagLab.layoutChain.right(15).centerY().height(20) - tagLab.setContentHuggingPriority(.required, for: .horizontal) - let pad = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) - tagLab.layoutMargins = pad - // approximate width from text - let w = (row.value as NSString).size(withAttributes: [.font: tagLab.font!]).width + 20 - tagLab.layoutChain.width(max(60, w)) + setupGroupTags(row: row) } if row.action != nil { + isAccessibilityElement = true + accessibilityTraits = .button addTarget(self, action: #selector(tap), for: .touchUpInside) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func setupGroupTags(row: MemberInfoRow) { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 8 + // The whole row owns the action; its display-only tags must not consume touches. + stack.isUserInteractionEnabled = false + addSubview(stack) + + if row.showsMoreTags { + let moreLabel = UILabel() + moreLabel.text = "..." + moreLabel.font = .systemFont(ofSize: 14, weight: .medium) + moreLabel.textColor = UIColor(hexStr: "#D5923F") + moreLabel.backgroundColor = UIColor(hexStr: "#FFF9DD") + moreLabel.textAlignment = .center + moreLabel.layer.cornerRadius = 7 + moreLabel.clipsToBounds = true + stack.addArrangedSubview(moreLabel) + moreLabel.layoutChain.width(26).height(26) + } + + let nameLabel = MemberInfoTagLabel() + nameLabel.text = row.value + nameLabel.font = .systemFont(ofSize: 14, weight: .medium) + nameLabel.textColor = UIColor(hexStr: "#D5923F") + nameLabel.backgroundColor = UIColor(hexStr: "#FFF9DD") + nameLabel.textAlignment = .center + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.layer.cornerRadius = 7 + nameLabel.clipsToBounds = true + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + stack.addArrangedSubview(nameLabel) + nameLabel.layoutChain.height(26) + nameLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 160).isActive = true + + if row.showsMoreTags { + arrow.contentMode = .scaleAspectFit + stack.addArrangedSubview(arrow) + arrow.layoutChain.width(12).height(12) + } + + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: titleLab.trailingAnchor, constant: 12) + ]) + } + @objc private func tap() { actionHandler?() } } + +private final class MemberInfoTagLabel: UILabel { + private let insets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + + 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 + ) + } +} + +final class CommonGroupsPopVC: DLCustomPopVC { + private let groups: [CommonGroupModel] + private let groupInfos: [String: GroupInfoModel] + private let rowHeight: CGFloat = 64 + private let maximumVisibleRows = 5 + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = "共同圈子" + label.font = .systemFont(ofSize: 18, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + label.textAlignment = .center + return label + }() + + private lazy var closeButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "Common/close"), for: .normal) + button.addTarget(self, action: #selector(close), for: .touchUpInside) + return button + }() + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .white + tableView.separatorStyle = .none + tableView.rowHeight = rowHeight + tableView.showsVerticalScrollIndicator = false + tableView.dataSource = self + tableView.register(CommonGroupReadonlyCell.self) + return tableView + }() + + init(groups: [CommonGroupModel], groupInfos: [GroupInfoModel]) { + self.groups = CommonGroupModel.deduplicated(groups) + self.groupInfos = Dictionary(groupInfos.map { ($0.group_key, $0) }, uniquingKeysWith: { first, _ in first }) + super.init(nibName: nil, bundle: nil) + popStyle = .bottom + bottomCornerRadius = 20 + dimmingClick = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + contentView.addSubview(titleLabel) + contentView.addSubview(closeButton) + contentView.addSubview(tableView) + + titleLabel.layoutChain.top(20).centerX().height(28) + closeButton.layoutChain.right(16).centerY(titleLabel).width(32).height(32) + tableView.layoutChain + .topToBottomOfView(titleLabel, offset: 12) + .left(12) + .right(12) + .bottom(16 + kSafeBottomMargin) + + let visibleRows = min(groups.count, maximumVisibleRows) + let height = 20 + 28 + 12 + CGFloat(visibleRows) * rowHeight + 16 + kSafeBottomMargin + // The custom transition lays out contentBgView before its descendants. Give + // the outer sheet an explicit height so the panel cannot collapse to zero. + contentBgView.layoutChain.height(height) + contentView.layoutChain.height(height) + tableView.isScrollEnabled = groups.count > maximumVisibleRows + view.layoutIfNeeded() + } + + @objc private func close() { + dismiss(animated: true) + } +} + +extension CommonGroupsPopVC: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + groups.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: CommonGroupReadonlyCell = tableView.dequeueReusableCell(for: indexPath) + let group = groups[indexPath.row] + cell.configure(group: group, info: groupInfos[group.group_key]) + return cell + } +} + +private final class CommonGroupReadonlyCell: UITableViewCell { + private let cardView = UIView() + private let iconView = UIImageView() + private let nameLabel = MemberInfoTagLabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + + cardView.backgroundColor = UIColor(hexStr: "#FAFAFA") + cardView.layer.cornerRadius = 10 + contentView.addSubview(cardView) + cardView.addSubview(iconView) + cardView.addSubview(nameLabel) + + cardView.layoutChain.edges(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + iconView.layoutChain.left(12).centerY().width(40).height(40) + nameLabel.layoutChain + .leftToRightOfView(iconView, offset: 12) + .right(12, relation: .greaterThanOrEqual) + .centerY() + .height(26) + + iconView.contentMode = .scaleAspectFill + iconView.layer.cornerRadius = 8 + iconView.clipsToBounds = true + nameLabel.font = .systemFont(ofSize: 14, weight: .medium) + nameLabel.textColor = UIColor(hexStr: "#D5923F") + nameLabel.backgroundColor = UIColor(hexStr: "#FFF9DD") + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.layer.cornerRadius = 7 + nameLabel.clipsToBounds = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + iconView.setGroupIcon(url: "", fallback: nil) + } + + func configure(group: CommonGroupModel, info: GroupInfoModel?) { + nameLabel.text = group.group_name.isEmpty ? "未知圈子" : group.group_name + if let info { + iconView.setGroupIcon(info) + } else { + iconView.setGroupIcon(url: "", fallback: UIImage(named: "GroupIcon/1")) + } + } +} diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift index 84fc0c03..4d9240a3 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift @@ -90,7 +90,8 @@ class RemoveMemberVC: BaseViewController { configureCell: { (_, tableView, indexPath, model) in let cell: RemoveMemberCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(model, - isOwn: self.viewModel.groupId.contains(model.user_id), + isOwn: GroupOwnerMatcher.matches(userId: model.user_id, + groupKey: self.viewModel.groupId), isSelected: self.viewModel.isSelected(id: model.user_id), showsSeparator: indexPath.row < tableView.numberOfRows(inSection: indexPath.section) - 1) return cell @@ -100,13 +101,23 @@ class RemoveMemberVC: BaseViewController { // MARK: - API private func requestGroupInfo() { DLToast.showLoading() - GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in - 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) + GroupService.groupUsers(groupKey: viewModel.groupId) + .subscribe(onNext: { [weak self] response in + guard let self else { return } + DLToast.dismiss() + guard response.isValid(for: self.viewModel.groupId), + let model = response.model else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self.viewModel.groupModel = model + self.rootView.updateMemberCount(response.list.count) + self.viewModel.loadData(response.list) + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } // MARK: - API删除成员 diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift index a822d560..bc912175 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberViewModel.swift @@ -34,7 +34,9 @@ class RemoveMemberViewModel { lazy var cellAction: Action = { this in return Action { model in let memberId = model.user_id - guard !self.groupId.contains(memberId) else { return .empty() } + guard !GroupOwnerMatcher.matches(userId: memberId, groupKey: self.groupId) else { + return .empty() + } var current = this.selectedMembers.value if let idx = current.firstIndex(of: memberId) { @@ -53,7 +55,11 @@ class RemoveMemberViewModel { } func loadData(_ list: [GroupMemberModel]) { - self.list = list + self.list = GroupMemberSorter.sorted( + list, + groupKey: groupId, + currentUserId: AppContextManager.shared.userId + ) selectedMembers.accept(selectedMembers.value.filter { selectedId in list.contains(where: { $0.user_id == selectedId }) }) diff --git a/QuickLocation/Section/Group/VerificationPopView.swift b/QuickLocation/Section/Group/VerificationPopView.swift index 5ff6c127..513f154d 100644 --- a/QuickLocation/Section/Group/VerificationPopView.swift +++ b/QuickLocation/Section/Group/VerificationPopView.swift @@ -111,12 +111,12 @@ class VerificationPopView: UIView { lazy var popBgView: UIView = { let view = UIView() view.backgroundColor = .white - view.cornerRadius = 30 + view.cornerRadius = 40 return view }() lazy var logoImgView: UIImageView = { - let view = UIImageView(image: UIImage(named: "Popup/bell")) + let view = UIImageView(image: UIImage(named: "Popup/pigeon")) return view }() @@ -128,9 +128,9 @@ class VerificationPopView: UIView { lazy var titleLab: UILabel = { let label = UILabel() - label.text = "确定要离开这个圈子吗?" - label.font = .systemFont(ofSize: 20, weight: .bold) - label.textColor = UIColor(hexStr: "#1A1A1A") + label.text = "确定要解散圈子吗?" + label.font = .systemFont(ofSize: 20, weight: .medium) + label.textColor = UIColor(hexStr: "#0F2846") label.textAlignment = .center label.numberOfLines = 0 return label @@ -140,7 +140,7 @@ class VerificationPopView: UIView { let label = UILabel() label.text = "您离开后将解散圈子,确定 要解散吗?" label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hexStr: "#767676") + label.textColor = UIColor(hexStr: "#333333") label.textAlignment = .center label.numberOfLines = 0 return label @@ -163,12 +163,12 @@ class VerificationPopView: UIView { let addLab = UILabel() addLab.text = "+" addLab.font = .systemFont(ofSize: 16, weight: .medium) - addLab.textColor = UIColor(hexStr: "#999999") + addLab.textColor = UIColor(hexStr: "#C6C6C6") let equalLab = UILabel() equalLab.text = "=" equalLab.font = .systemFont(ofSize: 16, weight: .medium) - equalLab.textColor = UIColor(hexStr: "#999999") + equalLab.textColor = UIColor(hexStr: "#C6C6C6") view.addSubview(num1View) view.addSubview(addLab) @@ -180,8 +180,8 @@ class VerificationPopView: UIView { num1View.layoutChain .left() .edgesVertical() - .width(30) - .height(30) + .width(32) + .height(32) addLab.layoutChain .leftToRightOfView(num1View, offset: 13) @@ -190,8 +190,8 @@ class VerificationPopView: UIView { num2View.layoutChain .topToView(num1View) .leftToRightOfView(addLab, offset: 13) - .width(30) - .height(30) + .width(32) + .height(32) equalLab.layoutChain .leftToRightOfView(num2View, offset: 13) @@ -200,8 +200,8 @@ class VerificationPopView: UIView { resultView.layoutChain .topToView(num1View) .leftToRightOfView(equalLab, offset: 13) - .height(30) - .width(30) + .height(32) + .width(32) resultIconView.layoutChain .leftToRightOfView(resultView, offset: 10) @@ -213,8 +213,8 @@ class VerificationPopView: UIView { lazy var num1View: UIView = { let view = UIView() - view.backgroundColor = UIColor(hexStr: "#EFF9FF") - view.cornerRadius = 2 + view.backgroundColor = UIColor(hexStr: "#E3F6FF") + view.cornerRadius = 10 view.addSubview(num1Lab) num1Lab.layoutChain @@ -226,15 +226,15 @@ class VerificationPopView: UIView { lazy var num1Lab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#666666") + label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center return label }() lazy var num2View: UIView = { let view = UIView() - view.backgroundColor = UIColor(hexStr: "#EFF9FF") - view.cornerRadius = 2 + view.backgroundColor = UIColor(hexStr: "#E3F6FF") + view.cornerRadius = 10 view.addSubview(num2Lab) num2Lab.layoutChain @@ -245,7 +245,7 @@ class VerificationPopView: UIView { lazy var num2Lab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = UIColor(hexStr: "#666666") + label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center return label }() @@ -253,7 +253,7 @@ class VerificationPopView: UIView { lazy var resultView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#5CBBFF") - view.cornerRadius = 2 + view.cornerRadius = 10 view.addSubview(resultTF) view.addSubview(resultLab) @@ -297,10 +297,10 @@ class VerificationPopView: UIView { lazy var closeBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("否", for: .normal) - btn.setTitleColor(UIColor(hexStr: "#0F2846"), for: .normal) + btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 16 return btn }() @@ -328,17 +328,17 @@ class VerificationPopView: UIView { popBgView.layoutChain.edges() logoImgView.layoutChain - .left(16) - .top(-45) - .width(109) - .height(109) + .centerX() + .top(-43) + .width(110) + .height(80) headerBgImg.layoutChain .edges(excludingEdge: .bottom) .heightToWidth(100/315) titleLab.layoutChain - .top(53) + .topToBottomOfView(logoImgView, offset: 16) .centerX() contentLab.layoutChain diff --git a/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift index 8d236d2f..b0a1e84e 100644 --- a/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift +++ b/QuickLocation/Section/Home/Bubble/BubbleHeroView.swift @@ -5,29 +5,95 @@ import UIKit -/// 绘制气泡 + 当前用户头像在圆内持续碰撞回弹 +/// 绘制气泡 + 头像与装饰切图在圆内持续碰撞回弹 final class BubbleHeroView: UIView { + private struct DecoSpec { + let name: String + let size: CGFloat + let origin: CGPoint + } + + private final class Particle { + let view: UIView + var center: CGPoint + var velocity: CGPoint + var angle: CGFloat + let radius: CGFloat + let size: CGSize + let speed: CGFloat + let originUnit: CGPoint + var sameDirectionBounceCount = 0 + var lastBounceNormal = CGPoint.zero + + init( + view: UIView, + center: CGPoint, + velocity: CGPoint, + angle: CGFloat, + radius: CGFloat, + size: CGSize, + speed: CGFloat, + originUnit: CGPoint + ) { + self.view = view + self.center = center + self.velocity = velocity + self.angle = angle + self.radius = radius + self.size = size + self.speed = speed + self.originUnit = originUnit + } + } + + private let decoSpecs: [DecoSpec] = [ + DecoSpec(name: "Bubble/deco_flower", size: 50, origin: CGPoint(x: 0.62, y: 0.22)), + DecoSpec(name: "Bubble/deco_phone", size: 50, origin: CGPoint(x: 0.78, y: 0.36)), + DecoSpec(name: "Bubble/deco_pin", size: 50, origin: CGPoint(x: 0.52, y: 0.46)), + DecoSpec(name: "Bubble/deco_pencil", size: 50, origin: CGPoint(x: 0.80, y: 0.56)), + DecoSpec(name: "Bubble/deco_ghost", size: 68, origin: CGPoint(x: 0.55, y: 0.74)) + ] + private let bubbleContainer = UIView() private let gradientLayer = CAGradientLayer() private let bubbleMaskLayer = CAShapeLayer() private let borderLayer = CAShapeLayer() + private let avatarWrap = UIView() private let avatarView = UIImageView() + private let avatarCornerRadius: CGFloat = 16 private let decoBubble1 = UIView() private let decoBubble2 = UIView() + private let decoBubble1Gradient = CAGradientLayer() + private let decoBubble2Gradient = CAGradientLayer() + private var decoViews: [UIImageView] = [] private var displayLink: CADisplayLink? - private var avatarCenter = CGPoint.zero - private var velocity = CGPoint(x: 42, y: 36) - private var avatarAngle: CGFloat = 0 + private var particles: [Particle] = [] private var lastTimestamp: CFTimeInterval = 0 - private var sameDirectionBounceCount = 0 - private var lastBounceNormal = CGPoint.zero private var hasRandomizedMotion = false + private var needsScatter = true private let avatarSize: CGFloat = 64 private let moveSpeed: CGFloat = 85 + /// 成功页圆外小粉泡;设置页关闭以免压到标题 + var showsOuterDecorBubbles: Bool = true { + didSet { + decoBubble1.isHidden = !showsOuterDecorBubbles + decoBubble2.isHidden = !showsOuterDecorBubbles + } + } + + /// 设置页仅头像碰撞;成功页再加入花/手机/图针/铅笔/小鬼 + var showsCollisionDecorations: Bool = false { + didSet { + guard oldValue != showsCollisionDecorations else { return } + applyDecorationVisibility() + rebuildParticlesPreservingAvatar() + } + } + override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = false @@ -44,7 +110,7 @@ final class BubbleHeroView: UIView { if window != nil { if UIAccessibility.isReduceMotionEnabled { stopAnimation() - resetAvatarCenter(randomize: false) + scatterParticles(randomize: false) } else { startAnimation() } @@ -66,15 +132,30 @@ final class BubbleHeroView: UIView { borderLayer.path = UIBezierPath(ovalIn: bubbleContainer.bounds).cgPath borderLayer.frame = bubbleContainer.bounds - decoBubble1.frame = CGRect(x: bubbleFrame.minX + diameter * 0.12, y: bubbleFrame.maxY + 8, width: 32, height: 32) - decoBubble2.frame = CGRect(x: bubbleFrame.maxX - diameter * 0.22, y: bubbleFrame.maxY + 18, width: 20, height: 20) - decoBubble1.layer.cornerRadius = 16 - decoBubble2.layer.cornerRadius = 10 + let largeBubbleSize = diameter * 0.19 + let smallBubbleSize = diameter * 0.105 + decoBubble1.frame = CGRect( + x: diameter * 0.82, + y: diameter - largeBubbleSize * 0.5, + width: largeBubbleSize, + height: largeBubbleSize + ) + decoBubble2.frame = CGRect( + x: diameter * 0.66, + y: diameter + smallBubbleSize * 0.7, + width: smallBubbleSize, + height: smallBubbleSize + ) + decoBubble1.layer.cornerRadius = largeBubbleSize / 2 + decoBubble2.layer.cornerRadius = smallBubbleSize / 2 + decoBubble1Gradient.frame = decoBubble1.bounds + decoBubble2Gradient.frame = decoBubble2.bounds - if avatarCenter == .zero || !bubbleContainer.bounds.contains(avatarCenter) { - resetAvatarCenter(randomize: true) + ensureParticles() + if needsScatter { + scatterParticles(randomize: !UIAccessibility.isReduceMotionEnabled) } - layoutAvatar() + applyParticleFrames() } func reloadAvatar() { @@ -106,64 +187,222 @@ final class BubbleHeroView: UIView { borderLayer.lineWidth = 1 bubbleContainer.layer.addSublayer(borderLayer) + avatarWrap.clipsToBounds = false + avatarWrap.layer.shadowColor = UIColor.black.withAlphaComponent(0.2).cgColor + avatarWrap.layer.shadowOpacity = 1 + avatarWrap.layer.shadowOffset = CGSize(width: 0, height: 0) + avatarWrap.layer.shadowRadius = 6 + bubbleContainer.addSubview(avatarWrap) + avatarView.contentMode = .scaleAspectFill avatarView.clipsToBounds = true - avatarView.layer.cornerRadius = 16 + avatarView.layer.cornerRadius = avatarCornerRadius avatarView.layer.borderWidth = 3 avatarView.layer.borderColor = UIColor.white.cgColor - bubbleContainer.addSubview(avatarView) + avatarWrap.addSubview(avatarView) - decoBubble1.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.85) - decoBubble2.backgroundColor = UIColor(hexStr: "#FFD6E8").withAlphaComponent(0.65) + for spec in decoSpecs { + let view = UIImageView(image: UIImage(named: spec.name)) + view.contentMode = .scaleAspectFit + view.isUserInteractionEnabled = false + view.isHidden = true + bubbleContainer.addSubview(view) + decoViews.append(view) + } + + configureOuterBubble(decoBubble1Gradient, in: decoBubble1, edgeColor: "#FFC9EF") + configureOuterBubble(decoBubble2Gradient, in: decoBubble2, edgeColor: "#FBD4EA") addSubview(decoBubble1) addSubview(decoBubble2) } - private func randomizeVelocity() { + private func configureOuterBubble(_ gradient: CAGradientLayer, in view: UIView, edgeColor: String) { + view.layer.masksToBounds = true + gradient.type = .radial + gradient.colors = [ + UIColor.white.withAlphaComponent(0.92).cgColor, + UIColor(hexStr: edgeColor).withAlphaComponent(0.9).cgColor + ] + gradient.locations = [0, 1] + gradient.startPoint = CGPoint(x: 0.42, y: 0.4) + gradient.endPoint = CGPoint(x: 1, y: 1) + view.layer.insertSublayer(gradient, at: 0) + } + + private func applyDecorationVisibility() { + for view in decoViews { + view.isHidden = !showsCollisionDecorations + } + } + + private func makeAvatarParticle() -> Particle { + Particle( + view: avatarWrap, + center: .zero, + velocity: CGPoint(x: 42, y: 36), + angle: 0, + radius: avatarSize / 2, + size: CGSize(width: avatarSize, height: avatarSize), + speed: moveSpeed, + originUnit: CGPoint(x: 0.28, y: 0.42) + ) + } + + private func makeDecoParticles() -> [Particle] { + decoSpecs.enumerated().map { index, spec in + Particle( + view: decoViews[index], + center: .zero, + velocity: .zero, + angle: 0, + radius: spec.size * 0.44, + size: CGSize(width: spec.size, height: spec.size), + speed: CGFloat.random(in: 68...92), + originUnit: spec.origin + ) + } + } + + private func ensureParticles() { + if particles.isEmpty { + particles.append(makeAvatarParticle()) + } + let hasDeco = particles.count > 1 + if showsCollisionDecorations, !hasDeco { + particles.append(contentsOf: makeDecoParticles()) + } else if !showsCollisionDecorations, hasDeco { + particles = [particles[0]] + } + } + + private func rebuildParticlesPreservingAvatar() { + let avatar = particles.first + particles.removeAll() + particles.append(makeAvatarParticle()) + if let avatar { + let restored = particles[0] + restored.center = avatar.center + restored.velocity = avatar.velocity + restored.angle = avatar.angle + restored.sameDirectionBounceCount = avatar.sameDirectionBounceCount + restored.lastBounceNormal = avatar.lastBounceNormal + } + if showsCollisionDecorations { + particles.append(contentsOf: makeDecoParticles()) + placeNewDecorations(randomize: !UIAccessibility.isReduceMotionEnabled) + } + applyParticleFrames() + } + + private func randomizeVelocity(_ particle: Particle) { let angle = CGFloat.random(in: 0...(2 * .pi)) - velocity = CGPoint(x: cos(angle) * moveSpeed, y: sin(angle) * moveSpeed) + particle.velocity = CGPoint(x: cos(angle) * particle.speed, y: sin(angle) * particle.speed) hasRandomizedMotion = true } - private func normalizeVelocity() { - let speed = hypot(velocity.x, velocity.y) + private func normalizeVelocity(_ particle: Particle) { + let speed = hypot(particle.velocity.x, particle.velocity.y) guard speed > 0.001 else { - randomizeVelocity() + randomizeVelocity(particle) return } - let scale = moveSpeed / speed - velocity.x *= scale - velocity.y *= scale + let scale = particle.speed / speed + particle.velocity.x *= scale + particle.velocity.y *= scale } - private func resetAvatarCenter(randomize: Bool) { + private func scatterParticles(randomize: Bool) { let bounds = bubbleContainer.bounds guard bounds.width > 0 else { return } + ensureParticles() - let offsetRange = bounds.width * 0.08 - avatarCenter = CGPoint( - x: bounds.midX + CGFloat.random(in: -offsetRange...offsetRange), - y: bounds.midY + CGFloat.random(in: -offsetRange...offsetRange) - ) - if randomize { - avatarAngle = CGFloat.random(in: -0.21...0.21) - randomizeVelocity() - sameDirectionBounceCount = 0 - lastBounceNormal = .zero + for particle in particles { + place(particle, randomize: randomize) } - layoutAvatar() + + for _ in 0..<4 { + resolvePairwise() + for particle in particles { + clampToCircle(particle) + } + } + + needsScatter = false + applyParticleFrames() } - private func layoutAvatar() { - avatarView.bounds = CGRect(x: 0, y: 0, width: avatarSize, height: avatarSize) - avatarView.center = avatarCenter - avatarView.transform = CGAffineTransform(rotationAngle: avatarAngle) + private func placeNewDecorations(randomize: Bool) { + guard bubbleContainer.bounds.width > 0 else { return } + for particle in particles where particle.view !== avatarWrap { + place(particle, randomize: randomize) + } + for _ in 0..<4 { + resolvePairwise() + for particle in particles { + clampToCircle(particle) + } + } + } + + private func place(_ particle: Particle, randomize: Bool) { + let bounds = bubbleContainer.bounds + if particle.view === avatarWrap && !showsCollisionDecorations { + let offsetRange = bounds.width * 0.08 + particle.center = CGPoint( + x: bounds.midX + CGFloat.random(in: -offsetRange...offsetRange), + y: bounds.midY + CGFloat.random(in: -offsetRange...offsetRange) + ) + } else { + particle.center = CGPoint( + x: bounds.width * particle.originUnit.x, + y: bounds.height * particle.originUnit.y + ) + } + clampToCircle(particle) + if randomize { + particle.angle = CGFloat.random(in: -0.21...0.21) + randomizeVelocity(particle) + particle.sameDirectionBounceCount = 0 + particle.lastBounceNormal = .zero + } else { + particle.velocity = .zero + } + } + + private func clampToCircle(_ particle: Particle) { + let bounds = bubbleContainer.bounds + guard bounds.width > 0 else { return } + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let bubbleRadius = min(bounds.width, bounds.height) / 2 + let maxDist = max(8, bubbleRadius - particle.radius - 4) + let offset = CGPoint(x: particle.center.x - center.x, y: particle.center.y - center.y) + let dist = hypot(offset.x, offset.y) + guard dist > maxDist else { return } + let nx = offset.x / max(dist, 0.001) + let ny = offset.y / max(dist, 0.001) + particle.center.x = center.x + nx * maxDist + particle.center.y = center.y + ny * maxDist + } + + private func applyParticleFrames() { + for particle in particles { + particle.view.bounds = CGRect(origin: .zero, size: particle.size) + particle.view.center = particle.center + particle.view.transform = CGAffineTransform(rotationAngle: particle.angle) + } + avatarView.frame = avatarWrap.bounds + avatarWrap.layer.shadowPath = UIBezierPath( + roundedRect: avatarWrap.bounds, + cornerRadius: avatarCornerRadius + ).cgPath } private func startAnimation() { guard displayLink == nil else { return } if !hasRandomizedMotion { - randomizeVelocity() + for particle in particles { + randomizeVelocity(particle) + } } lastTimestamp = 0 let link = CADisplayLink(target: self, selector: #selector(tick(_:))) @@ -178,53 +417,119 @@ final class BubbleHeroView: UIView { } @objc private func tick(_ link: CADisplayLink) { - guard bubbleContainer.bounds.width > 0 else { return } + guard bubbleContainer.bounds.width > 0, !particles.isEmpty else { return } let dt = lastTimestamp > 0 ? CGFloat(link.timestamp - lastTimestamp) : CGFloat(1.0 / 60.0) lastTimestamp = link.timestamp guard dt > 0, dt < 0.05 else { return } let bubbleRadius = min(bubbleContainer.bounds.width, bubbleContainer.bounds.height) / 2 - let maxDist = max(8, bubbleRadius - avatarSize / 2 - 4) let center = CGPoint(x: bubbleContainer.bounds.midX, y: bubbleContainer.bounds.midY) - avatarCenter.x += velocity.x * dt - avatarCenter.y += velocity.y * dt - - let offset = CGPoint(x: avatarCenter.x - center.x, y: avatarCenter.y - center.y) - let dist = hypot(offset.x, offset.y) - if dist > maxDist { - let nx = offset.x / max(dist, 0.001) - let ny = offset.y / max(dist, 0.001) - avatarCenter.x = center.x + nx * maxDist - avatarCenter.y = center.y + ny * maxDist - - if abs(nx - lastBounceNormal.x) < 0.15 && abs(ny - lastBounceNormal.y) < 0.15 { - sameDirectionBounceCount += 1 - } else { - sameDirectionBounceCount = 0 - } - lastBounceNormal = CGPoint(x: nx, y: ny) - - let dot = velocity.x * nx + velocity.y * ny - velocity.x = velocity.x - 2 * dot * nx - velocity.y = velocity.y - 2 * dot * ny - - let tx = -ny - let ty = nx - let jitterSign: CGFloat = Bool.random() ? 1 : -1 - let jitter = CGFloat.random(in: 16...26) * jitterSign - velocity.x += tx * jitter - velocity.y += ty * jitter - normalizeVelocity() - avatarAngle += CGFloat.random(in: -0.05...0.05) - - if sameDirectionBounceCount >= 3 { - randomizeVelocity() - sameDirectionBounceCount = 0 - lastBounceNormal = .zero - } + for particle in particles { + particle.center.x += particle.velocity.x * dt + particle.center.y += particle.velocity.y * dt } - layoutAvatar() + resolvePairwise() + + for particle in particles { + bounceOffWall(particle, center: center, bubbleRadius: bubbleRadius) + } + + applyParticleFrames() + } + + private func bounceOffWall(_ particle: Particle, center: CGPoint, bubbleRadius: CGFloat) { + let maxDist = max(8, bubbleRadius - particle.radius - 4) + let offset = CGPoint(x: particle.center.x - center.x, y: particle.center.y - center.y) + let dist = hypot(offset.x, offset.y) + guard dist > maxDist else { return } + + let nx = offset.x / max(dist, 0.001) + let ny = offset.y / max(dist, 0.001) + particle.center.x = center.x + nx * maxDist + particle.center.y = center.y + ny * maxDist + + if abs(nx - particle.lastBounceNormal.x) < 0.15 && abs(ny - particle.lastBounceNormal.y) < 0.15 { + particle.sameDirectionBounceCount += 1 + } else { + particle.sameDirectionBounceCount = 0 + } + particle.lastBounceNormal = CGPoint(x: nx, y: ny) + + let dot = particle.velocity.x * nx + particle.velocity.y * ny + particle.velocity.x = particle.velocity.x - 2 * dot * nx + particle.velocity.y = particle.velocity.y - 2 * dot * ny + + let tx = -ny + let ty = nx + let jitterSign: CGFloat = Bool.random() ? 1 : -1 + let jitter = CGFloat.random(in: 16...26) * jitterSign + particle.velocity.x += tx * jitter + particle.velocity.y += ty * jitter + normalizeVelocity(particle) + particle.angle += CGFloat.random(in: -0.05...0.05) + + if particle.sameDirectionBounceCount >= 3 { + randomizeVelocity(particle) + particle.sameDirectionBounceCount = 0 + particle.lastBounceNormal = .zero + } + } + + private func resolvePairwise() { + let count = particles.count + guard count > 1 else { return } + for _ in 0..<2 { + for i in 0..<(count - 1) { + for j in (i + 1).. Void)? var onSheetVisibilityChanged: ((Bool) -> Void)? - private let hours = Array(1...6) - let heroView = BubbleHeroView() let confirmBtn = UIButton(type: .custom) let sheetConfirmBtn = UIButton(type: .custom) private let subtitleLab = UILabel() private let titleLab = UILabel() - private let pickerView = UIPickerView() - private let hourLab = UILabel() + private let hourPicker = BubbleHourPickerView() private let sheetOverlay = UIView() private let sheetPanel = UIView() - private let pinIcon = UIImageView() + private let headerBgImage = UIImageView() + private let megaphoneImage = UIImageView() private let sheetTitleLab = UILabel() private let messageView = UIView() private let messageLab = UILabel() @@ -42,24 +40,27 @@ final class CreateBubbleSetupView: UIView { super.init(frame: frame) backgroundColor = .white setupUI() - setupPicker() setupSheet() - pickerView.selectRow(0, inComponent: 0, animated: false) - selectedHour.accept(hours[0]) + hourPicker.onHourChanged = { [weak self] hour in + self?.selectedHour.accept(hour) + } + selectedHour.accept(1) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func showNotifySheet(message: String) { messageText = message - messageLab.text = message + messageLab.attributedText = notificationMessage(from: message) sheetOverlay.isHidden = false sheetOverlay.alpha = 0 sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) + megaphoneImage.transform = CGAffineTransform(translationX: 0, y: 40) onSheetVisibilityChanged?(true) UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut) { self.sheetOverlay.alpha = 1 self.sheetPanel.transform = .identity + self.megaphoneImage.transform = .identity } } @@ -68,6 +69,7 @@ final class CreateBubbleSetupView: UIView { UIView.animate(withDuration: 0.22, delay: 0, options: .curveEaseIn) { self.sheetOverlay.alpha = 0 self.sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) + self.megaphoneImage.transform = CGAffineTransform(translationX: 0, y: 40) } completion: { _ in self.sheetOverlay.isHidden = true } @@ -79,10 +81,10 @@ final class CreateBubbleSetupView: UIView { addSubview(heroView) addSubview(titleLab) addSubview(subtitleLab) - addSubview(pickerView) - addSubview(hourLab) + addSubview(hourPicker) addSubview(confirmBtn) + heroView.showsOuterDecorBubbles = false heroView.layoutChain .top(33) .leading(30) @@ -98,17 +100,12 @@ final class CreateBubbleSetupView: UIView { subtitleLab.text = "设置气泡时间" subtitleLab.font = .systemFont(ofSize: 18, weight: .bold) subtitleLab.textColor = UIColor(hexStr: "#9CA3AF") - subtitleLab.layoutChain.topToBottomOfView(titleLab, offset: 4).centerX() + subtitleLab.layoutChain.topToBottomOfView(titleLab, offset: 19).centerX() - pickerView.layoutChain - .topToBottomOfView(subtitleLab, offset: 12) - .edgesHorzontal(15) - .height(180) - - hourLab.text = "小时" - hourLab.font = .systemFont(ofSize: 16, weight: .medium) - hourLab.textColor = UIColor(hexStr: "#16B3FF") - hourLab.layoutChain.centerX(self, offset: 100).centerY(pickerView) + hourPicker.layoutChain + .topToBottomOfView(subtitleLab, offset: 29) + .edgesHorzontal() + .height(96) confirmBtn.setTitle("确定", for: .normal) confirmBtn.setTitleColor(.white, for: .normal) @@ -118,26 +115,6 @@ final class CreateBubbleSetupView: UIView { confirmBtn.layoutChain.left(30).right(30).bottom(34).height(56) } - private func setupPicker() { - pickerView.delegate = self - pickerView.dataSource = self - pickerView.backgroundColor = .clear - } - - override func layoutSubviews() { - super.layoutSubviews() - clearPickerSelectionBackground() - } - - private func clearPickerSelectionBackground() { - pickerView.subviews.forEach { sub in - sub.backgroundColor = .clear - if sub.frame.height <= 1 { - sub.isHidden = true - } - } - } - private func setupSheet() { sheetOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45) sheetOverlay.isHidden = true @@ -145,35 +122,50 @@ final class CreateBubbleSetupView: UIView { sheetOverlay.layoutChain.edges() sheetPanel.backgroundColor = .white - sheetPanel.layer.cornerRadius = 24 + sheetPanel.layer.cornerRadius = 40 sheetPanel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetPanel.clipsToBounds = true sheetOverlay.addSubview(sheetPanel) sheetPanel.layoutChain.edgesHorzontal().bottom() - pinIcon.image = UIImage(named: "Bubble/pin") - pinIcon.contentMode = .scaleAspectFit - sheetPanel.addSubview(pinIcon) - pinIcon.layoutChain.top(-22).centerX().width(44).height(44) + headerBgImage.image = UIImage(named: "Common/popup_header_bg") + headerBgImage.contentMode = .scaleToFill + sheetPanel.addSubview(headerBgImage) + headerBgImage.layoutChain + .top() + .edgesHorzontal() + .heightToWidth(239.0 / 750.0) + + megaphoneImage.image = UIImage(named: "Bubble/notification_megaphone") + megaphoneImage.contentMode = .scaleAspectFit + sheetOverlay.addSubview(megaphoneImage) + megaphoneImage.layoutChain + .left(34) + .topToView(sheetPanel, offset: -44) + .width(130) + .height(90) sheetTitleLab.text = "通知您的圈子" - sheetTitleLab.font = .systemFont(ofSize: 18, weight: .bold) - sheetTitleLab.textColor = UIColor(hexStr: "#293445") + sheetTitleLab.font = FontManager.boboBold(26) + sheetTitleLab.textColor = UIColor(hexStr: "#173A5E") sheetTitleLab.textAlignment = .center sheetPanel.addSubview(sheetTitleLab) - sheetTitleLab.layoutChain.top(28).centerX() + sheetTitleLab.layoutChain.top(52).centerX() - messageView.backgroundColor = UIColor(hexStr: "#EBF6F9") - messageView.layer.cornerRadius = 12 + messageView.backgroundColor = UIColor(hexStr: "#E1F4FF") + messageView.layer.cornerRadius = 16 + messageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] sheetPanel.addSubview(messageView) messageView.addSubview(messageLab) - messageLab.font = .systemFont(ofSize: 15, weight: .medium) + messageLab.font = .systemFont(ofSize: 16, weight: .medium) messageLab.textColor = UIColor(hexStr: "#293445") messageLab.numberOfLines = 0 - messageLab.layoutChain.edgesHorzontal(16).edgesVertical(14) + messageLab.lineBreakMode = .byWordWrapping + messageLab.layoutChain.edgesHorzontal(16).edgesVertical(12) previewAvatar.contentMode = .scaleAspectFill previewAvatar.clipsToBounds = true - previewAvatar.layer.cornerRadius = 18 + previewAvatar.layer.cornerRadius = 20 previewAvatar.layer.borderWidth = 2 previewAvatar.layer.borderColor = UIColor.white.cgColor let avatar = AppContextManager.shared.avaterIcon @@ -181,21 +173,21 @@ final class CreateBubbleSetupView: UIView { sheetPanel.addSubview(previewAvatar) messageView.layoutChain - .topToBottomOfView(sheetTitleLab, offset: 20) - .left(24) - .right(72) + .topToBottomOfView(sheetTitleLab, offset: 42) + .left(35) + .right(75) previewAvatar.layoutChain .right(24) - .bottomToView(messageView) + .topToView(messageView, offset: -20) .width(40) .height(40) tipStack.axis = .vertical - tipStack.spacing = 12 + tipStack.spacing = 16 sheetPanel.addSubview(tipStack) tipStack.layoutChain - .topToBottomOfView(messageView, offset: 16) - .edgesHorzontal(24) + .topToBottomOfView(messageView, offset: 22) + .edgesHorzontal(35) [ "您的圈子成员只会看到您在气泡中。", @@ -205,14 +197,15 @@ final class CreateBubbleSetupView: UIView { sheetConfirmBtn.setTitle("确定", for: .normal) sheetConfirmBtn.setTitleColor(.white, for: .normal) sheetConfirmBtn.titleLabel?.font = FontManager.boboBold(18) - sheetConfirmBtn.backgroundColor = UIColor(hexStr: "#16B3FF") - sheetConfirmBtn.layer.cornerRadius = 16 + sheetConfirmBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + sheetConfirmBtn.layer.cornerRadius = 20 + sheetConfirmBtn.clipsToBounds = true sheetPanel.addSubview(sheetConfirmBtn) sheetConfirmBtn.layoutChain - .topToBottomOfView(tipStack, offset: 20) - .edgesHorzontal(20) + .topToBottomOfView(tipStack, offset: 32) + .edgesHorzontal(30) .height(56) - .bottom(24 + kSafeBottomMargin) + .bottom(12 + kSafeBottomMargin) let tap = UITapGestureRecognizer(target: self, action: #selector(tapMask)) sheetOverlay.addGestureRecognizer(tap) @@ -220,24 +213,50 @@ final class CreateBubbleSetupView: UIView { private func makeTipRow(_ text: String) -> UIView { let row = UIView() - let star = UIImageView(image: UIImage(systemName: "sparkle")) - star.tintColor = UIColor(hexStr: "#16B3FF") + let star = UIImageView(image: UIImage(named: "Bubble/notification_sparkle")) star.contentMode = .scaleAspectFit let lab = UILabel() lab.text = text lab.font = .systemFont(ofSize: 14, weight: .medium) - lab.textColor = UIColor(hexStr: "#767676") + lab.textColor = UIColor(hexStr: "#7C7F87") lab.numberOfLines = 0 row.addSubview(star) row.addSubview(lab) - star.layoutChain.left().top(2).width(14).height(14) - lab.layoutChain.leftToRightOfView(star, offset: 8).right().top().bottom() + star.layoutChain.left().top(4).width(11).height(11) + lab.layoutChain.leftToRightOfView(star, offset: 10).right().top().bottom() return row } + private func notificationMessage(from message: String) -> NSAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = 5 + + let attributed = NSMutableAttributedString( + string: message, + attributes: [ + .font: UIFont.systemFont(ofSize: 16, weight: .medium), + .foregroundColor: UIColor(hexStr: "#293445"), + .paragraphStyle: paragraphStyle + ] + ) + + if let separatorRange = message.range(of: "之前") { + let timePrefix = String(message[.. Int { 1 } +/// 1–6 小时横向循环选择器,中心粉色「N小时」胶囊 +private final class BubbleHourPickerView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - hours.count + var onHourChanged: ((Int) -> Void)? + + private let hours = Array(1...6) + private let loopCount = 200 + private let itemWidth: CGFloat = 52 + private let itemSpacing: CGFloat = 12 + private let accent = UIColor(hexStr: "#FF5CB8") + + private var currentHour = 1 + private var didInitialScroll = false + private var lastLayoutWidth: CGFloat = 0 + + private lazy var layout: UICollectionViewFlowLayout = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: itemWidth, height: 70) + layout.minimumLineSpacing = itemSpacing + layout.minimumInteritemSpacing = 0 + return layout + }() + + private lazy var collectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.decelerationRate = .fast + view.dataSource = self + view.delegate = self + view.register(HourCell.self, forCellWithReuseIdentifier: HourCell.reuseId) + return view + }() + + private let capsule = UIView() + private let capsuleLab = UILabel() + private let pointerLayer = CAShapeLayer() + private let centerBar = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = false + + addSubview(collectionView) + + capsule.backgroundColor = accent + capsule.layer.cornerRadius = 15 + capsule.clipsToBounds = false + capsule.isUserInteractionEnabled = false + addSubview(capsule) + + capsuleLab.text = "1小时" + capsuleLab.font = .systemFont(ofSize: 16, weight: .bold) + capsuleLab.textColor = .white + capsuleLab.textAlignment = .center + capsule.addSubview(capsuleLab) + + pointerLayer.fillColor = accent.cgColor + capsule.layer.addSublayer(pointerLayer) + + centerBar.backgroundColor = accent + centerBar.layer.cornerRadius = 4 + centerBar.isUserInteractionEnabled = false + addSubview(centerBar) } - func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { 44 } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView { - let container = view as? UIView ?? UIView() - container.subviews.forEach { $0.removeFromSuperview() } + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0 else { return } - let label = UILabel() - label.textAlignment = .center - label.font = .systemFont(ofSize: 20, weight: .medium) - let selected = row == pickerView.selectedRow(inComponent: component) - label.textColor = selected ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#999999") - label.text = "\(hours[row])" - container.addSubview(label) - label.layoutChain.center().width(60).height(36) - - if selected { - let capsule = UIView() - capsule.backgroundColor = UIColor(hexStr: "#E3F6FF") - capsule.layer.cornerRadius = 14 - container.insertSubview(capsule, at: 0) - capsule.layoutChain.center().width(130).height(36) + if abs(bounds.width - lastLayoutWidth) > 0.5 { + lastLayoutWidth = bounds.width + let inset = max(0, (bounds.width - itemWidth) / 2) + layout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) + layout.invalidateLayout() } + collectionView.frame = CGRect(x: 0, y: 18, width: bounds.width, height: bounds.height - 18) - return container + let capsuleSize = CGSize(width: 78, height: 30) + capsule.frame = CGRect( + x: (bounds.width - capsuleSize.width) / 2, + y: 0, + width: capsuleSize.width, + height: capsuleSize.height + ) + capsuleLab.frame = capsule.bounds + + let pointerWidth: CGFloat = 10 + let pointerHeight: CGFloat = 6 + let path = UIBezierPath() + let px = capsule.bounds.midX + let py = capsule.bounds.maxY - 1 + path.move(to: CGPoint(x: px - pointerWidth / 2, y: py)) + path.addLine(to: CGPoint(x: px + pointerWidth / 2, y: py)) + path.addLine(to: CGPoint(x: px, y: py + pointerHeight)) + path.close() + pointerLayer.path = path.cgPath + + centerBar.frame = CGRect(x: (bounds.width - 8) / 2, y: 46, width: 8, height: 36) + + if !didInitialScroll { + didInitialScroll = true + collectionView.layoutIfNeeded() + scrollToHour(currentHour, animated: false) + updateVisibleCells() + } } - func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { - selectedHour.accept(hours[row]) - pickerView.reloadAllComponents() - clearPickerSelectionBackground() + private var itemSpan: CGFloat { itemWidth + itemSpacing } + + private var totalItems: Int { hours.count * loopCount } + + private func hour(at item: Int) -> Int { + hours[((item % hours.count) + hours.count) % hours.count] + } + + private func centeredItem() -> Int { + let index = Int((collectionView.contentOffset.x / itemSpan).rounded()) + return min(max(0, index), totalItems - 1) + } + + private func scrollToHour(_ hour: Int, animated: Bool) { + let hourIndex = hours.firstIndex(of: hour) ?? 0 + let item = (loopCount / 2) * hours.count + hourIndex + collectionView.setContentOffset(CGPoint(x: CGFloat(item) * itemSpan, y: 0), animated: animated) + } + + private func commitCenteredHour() { + let hour = self.hour(at: centeredItem()) + guard hour != currentHour else { return } + currentHour = hour + capsuleLab.text = "\(hour)小时" + onHourChanged?(hour) + } + + private func updateVisibleCells() { + for cell in collectionView.visibleCells { + guard let hourCell = cell as? HourCell else { continue } + let converted = collectionView.convert(hourCell.center, to: self) + let distance = abs(converted.x - bounds.midX) + let isCenter = distance < itemWidth * 0.45 + hourCell.numberLab.alpha = isCenter ? 0 : max(0.28, 1 - distance / (itemWidth * 3.2)) + hourCell.tick.alpha = isCenter ? 0 : max(0.35, 1 - distance / (itemWidth * 2.6)) + } + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + totalItems + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HourCell.reuseId, for: indexPath) as! HourCell + cell.numberLab.text = "\(hour(at: indexPath.item))" + return cell + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + updateVisibleCells() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateVisibleCells() + commitCenteredHour() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + commitCenteredHour() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { commitCenteredHour() } + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + let raw = targetContentOffset.pointee.x / itemSpan + let index = CGFloat(Int(raw.rounded())) + let clamped = min(max(0, index), CGFloat(totalItems - 1)) + targetContentOffset.pointee.x = clamped * itemSpan + } +} + +private final class HourCell: UICollectionViewCell { + static let reuseId = "HourCell" + + let numberLab = UILabel() + let tick = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + numberLab.textAlignment = .center + numberLab.font = .systemFont(ofSize: 22, weight: .medium) + numberLab.textColor = UIColor(hexStr: "#B0B8C1") + tick.backgroundColor = UIColor(hexStr: "#FFD2E8") + tick.layer.cornerRadius = 5 + contentView.addSubview(numberLab) + contentView.addSubview(tick) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + numberLab.frame = CGRect(x: 0, y: 6, width: bounds.width, height: 28) + tick.frame = CGRect(x: (bounds.width - 10) / 2, y: 40, width: 10, height: 16) } } diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift index b3164e3a..20bf91b6 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift @@ -11,6 +11,7 @@ import SwiftyUserDefaults class CreateBubbleVC: BaseViewController { fileprivate var rootView: CreateBubbleView! + private var statusRequestVersion = 0 override func loadView() { rootView = CreateBubbleView(frame: UIScreen.main.bounds) @@ -20,7 +21,17 @@ class CreateBubbleVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() bind() - restoreActiveBubbleIfNeeded() + restoreCachedBubbleIfNeeded() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + requestUserStatus() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + statusRequestVersion += 1 } private func bind() { @@ -83,14 +94,55 @@ class CreateBubbleVC: BaseViewController { } } - private func restoreActiveBubbleIfNeeded() { + private func restoreCachedBubbleIfNeeded() { guard AppContextManager.shared.status == 2, - let endDate = Defaults[\.bubbleKeepTimes] else { return } + let endDate = Defaults[\.bubbleKeepTimes], + endDate > Date() else { + Defaults[\.bubbleKeepTimes] = nil + return + } rootView.showDone(animated: false) rootView.doneView.startCountdown(endDate: endDate) } + private func requestUserStatus() { + statusRequestVersion += 1 + let requestVersion = statusRequestVersion + + UserService.userStatus() + .subscribe(onNext: { [weak self] response in + guard let self, requestVersion == self.statusRequestVersion else { return } + guard response.code == "0", let model = response.model else { + DLToast.showError(text: response.message ?? "获取用户状态失败") + return + } + self.applyUserStatus(model) + }, onError: { [weak self] error in + guard let self, requestVersion == self.statusRequestVersion else { return } + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + + private func applyUserStatus(_ model: UserStatusModel) { + AppContextManager.shared.updateAccountState(status: model.status, mood: model.mood) + guard model.status == 2, + let endDate = model.bubbleEndDate, + endDate > Date() else { + Defaults[\.bubbleKeepTimes] = nil + rootView.doneView.stopCountdown() + rootView.showSetup(animated: false) + return + } + + rootView.setupView.dismissNotifySheet() + Defaults[\.bubbleKeepTimes] = endDate + rootView.doneView.startCountdown(endDate: endDate) + rootView.showDone(animated: false) + } + private func requestSetBubble(enable: Bool, keep_time: Int) { + statusRequestVersion += 1 DLToast.showLoading() UserService.setBubble(enable: enable, keep_time: keep_time).subscribe(onNext: { [weak self] _ in DLToast.dismiss() @@ -109,6 +161,7 @@ class CreateBubbleVC: BaseViewController { Defaults[\.bubbleKeepTimes] = endDate self.rootView.doneView.startCountdown(endDate: endDate) self.rootView.showDone(animated: true) + self.requestUserStatus() }, onError: { _ in }).disposed(by: disposeBag) } } diff --git a/QuickLocation/Section/Home/GroupListPopView.swift b/QuickLocation/Section/Home/GroupListPopView.swift index e0f2247e..aebb858a 100644 --- a/QuickLocation/Section/Home/GroupListPopView.swift +++ b/QuickLocation/Section/Home/GroupListPopView.swift @@ -28,6 +28,7 @@ class GroupListPopView: UIView { } private var selectedGroupKey: String = "" + private var currentGroupOnlineCount: Int? private var groupList: [GroupInfoModel] = [] { didSet { @@ -99,7 +100,7 @@ class GroupListPopView: UIView { btn.setTitleColor(Self.primaryBlue, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = .white - btn.cornerRadius = 25 + btn.cornerRadius = 16 btn.layer.borderWidth = 1 btn.layer.borderColor = Self.primaryBlue.cgColor btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside) @@ -112,7 +113,7 @@ class GroupListPopView: UIView { btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = Self.primaryBlue - btn.cornerRadius = 25 + btn.cornerRadius = 16 btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside) return btn }() @@ -186,6 +187,7 @@ extension GroupListPopView { /// 通过 window 从底部弹出圈子列表 static func show(groupModel: GroupModel, + currentMembers: [GroupMemberModel]? = nil, completion: @escaping ((String?) -> Void)) { guard let superView = kKeyWindow else { return } @@ -195,6 +197,7 @@ extension GroupListPopView { } pop.frame = CGRect(x: 0, y: 0, width: kScreenWidth, height: kScreenHeight) + pop.currentGroupOnlineCount = currentMembers.map { $0.filter(\.is_online).count } pop.groupModel = groupModel pop.completion = { text in completion(text) @@ -246,7 +249,7 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate { let cell: GroupListCell = tableView.dequeueReusableCell(for: indexPath) let model = groupList[indexPath.row] let onlineCount: Int? = (model.group_key == groupModel?.default_group_key) - ? groupModel?.select_group_employee.filter { $0.is_online }.count + ? currentGroupOnlineCount : nil cell.configure(model: model, isSelected: selectedGroupKey == model.group_key, onlineCount: onlineCount) return cell @@ -262,7 +265,7 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate { class GroupListCell: UITableViewCell { func configure(model: GroupInfoModel, isSelected: Bool, onlineCount: Int? = nil) { - avaterImgView.image = model.groupIcon + avaterImgView.setGroupIcon(model) nameLab.text = model.name if let onlineCount { countLab.text = "共\(model.people_no)成员/\(onlineCount)在线" @@ -356,6 +359,11 @@ class GroupListCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + avaterImgView.setGroupIcon(url: "", fallback: nil) + } + lazy var cardView: UIView = { let view = UIView() view.backgroundColor = .white diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 2bf96287..87494ee0 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -78,6 +78,7 @@ class HomeViewController: BaseViewController { private static var sosPlayerKey: UInt8 = 0 private var groupRefreshWorkItem: DispatchWorkItem? private var groupSwitchWorkItem: DispatchWorkItem? + private var groupInfoRequestID = UUID() override func loadView() { #if !targetEnvironment(simulator) @@ -230,7 +231,8 @@ class HomeViewController: BaseViewController { // 切换圈子 rootView.groupMemberView.changeGroupBtn.rx.tap.subscribe(onNext: { [weak self] in guard let self = self, let groupModel = self.viewModel.groupModel else { return } - GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in + GroupListPopView.show(groupModel: groupModel, + currentMembers: self.viewModel.memberList) { [weak self] groupKey in guard let self = self, let key = groupKey else { return } self.requestOperateGroup(groupKey: key) } @@ -515,24 +517,80 @@ class HomeViewController: BaseViewController { } private func requestGroupInfo(isDefaultGroup: Bool=true) { - GroupService.groupInfo().subscribe { response in - guard let model = response.model else { return } + let requestID = UUID() + groupInfoRequestID = requestID + + GroupService.groupInfo().subscribe(onNext: { [weak self] response in + guard let self, + self.groupInfoRequestID == requestID, + let model = response.model else { return } + + let previousGroupKey = self.viewModel.groupModel?.default_group_key + let requestedGroupKey = model.default_group_key + let isSwitchingGroup = previousGroupKey != nil && previousGroupKey != requestedGroupKey + self.viewModel.groupModel = model NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) - // 圈子接口是气泡结束的权威来源:status=0 就重新加点 - self.syncMemberAnnotations(self.viewModel.memberList) - guard isDefaultGroup else { return } -// self.rootView.groupMemberView.setupCountData(self.viewModel.memberCount, 1) - self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName - self.refreshMQTTSubscriptions(model.select_group_employee) - }.disposed(by: disposeBag) + if isDefaultGroup { + self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName + } + + if isSwitchingGroup || requestedGroupKey.isEmpty { + self.viewModel.clearMembers() + self.syncMemberAnnotations([]) + self.refreshMQTTSubscriptions([]) + } + + guard !requestedGroupKey.isEmpty else { return } + self.requestGroupMembers(groupKey: requestedGroupKey, + requestID: requestID) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }).disposed(by: disposeBag) + } + + private func requestGroupMembers(groupKey: String, requestID: UUID) { + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard let self, + self.groupInfoRequestID == requestID, + self.viewModel.groupModel?.default_group_key == groupKey else { return } + + guard response.isValid(for: groupKey) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + + self.viewModel.updateMembers(response.list) + // 成员接口是气泡结束的权威来源:status=0 时重新显示地图点。 + self.syncMemberAnnotations(self.viewModel.memberList) + self.refreshMQTTSubscriptions(self.viewModel.memberList) + self.updateOnlineCount() + }, onError: { [weak self] error in + guard let self, + self.groupInfoRequestID == requestID, + self.viewModel.groupModel?.default_group_key == groupKey else { return } + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } private func requestOperateGroup(groupKey: String) { - GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]).subscribe { response in - self.requestGroupInfo() - }.disposed(by: disposeBag) + if viewModel.groupModel?.default_group_key != groupKey { + groupInfoRequestID = UUID() + viewModel.clearMembers() + syncMemberAnnotations([]) + refreshMQTTSubscriptions([]) + } + + GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) + .subscribe(onNext: { [weak self] _ in + self?.requestGroupInfo() + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) } private func requestNotice() { @@ -642,7 +700,7 @@ class HomeViewController: BaseViewController { battery: "" ) - // 其他成员从 select_group_employee 取,过滤掉当前用户 + // 其他成员来自当前圈子的成员接口,过滤掉当前用户。 var others: [CircleMember] = [] let now = Date() for model in list where model.user_id != currentUserId || currentUserId.isEmpty { diff --git a/QuickLocation/Section/Home/HomeViewModel.swift b/QuickLocation/Section/Home/HomeViewModel.swift index ffdf785c..21fa65ad 100644 --- a/QuickLocation/Section/Home/HomeViewModel.swift +++ b/QuickLocation/Section/Home/HomeViewModel.swift @@ -31,12 +31,7 @@ class HomeViewModel { var targetUid: String = "" - var groupModel: GroupModel? { - didSet { - guard let model = groupModel else { return } - setMemberList(model.select_group_employee) - } - } + var groupModel: GroupModel? // 当前选中的圈子 var groupInfo: [String: Any] { @@ -57,18 +52,12 @@ class HomeViewModel { // 圈子成员数 var memberCount: Int { - get { - guard let model = groupModel else { return 0 } - return model.select_group_employee.count - } + memberList.count } // 圈子成员在线数 var memberOnlineCount: Int { - get { - guard let model = groupModel else { return 0 } - return model.select_group_employee.filter { $0.is_online == true}.count - } + memberList.filter(\.is_online).count } // 是否当前用户 @@ -79,7 +68,7 @@ class HomeViewModel { // 是否圈主 func isGroupOwn(id: String) -> Bool { guard let model = groupModel else { return false } - return model.default_group_key.contains(id) + return GroupOwnerMatcher.matches(userId: id, groupKey: model.default_group_key) } func getUserNickName(id: String) -> String { @@ -103,11 +92,19 @@ class HomeViewModel { return false } - private func setMemberList(_ list: [GroupMemberModel]) { - memberList = sortedMemberList(list) + func updateMembers(_ list: [GroupMemberModel]) { + memberList = GroupMemberSorter.sorted( + list, + groupKey: groupModel?.default_group_key ?? "", + currentUserId: AppContextManager.shared.userId + ) sectionedItems.onNext(memberList.mapSection()) } + func clearMembers() { + updateMembers([]) + } + private func canonicalUserIds(_ userId: String) -> Set { var ids: Set = [userId] if userId == "current" || isCurrentUser(id: userId) { @@ -120,31 +117,12 @@ class HomeViewModel { return ids } - private func sortedMemberList(_ list: [GroupMemberModel]) -> [GroupMemberModel] { - // 排列顺序:圈主 -> 自己 -> 在线;同优先级保持接口原始顺序 - list.enumerated() - .sorted { lhs, rhs in - let lhsPriority = memberSortPriority(lhs.element) - let rhsPriority = memberSortPriority(rhs.element) - if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } - return lhs.offset < rhs.offset - } - .map(\.element) - } - - private func memberSortPriority(_ member: GroupMemberModel) -> Int { - if isGroupOwn(id: member.user_id) { return 0 } - if member.user_id == AppContextManager.shared.userId { return 1 } - if member.is_online { return 2 } - return 3 - } - /// 标记成员离线(保留 last_active_time 用于显示"X分钟前") func setMemberOffline(userId: String) { guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return } var updated = memberList updated[idx].is_online = false - setMemberList(updated) + updateMembers(updated) } /// MQTT 位置更新后刷新成员的地址、电量、在线状态 @@ -155,7 +133,7 @@ class HomeViewModel { updated[idx].battery = battery updated[idx].is_online = isOnline updated[idx].last_active_time = lastUpdateTime - setMemberList(updated) + updateMembers(updated) } /// 成员是否存在 @@ -168,7 +146,7 @@ class HomeViewModel { guard let idx = memberList.firstIndex(where: { $0.user_id == userId }) else { return } var updated = memberList updated.remove(at: idx) - setMemberList(updated) + updateMembers(updated) } func updateMemberStatus(userId: String, status: Int) { diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift index 3932d235..cd0585e7 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift @@ -86,7 +86,8 @@ final class PhoneReportDetailVC: BaseViewController { private func switchGroup() { guard let groupModel else { return } - GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in + GroupListPopView.show(groupModel: groupModel, + currentMembers: viewModel.members) { [weak self] groupKey in guard let self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) .subscribe(onNext: { [weak self] _ in diff --git a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift index bb7bc94b..26db470a 100644 --- a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift +++ b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift @@ -162,8 +162,7 @@ final class SOSPracticeView: UIView, UIScrollViewDelegate { imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 36), imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - imageView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -75), - imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 300), + imageView.widthAnchor.constraint(equalToConstant: 240), imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), bulletList.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 28), @@ -352,8 +351,8 @@ final class SOSPracticeView: UIView, UIScrollViewDelegate { imageView.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 72), imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - imageView.widthAnchor.constraint(equalToConstant: 205), - imageView.heightAnchor.constraint(equalToConstant: 205), + imageView.widthAnchor.constraint(equalToConstant: 270), + imageView.heightAnchor.constraint(equalToConstant: 190), titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 52), titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), diff --git a/QuickLocation/Section/Home/SOS/SOSView.swift b/QuickLocation/Section/Home/SOS/SOSView.swift index fbd78b0f..a723bc12 100644 --- a/QuickLocation/Section/Home/SOS/SOSView.swift +++ b/QuickLocation/Section/Home/SOS/SOSView.swift @@ -38,7 +38,8 @@ final class SOSView: UIView { private let contactStack = UIStackView() private let contactScrollView = UIScrollView() private let contactPanel = UIView() - private let emptyContactLabel = UILabel() + private let contactStateStack = UIStackView() + private lazy var addContactCard = makeAddContactCard() override init(frame: CGRect) { super.init(frame: frame) @@ -116,8 +117,8 @@ final class SOSView: UIView { $0.removeFromSuperview() } - emptyContactLabel.isHidden = !contacts.isEmpty - contactScrollView.isHidden = contacts.isEmpty + contactPanel.isHidden = contacts.isEmpty + addContactCard.isHidden = !contacts.isEmpty guard !contacts.isEmpty else { return } contacts.forEach { contact in @@ -208,14 +209,11 @@ final class SOSView: UIView { infoCard.translatesAutoresizingMaskIntoConstraints = false let iconBackground = UIView() - iconBackground.backgroundColor = UIColor(hexStr: "#FFECEE") - iconBackground.layer.cornerRadius = 10 infoCard.addSubview(iconBackground) iconBackground.translatesAutoresizingMaskIntoConstraints = false - let iconView = UIImageView(image: UIImage(named: "Home/sos")) + let iconView = UIImageView(image: UIImage(named: "SOS/sos_info_alert")) iconView.contentMode = .scaleAspectFit - iconView.isHidden = true iconBackground.addSubview(iconView) iconView.translatesAutoresizingMaskIntoConstraints = false @@ -248,8 +246,8 @@ final class SOSView: UIView { iconView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), - iconView.widthAnchor.constraint(equalToConstant: 24), - iconView.heightAnchor.constraint(equalToConstant: 24), + iconView.widthAnchor.constraint(equalToConstant: 30), + iconView.heightAnchor.constraint(equalToConstant: 30), titleLabel.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: 7), titleLabel.leadingAnchor.constraint(equalTo: iconBackground.trailingAnchor, constant: 6), @@ -271,12 +269,19 @@ final class SOSView: UIView { idlePulseView.translatesAutoresizingMaskIntoConstraints = false setupContactPanel() - contentView.addSubview(contactPanel) - contactPanel.translatesAutoresizingMaskIntoConstraints = false - - let addContactCard = makeAddContactCard() - contentView.addSubview(addContactCard) - addContactCard.translatesAutoresizingMaskIntoConstraints = false + let contactPanelHeight = contactPanel.heightAnchor.constraint(equalToConstant: 104) + contactPanelHeight.priority = .defaultHigh + contactPanelHeight.isActive = true + let addContactCardHeight = addContactCard.heightAnchor.constraint(equalToConstant: 88) + addContactCardHeight.priority = .defaultHigh + addContactCardHeight.isActive = true + contactStateStack.axis = .vertical + contactStateStack.alignment = .fill + contactStateStack.distribution = .fill + contactStateStack.addArrangedSubview(contactPanel) + contactStateStack.addArrangedSubview(addContactCard) + contentView.addSubview(contactStateStack) + contactStateStack.translatesAutoresizingMaskIntoConstraints = false let pulseWidth = idlePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) pulseWidth.priority = .defaultHigh @@ -287,16 +292,10 @@ final class SOSView: UIView { idlePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), idlePulseView.heightAnchor.constraint(equalTo: idlePulseView.widthAnchor), - contactPanel.topAnchor.constraint(equalTo: idlePulseView.bottomAnchor, constant: 18), - contactPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), - contactPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), - contactPanel.heightAnchor.constraint(equalToConstant: 104), - - addContactCard.topAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: 17), - addContactCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), - addContactCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), - addContactCard.heightAnchor.constraint(equalToConstant: 88), - addContactCard.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) + contactStateStack.topAnchor.constraint(equalTo: idlePulseView.bottomAnchor, constant: 18), + contactStateStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), + contactStateStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), + contactStateStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) ]) } @@ -308,10 +307,10 @@ final class SOSView: UIView { contactPanel.layer.shadowRadius = 8 contactPanel.layer.shadowOffset = CGSize(width: 0, height: 3) - let locationIcon = UIImageView(image: UIImage(named: "SOS/sos_contact_location")) - locationIcon.contentMode = .scaleAspectFit - contactPanel.addSubview(locationIcon) - locationIcon.translatesAutoresizingMaskIntoConstraints = false + let contactIcon = UIImageView(image: UIImage(named: "SOS/sos_contact_alert")) + contactIcon.contentMode = .scaleAspectFit + contactPanel.addSubview(contactIcon) + contactIcon.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.text = "紧急联系人" @@ -337,30 +336,24 @@ final class SOSView: UIView { contactScrollView.addSubview(contactStack) contactStack.translatesAutoresizingMaskIntoConstraints = false - emptyContactLabel.text = "暂无紧急联系人" - emptyContactLabel.textColor = UIColor(hexStr: "#AAAAAA") - emptyContactLabel.font = .systemFont(ofSize: 13) - contactPanel.addSubview(emptyContactLabel) - emptyContactLabel.translatesAutoresizingMaskIntoConstraints = false - let tap = UITapGestureRecognizer(target: self, action: #selector(handleManageContacts)) contactPanel.addGestureRecognizer(tap) NSLayoutConstraint.activate([ - locationIcon.topAnchor.constraint(equalTo: contactPanel.topAnchor, constant: 13), - locationIcon.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), - locationIcon.widthAnchor.constraint(equalToConstant: 16), - locationIcon.heightAnchor.constraint(equalToConstant: 16), + contactIcon.topAnchor.constraint(equalTo: contactPanel.topAnchor, constant: 13), + contactIcon.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), + contactIcon.widthAnchor.constraint(equalToConstant: 16), + contactIcon.heightAnchor.constraint(equalToConstant: 16), - titleLabel.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), - titleLabel.leadingAnchor.constraint(equalTo: locationIcon.trailingAnchor, constant: 8), + titleLabel.centerYAnchor.constraint(equalTo: contactIcon.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: contactIcon.trailingAnchor, constant: 8), - arrow.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), + arrow.centerYAnchor.constraint(equalTo: contactIcon.centerYAnchor), arrow.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), arrow.widthAnchor.constraint(equalToConstant: 14), arrow.heightAnchor.constraint(equalToConstant: 14), - contactScrollView.topAnchor.constraint(equalTo: locationIcon.bottomAnchor, constant: 8), + contactScrollView.topAnchor.constraint(equalTo: contactIcon.bottomAnchor, constant: 8), contactScrollView.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), contactScrollView.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), contactScrollView.bottomAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: -9), @@ -369,10 +362,7 @@ final class SOSView: UIView { contactStack.leadingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.leadingAnchor), contactStack.trailingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.trailingAnchor), contactStack.bottomAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.bottomAnchor), - contactStack.heightAnchor.constraint(equalTo: contactScrollView.frameLayoutGuide.heightAnchor), - - emptyContactLabel.centerXAnchor.constraint(equalTo: contactPanel.centerXAnchor), - emptyContactLabel.centerYAnchor.constraint(equalTo: contactScrollView.centerYAnchor) + contactStack.heightAnchor.constraint(equalTo: contactScrollView.frameLayoutGuide.heightAnchor) ]) } @@ -573,7 +563,6 @@ final class SOSView: UIView { private final class SOSEmergencyContactCard: UIView { - private let avatarView = UIImageView() private let phoneBadgeView = UIImageView(image: UIImage(named: "SOS/sos_phone_badge")) private let nameLabel = UILabel() private let phoneLabel = UILabel() @@ -585,13 +574,6 @@ private final class SOSEmergencyContactCard: UIView { layer.borderWidth = 0.5 layer.borderColor = UIColor(hexStr: "#E6E6E6").cgColor - avatarView.image = UIImage(named: "Common/default_avatar") - avatarView.contentMode = .scaleAspectFill - avatarView.clipsToBounds = true - avatarView.layer.cornerRadius = 13 - addSubview(avatarView) - avatarView.translatesAutoresizingMaskIntoConstraints = false - phoneBadgeView.contentMode = .scaleAspectFit addSubview(phoneBadgeView) phoneBadgeView.translatesAutoresizingMaskIntoConstraints = false @@ -610,18 +592,13 @@ private final class SOSEmergencyContactCard: UIView { phoneLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - avatarView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), - avatarView.centerYAnchor.constraint(equalTo: centerYAnchor), - avatarView.widthAnchor.constraint(equalToConstant: 26), - avatarView.heightAnchor.constraint(equalToConstant: 26), - - phoneBadgeView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 2), - phoneBadgeView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 2), + phoneBadgeView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + phoneBadgeView.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), phoneBadgeView.widthAnchor.constraint(equalToConstant: 12), phoneBadgeView.heightAnchor.constraint(equalToConstant: 12), nameLabel.topAnchor.constraint(equalTo: topAnchor, constant: 7), - nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), + nameLabel.leadingAnchor.constraint(equalTo: phoneBadgeView.trailingAnchor, constant: 5), nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), phoneLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 2), diff --git a/QuickLocation/Section/Home/SOS/SOSViewController.swift b/QuickLocation/Section/Home/SOS/SOSViewController.swift index 30c5baa1..eae7f8ce 100644 --- a/QuickLocation/Section/Home/SOS/SOSViewController.swift +++ b/QuickLocation/Section/Home/SOS/SOSViewController.swift @@ -10,6 +10,7 @@ final class SOSViewController: BaseViewController { private var rootView: SOSView! private var isRequestingSOS = false + private var statusRequestVersion = 0 override func loadView() { rootView = SOSView(frame: UIScreen.main.bounds) @@ -27,10 +28,12 @@ final class SOSViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) requestEmergencyContacts() + requestUserStatus() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + statusRequestVersion += 1 rootView.stopTransientActivities() } @@ -54,8 +57,37 @@ final class SOSViewController: BaseViewController { .disposed(by: disposeBag) } + private func requestUserStatus() { + statusRequestVersion += 1 + let requestVersion = statusRequestVersion + + UserService.userStatus() + .subscribe(onNext: { [weak self] response in + guard let self, + requestVersion == self.statusRequestVersion, + !self.isRequestingSOS else { return } + switch self.rootView.state { + case .idle, .active: + break + case .countdown, .sending: + return + } + guard response.code == "0", let model = response.model else { + DLToast.showError(text: response.message ?? "获取用户状态失败") + return + } + AppContextManager.shared.updateAccountState(status: model.status, mood: model.mood) + self.rootView.setState(model.status == 1 ? .active : .idle) + }, onError: { [weak self] error in + guard let self, requestVersion == self.statusRequestVersion else { return } + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + private func requestSOS(enable: Bool) { guard !isRequestingSOS else { return } + statusRequestVersion += 1 isRequestingSOS = true dl.showLoading(text: enable ? "正在发送SOS" : "正在取消SOS") diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift index a5ca136e..bfed7086 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift @@ -43,7 +43,7 @@ class SearchLocationResultVC: BaseViewController { private func applyResultPresentation() { handleCode() let isVip = isVipUnlocked() - rootView.areaBlurView.isHidden = isVip + rootView.vipMaskView.isHidden = isVip if !isVip { rootView.actionBtn.setTitle("开通会员", for: .normal) rootView.actionBtn.isHidden = false diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift index f15976a5..857db5eb 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift @@ -97,7 +97,7 @@ class SearchLocationResultView: UIView { locationRow.addSubview(pinIcon) locationRow.addSubview(phoneAreaLab) - locationRow.addSubview(areaBlurView) +// locationRow.addSubview(areaBlurView) pinIcon.layoutChain.left().centerY().width(18).height(18) phoneAreaLab.layoutChain @@ -105,11 +105,11 @@ class SearchLocationResultView: UIView { .right() .top() .bottom() - areaBlurView.layoutChain - .topToView(phoneAreaLab, offset: -2) - .bottomToView(phoneAreaLab, offset: 2) - .leftToView(phoneAreaLab, offset: -4) - .rightToView(phoneAreaLab, offset: 4) +// areaBlurView.layoutChain +// .topToView(phoneAreaLab, offset: -2) +// .bottomToView(phoneAreaLab, offset: 2) +// .leftToView(phoneAreaLab, offset: -4) +// .rightToView(phoneAreaLab, offset: 4) locationRow.layoutChain.top(20).centerX() let pillContainer = UIView() @@ -143,6 +143,9 @@ class SearchLocationResultView: UIView { .height(0) .bottom(16) + card.addSubview(vipMaskView) + vipMaskView.layoutChain.edges() + return card }() @@ -236,17 +239,11 @@ class SearchLocationResultView: UIView { btn.cornerRadius = 18 return btn }() - - // 兼容 VC 旧引用 - lazy var messageLab: UILabel = statusPillLab - lazy var tipsLab: UILabel = disclaimerLab - lazy var normalLottieView: LottieAnimationView = { - let view = LottieAnimationView() - view.isHidden = true - return view - }() - lazy var successLottieView: LottieAnimationView = { - let view = LottieAnimationView(name: "phone_search_fireworks") + + lazy var vipMaskView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "SearchLocation/vip_mask") + view.contentMode = .scaleAspectFill view.isHidden = true return view }() diff --git a/QuickLocation/Section/Home/SignIn/InputEmailPopupView.swift b/QuickLocation/Section/Home/SignIn/InputEmailPopupView.swift index 116be6d8..4d685c45 100644 --- a/QuickLocation/Section/Home/SignIn/InputEmailPopupView.swift +++ b/QuickLocation/Section/Home/SignIn/InputEmailPopupView.swift @@ -183,9 +183,9 @@ class InputEmailPopupView: UIView { let btn = UIButton(type: .custom) btn.setTitle("立即添加", for: .normal) btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.titleLabel?.font = FontManager.boboBold(18) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 16 btn.clipsToBounds = true return btn }() diff --git a/QuickLocation/Section/Home/SignIn/SignInView.swift b/QuickLocation/Section/Home/SignIn/SignInView.swift index fe4001ad..f163fe81 100644 --- a/QuickLocation/Section/Home/SignIn/SignInView.swift +++ b/QuickLocation/Section/Home/SignIn/SignInView.swift @@ -16,12 +16,12 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl private let gradientLayer: CAGradientLayer = { let layer = CAGradientLayer() layer.colors = [ - UIColor(hexStr: "#FFEAA1").cgColor, + UIColor(hexStr: "#FFF6BA").cgColor, UIColor(hexStr: "#FFFFFF").cgColor ] layer.locations = [0, 1] layer.startPoint = CGPoint(x: 0.5, y: 0) - layer.endPoint = CGPoint(x: 1, y: 1) + layer.endPoint = CGPoint(x: 0.5, y: 1) return layer }() @@ -48,12 +48,27 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl func setupData(_ model: SignInModel) { let email = model.email.trimmingCharacters(in: .whitespacesAndNewlines) emailLab.text = email.isEmpty ? "暂未添加" : email - updateSignButton(signed: model.signInStatus == 1) + let signed = model.signInStatus == 1 + heroImg.image = UIImage(named: heroImageName(for: model)) + updateSignButton(signed: signed) calendarDays = model.calendarDays() calendarView.reloadData() scrollCalendarToEnd(animated: false) } + private func heroImageName(for model: SignInModel) -> String { + if model.signInStatus == 1 { + return "SignIn/hero_bg_signed" + } + if model.signInStatus == 2 || model.missCount >= 2 { + return "SignIn/hero_bg_missed_two_days" + } + if model.missCount == 1 { + return "SignIn/hero_bg_missed_one_day" + } + return "SignIn/hero_bg_unsigned" + } + func currentEmail() -> String? { let text = emailLab.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if text.isEmpty || text == "暂未添加" { @@ -77,13 +92,12 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl private func setupUI() { layer.insertSublayer(gradientLayer, at: 0) - addSubview(navView) + addSubview(headerBgView) addSubview(signInBtn) addSubview(tipsView) addSubview(contentView) + addSubview(navView) - contentView.addSubview(titleLab) - contentView.addSubview(subtitleLab) contentView.addSubview(emailView) contentView.addSubview(cardView) @@ -96,6 +110,11 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl .edges(excludingEdge: .bottom) .height(kNaviHeight) + headerBgView.layoutChain + .top() + .edgesHorzontal() + .heightToWidth(400.0 / 750.0) + tipsView.layoutChain .topToBottomOfView(signInBtn, offset: 16) .centerX() @@ -113,17 +132,8 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl .edgesHorzontal() .bottomToTopOfView(signInBtn, offset: -10) - titleLab.layoutChain - .top(4) - .centerX() - - subtitleLab.layoutChain - .topToBottomOfView(titleLab, offset: 12) - .centerX() - .edgesHorzontal(24) - emailView.layoutChain - .topToBottomOfView(subtitleLab, offset: 20) + .topToBottomOfView(headerBgView, offset: -35) .centerX() .height(44) .left(48, relation: .greaterThanOrEqual) @@ -153,6 +163,13 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl BaseNavigationView(title: "") }() + private lazy var headerBgView: UIImageView = { + let view = UIImageView(image: UIImage(named: "SignIn/header_bg")) + view.contentMode = .scaleAspectFit + view.isUserInteractionEnabled = false + return view + }() + private lazy var scrollView: UIScrollView = { let view = UIScrollView() view.showsVerticalScrollIndicator = false @@ -162,24 +179,6 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl private lazy var contentView = UIView() - private lazy var titleLab: UILabel = { - let label = UILabel() - label.text = "今天报到" - label.textColor = UIColor(hexStr: "#5B3F39") - label.font = FontManager.boboBold(30) - label.textAlignment = .center - return label - }() - - private lazy var subtitleLab: UILabel = { - let label = UILabel() - label.text = "不发奖励,只期待你今天有出现" - label.textColor = UIColor(hexStr: "#322013") - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textAlignment = .center - return label - }() - lazy var emailView: UIView = { let view = UIView() view.backgroundColor = .white @@ -226,7 +225,7 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl }() private lazy var heroImg: UIImageView = { - let view = UIImageView(image: UIImage(named: "SignIn/hero_bg")) + let view = UIImageView(image: UIImage(named: "SignIn/hero_bg_unsigned")) view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 20 diff --git a/QuickLocation/Section/LockDistract/LockDistractVC.swift b/QuickLocation/Section/LockDistract/LockDistractVC.swift index c1f076c5..e4e2342d 100644 --- a/QuickLocation/Section/LockDistract/LockDistractVC.swift +++ b/QuickLocation/Section/LockDistract/LockDistractVC.swift @@ -62,13 +62,31 @@ final class LockDistractVC: BaseViewController { GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, let model = response.model else { return } self.groupModel = model - self.members = model.select_group_employee - self.selectedIndex = 0 - self.refreshMember() - self.rootView.setTodayLockCount(0) + guard !model.default_group_key.isEmpty else { + self.applyMembers([]) + return + } + GroupService.groupUsers(groupKey: model.default_group_key) + .subscribe(onNext: { [weak self] response in + guard response.isValid(for: model.default_group_key) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.applyMembers(response.list) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: self.disposeBag) }).disposed(by: disposeBag) } + private func applyMembers(_ members: [GroupMemberModel]) { + self.members = members + selectedIndex = 0 + refreshMember() + rootView.setTodayLockCount(0) + } + private func stepMember(_ delta: Int) { guard !members.isEmpty else { return } let count = members.count @@ -90,7 +108,7 @@ final class LockDistractVC: BaseViewController { loadGroupMembers() return } - GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in + GroupListPopView.show(groupModel: groupModel, currentMembers: members) { [weak self] groupKey in guard let self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) .subscribe(onNext: { [weak self] _ in diff --git a/QuickLocation/Section/Login/LoginView.swift b/QuickLocation/Section/Login/LoginView.swift index 87cb6a38..c50986ee 100644 --- a/QuickLocation/Section/Login/LoginView.swift +++ b/QuickLocation/Section/Login/LoginView.swift @@ -94,8 +94,8 @@ class LoginView: UIView { backBtn.layoutChain .top(54) .left(15) - .width(24) - .height(24) + .width(44) + .height(44) guestLoginButton.layoutChain .centerY(backBtn) diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index d32ca167..0ef69bbc 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -11,7 +11,6 @@ import RxCocoa final class MinePhotoWallView: UIView { var onCenteredMember: ((GroupMemberModel) -> Void)? - var onCopyID: ((String) -> Void)? /// 点击照片墙上某成员(含自己)→ 进入成员信息 var onTapMember: ((GroupMemberModel) -> Void)? @@ -157,7 +156,7 @@ final class MinePhotoWallView: UIView { private lazy var titleLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .heavy) - label.textColor = UIColor(hexStr: "#353B4F") + label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center label.text = " " return label @@ -165,7 +164,6 @@ final class MinePhotoWallView: UIView { private lazy var daysRow: UIView = { let v = UIView() - v.isHidden = true v.addSubview(daysPrefixLab) v.addSubview(daysBadge) v.addSubview(daysSuffixLab) @@ -189,7 +187,7 @@ final class MinePhotoWallView: UIView { private lazy var daysPrefixLab: UILabel = { let label = UILabel() label.text = "已经在圈子" - label.font = .systemFont(ofSize: 16, weight: .heavy) + label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#293445") return label }() @@ -214,8 +212,8 @@ final class MinePhotoWallView: UIView { private lazy var daysLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 12, weight: .bold) - label.textColor = UIColor(hexStr: "#16B3FF") + label.font = .systemFont(ofSize: 14, weight: .heavy) + label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.6 @@ -225,43 +223,11 @@ final class MinePhotoWallView: UIView { private lazy var daysSuffixLab: UILabel = { let label = UILabel() label.text = "天" - label.font = .systemFont(ofSize: 16, weight: .heavy) + label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = UIColor(hexStr: "#293445") return label }() - private lazy var idRow: UIView = { - let v = UIView() - v.addSubview(idLab) - v.addSubview(copyBtn) - idLab.layoutChain - .left() - .top() - .bottom() - .centerY() - copyBtn.layoutChain - .leftToRightOfView(idLab, offset: 4) - .right() - .centerY() - .width(14) - .height(14) - return v - }() - - private lazy var idLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hexStr: "#767676") - label.text = " " - return label - }() - - private lazy var copyBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Mine/copy"), for: .normal) - return btn - }() - override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear @@ -270,7 +236,6 @@ final class MinePhotoWallView: UIView { addSubview(collectionView) addSubview(titleLab) addSubview(daysRow) - addSubview(idRow) collectionView.layoutChain .top() @@ -283,26 +248,12 @@ final class MinePhotoWallView: UIView { .height(24) daysRow.layoutChain - .centerY(titleLab) + .topToBottomOfView(titleLab, offset: 4) .centerX() .height(26) - - idRow.layoutChain - .topToBottomOfView(titleLab, offset: 6) - .centerX() - .height(16) .bottom(4) - - copyBtn.rx.tap - .subscribe(onNext: { [weak self] in - guard let self = self, self.centeredIndex < self.members.count else { return } - self.onCopyID?(self.members[self.centeredIndex].user_id) - }) - .disposed(by: disposeBag) } - private let disposeBag = DisposeBag() - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -429,22 +380,13 @@ final class MinePhotoWallView: UIView { private func updateInfo(_ model: GroupMemberModel?) { guard let model = model else { titleLab.text = " " - titleLab.isHidden = false daysRow.isHidden = true - idLab.text = " " return } - let isSelf = model.user_id == AppContextManager.shared.userId - if isSelf { - titleLab.isHidden = false - daysRow.isHidden = true - titleLab.text = model.nick_name - } else { - titleLab.isHidden = true - daysRow.isHidden = false - daysLab.text = model.joinDaysDisplay - } - idLab.text = "ID:\(model.user_id)" + let nickname = model.nick_name.trimmingCharacters(in: .whitespacesAndNewlines) + titleLab.text = nickname.isEmpty ? "未设置昵称" : nickname + daysLab.text = model.joinDaysDisplay + daysRow.isHidden = false } private func playSwayOnce() { @@ -778,8 +720,8 @@ private final class PaddingLabel: UILabel { extension GroupMemberModel { var joinDaysDisplay: String { if join_days > 0 { return "\(join_days)" } - if join_time > 0 { - let days = max(1, Int((Date().timeIntervalSince1970 - TimeInterval(join_time) / 1000) / 86400)) + if let joinDate { + let days = max(1, Int(Date().timeIntervalSince(joinDate) / 86400)) return "\(days)" } return "--" diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index cef6d524..eb0e33c1 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -62,11 +62,6 @@ final class MineViewController: BaseViewController { }) .disposed(by: disposeBag) - rootView.photoWall.onCopyID = { id in - UIPasteboard.general.string = id - DLToast.showSuccess(text: "已复制") - } - rootView.photoWall.onTapMember = { [weak self] member in self?.openMemberInfo(selectedUserId: member.user_id) } @@ -101,18 +96,38 @@ final class MineViewController: BaseViewController { guard let self = self, let model = response.model else { return } self.groupModel = model self.groupKey = model.default_group_key - let sorted = self.viewModel.sortedMembers(model.select_group_employee, groupKey: model.default_group_key) - self.photoWallMembers = sorted - // 圈主 id:group_key 含 userId - let ownerId = sorted.first(where: { model.default_group_key.contains($0.user_id) })?.user_id ?? "" - self.rootView.photoWall.reload( - members: sorted, - ownerUserId: ownerId, - preferUserId: AppContextManager.shared.userId - ) + guard !model.default_group_key.isEmpty else { + self.reloadPhotoWall(members: [], groupKey: "") + return + } + GroupService.groupUsers(groupKey: model.default_group_key) + .subscribe(onNext: { [weak self] response in + guard response.isValid(for: model.default_group_key) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.reloadPhotoWall(members: response.list, + groupKey: model.default_group_key) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: self.disposeBag) }).disposed(by: disposeBag) } + private func reloadPhotoWall(members: [GroupMemberModel], groupKey: String) { + let sorted = viewModel.sortedMembers(members, groupKey: groupKey) + photoWallMembers = sorted + let ownerId = sorted.first { + GroupOwnerMatcher.matches(userId: $0.user_id, groupKey: groupKey) + }?.user_id ?? "" + rootView.photoWall.reload( + members: sorted, + ownerUserId: ownerId, + preferUserId: AppContextManager.shared.userId + ) + } + private func openMemberInfo(selectedUserId: String) { let members = photoWallMembers.map { $0.toJSON() } guard !members.isEmpty else { return } diff --git a/QuickLocation/Section/Mine/MineViewModel.swift b/QuickLocation/Section/Mine/MineViewModel.swift index 83c51705..5f709ec9 100644 --- a/QuickLocation/Section/Mine/MineViewModel.swift +++ b/QuickLocation/Section/Mine/MineViewModel.swift @@ -75,21 +75,11 @@ class MineViewModel { /// 圈主 → 自己 → 在线(对齐首页) func sortedMembers(_ list: [GroupMemberModel], groupKey: String) -> [GroupMemberModel] { - list.enumerated() - .sorted { lhs, rhs in - let lp = memberSortPriority(lhs.element, groupKey: groupKey) - let rp = memberSortPriority(rhs.element, groupKey: groupKey) - if lp != rp { return lp < rp } - return lhs.offset < rhs.offset - } - .map(\.element) - } - - private func memberSortPriority(_ member: GroupMemberModel, groupKey: String) -> Int { - if !groupKey.isEmpty, groupKey.contains(member.user_id) { return 0 } - if member.user_id == AppContextManager.shared.userId { return 1 } - if member.is_online { return 2 } - return 3 + GroupMemberSorter.sorted( + list, + groupKey: groupKey, + currentUserId: AppContextManager.shared.userId + ) } func requestWechatService() { diff --git a/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift b/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift index 862b0397..a4e124dc 100644 --- a/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift +++ b/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift @@ -11,6 +11,8 @@ final class MoodStatusVC: BaseViewController { private var rootView: MoodStatusView! private let viewModel = MoodStatusViewModel() + private var statusRequestVersion = 0 + private var moodSelectionVersion = 0 override func loadView() { rootView = MoodStatusView(frame: UIScreen.main.bounds) @@ -26,6 +28,16 @@ final class MoodStatusVC: BaseViewController { ) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + requestUserStatus() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + statusRequestVersion += 1 + } + private func bind() { rootView.onBack = { [weak self] in guard self?.viewModel.isSaving == false else { return } @@ -33,6 +45,7 @@ final class MoodStatusVC: BaseViewController { } rootView.onSelectMood = { [weak self] moodIndex in + self?.moodSelectionVersion += 1 self?.viewModel.select(moodIndex: moodIndex) } @@ -48,6 +61,29 @@ final class MoodStatusVC: BaseViewController { .disposed(by: disposeBag) } + private func requestUserStatus() { + statusRequestVersion += 1 + let requestVersion = statusRequestVersion + let selectionVersion = moodSelectionVersion + + UserService.userStatus() + .subscribe(onNext: { [weak self] response in + guard let self, requestVersion == self.statusRequestVersion else { return } + guard response.code == "0", let model = response.model else { + DLToast.showError(text: response.message ?? "获取用户状态失败") + return + } + AppContextManager.shared.updateAccountState(status: model.status, mood: model.mood) + if selectionVersion == self.moodSelectionVersion { + self.viewModel.applyServerMood(model.mood) + } + }, onError: { [weak self] error in + guard let self, requestVersion == self.statusRequestVersion else { return } + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + private func save() { guard !viewModel.isSaving else { return } @@ -55,15 +91,21 @@ final class MoodStatusVC: BaseViewController { case .missing: DLToast.show(text: "请选择心情") case let .ready(moodIndex): + statusRequestVersion += 1 viewModel.beginSaving() rootView.setSaving(true) DLToast.showLoading() UserService.setMood(moodIndex: moodIndex) - .subscribe(onNext: { _ in + .subscribe(onNext: { [weak self] _ in + self?.viewModel.endSaving() + AppContextManager.shared.updateAccountMood(moodIndex) DLToast.show(text: "保存成功") { AppRouter.shared.popOrDismiss() NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) } + }, onError: { [weak self] _ in + self?.viewModel.endSaving() + self?.rootView.setSaving(false) }).disposed(by: disposeBag) } } diff --git a/QuickLocation/Section/Mine/MoodStatus/MoodStatusViewModel.swift b/QuickLocation/Section/Mine/MoodStatus/MoodStatusViewModel.swift index 0790a9c8..7a5e4add 100644 --- a/QuickLocation/Section/Mine/MoodStatus/MoodStatusViewModel.swift +++ b/QuickLocation/Section/Mine/MoodStatus/MoodStatusViewModel.swift @@ -32,6 +32,14 @@ final class MoodStatusViewModel { selectedMoodIndex.accept(moodIndex) } + func applyServerMood(_ moodIndex: Int) { + if MoodStatusCatalog.item(moodIndex: moodIndex) != nil { + selectedMoodIndex.accept(moodIndex) + } else { + selectedMoodIndex.accept(items.first?.moodIndex) + } + } + func selectedItem() -> MoodStatusItem? { guard let moodIndex = selectedMoodIndex.value else { return nil } return MoodStatusCatalog.item(moodIndex: moodIndex) diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 7396890b..58804225 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -73,14 +73,42 @@ final class PigeonMessageVC: BaseViewController { private func loadMembers() { DLToast.showLoading() GroupService.groupInfo().subscribe(onNext: { [weak self] response in + guard let self else { return } + guard let model = response.model else { + DLToast.dismiss() + DLToast.showError(text: response.message ?? "获取圈子信息失败") + return + } + guard !model.default_group_key.isEmpty else { + DLToast.dismiss() + self.applyMembers([]) + return + } + GroupService.groupUsers(groupKey: model.default_group_key) + .subscribe(onNext: { [weak self] response in + DLToast.dismiss() + guard response.isValid(for: model.default_group_key) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.applyMembers(response.list) + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: self.disposeBag) + }, onError: { error in DLToast.dismiss() - guard let self, let model = response.model else { return } - self.members = model.select_group_employee - self.selectedMember = self.members.first - self.refreshRecipient() + DLToast.showError(text: error.localizedDescription) }).disposed(by: disposeBag) } + private func applyMembers(_ members: [GroupMemberModel]) { + self.members = members + selectedMember = members.first + refreshRecipient() + } + private func refreshRecipient() { rootView.configureRecipient( selectedMember, diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift index 75cb675c..525e2b62 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateSchedulePopView.swift @@ -70,11 +70,9 @@ class CreateSchedulePopView: UIView { let view = UIView() view.backgroundColor = .clear - let pin = UIImageView(image: UIImage(named: "Schedule/location")?.withRenderingMode(.alwaysTemplate)) - pin.tintColor = UIColor(hexStr: "#16B3FF") - pin.contentMode = .scaleAspectFit + let pin = UIImageView(image: UIImage(named: "Schedule/title_location")) view.addSubview(pin) - pin.layoutChain.left(16).centerY().width(14).height(14) + pin.layoutChain.left(16).centerY().width(18).height(18) let titleLab = UILabel() titleLab.text = "行程详情" diff --git a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVC.swift b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVC.swift index d0171f12..476c1202 100644 --- a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVC.swift +++ b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVC.swift @@ -8,141 +8,286 @@ import UIKit import RxSwift import RxCocoa -import RxDataSources import ObjectMapper +import SwiftyUserDefaults +import AMapNaviKit +#if !targetEnvironment(simulator) +import AMapSearchKit +#endif -class ScheduleDetailVC: BaseViewController { +final class ScheduleDetailVC: BaseViewController { fileprivate var rootView: ScheduleDetailView! + private let viewModel: ScheduleDetailViewModel + private var routeOverlays: [MAPolyline] = [] + private var pointAnnotations: [ScheduleRouteAnnotation] = [] + private var validCoordinates: [CLLocationCoordinate2D] = [] + private var hasDrawnFallbackRoute = false + private let routeSearch = AMapSearchAPI() override func loadView() { rootView = ScheduleDetailView(frame: UIScreen.main.bounds) view = rootView } - - private var viewModel: ScheduleDetailViewModel - + override func viewDidLoad() { super.viewDidLoad() + rootView.tableView.dataSource = self + configureData() + bindActions() + setupMap() + addPointAnnotations() + requestRoute() + } - // Do any additional setup after loading the view. - setupData() - bindViewModel() - reactiveAction() - requestFollowList() - - viewModel.loadPointData() + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + rootView.cleanupMap() + } } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { return } - - rootView.vipTipsLab.text = AppContextManager.shared.vip > 1 ? "" : "升级 VIP,可查看具体人员与节点" - } - - private func reactiveAction() { - rootView.operateBtn.rx.tap.subscribe(onNext: { _ in - guard let model = self.viewModel.scheduModel else { return } - if model.is_own { - AppRouter.push(Route.createSchedule, userInfo: ["scheduleJson": model.toJSON()]) - } - else { - self.requestSetFollow(id: model.id, op: self.rootView.operateBtn.isSelected) - } - }).disposed(by: disposeBag) - rootView.routeBtn.rx.tap.subscribe(onNext: { _ in - guard let model = self.viewModel.scheduModel else { return } - AppRouter.push(Route.itineraryDetail, userInfo: ["scheduleJson": model.toJSON()]) - }).disposed(by: disposeBag) - - rootView.vipTipsLab.rx.tapGesture.subscribe(onNext: { _ in - AppRouter.push(Route.vipRecharge) - }).disposed(by: disposeBag) - } - - private func bindViewModel() { - viewModel.output.sectionedItems - .bind(to: rootView.collectionView.rx.items(dataSource: dataSource)) - .disposed(by: disposeBag) - - viewModel.output.pointSectionedItems - .bind(to: rootView.tableView.rx.items(dataSource: tableViewDataSource)) - .disposed(by: disposeBag) - } - - private func setupData() { + private func configureData() { guard let model = viewModel.scheduModel else { return } - rootView.dateLab.text = rootView.dateLab.getDateInterval2String(date: "\(model.timestamp / 1000)", dateFormat: "yyyy年MM月dd日") - rootView.creatorIcon.image = model.userIcon - - var operateName = "" - if model.is_own { - operateName = "编辑行程" - } - else { - operateName = "关注行程" - rootView.operateBtn.isSelected = model.is_follow - } - rootView.operateBtn.setTitle(operateName, for: .normal) + rootView.configure(model) + rootView.tableView.reloadData() + } + + private func bindActions() { + rootView.editBtn.rx.tap + .subscribe(onNext: { [weak self] in + guard let model = self?.viewModel.scheduModel, model.is_own else { return } + AppRouter.push(Route.createSchedule, userInfo: ["scheduleJson": model.toJSON()]) + }) + .disposed(by: disposeBag) + } + + private func setupMap() { + #if !targetEnvironment(simulator) + rootView.mapView.delegate = self + routeSearch?.delegate = self + if let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + if CLLocationCoordinate2DIsValid(coordinate) { + rootView.mapView.setCenter(coordinate, animated: false) + } + } + #endif + } + + private func addPointAnnotations() { + #if !targetEnvironment(simulator) + pointAnnotations.forEach { rootView.mapView.removeAnnotation($0) } + pointAnnotations.removeAll() + + let validPoints = viewModel.orderedPoints.filter { + guard let latitude = $0.latitude, let longitude = $0.longitude else { return false } + return abs(latitude) > 0.0001 && abs(longitude) > 0.0001 + } + validCoordinates = validPoints.compactMap { + guard let latitude = $0.latitude, let longitude = $0.longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + for (index, coordinate) in validCoordinates.enumerated() { + let annotation = ScheduleRouteAnnotation() + annotation.coordinate = coordinate + if validCoordinates.count == 1 { + annotation.role = validPoints[0].type == 2 ? .end : .start + } else if index == 0 { + annotation.role = .start + } else if index == validCoordinates.count - 1 { + annotation.role = .end + } else { + annotation.role = .waypoint + } + rootView.mapView.addAnnotation(annotation) + pointAnnotations.append(annotation) + } + + fitMapContent() + #endif + } + + private func requestRoute() { + #if !targetEnvironment(simulator) + guard validCoordinates.count >= 2 else { return } + let request = AMapDrivingRouteSearchRequest() + request.origin = AMapGeoPoint.location( + withLatitude: CGFloat(validCoordinates[0].latitude), + longitude: CGFloat(validCoordinates[0].longitude) + ) + if let last = validCoordinates.last { + request.destination = AMapGeoPoint.location( + withLatitude: CGFloat(last.latitude), + longitude: CGFloat(last.longitude) + ) + } + if validCoordinates.count > 2 { + request.waypoints = validCoordinates.dropFirst().dropLast().compactMap { + AMapGeoPoint.location(withLatitude: CGFloat($0.latitude), longitude: CGFloat($0.longitude)) + } + } + request.strategy = 0 + routeSearch?.aMapDrivingRouteSearch(request) + #endif + } + + private func drawRoute(_ coordinates: [CLLocationCoordinate2D], fallback: Bool) { + #if !targetEnvironment(simulator) + guard coordinates.count > 1 else { return } + if !fallback { + routeOverlays.forEach { rootView.mapView.remove($0) } + routeOverlays.removeAll() + } else if !routeOverlays.isEmpty || hasDrawnFallbackRoute { + return + } + + var mutableCoordinates = coordinates + guard let polyline = MAPolyline(coordinates: &mutableCoordinates, count: UInt(mutableCoordinates.count)) else { + return + } + rootView.mapView.add(polyline) + routeOverlays.append(polyline) + hasDrawnFallbackRoute = fallback + fitMapContent() + #endif + } + + private func fitMapContent() { + #if !targetEnvironment(simulator) + rootView.layoutIfNeeded() + let padding = UIEdgeInsets( + top: kNaviHeight + 18, + left: 36, + bottom: rootView.mapBottomPadding, + right: 36 + ) + if !routeOverlays.isEmpty { + rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true) + } else if !pointAnnotations.isEmpty { + rootView.mapView.showAnnotations(pointAnnotations, edgePadding: padding, animated: true) + } + #endif + } + + private static func markerImage(for role: ScheduleTimelineRole) -> UIImage? { + let size = role == .waypoint ? CGSize(width: 16, height: 16) : CGSize(width: 28, height: 28) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + defer { UIGraphicsEndImageContext() } + guard let context = UIGraphicsGetCurrentContext() else { return nil } + + if role == .waypoint { + let markerRect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) + context.setFillColor(UIColor(hexStr: "#00ADFE").cgColor) + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(2) + context.fillEllipse(in: markerRect) + context.strokeEllipse(in: markerRect) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(x: 7, y: 7, width: 4, height: 4)) + } else { + let color = role == .start ? UIColor(hexStr: "#35C8F4") : UIColor(hexStr: "#FF5665") + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect(origin: .zero, size: size)) + let text = role == .start ? "始" : "终" + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13, weight: .bold), + .foregroundColor: UIColor.white + ] + let textSize = text.size(withAttributes: attributes) + text.draw( + at: CGPoint(x: (size.width - textSize.width) / 2, y: (size.height - textSize.height) / 2), + withAttributes: attributes + ) + } + return UIGraphicsGetImageFromCurrentImageContext() } - - // MARK: - API - private func requestFollowList() { - DLToast.showLoading() - ItineraryService.queryFollowList(id: viewModel.routeId).subscribe(onNext: { response in - DLToast.dismiss() - self.viewModel.loadViewedData(response.list) - self.rootView.noDataLab.isHidden = response.list.count > 0 - }, onError: { _ in }).disposed(by: disposeBag) - } - - private func requestSetFollow(id: String, op: Bool) { - DLToast.showLoading() - ItineraryService.follow(id: id, op: op ? 2 : 1).subscribe(onNext: { response in - DLToast.dismiss() - DLToast.show(text: op ? "已取消" : "已关注此行程, \n行程路线将自动添加到您的行程列表。") - self.rootView.operateBtn.isSelected = !op - }, onError: { _ in }).disposed(by: disposeBag) - } - - // MARK: - dataSource - private lazy var dataSource: RxCollectionViewSectionedReloadDataSource = { - RxCollectionViewSectionedReloadDataSource { datasource, collectionView, indexPath, model in - let cell: ViewedCell = collectionView.dequeueReusableCell(for: indexPath) - cell.configure(model) - return cell - } - }() - - lazy private var tableViewDataSource: RxTableViewSectionedReloadDataSource = { - return RxTableViewSectionedReloadDataSource( - configureCell: { (dataSource, tableView, indexPath, item) in - switch item { - case let .point(model): - let cell: SchedulePointDetailCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(model: model, index: indexPath.section, total: dataSource.sectionModels.count) - return cell - case let .event(model): - let cell: SchedulePointEventCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(model: model, index: indexPath.section, total: dataSource.sectionModels.count) - return cell - } - } - ) - }() - // MARK: - Init init(routeId: String, scheduleJson: [String: Any]) { - self.viewModel = ScheduleDetailViewModel(routeId: routeId, - model: scheduleJson.isEmpty ? nil : ScheduleModel.init(JSON: scheduleJson)) + viewModel = ScheduleDetailViewModel( + routeId: routeId, + model: scheduleJson.isEmpty ? nil : ScheduleModel(JSON: scheduleJson) + ) super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - } + +extension ScheduleDetailVC: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.timelineItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: ScheduleTimelineCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(viewModel.timelineItems[indexPath.row]) + return cell + } +} + +private final class ScheduleRouteAnnotation: MAPointAnnotation { + var role: ScheduleTimelineRole = .waypoint +} + +#if !targetEnvironment(simulator) +extension ScheduleDetailVC: MAMapViewDelegate { + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + guard !(annotation is MAUserLocation), let routeAnnotation = annotation as? ScheduleRouteAnnotation else { + return nil + } + let identifier = "ScheduleRouteMarker" + var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + if annotationView == nil { + annotationView = MAAnnotationView(annotation: annotation, reuseIdentifier: identifier) + } else { + annotationView?.annotation = annotation + } + annotationView?.image = Self.markerImage(for: routeAnnotation.role) + annotationView?.centerOffset = .zero + annotationView?.canShowCallout = false + return annotationView + } + + 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: "#58EDFF") + renderer?.lineWidth = 6 + renderer?.lineJoinType = kMALineJoinRound + renderer?.lineCapType = kMALineCapRound + return renderer + } +} + +extension ScheduleDetailVC: AMapSearchDelegate { + func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) { + guard let path = response.route?.paths?.first as? AMapPath else { + drawRoute(validCoordinates, fallback: true) + return + } + var coordinates: [CLLocationCoordinate2D] = [] + for step in path.steps { + guard let polyline = step.polyline else { continue } + for point in polyline.components(separatedBy: ";") { + let values = point.components(separatedBy: ",") + if values.count == 2, let longitude = Double(values[0]), let latitude = Double(values[1]) { + coordinates.append(CLLocationCoordinate2D(latitude: latitude, longitude: longitude)) + } + } + } + if coordinates.count > 1 { + drawRoute(coordinates, fallback: false) + } else { + drawRoute(validCoordinates, fallback: true) + } + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + drawRoute(validCoordinates, fallback: true) + } +} +#endif diff --git a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVM.swift b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVM.swift index 97180444..02894db8 100644 --- a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVM.swift +++ b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailVM.swift @@ -5,61 +5,68 @@ // Created by 八条 on 2026/6/25. // -import RxSwift -import RxCocoa -import RxDataSources +import Foundation -enum SchedulePointDetailItem { - case point(model: SchedulePointModel) - case event(model: SchedulePointEventModel) +enum ScheduleTimelineRole { + case start + case waypoint + case end } -typealias SchedulePointDetailSectionModel = SectionModel +struct ScheduleTimelineItem { + let point: SchedulePointModel + let role: ScheduleTimelineRole + let durationToNext: Int64? + let isFirst: Bool + let isLast: Bool +} struct ScheduleDetailViewModel { - - struct Output { - var sectionedItems: Observable<[ViewedListSectionModel]> - var pointSectionedItems: Observable<[SchedulePointDetailSectionModel]> - } - - let output: Output - - private let sectionedItems = PublishSubject<[ViewedListSectionModel]>() - private let pointSectionedItems = PublishSubject<[SchedulePointDetailSectionModel]>() - let routeId: String let scheduModel: ScheduleModel? - - func loadViewedData(_ list: [ViewedModel]) { - sectionedItems.onNext(list.mapSection()) - } - - func loadPointData() { - guard let model = scheduModel else { return } - - var sectionModels: [SchedulePointDetailSectionModel] = [] - var items: [SchedulePointDetailItem] = [] - - for point in model.points { - items.append(.point(model: point)) - for event in point.events { - items.append(.event(model: event)) - } - sectionModels.append(SchedulePointDetailSectionModel(model: point.id, items: items)) - items = [] - } - - pointSectionedItems.onNext(sectionModels) - } - - // MARK: - init + let orderedPoints: [SchedulePointModel] + let timelineItems: [ScheduleTimelineItem] + init(routeId: String, model: ScheduleModel?) { - self.routeId = routeId self.scheduModel = model - output = Output( - sectionedItems: sectionedItems.asObservable(), - pointSectionedItems: pointSectionedItems.asObservable() - ) + self.routeId = routeId.isEmpty ? (model?.id ?? "") : routeId + + let points = Self.order(model?.points ?? []) + self.orderedPoints = points + self.timelineItems = points.enumerated().map { index, point in + let role: ScheduleTimelineRole + if points.count == 1 { + role = point.type == 2 ? .end : .start + } else if index == 0 { + role = .start + } else if index == points.count - 1 { + role = .end + } else { + role = .waypoint + } + + var duration: Int64? + if index + 1 < points.count { + let nextTimestamp = points[index + 1].expected_timestamp + if point.expected_timestamp > 0, nextTimestamp > point.expected_timestamp { + duration = nextTimestamp - point.expected_timestamp + } + } + + return ScheduleTimelineItem( + point: point, + role: role, + durationToNext: duration, + isFirst: index == 0, + isLast: index == points.count - 1 + ) + } + } + + private static func order(_ points: [SchedulePointModel]) -> [SchedulePointModel] { + guard points.count > 1 else { return points } + let sequences = points.map(\.sequence) + guard Set(sequences).count == points.count else { return points } + return points.sorted { $0.sequence < $1.sequence } } } diff --git a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailView.swift b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailView.swift index e621f4df..5b427d0f 100644 --- a/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailView.swift +++ b/QuickLocation/Section/Schedule/ScheduleDetail/ScheduleDetailView.swift @@ -8,568 +8,516 @@ import UIKit import RxSwift import RxCocoa +import AMapNaviKit -class ScheduleDetailView: UIView { +final class ScheduleDetailView: UIView { - var disposeBag = DisposeBag() - - private func setupRx() { - backBtn.rx.tap.subscribe(onNext: { _ in - AppRouter.shared.popOrDismiss() - }).disposed(by: disposeBag) + private let disposeBag = DisposeBag() + private let panelTopOffset = max(kNaviHeight + 190, UIScreen.main.bounds.height * 0.44) + + var mapBottomPadding: CGFloat { + max(220, bounds.height - detailPanelView.frame.minY + 24) } - + + func configure(_ model: ScheduleModel) { + dateLab.text = formattedDate(model.timestamp) + editBtn.isHidden = !model.is_own + } + + func cleanupMap() { + mapView?.delegate = nil + mapView?.removeFromSuperview() + mapView = nil + } + private func setupUI() { - addSubview(navBgView) - addSubview(navBarView) - navBarView.addSubview(navTitleLabel) - navBarView.addSubview(backBtn) - addSubview(headerView) - addSubview(travelRouteView) - addSubview(tableView) - addSubview(bottomView) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) - - navBarView.layoutChain - .edges(excludingEdge: .bottom) - .height(kNaviHeight) - - navTitleLabel.layoutChain - .top(kStatusBarHeight + 12) - .centerY(backBtn) - .centerX() - - backBtn.layoutChain - .centerY(navTitleLabel) - .left(15) - .width(24) - .height(24) - - headerView.layoutChain - .topToBottomOfView(navBarView) - .edgesHorzontal() - - travelRouteView.layoutChain - .topToBottomOfView(headerView) - .edgesHorzontal() - - bottomView.layoutChain - .edgesHorzontal() - .height(kSafeBottomMargin + 80) - .bottom() - - tableView.layoutChain - .topToBottomOfView(travelRouteView, offset: 15) - .edgesHorzontal() - .bottomToTopOfView(bottomView, offset: 0) + backgroundColor = .white + addSubview(mapView) + addSubview(detailPanelView) + addSubview(backBtn) + addSubview(editBtn) + + detailPanelView.addSubview(titleIconView) + detailPanelView.addSubview(titleLab) + detailPanelView.addSubview(dateLab) + detailPanelView.addSubview(tableView) + + [mapView, detailPanelView, backBtn, editBtn, titleIconView, titleLab, dateLab, tableView] + .forEach { $0?.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + mapView.topAnchor.constraint(equalTo: topAnchor), + mapView.leadingAnchor.constraint(equalTo: leadingAnchor), + mapView.trailingAnchor.constraint(equalTo: trailingAnchor), + mapView.bottomAnchor.constraint(equalTo: bottomAnchor), + + backBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + backBtn.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), + backBtn.widthAnchor.constraint(equalToConstant: 44), + backBtn.heightAnchor.constraint(equalToConstant: 44), + + editBtn.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + editBtn.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18), + editBtn.widthAnchor.constraint(equalToConstant: 44), + editBtn.heightAnchor.constraint(equalToConstant: 44), + + detailPanelView.topAnchor.constraint(equalTo: topAnchor, constant: panelTopOffset), + detailPanelView.leadingAnchor.constraint(equalTo: leadingAnchor), + detailPanelView.trailingAnchor.constraint(equalTo: trailingAnchor), + detailPanelView.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleIconView.leadingAnchor.constraint(equalTo: detailPanelView.leadingAnchor, constant: 18), + titleIconView.topAnchor.constraint(equalTo: detailPanelView.topAnchor, constant: 23), + titleIconView.widthAnchor.constraint(equalToConstant: 18), + titleIconView.heightAnchor.constraint(equalToConstant: 18), + + titleLab.leadingAnchor.constraint(equalTo: titleIconView.trailingAnchor, constant: 8), + titleLab.centerYAnchor.constraint(equalTo: titleIconView.centerYAnchor), + + dateLab.trailingAnchor.constraint(equalTo: detailPanelView.trailingAnchor, constant: -20), + dateLab.centerYAnchor.constraint(equalTo: titleLab.centerYAnchor), + dateLab.leadingAnchor.constraint(greaterThanOrEqualTo: titleLab.trailingAnchor, constant: 12), + + tableView.topAnchor.constraint(equalTo: titleIconView.bottomAnchor, constant: 14), + tableView.leadingAnchor.constraint(equalTo: detailPanelView.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: detailPanelView.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: detailPanelView.bottomAnchor) + ]) } - - lazy var navBgView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") - iv.contentMode = .scaleAspectFill - return iv - }() - - lazy var navBarView: UIView = { - let view = UIView() - view.backgroundColor = .clear + + private func setupRx() { + backBtn.rx.tap + .subscribe(onNext: { AppRouter.shared.popOrDismiss() }) + .disposed(by: disposeBag) + } + + private func formattedDate(_ timestamp: Int64) -> String { + guard timestamp > 0 else { return "" } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy年M月d日" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000)) + } + + lazy var mapView: MAMapView! = { + let view = MAMapView() + view.zoomLevel = 14 + view.showsUserLocation = false + view.showsCompass = false + view.userTrackingMode = .none return view }() - - lazy var navTitleLabel: UILabel = { + + lazy var detailPanelView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#FAFAFA") + view.layer.cornerRadius = 24 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + + lazy var backBtn: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "Common/back"), for: .normal) + button.backgroundColor = .clear + button.cornerRadius = 12 + button.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + button.layer.shadowOpacity = 1 + button.layer.shadowRadius = 8 + button.layer.shadowOffset = CGSize(width: 0, height: 3) + button.clipsToBounds = false + return button + }() + + lazy var editBtn: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "IM/setting"), for: .normal) + button.backgroundColor = UIColor.white.withAlphaComponent(0.92) + button.cornerRadius = 12 + button.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + button.layer.shadowOpacity = 1 + button.layer.shadowRadius = 8 + button.layer.shadowOffset = CGSize(width: 0, height: 3) + button.clipsToBounds = false + button.isHidden = true + return button + }() + + private lazy var titleIconView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Schedule/title_location")) + return view + }() + + private lazy var titleLab: UILabel = { let label = UILabel() label.text = "行程详情" - label.font = .systemFont(ofSize: 18, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor - label.textAlignment = .center + label.font = .systemFont(ofSize: 18, weight: .bold) + label.textColor = UIColor(hexStr: "#293445") return label }() - - lazy var backBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Common/back"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100) - return btn - }() - - /// 谁在关注 - lazy var headerView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "谁在关注" - titleLab.font = .systemFont(ofSize: 16, weight: .medium) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(titleLab) - titleLab.layoutChain - .top(15) - - let dotView = UIView() - dotView.backgroundColor = UIColor(hexStr: "#16B3FF") - dotView.cornerRadius = 2 - view.addSubview(dotView) - dotView.layoutChain - .left(15) - .centerY(titleLab) - .width(4) - .height(11) - - titleLab.layoutChain.leftToRightOfView(dotView, offset: 5) - - view.addSubview(collectionView) - collectionView.layoutChain - .topToBottomOfView(titleLab, offset: 15) - .edgesHorzontal() - .height(80) - .bottom(15) - - view.addSubview(noDataLab) - noDataLab.layoutChain - .centerX().centerY() - - return view - }() - - lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.itemSize = CGSize(width: 80, height: 80) - layout.minimumLineSpacing = 15 - layout.sectionInset = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15) - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.register(ViewedCell.self) - return cv - }() - - lazy var noDataLab: UILabel = { + + private lazy var dateLab: UILabel = { let label = UILabel() - label.text = " 暂无关注" - label.textColor = UIColor(hexStr: "#999999") - label.font = .systemFont(ofSize: 14, weight: .regular) - label.isHidden = true - return label - }() - - /// 行程日期 - lazy var travelRouteView: UIView = { - let view = UIView() - view.backgroundColor = .clear - - let titleLab = UILabel() - titleLab.text = "行程日期" - titleLab.font = .systemFont(ofSize: 16, weight: .medium) - titleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(titleLab) - titleLab.layoutChain - .top(5) - - let dotView = UIView() - dotView.backgroundColor = UIColor(hexStr: "#16B3FF") - dotView.cornerRadius = 2 - view.addSubview(dotView) - dotView.layoutChain - .left(15) - .centerY(titleLab) - .width(4) - .height(11) - - titleLab.layoutChain.leftToRightOfView(dotView, offset: 5) - - view.addSubview(dateLab) - dateLab.layoutChain - .centerY(titleLab) - .leftToRightOfView(titleLab, offset: 10) - - view.addSubview(creatorIcon) - creatorIcon.layoutChain - .right(15) - .width(30) - .height(30) - .centerY(titleLab) - - let creatorTitleLab = UILabel() - creatorTitleLab.text = "创建人" - creatorTitleLab.font = .systemFont(ofSize: 12, weight: .medium) - creatorTitleLab.textColor = ThemeManager.shared.color.titleAuxColor - view.addSubview(creatorTitleLab) - creatorTitleLab.layoutChain - .rightToLeftOfView(creatorIcon, offset: -5) - .centerY(titleLab) - - view.addSubview(vipTipsLab) - vipTipsLab.layoutChain - .topToBottomOfView(titleLab, offset: 5) - .leftToView(titleLab) - .bottom() - - return view - }() - - lazy var dateLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#16B3FF") label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") return label }() - - lazy var creatorIcon: UIImageView = { - let view = UIImageView() - view.cornerRadius = 15 + + lazy var tableView: UITableView = { + let view = UITableView(frame: .zero, style: .plain) + view.backgroundColor = .clear + view.separatorStyle = .none + view.showsVerticalScrollIndicator = false + view.estimatedRowHeight = 150 + view.rowHeight = UITableView.automaticDimension + view.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: kSafeBottomMargin + 16, right: 0) + view.register(ScheduleTimelineCell.self) return view }() - - lazy var vipTipsLab: UILabel = { - let label = UILabel() - label.textColor = UIColor(hexStr: "#FF7D52") - label.font = .systemFont(ofSize: 10, weight: .regular) - return label - }() - - lazy var tableView: UITableView = { - let tv = UITableView(frame: .zero, style: .plain) - tv.backgroundColor = .clear - tv.separatorStyle = .none - tv.estimatedRowHeight = 77 - tv.showsVerticalScrollIndicator = false - tv.bounces = false - tv.register(SchedulePointDetailCell.self) - tv.register(SchedulePointEventCell.self) - tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0) - return tv - }() - - lazy var bottomView: UIView = { - let v = UIView() - v.backgroundColor = .white - v.layer.cornerRadius = 16 - v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor - v.layer.shadowOffset = CGSize(width: 0, height: -2) - v.layer.shadowRadius = 10 - v.layer.shadowOpacity = 1 - forwardBtn.isHidden = true - v.addSubview(forwardBtn) - v.addSubview(operateBtn) - v.addSubview(routeBtn) - - forwardBtn.layoutChain - .centerY() - .left(15) - .width(80) - - routeBtn.layoutChain - .centerY() - .right(15) - .width(80) - - operateBtn.layoutChain - .centerY() - .leftToRightOfView(forwardBtn, offset: 20) - .rightToLeftOfView(routeBtn, offset: -20) - .height(50) - - return v - }() - - lazy var forwardBtn: UIButton = makeVerticalButton(image: UIImage(named: "Schedule/forward"), title: "发送到圈子") - lazy var routeBtn: UIButton = makeVerticalButton(image: UIImage(named: "Schedule/route"), title: "查看路线") - - /// 创建图片在上、文字在下的按钮 - private func makeVerticalButton(image: UIImage?, title: String) -> UIButton { - let btn = UIButton(type: .custom) - btn.setImage(image, for: .normal) - btn.setTitle(title, for: .normal) - btn.setTitleColor(UIColor(hexStr: "#333333"), for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) - btn.titleLabel?.textAlignment = .center - btn.imageView?.contentMode = .scaleAspectFit - // 图片在上、文字在下 - let width = btn.titleLabel?.intrinsicContentSize.width ?? 0 - btn.imageEdgeInsets = UIEdgeInsets(top: -20, left: 0, bottom: 0, right: -width) - btn.titleEdgeInsets = UIEdgeInsets(top: 0, left: -24, bottom: -24, right: 0) - btn.contentEdgeInsets = UIEdgeInsets(top: 24, left: 0, bottom: 24, right: 0) - return btn - } - - lazy var operateBtn: UIButton = { - let btn = UIButton() - btn.setTitle("取消关注", for: .selected) - btn.setTitleColor(.white, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 - return btn - }() - override init(frame: CGRect) { - super.init(frame: .zero) - backgroundColor = .white + super.init(frame: frame) setupUI() setupRx() } - required init?(coder aDecoder: NSCoder) { + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } -// MARK: - SchedulePointDetailCell -class SchedulePointDetailCell: UITableViewCell { +final class ScheduleTimelineCell: UITableViewCell { - var disposeBag = DisposeBag() + func configure(_ item: ScheduleTimelineItem) { + markerView.role = item.role + upperLine.isHidden = item.isFirst + lowerLine.isHidden = item.isLast + timeLab.text = Self.timeText(item.point.expected_timestamp) + locationLab.text = Self.locationText(item.point) + let remark = item.point.remark.isEmpty ? "无备注" : item.point.remark + remarkLab.text = "备注:\(remark)" + configureEvents(item.point.events) - func configure(model: SchedulePointModel, index: Int, total: Int) { - indexLabel.text = "\(index + 1)" - locationLabel.text = model.street - remarkLab.text = model.remark.isEmpty ? "备注:无备注" : model.remark - timeLabel.text = getDateInterval2String(date: "\(model.expected_timestamp / 1000)", dateFormat: "HH:mm") - - var indexName = "" - if index == 0 { - indexName = "起点:" - } - else if index == total - 1 { - indexName = "终点:" - } - else { - indexName = "途经点:" - } - indexNameLab.text = indexName - bottomDashView.isHidden = index == total - 1 - } - - // MARK: - Init - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = .clear - setupViews() - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - private func setupViews() { - contentView.addSubview(pointIcon) - contentView.addSubview(bottomDashView) - contentView.addSubview(indexLabel) - contentView.addSubview(indexNameLab) - contentView.addSubview(locationLabel) - contentView.addSubview(timeLabel) - contentView.addSubview(remarkLab) - - pointIcon.layoutChain - .top() - .left(15) - .width(18).height(18) - - indexLabel.layoutChain - .top().leftToRightOfView(pointIcon, offset: 10) - .width(22).height(20) - - indexNameLab.layoutChain - .leftToRightOfView(indexLabel, offset: 5) - .centerY(indexLabel) - - locationLabel.layoutChain - .centerY(indexLabel) - .leftToRightOfView(indexNameLab, offset: 2) - .compressionHorizontal(.defaultLow) - - timeLabel.layoutChain - .centerY(indexLabel) - .leftToRightOfView(locationLabel, offset: 20) - .right(10, relation: .greaterThanOrEqual) - .compressionHorizontal(.required) - - remarkLab.layoutChain - .topToBottomOfView(indexLabel, offset: 5) - .leftToView(indexLabel) - .right(15) - .bottom(10) - .compressionVertical(.required) - - bottomDashView.layoutChain - .topToBottomOfView(pointIcon, offset: 5) - .centerX(pointIcon) - .width(0.5) - .bottom(5) - } - - // MARK: - Views - private let pointIcon: UIImageView = { - let iv = UIImageView(image: UIImage(named: "Schedule/point")) - iv.contentMode = .scaleAspectFit - return iv - }() - - private let bottomDashView: DashLineView = { - let v = DashLineView() - v.backgroundColor = .clear - return v - }() - - private let indexLabel: UILabel = { - let l = UILabel() - l.backgroundColor = UIColor(hexStr: "#DCF4FF") - l.textColor = UIColor(hexStr: "#176F9B") - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textAlignment = .center - return l - }() - - lazy var indexNameLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 14, weight: .medium) - l.textColor = UIColor(hexStr: "#16B3FF") - return l - }() - - private let locationLabel: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 14, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") -// l.numberOfLines = 0 - return l - }() - - private let timeLabel: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - l.isUserInteractionEnabled = true - l.textAlignment = .right - return l - }() - - lazy var remarkLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .regular) - l.textColor = UIColor(hexStr: "#999999") - l.numberOfLines = 0 - return l - }() - - override func layoutSubviews() { - super.layoutSubviews() - contentView.layoutIfNeeded() - indexLabel.setCornerRadius(corners: [.bottomRight, .topLeft], withCornerRadii: CGSize(width: 10, height: 10)) - } -} - -// MARK: - SchedulePointEventCell -class SchedulePointEventCell: UITableViewCell { - - var disposeBag = DisposeBag() - - func configure(model: SchedulePointEventModel, index: Int, total: Int) { - var suffix = "" - if index == 0 { - suffix = " 起点" - } else if index == total - 1 { - suffix = " 终点" + if let duration = item.durationToNext { + durationBadge.configure( + title: (item.role == .waypoint ? "停留 " : "") + Self.durationText(duration) + ) + durationRow.isHidden = false } else { - suffix = " 途经点" + durationRow.isHidden = true } - infoLab.text = model.text + "到达" + suffix } - // MARK: - Init - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + private func setupUI() { selectionStyle = .none backgroundColor = .clear - setupViews() + contentView.backgroundColor = .clear + + contentView.addSubview(upperLine) + contentView.addSubview(lowerLine) + contentView.addSubview(markerView) + contentView.addSubview(bodyStack) + bodyStack.addArrangedSubview(headerRow) + bodyStack.addArrangedSubview(remarkLab) + bodyStack.addArrangedSubview(eventScrollView) + bodyStack.addArrangedSubview(durationRow) + eventScrollView.addSubview(eventStackView) + durationRow.addSubview(durationBadge) + headerRow.addSubview(timeLab) + headerRow.addSubview(locationLab) + + [upperLine, lowerLine, markerView, bodyStack, headerRow, timeLab, locationLab, + eventScrollView, eventStackView, durationRow, durationBadge] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + let eventHeight = eventScrollView.heightAnchor.constraint(equalToConstant: 42) + eventHeight.priority = .defaultHigh + let durationHeight = durationRow.heightAnchor.constraint(equalToConstant: 40) + durationHeight.priority = .defaultHigh + + NSLayoutConstraint.activate([ + markerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + markerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + markerView.widthAnchor.constraint(equalToConstant: 22), + markerView.heightAnchor.constraint(equalToConstant: 22), + + upperLine.centerXAnchor.constraint(equalTo: markerView.centerXAnchor), + upperLine.topAnchor.constraint(equalTo: contentView.topAnchor), + upperLine.bottomAnchor.constraint(equalTo: markerView.centerYAnchor), + upperLine.widthAnchor.constraint(equalToConstant: 1), + + lowerLine.centerXAnchor.constraint(equalTo: markerView.centerXAnchor), + lowerLine.topAnchor.constraint(equalTo: markerView.centerYAnchor), + lowerLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + lowerLine.widthAnchor.constraint(equalToConstant: 1), + + bodyStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 62), + bodyStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + bodyStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + bodyStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16), + + headerRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 26), + timeLab.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor), + timeLab.centerYAnchor.constraint(equalTo: headerRow.centerYAnchor), + timeLab.widthAnchor.constraint(equalToConstant: 64), + + locationLab.leadingAnchor.constraint(equalTo: timeLab.trailingAnchor, constant: 8), + locationLab.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor), + locationLab.centerYAnchor.constraint(equalTo: headerRow.centerYAnchor), + + eventHeight, + eventStackView.leadingAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.leadingAnchor), + eventStackView.trailingAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.trailingAnchor), + eventStackView.topAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.topAnchor), + eventStackView.bottomAnchor.constraint(equalTo: eventScrollView.contentLayoutGuide.bottomAnchor), + eventStackView.heightAnchor.constraint(equalTo: eventScrollView.frameLayoutGuide.heightAnchor), + + durationHeight, + durationBadge.leadingAnchor.constraint(equalTo: durationRow.leadingAnchor), + durationBadge.centerYAnchor.constraint(equalTo: durationRow.centerYAnchor), + durationBadge.trailingAnchor.constraint(lessThanOrEqualTo: durationRow.trailingAnchor) + ]) } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - private func setupViews() { - contentView.addSubview(verticalDashLineView) - contentView.addSubview(horizontalDashLineView) - contentView.addSubview(dotView) - contentView.addSubview(infoView) - infoView.addSubview(infoLab) - - verticalDashLineView.layoutChain - .left(24) - .edgesVertical() - .width(1) - - dotView.layoutChain - .centerX(verticalDashLineView) - .centerY() - .width(8) - .height(8) - - horizontalDashLineView.layoutChain - .leftToRightOfView(dotView) - .centerY(dotView) - .height(1) - .width(30) - - infoView.layoutChain - .leftToRightOfView(horizontalDashLineView, offset: 5) - .edgesVertical(10) - .right(15) - - infoLab.layoutChain - .edgesHorzontal(15) - .edgesVertical(15) + private func configureEvents(_ events: [SchedulePointEventModel]) { + eventStackView.arrangedSubviews.forEach { + eventStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + eventScrollView.isHidden = events.isEmpty + for event in events { + let chip = ScheduleTimelineEventChip() + chip.configure(time: Self.timeText(event.arrival_timestamp), text: event.text) + eventStackView.addArrangedSubview(chip) + } } - // MARK: - Views - lazy var verticalDashLineView: DashLineView = { - let v = DashLineView() - v.backgroundColor = .clear - return v - }() - - lazy var horizontalDashLineView: HorizontalDashLineView = { - let v = HorizontalDashLineView() - v.backgroundColor = .clear - return v - }() - - lazy var dotView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#16B3FF") - view.cornerRadius = 4 + private static func locationText(_ point: SchedulePointModel) -> String { + if !point.street.isEmpty { return point.street } + if !point.formatted_address.isEmpty { return point.formatted_address } + return "暂无地点" + } + + private static func timeText(_ timestamp: Int64) -> String { + guard timestamp > 0 else { return "--:--" } + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000)) + } + + private static func durationText(_ milliseconds: Int64) -> String { + let minutes = max(1, milliseconds / 60_000) + let hours = minutes / 60 + let remainder = minutes % 60 + return hours > 0 ? "\(hours)小时\(remainder)分" : "\(minutes)分钟" + } + + private lazy var upperLine = ScheduleTimelineDashLineView() + private lazy var lowerLine = ScheduleTimelineDashLineView() + private lazy var markerView = ScheduleTimelineMarkerView() + + private lazy var bodyStack: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = 10 return view }() - lazy var infoView: UIView = { - let v = UIView() - v.backgroundColor = .white - v.layer.cornerRadius = 10 - v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor - v.layer.shadowOffset = CGSize(width: 0, height: -2) - v.layer.shadowRadius = 10 - v.layer.shadowOpacity = 1 - return v + private lazy var headerRow = UIView() + + private lazy var timeLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = UIColor(hexStr: "#293445") + return label }() - - lazy var infoLab: UILabel = { - let l = UILabel() - l.font = .systemFont(ofSize: 12, weight: .medium) - l.textColor = UIColor(hexStr: "#333333") - return l + + private lazy var locationLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = UIColor(hexStr: "#293445") + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label }() + + private lazy var remarkLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = UIColor(hexStr: "#8B8B8B") + label.numberOfLines = 0 + return label + }() + + private lazy var eventScrollView: UIScrollView = { + let view = UIScrollView() + view.showsHorizontalScrollIndicator = false + view.alwaysBounceHorizontal = false + return view + }() + + private lazy var eventStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.spacing = 8 + return view + }() + + private lazy var durationRow = UIView() + private lazy var durationBadge = ScheduleTimelineDurationBadge() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ScheduleTimelineMarkerView: UIView { + var role: ScheduleTimelineRole = .waypoint { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + context.clear(rect) + switch role { + case .waypoint: + let dotRect = CGRect(x: rect.midX - 5, y: rect.midY - 5, width: 10, height: 10) + context.setFillColor(UIColor(hexStr: "#293445").cgColor) + context.fillEllipse(in: dotRect) + let innerDotRect = CGRect(x: rect.midX - 2, y: rect.midY - 2, width: 4, height: 4) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: innerDotRect) + case .start, .end: + let color = role == .start ? UIColor(hexStr: "#35C8F4") : UIColor(hexStr: "#FF5665") + context.setFillColor(color.cgColor) + context.fillEllipse(in: rect.insetBy(dx: 1, dy: 1)) + let text = role == .start ? "始" : "终" + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 11, weight: .bold), + .foregroundColor: UIColor.white + ] + let size = text.size(withAttributes: attributes) + text.draw(at: CGPoint(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2), + withAttributes: attributes) + } + } +} + +private final class ScheduleTimelineDashLineView: UIView { + override func layoutSubviews() { + super.layoutSubviews() + layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } } + let line = CAShapeLayer() + line.strokeColor = UIColor(hexStr: "#D8D8D8").cgColor + line.lineWidth = 1.5 + line.lineDashPattern = [5, 5] + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.midX, y: 0)) + path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY)) + line.path = path.cgPath + layer.addSublayer(line) + } +} + +private final class ScheduleTimelineEventChip: UIView { + func configure(time: String, text: String) { + let status = text.trimmingCharacters(in: .whitespacesAndNewlines) + label.text = "\(time) \(status.isEmpty ? "已到达" : status)" + } + + private let label: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + cornerRadius = 10 + layer.shadowColor = UIColor.black.withAlphaComponent(0.06).cgColor + layer.shadowOpacity = 1 + layer.shadowRadius = 8 + layer.shadowOffset = CGSize(width: 0, height: 2) + clipsToBounds = false + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 38) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ScheduleTimelineDurationBadge: UIView { + func configure(title: String) { + titleLab.text = title + } + + private let iconView: UIImageView = { + let view = UIImageView(image: UIImage(named: "Schedule/clock")) + view.contentMode = .scaleAspectFit + return view + }() + + private let titleLab: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + cornerRadius = 10 + layer.shadowColor = UIColor.black.withAlphaComponent(0.05).cgColor + layer.shadowOpacity = 1 + layer.shadowRadius = 8 + layer.shadowOffset = CGSize(width: 0, height: 2) + clipsToBounds = false + addSubview(iconView) + addSubview(titleLab) + iconView.translatesAutoresizingMaskIntoConstraints = false + titleLab.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 18), + iconView.heightAnchor.constraint(equalToConstant: 18), + titleLab.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 7), + titleLab.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 38) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/QuickLocation/Section/Schedule/ScheduleHistory/ScheduleHistoryVM.swift b/QuickLocation/Section/Schedule/ScheduleHistory/ScheduleHistoryVM.swift index 4845e05c..32faca3e 100644 --- a/QuickLocation/Section/Schedule/ScheduleHistory/ScheduleHistoryVM.swift +++ b/QuickLocation/Section/Schedule/ScheduleHistory/ScheduleHistoryVM.swift @@ -41,7 +41,7 @@ class ScheduleHistoryViewModel: ViewModelType { lazy var cellAction: Action = { this in return Action { model in - AppRouter.push(Route.itineraryDetail, userInfo: ["scheduleJson": model.toJSON()]) + AppRouter.push(Route.scheduleDetail, userInfo: ["scheduleJson": model.toJSON()]) return .empty() } }(self) diff --git a/QuickLocation/Section/TextInput/TextInputViewController.swift b/QuickLocation/Section/TextInput/TextInputViewController.swift index 081e11aa..153756a9 100644 --- a/QuickLocation/Section/TextInput/TextInputViewController.swift +++ b/QuickLocation/Section/TextInput/TextInputViewController.swift @@ -196,9 +196,9 @@ final class TextInputViewController: UIViewController { private lazy var confirmBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("确定", for: .normal) - btn.setTitleColor(UIColor(hexStr: "#0F2846"), for: .normal) - btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + btn.setTitleColor(.white, for: .normal) + btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) btn.cornerRadius = 25 btn.isEnabled = false return btn diff --git a/QuickLocation/Section/VipRecharge/VipRechargeView.swift b/QuickLocation/Section/VipRecharge/VipRechargeView.swift index 25bd206e..bfa4f47c 100644 --- a/QuickLocation/Section/VipRecharge/VipRechargeView.swift +++ b/QuickLocation/Section/VipRecharge/VipRechargeView.swift @@ -55,8 +55,8 @@ class VipRechargeView: UIView { backBtn.layoutChain .top(kStatusBarHeight + 5) .left(15) - .width(36) - .height(36) + .width(44) + .height(44) navView.layoutChain .edges(excludingEdge: .bottom) diff --git a/QuickLocation/Service/GroupService.swift b/QuickLocation/Service/GroupService.swift index 51e2baf7..20c33825 100644 --- a/QuickLocation/Service/GroupService.swift +++ b/QuickLocation/Service/GroupService.swift @@ -18,6 +18,22 @@ struct GroupService { .map(UserGroupResponse.self) .asObservable() } + + /// 圈子类型模板 + static func groupTemplates() -> Observable { + let api = GroupAPI.groupTemplates.multiTarget + return APIProvider.request(token: api) + .map(GroupTemplateResponse.self) + .asObservable() + } + + /// 指定圈子的资料及完整成员列表 + static func groupUsers(groupKey: String) -> Observable { + let api = GroupAPI.groupUsers(groupKey: groupKey).multiTarget + return APIProvider.request(token: api) + .map(GroupUsersResponse.self) + .asObservable() + } /// 圈子操作(创建、解散、设为默认等) /// - Parameters: diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index 05b77d7c..ab81e935 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -33,6 +33,14 @@ struct UserService { .map(UserConfigResponse.self) .asObservable() } + + /// 用户当前状态 + static func userStatus() -> Observable { + let api = UserAPI.userStatus.multiTarget + return APIProvider.request(token: api, handle: false) + .map(UserStatusResponse.self) + .asObservable() + } static func imToken() -> Observable { let api = UserAPI.imToken.multiTarget diff --git a/QuickLocation/UIKit/BaseNavigationView.swift b/QuickLocation/UIKit/BaseNavigationView.swift index f2dace10..0c88dd38 100644 --- a/QuickLocation/UIKit/BaseNavigationView.swift +++ b/QuickLocation/UIKit/BaseNavigationView.swift @@ -36,8 +36,8 @@ class BaseNavigationView: UIView { backBtn.layoutChain .centerY(titleLabel) .left(15) - .width(36) - .height(36) + .width(44) + .height(44) titleLabel.layoutChain .top(kStatusBarHeight + 12) diff --git a/QuickLocation/UIKit/Pop/ConfirmPopVC.swift b/QuickLocation/UIKit/Pop/ConfirmPopVC.swift index 70112314..ef01bcaa 100644 --- a/QuickLocation/UIKit/Pop/ConfirmPopVC.swift +++ b/QuickLocation/UIKit/Pop/ConfirmPopVC.swift @@ -65,10 +65,10 @@ class ConfirmPopVC: DLCustomPopVC { private func setupLayout() { logoImgView.layoutChain - .left(16) - .top(-45) - .width(109) - .height(109) + .centerX() + .top(-43) + .width(110) + .height(80) headerBgImg.layoutChain .edges(excludingEdge: .bottom) @@ -78,7 +78,7 @@ class ConfirmPopVC: DLCustomPopVC { if titleText != nil, titleText?.isEmpty == false { titleLab.layoutChain - .top(53) + .topToBottomOfView(logoImgView, offset: 16) .edgesHorzontal(20) tempView = titleLab } @@ -130,12 +130,12 @@ class ConfirmPopVC: DLCustomPopVC { lazy var popBgView: UIView = { let view = UIView() view.backgroundColor = .white - view.cornerRadius = 30 + view.cornerRadius = 40 return view }() lazy var logoImgView: UIImageView = { - let view = UIImageView(image: UIImage(named: "Popup/bell")) + let view = UIImageView(image: UIImage(named: "Popup/pigeon")) return view }() @@ -178,7 +178,7 @@ class ConfirmPopVC: DLCustomPopVC { btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) - btn.cornerRadius = 25 + btn.cornerRadius = 16 btn.addTouchBlock { [weak self] _ in self?.dismiss(animated: true, completion: { self?.confirmBlock?() @@ -246,6 +246,7 @@ public extension UIViewController { vc.cancelBlock = cancelBlock vc.closeBtn.isHidden = !showCloseBtn vc.dimmingClick = showCloseBtn + vc.centerCornerRadius = 40 present(vc, animated: true) } @@ -266,6 +267,7 @@ public extension UIViewController { vc.cancelBlock = cancelBlock vc.closeBtn.isHidden = !showCloseBtn vc.dimmingClick = showCloseBtn + vc.centerCornerRadius = 40 UIViewController.topMost?.present(vc, animated: true) } }