- UI调整、接口联调

This commit is contained in:
linshujie 2026-08-25 18:30:21 +08:00
parent 4daddba6ba
commit 59e09f82e7
152 changed files with 5044 additions and 3485 deletions

View File

@ -13,6 +13,10 @@ internal import Alamofire
enum GroupAPI { enum GroupAPI {
/// ///
case groupInfo case groupInfo
///
case groupTemplates
///
case groupUsers(groupKey: String)
/// ///
/// - Parameters: /// - Parameters:
/// - opType: createdismisssetdefault /// - opType: createdismisssetdefault
@ -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
@ -45,6 +53,12 @@ extension GroupAPI: MultiTargetProtocol {
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()
params["op_type"] = opType params["op_type"] = opType

View File

@ -22,6 +22,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
@ -148,6 +153,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()
params["platform_id"] = 1 params["platform_id"] = 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -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) }
} }

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 {
} }
} }
} }

View File

@ -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/"
} }
} }

View File

@ -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
} }
if Defaults[\.unlockWasLocked] || isSessionLocked {
debugLog("protectedDataPoll protected=1 wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
recordUnlock(reason: "protectedDataPoll") 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,10 +606,20 @@ 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()
endBackgroundPollTask()
if UIApplication.shared.isProtectedDataAvailable {
if isSessionLocked || Defaults[\.unlockWasLocked] {
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked { } else {
repairOpenScreenSessionIfStale()
startScreenSession() startScreenSession()
} }
if !isSessionLocked {
stopProtectedDataPolling()
}
} else {
markLocked()
}
recordAppUsage() recordAppUsage()
restartLocationMonitoringIfPossible(reassertContinuous: true) restartLocationMonitoringIfPossible(reassertContinuous: true)
Defaults[\.unlockLastHeartbeatAt] = Date() Defaults[\.unlockLastHeartbeatAt] = Date()
@ -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()
} }
} }

View File

@ -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
} }
} }

View File

@ -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
@ -66,6 +68,13 @@ class CreateGroupVC: BaseViewController {
.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)
.subscribe(onNext: { [weak self] _ in .subscribe(onNext: { [weak self] _ in

View File

@ -18,6 +18,14 @@ class CreateGroupView: UIView {
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
.subscribe(onNext: { [weak self] text in .subscribe(onNext: { [weak self] text in
@ -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 { func configure(templates: [GroupTemplateModel]) {
didSet { self.templates = templates
guard oldValue != selectedIndex else { return } selectedItem = 0
onSelectIndex?(selectedIndex) 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
} }

View File

@ -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: "")
@ -82,6 +84,21 @@ class CreateGroupViewModel {
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() {
let name = groupName.value.trimmingCharacters(in: .whitespacesAndNewlines) let name = groupName.value.trimmingCharacters(in: .whitespacesAndNewlines)
@ -96,10 +113,15 @@ class CreateGroupViewModel {
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()
) )
} }
} }

View File

@ -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
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.groupModel = model
self.viewModel.memberList = response.list 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.groupNameLabel.text = "\(model.name)\(response.list.count)"
self.rootView.groupAvatarView.image = model.groupIcon self.rootView.groupAvatarView.setGroupIcon(model)
self.rootView.reviewBtn.isHidden = !model.is_owner 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):

View File

@ -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)

Some files were not shown because too many files have changed in this diff Show More