- UI调整、接口联调
|
|
@ -13,6 +13,10 @@ internal import Alamofire
|
||||||
enum GroupAPI {
|
enum GroupAPI {
|
||||||
/// 圈子信息
|
/// 圈子信息
|
||||||
case groupInfo
|
case groupInfo
|
||||||
|
/// 圈子类型模板
|
||||||
|
case groupTemplates
|
||||||
|
/// 指定圈子的资料及成员
|
||||||
|
case groupUsers(groupKey: String)
|
||||||
/// 圈子操作(创建、解散、设为默认等)
|
/// 圈子操作(创建、解散、设为默认等)
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - opType: create、dismiss、setdefault
|
/// - opType: create、dismiss、setdefault
|
||||||
|
|
@ -26,6 +30,10 @@ extension GroupAPI: MultiTargetProtocol {
|
||||||
switch self {
|
switch self {
|
||||||
case .groupInfo:
|
case .groupInfo:
|
||||||
return "mapi/user/fullinfo"
|
return "mapi/user/fullinfo"
|
||||||
|
case .groupTemplates:
|
||||||
|
return "mapi/group/template"
|
||||||
|
case .groupUsers:
|
||||||
|
return "mapi/group/users"
|
||||||
case .operate:
|
case .operate:
|
||||||
return "mapi/group/operate"
|
return "mapi/group/operate"
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +41,7 @@ extension GroupAPI: MultiTargetProtocol {
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .groupInfo:
|
case .groupInfo, .groupTemplates, .groupUsers:
|
||||||
return .get
|
return .get
|
||||||
default:
|
default:
|
||||||
return .post
|
return .post
|
||||||
|
|
@ -44,6 +52,12 @@ extension GroupAPI: MultiTargetProtocol {
|
||||||
switch self {
|
switch self {
|
||||||
case .groupInfo:
|
case .groupInfo:
|
||||||
return .requestParameters(parameters: Parameters(), encoding: URLEncoding())
|
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):
|
case let .operate(opType, requestData):
|
||||||
var params = Parameters()
|
var params = Parameters()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ enum UserAPI {
|
||||||
|
|
||||||
/// 用户信息
|
/// 用户信息
|
||||||
case userInfo
|
case userInfo
|
||||||
|
|
||||||
|
/// 用户当前状态
|
||||||
|
case userStatus
|
||||||
|
|
||||||
/// 用户IM Token
|
/// 用户IM Token
|
||||||
case imToken
|
case imToken
|
||||||
|
|
@ -90,6 +93,8 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
return "api/user/login"
|
return "api/user/login"
|
||||||
case .userInfo:
|
case .userInfo:
|
||||||
return "api/user"
|
return "api/user"
|
||||||
|
case .userStatus:
|
||||||
|
return "mapi/user/status"
|
||||||
case .imToken:
|
case .imToken:
|
||||||
return "mapi/openim/user/token/get"
|
return "mapi/openim/user/token/get"
|
||||||
case .signInInfo:
|
case .signInInfo:
|
||||||
|
|
@ -125,7 +130,7 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .userInfo, .signInInfo, .notice, .followList:
|
case .userInfo, .userStatus, .signInInfo, .notice, .followList:
|
||||||
return .get
|
return .get
|
||||||
case .changePhone, .setGender:
|
case .changePhone, .setGender:
|
||||||
return .put
|
return .put
|
||||||
|
|
@ -147,6 +152,9 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
|
|
||||||
case .userInfo:
|
case .userInfo:
|
||||||
return .requestParameters(parameters: Parameters(), encoding: URLEncoding())
|
return .requestParameters(parameters: Parameters(), encoding: URLEncoding())
|
||||||
|
|
||||||
|
case .userStatus:
|
||||||
|
return .requestPlain
|
||||||
|
|
||||||
case .imToken:
|
case .imToken:
|
||||||
var params = Parameters()
|
var params = Parameters()
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
QuickLocation/Assets.xcassets/Bubble/deco_flower.imageset/deco_flower@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
QuickLocation/Assets.xcassets/Bubble/deco_pencil.imageset/deco_pencil@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
22
QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
QuickLocation/Assets.xcassets/Bubble/notification_megaphone.imageset/notification_megaphone@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
22
QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
QuickLocation/Assets.xcassets/Bubble/notification_sparkle.imageset/notification_sparkle@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 787 B |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
QuickLocation/Assets.xcassets/Common/popup_header_bg.imageset/popup_header_bg@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"provides-namespace" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 223 B |
BIN
QuickLocation/Assets.xcassets/GroupSetting/arrow_right.imageset/arrow-right@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 383 B |
22
QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 377 B |
BIN
QuickLocation/Assets.xcassets/GroupSetting/invite_code.imageset/invite-code@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
22
QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
QuickLocation/Assets.xcassets/GroupSetting/remove_member.imageset/remove-member@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 555 B |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 555 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 30 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 460 B |
BIN
QuickLocation/Assets.xcassets/SOS/sos_contact_alert.imageset/sos_contact_alert@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 963 B |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 62 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
QuickLocation/Assets.xcassets/SOS/sos_info_alert.imageset/sos_info_alert@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 569 B After Width: | Height: | Size: 671 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 1.7 KiB |
22
QuickLocation/Assets.xcassets/Schedule/title_location.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 575 B |
BIN
QuickLocation/Assets.xcassets/Schedule/title_location.imageset/location@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
22
QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
QuickLocation/Assets.xcassets/SearchLocation/vip_mask.imageset/组 48245@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 158 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
22
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_one_day.imageset/hero_bg_missed_one_day@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 143 KiB |
22
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_missed_two_days.imageset/hero_bg_missed_two_days@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 134 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_signed.imageset/hero_bg_signed@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 590 KiB |
22
QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
QuickLocation/Assets.xcassets/SignIn/hero_bg_unsigned.imageset/hero_bg_unsigned@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -41,6 +41,8 @@ extension DefaultsKeys {
|
||||||
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
||||||
/// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计)
|
/// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计)
|
||||||
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
||||||
|
/// 手机报告:最后一次确认亮屏的时间(锁屏结算用,避免用 Date() 把灭屏空档算进去)
|
||||||
|
var screenLastOnAt: DefaultsKey<Date?> { .init("screenLastOnAt") }
|
||||||
/// 手机报告:本 App 今日进入前台次数
|
/// 手机报告:本 App 今日进入前台次数
|
||||||
var appUsageToday: DefaultsKey<Int> { .init("appUsageToday", defaultValue: 0) }
|
var appUsageToday: DefaultsKey<Int> { .init("appUsageToday", defaultValue: 0) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,14 @@ class AppContextManager: NSObject {
|
||||||
saveAccount(account)
|
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() {
|
public func deleteAccount() {
|
||||||
Defaults[\.loginToken] = nil
|
Defaults[\.loginToken] = nil
|
||||||
Defaults[\.userConfig] = nil
|
Defaults[\.userConfig] = nil
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,26 @@
|
||||||
// Created by 八条 on 2026/5/26.
|
// Created by 八条 on 2026/5/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import ObjectMapper
|
import ObjectMapper
|
||||||
|
|
||||||
|
private let kUserStatusTransformInt64 = TransformOf<Int64, Any>(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 {
|
class UserConfigResponse: BaseModelProtocol {
|
||||||
var code: String?
|
var code: String?
|
||||||
|
|
||||||
|
|
@ -42,3 +60,39 @@ class UserLoginRespons: BaseModelProtocol {
|
||||||
model <- map["data"]
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,9 @@ extension AppRouter: AppRouterProtocol {
|
||||||
|
|
||||||
// MARK: - 行程路线
|
// MARK: - 行程路线
|
||||||
AppRouter.register(Route.itineraryDetail) { url, parameters in
|
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: - 历史行程
|
// MARK: - 历史行程
|
||||||
|
|
@ -445,4 +447,3 @@ extension AppRouter: AppRouterProtocol {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ extension DefaultsKeys {
|
||||||
|
|
||||||
func setupNetworkMode() {
|
func setupNetworkMode() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
setupApiEnv(1)
|
setupApiEnv(0)
|
||||||
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
||||||
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
||||||
#elseif AdHoc
|
#elseif AdHoc
|
||||||
|
|
@ -86,7 +86,7 @@ extension DefaultsKeys {
|
||||||
case -1: // UAT
|
case -1: // UAT
|
||||||
return "https://jsapi.zuom8.cn/"
|
return "https://jsapi.zuom8.cn/"
|
||||||
default: // SIT
|
default: // SIT
|
||||||
return "https://jsapi.zuom8.cn/"
|
return "http://172.16.10.24:9243/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,13 @@ final class UnlockCountManager: NSObject {
|
||||||
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
||||||
private var lastKnownLocation: CLLocation?
|
private var lastKnownLocation: CLLocation?
|
||||||
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
private var backgroundPollTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
/// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程)
|
/// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程)
|
||||||
private var screenSessionStart: Date?
|
private var screenSessionStart: Date?
|
||||||
|
/// 最后一次确认亮屏(锁屏结算用,不用 Date() 把灭屏空档算进去)
|
||||||
|
private var lastScreenOnAt: Date?
|
||||||
|
/// 亮屏心跳超过该间隔视为观察中断(可能错过锁屏)
|
||||||
|
private let screenOnStaleInterval: TimeInterval = 10
|
||||||
/// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算
|
/// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算
|
||||||
private var hasUserActivatedApp = false
|
private var hasUserActivatedApp = false
|
||||||
|
|
||||||
|
|
@ -84,7 +89,7 @@ final class UnlockCountManager: NSObject {
|
||||||
let today = todayString()
|
let today = todayString()
|
||||||
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
||||||
if let start = screenSessionStart {
|
if let start = screenSessionStart {
|
||||||
total += screenTimeSeconds(from: start, to: Date(), onDay: today)
|
total += screenTimeSeconds(from: start, to: resolvedScreenSessionEnd(), onDay: today)
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +120,7 @@ final class UnlockCountManager: NSObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
didStart = true
|
didStart = true
|
||||||
|
lastScreenOnAt = Defaults[\.screenLastOnAt]
|
||||||
closeOrphanScreenSessionIfNeeded()
|
closeOrphanScreenSessionIfNeeded()
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
|
|
||||||
|
|
@ -140,6 +146,12 @@ final class UnlockCountManager: NSObject {
|
||||||
name: UIApplication.protectedDataDidBecomeAvailableNotification,
|
name: UIApplication.protectedDataDidBecomeAvailableNotification,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(appWillResignActive),
|
||||||
|
name: UIApplication.willResignActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(appDidEnterBackground),
|
selector: #selector(appDidEnterBackground),
|
||||||
|
|
@ -162,7 +174,11 @@ final class UnlockCountManager: NSObject {
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
handleNonActiveLaunchIfNeeded(launchOptions)
|
handleNonActiveLaunchIfNeeded(launchOptions)
|
||||||
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
||||||
|
if UIApplication.shared.isProtectedDataAvailable {
|
||||||
|
noteScreenOn()
|
||||||
|
}
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
|
startProtectedDataPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshAuthorizationAndMonitoring() {
|
func refreshAuthorizationAndMonitoring() {
|
||||||
|
|
@ -270,37 +286,84 @@ final class UnlockCountManager: NSObject {
|
||||||
return formatter.date(from: key)
|
return formatter.date(from: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 上一进程留下的 session 只结算到最后一次存活心跳,死掉的空档不再计入
|
/// 上一进程留下的 session 只结算到最后一次确认亮屏,死掉/锁屏的空档不再计入
|
||||||
private func closeOrphanScreenSessionIfNeeded() {
|
private func closeOrphanScreenSessionIfNeeded() {
|
||||||
guard screenSessionStart == nil else { return }
|
guard screenSessionStart == nil else { return }
|
||||||
guard let start = Defaults[\.screenSessionStartAt] else { return }
|
guard let start = Defaults[\.screenSessionStartAt] else { return }
|
||||||
let heartbeat = Defaults[\.unlockLastHeartbeatAt]
|
let end = Defaults[\.screenLastOnAt] ?? Defaults[\.unlockLastHeartbeatAt]
|
||||||
if let heartbeat, heartbeat > start {
|
if let end, end > start {
|
||||||
addScreenTime(from: start, to: heartbeat)
|
addScreenTime(from: start, to: end)
|
||||||
}
|
}
|
||||||
Defaults[\.screenSessionStartAt] = nil
|
Defaults[\.screenSessionStartAt] = nil
|
||||||
debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: heartbeat))")
|
debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: end))")
|
||||||
notifyChange()
|
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() {
|
private func startScreenSession() {
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
||||||
guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return }
|
guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return }
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
guard screenSessionStart == nil else { return }
|
let now = Date()
|
||||||
screenSessionStart = Date()
|
if screenSessionStart != nil {
|
||||||
Defaults[\.screenSessionStartAt] = screenSessionStart
|
noteScreenOn(at: now)
|
||||||
Defaults[\.unlockLastHeartbeatAt] = screenSessionStart
|
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 }
|
guard let start = screenSessionStart else { return }
|
||||||
addScreenTime(from: start, to: end)
|
let resolvedEnd = end ?? resolvedScreenSessionEnd()
|
||||||
|
addScreenTime(from: start, to: max(start, resolvedEnd))
|
||||||
screenSessionStart = nil
|
screenSessionStart = nil
|
||||||
Defaults[\.screenSessionStartAt] = nil
|
Defaults[\.screenSessionStartAt] = nil
|
||||||
|
debugLog("flushScreenSession start=\(start) end=\(resolvedEnd)")
|
||||||
notifyChange()
|
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() {
|
private func recordAppUsage() {
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1
|
Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1
|
||||||
|
|
@ -324,13 +387,23 @@ final class UnlockCountManager: NSObject {
|
||||||
Defaults[\.unlockLastHeartbeatAt] = now
|
Defaults[\.unlockLastHeartbeatAt] = now
|
||||||
stopProtectedDataPolling()
|
stopProtectedDataPolling()
|
||||||
startScreenSession()
|
startScreenSession()
|
||||||
|
if UIApplication.shared.applicationState != .active {
|
||||||
|
startProtectedDataPolling()
|
||||||
|
}
|
||||||
notifyChange()
|
notifyChange()
|
||||||
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||||||
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markLocked() {
|
private func markLocked() {
|
||||||
flushScreenSession()
|
if isSessionLocked, screenSessionStart == nil {
|
||||||
|
Defaults[\.unlockWasLocked] = true
|
||||||
|
hasSeenLockInSession = true
|
||||||
|
startProtectedDataPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let end = lastScreenOnAt ?? screenSessionStart
|
||||||
|
flushScreenSession(end: end)
|
||||||
isSessionLocked = true
|
isSessionLocked = true
|
||||||
hasSeenLockInSession = true
|
hasSeenLockInSession = true
|
||||||
Defaults[\.unlockWasLocked] = true
|
Defaults[\.unlockWasLocked] = true
|
||||||
|
|
@ -341,10 +414,10 @@ final class UnlockCountManager: NSObject {
|
||||||
private func persistLifecycleHeartbeat() {
|
private func persistLifecycleHeartbeat() {
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
if !UIApplication.shared.isProtectedDataAvailable {
|
if !UIApplication.shared.isProtectedDataAvailable {
|
||||||
Defaults[\.unlockWasLocked] = true
|
markLocked()
|
||||||
isSessionLocked = true
|
return
|
||||||
hasSeenLockInSession = true
|
|
||||||
}
|
}
|
||||||
|
noteScreenOn()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifyChange() {
|
private func notifyChange() {
|
||||||
|
|
@ -370,9 +443,15 @@ final class UnlockCountManager: NSObject {
|
||||||
logs.suffix(50).forEach { NSLog("%@ stored %@", logPrefix, $0) }
|
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() {
|
private func startProtectedDataPolling() {
|
||||||
|
guard shouldPollProtectedData else { return }
|
||||||
guard protectedDataPollTimer == nil else { return }
|
guard protectedDataPollTimer == nil else { return }
|
||||||
guard Defaults[\.unlockWasLocked] || isSessionLocked else { return }
|
|
||||||
debugLog("startProtectedDataPolling")
|
debugLog("startProtectedDataPolling")
|
||||||
|
|
||||||
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
|
@ -393,13 +472,20 @@ final class UnlockCountManager: NSObject {
|
||||||
|
|
||||||
private func pollProtectedDataAvailability() {
|
private func pollProtectedDataAvailability() {
|
||||||
let available = UIApplication.shared.isProtectedDataAvailable
|
let available = UIApplication.shared.isProtectedDataAvailable
|
||||||
debugLog("protectedDataPoll protected=\(available) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
if !available {
|
||||||
guard available else { return }
|
debugLog("protectedDataPoll protected=0 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||||||
guard Defaults[\.unlockWasLocked] || isSessionLocked else {
|
markLocked()
|
||||||
stopProtectedDataPolling()
|
|
||||||
return
|
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 纠正锁态(定位回调补强,避免挂起丢通知)
|
/// 对照 protectedData 纠正锁态(定位回调补强,避免挂起丢通知)
|
||||||
|
|
@ -410,7 +496,9 @@ final class UnlockCountManager: NSObject {
|
||||||
}
|
}
|
||||||
if Defaults[\.unlockWasLocked] || isSessionLocked {
|
if Defaults[\.unlockWasLocked] || isSessionLocked {
|
||||||
recordUnlock(reason: reason)
|
recordUnlock(reason: reason)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
repairOpenScreenSessionIfStale()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言
|
/// 定位事件短窗口:锁态同步 + 心跳 + FinishTask 断言
|
||||||
|
|
@ -455,6 +543,25 @@ final class UnlockCountManager: NSObject {
|
||||||
debugLog("bootTask end id=\(id.rawValue)")
|
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
|
// MARK: - protectedData / lifecycle
|
||||||
|
|
||||||
@objc private func protectedDataWillBecomeUnavailable() {
|
@objc private func protectedDataWillBecomeUnavailable() {
|
||||||
|
|
@ -469,8 +576,20 @@ final class UnlockCountManager: NSObject {
|
||||||
recordUnlock(reason: "protectedData")
|
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() {
|
@objc private func appDidEnterBackground() {
|
||||||
persistLifecycleHeartbeat()
|
persistLifecycleHeartbeat()
|
||||||
|
startProtectedDataPolling()
|
||||||
|
beginBackgroundPollTask()
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground")
|
forceRefreshGeofenceFromLastKnown(reason: "didEnterBackground")
|
||||||
debugLog("didEnterBackground auth=\(authorizationStatus().rawValue) protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) regions=\(locationManager.monitoredRegions.count)")
|
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)")
|
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||||||
hasUserActivatedApp = true
|
hasUserActivatedApp = true
|
||||||
endBootBackgroundTask()
|
endBootBackgroundTask()
|
||||||
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
endBackgroundPollTask()
|
||||||
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked {
|
if UIApplication.shared.isProtectedDataAvailable {
|
||||||
startScreenSession()
|
if isSessionLocked || Defaults[\.unlockWasLocked] {
|
||||||
|
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
||||||
|
} else {
|
||||||
|
repairOpenScreenSessionIfStale()
|
||||||
|
startScreenSession()
|
||||||
|
}
|
||||||
|
if !isSessionLocked {
|
||||||
|
stopProtectedDataPolling()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
markLocked()
|
||||||
}
|
}
|
||||||
recordAppUsage()
|
recordAppUsage()
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
|
|
@ -679,6 +808,7 @@ final class UnlockCountManager: NSObject {
|
||||||
reassertContinuousLocationUpdates(forceStopFirst: true)
|
reassertContinuousLocationUpdates(forceStopFirst: true)
|
||||||
forceRefreshGeofenceFromLastKnown(reason: reason)
|
forceRefreshGeofenceFromLastKnown(reason: reason)
|
||||||
performLocationTick(reason: reason)
|
performLocationTick(reason: reason)
|
||||||
|
startProtectedDataPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,24 @@
|
||||||
|
|
||||||
import ObjectMapper
|
import ObjectMapper
|
||||||
import RxDataSources
|
import RxDataSources
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
private let kGroupTransformInt64 = TransformOf<Int64, Any>(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 {
|
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 {
|
struct ReviewMemberListResponse: BaseModelProtocol {
|
||||||
// 状态码
|
// 状态码
|
||||||
var code: String?
|
var code: String?
|
||||||
|
|
@ -125,6 +195,21 @@ struct GroupInfoModel: Mappable, Equatable {
|
||||||
var groupIcon: UIImage {
|
var groupIcon: UIImage {
|
||||||
UIImage(named: "GroupIcon/\(icon_index)") ?? 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] = []
|
var labels: [String] = []
|
||||||
/// 审核开关
|
/// 审核开关
|
||||||
|
|
@ -147,8 +232,10 @@ struct GroupInfoModel: Mappable, Equatable {
|
||||||
is_owner <- map["is_owner"]
|
is_owner <- map["is_owner"]
|
||||||
name <- map["name"]
|
name <- map["name"]
|
||||||
icon_index <- map["icon_index"]
|
icon_index <- map["icon_index"]
|
||||||
|
group_type <- (map["group_type"], kStrTransformInt)
|
||||||
|
group_template <- map["group_template"]
|
||||||
labels <- map["labels"]
|
labels <- map["labels"]
|
||||||
level <- map["level"]
|
level <- (map["level"], kIntTransformStr)
|
||||||
review <- map["review"]
|
review <- map["review"]
|
||||||
description <- map["description"]
|
description <- map["description"]
|
||||||
share_code <- map["share_code"]
|
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 {
|
extension GroupInfoModel: IdentifiableType {
|
||||||
public typealias Identity = String
|
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<String>()
|
||||||
|
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 {
|
struct GroupMemberModel: Mappable, Equatable {
|
||||||
///
|
///
|
||||||
var user_id: String = ""
|
var user_id: String = ""
|
||||||
|
|
@ -171,7 +305,12 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
var head_pic: String = "1"
|
var head_pic: String = "1"
|
||||||
/// 用户头像
|
/// 用户头像
|
||||||
var userIcon: UIImage {
|
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 = ""
|
var nick_name: String = ""
|
||||||
|
|
@ -191,7 +330,7 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
var status: Int = 0
|
var status: Int = 0
|
||||||
/// 入圈天数(若服务端下发)
|
/// 入圈天数(若服务端下发)
|
||||||
var join_days: Int = 0
|
var join_days: Int = 0
|
||||||
/// 入圈时间戳毫秒(若服务端下发)
|
/// 入圈时间戳。新接口使用秒,历史数据可能使用毫秒。
|
||||||
var join_time: Int64 = 0
|
var join_time: Int64 = 0
|
||||||
/// 是否在线
|
/// 是否在线
|
||||||
var is_online: Bool = false
|
var is_online: Bool = false
|
||||||
|
|
@ -221,6 +360,8 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
}
|
}
|
||||||
/// 最后位置
|
/// 最后位置
|
||||||
var last_position: String = ""
|
var last_position: String = ""
|
||||||
|
/// 当前用户与该成员共同加入的圈子
|
||||||
|
var common_groups: [CommonGroupModel] = []
|
||||||
var lastLocation: String {
|
var lastLocation: String {
|
||||||
guard last_position.contains(":") else { return "" }
|
guard last_position.contains(":") else { return "" }
|
||||||
let arr = last_position.components(separatedBy: ":")
|
let arr = last_position.components(separatedBy: ":")
|
||||||
|
|
@ -243,7 +384,55 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
status <- map["status"]
|
status <- map["status"]
|
||||||
mood <- (map["mood"], kStrTransformInt)
|
mood <- (map["mood"], kStrTransformInt)
|
||||||
join_days <- (map["join_days"], 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,13 @@ class CreateGroupVC: BaseViewController {
|
||||||
reactiveAction()
|
reactiveAction()
|
||||||
|
|
||||||
viewModel.loadData()
|
viewModel.loadData()
|
||||||
|
viewModel.requestGroupTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reactiveAction() {
|
private func reactiveAction() {
|
||||||
rootView.iconCarousel.onSelectIndex = { [weak self] index in
|
rootView.iconCarousel.onSelectTemplate = { [weak self] template in
|
||||||
self?.viewModel.iconIndex = index
|
self?.viewModel.groupType = template.type
|
||||||
|
self?.rootView.typeNameLab.text = template.name
|
||||||
}
|
}
|
||||||
|
|
||||||
rootView.submitBtn.rx.tap.subscribe(onNext: { [weak self] _ in
|
rootView.submitBtn.rx.tap.subscribe(onNext: { [weak self] _ in
|
||||||
|
|
@ -65,6 +67,13 @@ class CreateGroupVC: BaseViewController {
|
||||||
viewModel.output.sectionedItems
|
viewModel.output.sectionedItems
|
||||||
.bind(to: rootView.tagView.rx.items(dataSource: dataSource))
|
.bind(to: rootView.tagView.rx.items(dataSource: dataSource))
|
||||||
.disposed(by: disposeBag)
|
.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
|
viewModel.output.sectionedItems
|
||||||
.observe(on: MainScheduler.instance)
|
.observe(on: MainScheduler.instance)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@ class CreateGroupView: UIView {
|
||||||
private let tagMaxLength = 10
|
private let tagMaxLength = 10
|
||||||
|
|
||||||
var onConfirmAddTag: ((String) -> Void)?
|
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() {
|
private func setupRx() {
|
||||||
groupNameTF.rx.text.orEmpty
|
groupNameTF.rx.text.orEmpty
|
||||||
|
|
@ -465,6 +473,8 @@ class CreateGroupView: UIView {
|
||||||
btn.titleLabel?.font = FontManager.boboBold(18)
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
|
btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
|
||||||
btn.cornerRadius = 20
|
btn.cornerRadius = 20
|
||||||
|
btn.isEnabled = false
|
||||||
|
btn.alpha = 0.5
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -537,17 +547,23 @@ class CreateGroupView: UIView {
|
||||||
|
|
||||||
final class CreateGroupIconCarouselView: 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 let itemSize: CGFloat = 80
|
||||||
private var lastWidth: CGFloat = 0
|
private var lastWidth: CGFloat = 0
|
||||||
|
private var selectedItem = 0
|
||||||
var selectedIndex: Int = 1 {
|
|
||||||
didSet {
|
func configure(templates: [GroupTemplateModel]) {
|
||||||
guard oldValue != selectedIndex else { return }
|
self.templates = templates
|
||||||
onSelectIndex?(selectedIndex)
|
selectedItem = 0
|
||||||
|
collectionView.reloadData()
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
scrollToItem(selectedItem, animated: false)
|
||||||
|
if let first = templates.first {
|
||||||
|
onSelectTemplate?(first)
|
||||||
}
|
}
|
||||||
|
applyTransforms()
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
|
@ -569,23 +585,29 @@ final class CreateGroupIconCarouselView: UIView {
|
||||||
let inset = max(0, (bounds.width - itemSize) / 2)
|
let inset = max(0, (bounds.width - itemSize) / 2)
|
||||||
flowLayout.sectionInset = UIEdgeInsets(top: 25, left: inset, bottom: 25, right: inset)
|
flowLayout.sectionInset = UIEdgeInsets(top: 25, left: inset, bottom: 25, right: inset)
|
||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
scrollToIndex(selectedIndex, animated: false)
|
scrollToItem(selectedItem, animated: false)
|
||||||
}
|
}
|
||||||
applyTransforms()
|
applyTransforms()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToIndex(_ index: Int, animated: Bool) {
|
private func scrollToItem(_ item: Int, animated: Bool) {
|
||||||
let item = max(0, min(iconCount - 1, index - 1))
|
guard templates.indices.contains(item) else { return }
|
||||||
collectionView.scrollToItem(at: IndexPath(item: item, section: 0),
|
collectionView.scrollToItem(at: IndexPath(item: item, section: 0),
|
||||||
at: .centeredHorizontally,
|
at: .centeredHorizontally,
|
||||||
animated: animated)
|
animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func selectItem(_ item: Int) {
|
||||||
|
guard templates.indices.contains(item) else { return }
|
||||||
|
selectedItem = item
|
||||||
|
onSelectTemplate?(templates[item])
|
||||||
|
}
|
||||||
|
|
||||||
private func nearestIndex() -> Int {
|
private func nearestIndex() -> Int {
|
||||||
let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2
|
let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2
|
||||||
var nearest = 0
|
var nearest = 0
|
||||||
var minDist = CGFloat.greatestFiniteMagnitude
|
var minDist = CGFloat.greatestFiniteMagnitude
|
||||||
for i in 0..<iconCount {
|
for i in templates.indices {
|
||||||
guard let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0)) else { continue }
|
guard let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0)) else { continue }
|
||||||
let dist = abs(attr.center.x - centerX)
|
let dist = abs(attr.center.x - centerX)
|
||||||
if dist < minDist {
|
if dist < minDist {
|
||||||
|
|
@ -632,12 +654,12 @@ final class CreateGroupIconCarouselView: UIView {
|
||||||
|
|
||||||
extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
iconCount
|
templates.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
let cell = collectionView.dequeueReusableCell(for: indexPath) as CreateGroupIconCell
|
let cell = collectionView.dequeueReusableCell(for: indexPath) as CreateGroupIconCell
|
||||||
cell.configure(UIImage(named: "GroupIcon/\(indexPath.item + 1)"))
|
cell.configure(templates[indexPath.item])
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,7 +668,7 @@ extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionV
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
selectedIndex = indexPath.item + 1
|
selectItem(indexPath.item)
|
||||||
collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
|
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
|
let proposedCenter = targetContentOffset.pointee.x + scrollView.bounds.width / 2
|
||||||
var nearest = 0
|
var nearest = 0
|
||||||
var minDist = CGFloat.greatestFiniteMagnitude
|
var minDist = CGFloat.greatestFiniteMagnitude
|
||||||
for i in 0..<iconCount {
|
for i in templates.indices {
|
||||||
guard let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0)) else { continue }
|
guard let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0)) else { continue }
|
||||||
let dist = abs(attr.center.x - proposedCenter)
|
let dist = abs(attr.center.x - proposedCenter)
|
||||||
if dist < minDist {
|
if dist < minDist {
|
||||||
|
|
@ -669,11 +691,11 @@ extension CreateGroupIconCarouselView: UICollectionViewDataSource, UICollectionV
|
||||||
if let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: nearest, section: 0)) {
|
if let attr = collectionView.layoutAttributesForItem(at: IndexPath(item: nearest, section: 0)) {
|
||||||
targetContentOffset.pointee.x = attr.center.x - scrollView.bounds.width / 2
|
targetContentOffset.pointee.x = attr.center.x - scrollView.bounds.width / 2
|
||||||
}
|
}
|
||||||
selectedIndex = nearest + 1
|
selectItem(nearest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
selectedIndex = nearestIndex() + 1
|
selectItem(nearestIndex())
|
||||||
applyTransforms()
|
applyTransforms()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,12 +725,14 @@ final class CreateGroupIconCell: UICollectionViewCell {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(_ image: UIImage?) {
|
func configure(_ template: GroupTemplateModel) {
|
||||||
imageView.image = image
|
let fallback = UIImage(named: "GroupIcon/\(template.type)") ?? UIImage(named: "GroupIcon/1")
|
||||||
|
imageView.setGroupIcon(url: template.icon, fallback: fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
imageView.setGroupIcon(url: "", fallback: nil)
|
||||||
transform = .identity
|
transform = .identity
|
||||||
alpha = 1
|
alpha = 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class CreateGroupViewModel {
|
||||||
}
|
}
|
||||||
struct Output {
|
struct Output {
|
||||||
var sectionedItems: Observable<[GroupTagListSectionModel]>
|
var sectionedItems: Observable<[GroupTagListSectionModel]>
|
||||||
|
var groupTemplates: Observable<[GroupTemplateModel]>
|
||||||
}
|
}
|
||||||
|
|
||||||
let input: Input
|
let input: Input
|
||||||
|
|
@ -34,6 +35,7 @@ class CreateGroupViewModel {
|
||||||
var disposeBag = DisposeBag()
|
var disposeBag = DisposeBag()
|
||||||
|
|
||||||
private let sectionedItems = PublishSubject<[GroupTagListSectionModel]>()
|
private let sectionedItems = PublishSubject<[GroupTagListSectionModel]>()
|
||||||
|
private let groupTemplates = PublishSubject<[GroupTemplateModel]>()
|
||||||
|
|
||||||
private var tagList = ["私密", "游戏", "运动", "美食",
|
private var tagList = ["私密", "游戏", "运动", "美食",
|
||||||
"自驾", "聚会", "旅行", "学习"]
|
"自驾", "聚会", "旅行", "学习"]
|
||||||
|
|
@ -44,7 +46,7 @@ class CreateGroupViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconIndex = 1
|
var groupType = 0
|
||||||
let groupName = BehaviorRelay<String>(value: "")
|
let groupName = BehaviorRelay<String>(value: "")
|
||||||
let groupDesc = BehaviorRelay<String>(value: "")
|
let groupDesc = BehaviorRelay<String>(value: "")
|
||||||
|
|
||||||
|
|
@ -81,6 +83,21 @@ class CreateGroupViewModel {
|
||||||
func loadData() {
|
func loadData() {
|
||||||
sectionedItems.onNext(tagList.mapSection())
|
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
|
// MARK: - Request
|
||||||
func requestCreateGroup() {
|
func requestCreateGroup() {
|
||||||
|
|
@ -95,11 +112,16 @@ class CreateGroupViewModel {
|
||||||
DLToast.show(text: "请输入圈子描述")
|
DLToast.show(text: "请输入圈子描述")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard groupType > 0 else {
|
||||||
|
DLToast.showError(text: "圈子类型尚未加载")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
DLToast.showLoading()
|
DLToast.showLoading()
|
||||||
GroupService.operate(opType: "create",
|
GroupService.operate(opType: "create",
|
||||||
requestData: ["group_name": name,
|
requestData: ["group_name": name,
|
||||||
"icon_index": iconIndex,
|
"group_type": groupType,
|
||||||
"description": desc,
|
"description": desc,
|
||||||
"labels": selectedTagList]).subscribe(onNext: { response in
|
"labels": selectedTagList]).subscribe(onNext: { response in
|
||||||
DLToast.showSuccess(text: "创建成功") {
|
DLToast.showSuccess(text: "创建成功") {
|
||||||
|
|
@ -123,7 +145,8 @@ class CreateGroupViewModel {
|
||||||
init() {
|
init() {
|
||||||
input = Input()
|
input = Input()
|
||||||
output = Output(
|
output = Output(
|
||||||
sectionedItems: sectionedItems.asObservable()
|
sectionedItems: sectionedItems.asObservable(),
|
||||||
|
groupTemplates: groupTemplates.asObservable()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ final class GroupChatVC: BaseViewController {
|
||||||
/// 加载更早历史前记录,用于保持阅读位置
|
/// 加载更早历史前记录,用于保持阅读位置
|
||||||
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
||||||
private var didSetupKeyboard = false
|
private var didSetupKeyboard = false
|
||||||
|
/// 首屏等行高落地后再贴底;用户手滑列表后取消
|
||||||
|
private var needsInitialBottomScroll = true
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(groupId: String) {
|
init(groupId: String) {
|
||||||
|
|
@ -85,7 +87,10 @@ final class GroupChatVC: BaseViewController {
|
||||||
IQKeyboardManager.shared.resignOnTouchOutside = false
|
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
|
let canSend = isCircleOwner || AppContextManager.shared.vip > 1
|
||||||
rootView.disableIMView.isHidden = canSend
|
rootView.disableIMView.isHidden = canSend
|
||||||
rootView.quickActionBar.isUserInteractionEnabled = canSend
|
rootView.quickActionBar.isUserInteractionEnabled = canSend
|
||||||
|
|
@ -156,6 +161,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
|
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
|
||||||
self.rootView.dismissAllPanels()
|
self.rootView.dismissAllPanels()
|
||||||
self.bubbleMenu?.dismiss()
|
self.bubbleMenu?.dismiss()
|
||||||
|
self.needsInitialBottomScroll = false
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
|
@ -193,6 +199,14 @@ final class GroupChatVC: BaseViewController {
|
||||||
}
|
}
|
||||||
self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory
|
self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory
|
||||||
self.scrollToBottom()
|
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)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
|
@ -276,12 +290,24 @@ final class GroupChatVC: BaseViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom() {
|
private func scrollToBottom() {
|
||||||
let count = dataSource.sectionModels.first?.items.count ?? 0
|
let tableView = rootView.tableView
|
||||||
guard count > 0 else { return }
|
tableView.layoutIfNeeded()
|
||||||
DispatchQueue.main.async {
|
let inset = tableView.adjustedContentInset
|
||||||
self.rootView.tableView.layoutIfNeeded()
|
let y = tableView.contentSize.height - tableView.bounds.height + inset.bottom
|
||||||
let indexPath = IndexPath(row: count - 1, section: 0)
|
guard y > -inset.top else { return }
|
||||||
self.rootView.tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
|
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
|
// MARK: - API
|
||||||
private func requestGroupInfoByKey() {
|
private func requestGroupInfoByKey() {
|
||||||
GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in
|
GroupService.groupUsers(groupKey: viewModel.groupId)
|
||||||
guard let model = response.model else { return }
|
.subscribe(onNext: { [weak self] response in
|
||||||
self.viewModel.groupModel = model
|
guard let self else { return }
|
||||||
self.viewModel.memberList = response.list
|
guard response.isValid(for: self.viewModel.groupId),
|
||||||
self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))"
|
let model = response.model else {
|
||||||
self.rootView.groupAvatarView.image = model.groupIcon
|
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||||
self.rootView.reviewBtn.isHidden = !model.is_owner
|
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
|
// self.rootView.reviewDotView.isHidden = response.reviewCount == 0
|
||||||
}.disposed(by: disposeBag)
|
}, onError: { error in
|
||||||
|
DLToast.showError(text: error.localizedDescription)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - dataSource
|
// MARK: - dataSource
|
||||||
|
|
@ -890,11 +929,12 @@ final class GroupChatVC: BaseViewController {
|
||||||
cell.onImageTap = { [weak self] in
|
cell.onImageTap = { [weak self] in
|
||||||
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
||||||
}
|
}
|
||||||
cell.onNeedRelayout = { [weak tableView] in
|
cell.onNeedRelayout = { [weak self, weak tableView] in
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
tableView?.beginUpdates()
|
tableView?.beginUpdates()
|
||||||
tableView?.endUpdates()
|
tableView?.endUpdates()
|
||||||
}
|
}
|
||||||
|
self?.scrollToBottomIfPinned()
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .imageReceived(msg):
|
case let .imageReceived(msg):
|
||||||
|
|
@ -903,11 +943,12 @@ final class GroupChatVC: BaseViewController {
|
||||||
cell.onImageTap = { [weak self] in
|
cell.onImageTap = { [weak self] in
|
||||||
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
|
||||||
}
|
}
|
||||||
cell.onNeedRelayout = { [weak tableView] in
|
cell.onNeedRelayout = { [weak self, weak tableView] in
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
tableView?.beginUpdates()
|
tableView?.beginUpdates()
|
||||||
tableView?.endUpdates()
|
tableView?.endUpdates()
|
||||||
}
|
}
|
||||||
|
self?.scrollToBottomIfPinned()
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
case let .locationSend(msg):
|
case let .locationSend(msg):
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ class GroupChatView: UIView {
|
||||||
|
|
||||||
keyboardBtn.layoutChain
|
keyboardBtn.layoutChain
|
||||||
.right(12).centerY()
|
.right(12).centerY()
|
||||||
.width(28).height(28)
|
.width(30).height(30)
|
||||||
|
|
||||||
sendBtn.layoutChain
|
sendBtn.layoutChain
|
||||||
.right(8).centerY()
|
.right(8).centerY()
|
||||||
|
|
@ -414,7 +414,7 @@ class GroupChatView: UIView {
|
||||||
|
|
||||||
voiceBtn.layoutChain
|
voiceBtn.layoutChain
|
||||||
.right(12).centerY()
|
.right(12).centerY()
|
||||||
.width(28).height(28)
|
.width(30).height(30)
|
||||||
|
|
||||||
emojiBtn.layoutChain
|
emojiBtn.layoutChain
|
||||||
.rightToLeftOfView(sendBtn, offset: -8)
|
.rightToLeftOfView(sendBtn, offset: -8)
|
||||||
|
|
|
||||||