- 驾驶分析接口调用

- 行程记录列表接口调用
- 高德地图logo遮挡处理
- 签到添加紧急邮箱
This commit is contained in:
linshujie 2026-06-30 18:32:00 +08:00
parent 7cddc4499a
commit 6de0cfd68a
33 changed files with 1519 additions and 84 deletions

View File

@ -199,6 +199,13 @@
30B74B412FF2437E00F6744D /* GroupMemberListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */; };
30B74B432FF2438800F6744D /* GroupMemberListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B422FF2438800F6744D /* GroupMemberListView.swift */; };
30B74B452FF24D1B00F6744D /* GroupMemberListVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B442FF24D1B00F6744D /* GroupMemberListVM.swift */; };
30B74B472FF3608600F6744D /* ScheduleRecordModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B462FF3608600F6744D /* ScheduleRecordModel.swift */; };
30B74B492FF3680200F6744D /* InputEmailPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B482FF3680200F6744D /* InputEmailPopupView.swift */; };
30B74B4B2FF390EF00F6744D /* DrivingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B4A2FF390EF00F6744D /* DrivingAPI.swift */; };
30B74B4D2FF391FF00F6744D /* DrivingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B4C2FF391FF00F6744D /* DrivingService.swift */; };
30B74B4F2FF392D800F6744D /* DrivingStatsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B74B4E2FF392D800F6744D /* DrivingStatsModel.swift */; };
30B819F22FF3CE3C00FAB693 /* ItineraryTraceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B819F12FF3CE3C00FAB693 /* ItineraryTraceView.swift */; };
30B819F42FF3CE4900FAB693 /* ItineraryTraceVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B819F32FF3CE4900FAB693 /* ItineraryTraceVC.swift */; };
30BAB84D2FCD2FDE00C33B5C /* InviteJoinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BAB84C2FCD2FDE00C33B5C /* InviteJoinView.swift */; };
30BAB84F2FCD2FED00C33B5C /* InviteJoinVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BAB84E2FCD2FED00C33B5C /* InviteJoinVC.swift */; };
30BAB8512FCD331C00C33B5C /* GroupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BAB8502FCD331C00C33B5C /* GroupAPI.swift */; };
@ -486,6 +493,13 @@
30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberListVC.swift; sourceTree = "<group>"; };
30B74B422FF2438800F6744D /* GroupMemberListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberListView.swift; sourceTree = "<group>"; };
30B74B442FF24D1B00F6744D /* GroupMemberListVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberListVM.swift; sourceTree = "<group>"; };
30B74B462FF3608600F6744D /* ScheduleRecordModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleRecordModel.swift; sourceTree = "<group>"; };
30B74B482FF3680200F6744D /* InputEmailPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputEmailPopupView.swift; sourceTree = "<group>"; };
30B74B4A2FF390EF00F6744D /* DrivingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrivingAPI.swift; sourceTree = "<group>"; };
30B74B4C2FF391FF00F6744D /* DrivingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrivingService.swift; sourceTree = "<group>"; };
30B74B4E2FF392D800F6744D /* DrivingStatsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrivingStatsModel.swift; sourceTree = "<group>"; };
30B819F12FF3CE3C00FAB693 /* ItineraryTraceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItineraryTraceView.swift; sourceTree = "<group>"; };
30B819F32FF3CE4900FAB693 /* ItineraryTraceVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItineraryTraceVC.swift; sourceTree = "<group>"; };
30BAB84C2FCD2FDE00C33B5C /* InviteJoinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteJoinView.swift; sourceTree = "<group>"; };
30BAB84E2FCD2FED00C33B5C /* InviteJoinVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteJoinVC.swift; sourceTree = "<group>"; };
30BAB8502FCD331C00C33B5C /* GroupAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupAPI.swift; sourceTree = "<group>"; };
@ -636,6 +650,7 @@
30BAB8502FCD331C00C33B5C /* GroupAPI.swift */,
30D891F42FE22E0600E958FD /* OrderAPI.swift */,
30D74AB52FEA34FF0050EB2C /* ItineraryAPI.swift */,
30B74B4A2FF390EF00F6744D /* DrivingAPI.swift */,
);
path = API;
sourceTree = "<group>";
@ -1060,6 +1075,7 @@
30BAB8522FCD337C00C33B5C /* GroupService.swift */,
30D891F62FE22E6E00E958FD /* OrderService.swift */,
30D74AB72FEA36A50050EB2C /* ItineraryService.swift */,
30B74B4C2FF391FF00F6744D /* DrivingService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -1337,6 +1353,10 @@
30B74B402FF2437E00F6744D /* GroupMemberListVC.swift */,
30B74B422FF2438800F6744D /* GroupMemberListView.swift */,
30B74B442FF24D1B00F6744D /* GroupMemberListVM.swift */,
30B74B4E2FF392D800F6744D /* DrivingStatsModel.swift */,
30B74B462FF3608600F6744D /* ScheduleRecordModel.swift */,
30B819F32FF3CE4900FAB693 /* ItineraryTraceVC.swift */,
30B819F12FF3CE3C00FAB693 /* ItineraryTraceView.swift */,
);
path = GroupMemberList;
sourceTree = "<group>";
@ -1402,6 +1422,7 @@
30CCDE502FE2785D00F5214A /* SignInVC.swift */,
30CCDE522FE2786600F5214A /* SignInView.swift */,
30CCDE542FE2903100F5214A /* SignInModel.swift */,
30B74B482FF3680200F6744D /* InputEmailPopupView.swift */,
);
path = SignIn;
sourceTree = "<group>";
@ -1802,6 +1823,7 @@
305A769F2FCA8C7000227D26 /* TextContentArrowCell.swift in Sources */,
30D87D042FE1336300E958FD /* NavigationVC.swift in Sources */,
30D87D052FE1336300E958FD /* NavigationView.swift in Sources */,
30B819F42FF3CE4900FAB693 /* ItineraryTraceVC.swift in Sources */,
305A76A02FCA8C7000227D26 /* TextTableViewCell.swift in Sources */,
305A76A12FCA8C7000227D26 /* UIButton+RTL.m in Sources */,
30EFF3A62FD7C5AF00EB35D4 /* GroupSettingVC.swift in Sources */,
@ -1827,6 +1849,7 @@
305A76AC2FCA8C7000227D26 /* String+Extension.swift in Sources */,
30D74AB82FEA36A50050EB2C /* ItineraryService.swift in Sources */,
30EFF3C62FDA433E00EB35D4 /* ChangePhoneView.swift in Sources */,
30B74B492FF3680200F6744D /* InputEmailPopupView.swift in Sources */,
305A76AD2FCA8C7000227D26 /* UIApplicationExtension.swift in Sources */,
305A76AE2FCA8C7000227D26 /* UIButton+Extension.swift in Sources */,
305A76AF2FCA8C7000227D26 /* UIColor+Extension.swift in Sources */,
@ -1841,6 +1864,7 @@
30EFF3E02FDA9CE300EB35D4 /* EmergencyContactModel.swift in Sources */,
305A76B62FCA8C7000227D26 /* UITextField+Extensions.swift in Sources */,
30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */,
30B819F22FF3CE3C00FAB693 /* ItineraryTraceView.swift in Sources */,
305A76B72FCA8C7000227D26 /* UIView+Extension.swift in Sources */,
305A76B82FCA8C7000227D26 /* UIViewController+Extension.swift in Sources */,
305A76B92FCA8C7000227D26 /* URL+Extension.swift in Sources */,
@ -1872,6 +1896,7 @@
305A76C62FCA8C7000227D26 /* AppContextManager.swift in Sources */,
30D74ABD2FEA67EA0050EB2C /* CreateScheduleVC.swift in Sources */,
305A76C72FCA8C7000227D26 /* UserConfigModel.swift in Sources */,
30B74B472FF3608600F6744D /* ScheduleRecordModel.swift in Sources */,
305A76C82FCA8C7000227D26 /* UserConfigResponse.swift in Sources */,
305A76C92FCA8C7000227D26 /* ApiManager.swift in Sources */,
305A76CA2FCA8C7000227D26 /* AppSettings.swift in Sources */,
@ -1891,6 +1916,7 @@
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
305A798C2FCAB99300227D26 /* HomeViewModel.swift in Sources */,
30EFF3D12FDA69EC00EB35D4 /* AvatarIconListVC.swift in Sources */,
30B74B4B2FF390EF00F6744D /* DrivingAPI.swift in Sources */,
305A76D52FCA8C7000227D26 /* SystemResponse.swift in Sources */,
305A76D62FCA8C7000227D26 /* ImagePlugin.swift in Sources */,
305A76D72FCA8C7000227D26 /* NotEmpty.swift in Sources */,
@ -1901,6 +1927,7 @@
305A76DA2FCA8C7000227D26 /* Button+Action.swift in Sources */,
305A76DB2FCA8C7000227D26 /* Control+Action.swift in Sources */,
305A76DC2FCA8C7000227D26 /* InputSubject.swift in Sources */,
30B74B4D2FF391FF00F6744D /* DrivingService.swift in Sources */,
305A76DD2FCA8C7000227D26 /* NSObject+Rx.swift in Sources */,
305A79902FCAC61A00227D26 /* InviteMemberVC.swift in Sources */,
30A87A542FEE50B10095E7C6 /* ScheduleHistoryVM.swift in Sources */,
@ -1967,6 +1994,7 @@
30EFF3B32FD8F1C200EB35D4 /* ReviewMemberListView.swift in Sources */,
305A76F82FCA8C7000227D26 /* DLAlert.swift in Sources */,
305A76F92FCA8C7000227D26 /* DLToast.swift in Sources */,
30B74B4F2FF392D800F6744D /* DrivingStatsModel.swift in Sources */,
305A76FA2FCA8C7000227D26 /* DLEmptyDataSet.swift in Sources */,
305A76FB2FCA8C7000227D26 /* EmptyDataSet.swift in Sources */,
30BF300E2FED09CC00D9CB52 /* ScheduleDetailVC.swift in Sources */,

View File

@ -11,11 +11,14 @@ import SwiftyUserDefaults
public protocol MultiTargetProtocol: TargetType {
var multiTarget: MultiTarget { get }
/// true Response Body
var suppressResponseLog: Bool { get }
}
public extension MultiTargetProtocol {
var multiTarget: MultiTarget { MultiTarget(self) }
var suppressResponseLog: Bool { false }
var headers: [String : String]? {
AppNetworkConfig.shared.httpHeader?()

View File

@ -38,32 +38,53 @@ let requestClosure = { (endpoint: Endpoint, done: MoyaProvider.RequestResultClos
do {
var request = try endpoint.urlRequest()
// Modify the request however you like.
request.timeoutInterval = 15 //
request.timeoutInterval = 60 //
done(.success(request))
} catch {
done(.failure(MoyaError.underlying(error, nil)))
}
}
let plugins: [PluginType] = [
SignPlugin(),
NetworkLoggerPlugin(configuration: .init(formatter: .init(responseData: JSONResponseDataFormatter), logOptions: .verbose)),
NetworkActivityPlugin(networkActivityClosure: { change, _ in
#if !SHARE_EXTENSION
DispatchQueue.main.async {
switch change {
case .began:
UIApplication.shared.isNetworkActivityIndicatorVisible = true
case .ended:
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}
/// Logger playback Response Body
private final class FilteredLogger: PluginType {
private let logger = NetworkLoggerPlugin(configuration: .init(
formatter: .init(responseData: JSONResponseDataFormatter),
logOptions: .verbose
))
func willSend(_ request: RequestType, target: TargetType) {
logger.willSend(request, target: target)
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
if let mt = target as? MultiTarget,
case let .target(underlying) = mt,
let api = underlying as? MultiTargetProtocol,
api.suppressResponseLog,
case .success(let response) = result {
print("[suppress] \(api.path) status: \(response.statusCode)")
return
}
#endif
})
]
logger.didReceive(result, target: target)
}
}
public let APIProvider = MoyaProvider<MultiTarget>(requestClosure: requestClosure,
plugins: plugins).rx
plugins: [
SignPlugin(),
FilteredLogger(),
NetworkActivityPlugin(networkActivityClosure: { change, _ in
#if !SHARE_EXTENSION
DispatchQueue.main.async {
switch change {
case .began:
UIApplication.shared.isNetworkActivityIndicatorVisible = true
case .ended:
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}
}
#endif
})
]).rx
public extension Error {
var underlyingError: NSError? {
guard let moyaError = self as? MoyaError else { return nil }

View File

@ -0,0 +1,64 @@
//
// DrivingAPI.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import Moya
import SwiftyUserDefaults
internal import Alamofire
/// API
enum DrivingAPI {
///
case drivingEvents(user_id: String, start_time: String, end_time: String)
///
case playback(user_id: String, date: String)
}
extension DrivingAPI: MultiTargetProtocol {
var path: String {
switch self {
case .drivingEvents:
return "mapi/driving-events/stats"
case .playback:
return "mapi/trips/playback"
}
}
var method: Moya.Method {
switch self {
case .drivingEvents, .playback:
return .get
default:
return .post
}
}
var suppressResponseLog: Bool {
switch self {
case .playback: return true
default: return false
}
}
var task: Moya.Task {
switch self {
case let .drivingEvents(user_id, start_time, end_time):
var params = Parameters()
params["user_id"] = user_id
params["start_time"] = start_time
params["end_time"] = end_time
return .requestParameters(parameters: params, encoding: URLEncoding())
case let .playback(user_id, date):
var params = Parameters()
params["user_id"] = user_id
params["date"] = date
params["simplify"] = true
return .requestParameters(parameters: params, encoding: URLEncoding())
}
}
}

View File

@ -72,6 +72,9 @@ enum UserAPI {
/// - enable:
/// - keep_time
case bubble(enable: Bool, keep_time: Int)
///
case setEmail(email: String)
}
extension UserAPI: MultiTargetProtocol {
@ -108,6 +111,8 @@ extension UserAPI: MultiTargetProtocol {
return "mapi/user/followed"
case .bubble:
return "mapi/bubble/operate"
case .setEmail:
return "mapi/user/signin/setemail"
}
}
@ -199,6 +204,11 @@ extension UserAPI: MultiTargetProtocol {
params["keep_time"] = keep_time
}
return .requestParameters(parameters: params, encoding: JSONEncoding())
case let .setEmail(email):
var params = Parameters()
params["email"] = email
return .requestParameters(parameters: params, encoding: JSONEncoding())
}
}
}

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2170@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2170@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group_2205@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group_2205@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View File

@ -464,7 +464,7 @@ public extension String {
// MARK: -
public extension String {
/// Email
static func checkEmailAddressIsValid(address: String?) -> Bool {
static func checkEmailAddressIsValid(_ address: String?) -> Bool {
guard address != nil else {
return false
}
@ -519,3 +519,32 @@ extension String {
return NSPredicate(format: "SELF MATCHES %@", reg).evaluate(with: self)
}
}
// MARK: -
extension String {
// MARK: - ISO8601 2026-06-26T14:18:52+08:00 -> Date
private func parseISO8601(_ isoStr: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withTimeZone]
return formatter.date(from: isoStr)
}
// MARK: - Date
/// - Parameters:
/// - date:
/// - format: yyyy-MM-dd HH:mm:ss
/// - Returns:
func formatDate(_ date: Date, format: String) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
formatter.dateFormat = format
return formatter.string(from: date)
}
// MARK: - ISO
func isoStringToCustom(_ isoStr: String, format: String) -> String? {
guard let date = parseISO8601(isoStr) else { return nil }
return formatDate(date, format: format)
}
}

View File

@ -72,13 +72,23 @@ extension ApiManager {
DLToast.dismiss()
switch result {
case let .success(response):
let isPlayback = response.request?.url?.absoluteString.contains("mapi/trips/playback") ?? false
//
let t0 = CFAbsoluteTimeGetCurrent()
let decryptedData = ApiManager.decryptIfNeeded(response.data)
if isPlayback {
let rawSize = response.data.count
let decryptTime = CFAbsoluteTimeGetCurrent() - t0
print("[playback] raw: \(rawSize)B, decrypt: \(String(format: "%.2f", decryptTime))s")
}
// responsejson
let json = JSON(decryptedData ?? response.data)
print("============== decrypt ====================")
print(json)
if !isPlayback {
print("============== decrypt ====================")
print(json)
}
let code = json["code"].int
let success: Bool? = json["success"].bool
let message = json["message"].string
let combineMessage = message ?? "请检查网络连接"
@ -100,6 +110,16 @@ extension ApiManager {
case GatewayStatusCode.failure.rawValue, GatewayStatusCode.noAuthority.rawValue: // 退
handlePopView(message)
default:
// codesuccess
if success != nil, success == true {
if let decrypted = decryptedData {
let decryptedResponse = Response(statusCode: response.statusCode,
data: decrypted,
request: response.request, response: response.response)
return .success(decryptedResponse)
}
return result
}
///
let code = GatewayStatusCode(rawValue: code ?? -9999) ?? .unknownError
if code == .unknownError, handle {
@ -167,15 +187,15 @@ extension ApiManager {
let encrypt = json["encrypt"] as? Int,
encrypt == 1,
let encryptedString = json["data"] as? String,
let decryptedString = aes256CBCDecrypt(encryptedString, key: jieMiKey, iv: jieMiIV),
let decryptedData = decryptedString.data(using: .utf8),
let decryptedJSON = try? JSONSerialization.jsonObject(with: decryptedData) as? [String: Any]
let decryptedData = aes256CBCDecrypt(encryptedString, key: jieMiKey, iv: jieMiIV)
else { return nil }
return try? JSONSerialization.data(withJSONObject: decryptedJSON)
// JSON
guard (try? JSONSerialization.jsonObject(with: decryptedData)) != nil else { return nil }
return decryptedData
}
private class func aes256CBCDecrypt(_ base64String: String, key: String, iv: String) -> String? {
private class func aes256CBCDecrypt(_ base64String: String, key: String, iv: String) -> Data? {
guard let data = Data(base64Encoded: base64String),
let keyData = key.data(using: .utf8),
let ivData = iv.data(using: .utf8) else { return nil }
@ -202,7 +222,7 @@ extension ApiManager {
}
guard status == kCCSuccess else { return nil }
return String(data: Data(bytes: buffer, count: numBytesDecrypted), encoding: .utf8)
return Data(bytes: buffer, count: numBytesDecrypted)
}
class func getMD5ID() -> String {

View File

@ -0,0 +1,69 @@
//
// DrivingStatsModel.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import ObjectMapper
import RxDataSources
///
struct DrivingStatsResponse: BaseModelProtocol {
//
var code: String?
//
var message: String?
//
var model: DrivingStatsModel?
init?(map: Map) {}
mutating func mapping(map: Map) {
code <- map["code"]
message <- map["msg"]
model <- map["data"]
}
}
struct DrivingStatsModel: Mappable {
var uuid: String = UUID().uuidString
///
var distance_km: Double = 0
///
var frequent_lane_change: Int = 0
///
var hard_acceleration: Int = 0
///
var hard_braking: Int = 0
///
var long_driving: Int = 0
///
var low_speeding: Int = 0
///
var max_speed: Double = 0
///
var sharp_turn: Int = 0
///
var signal_loss: Int = 0
///
var speeding: Int = 0
///
var total: Int = 0
init?(map: Map) {}
mutating func mapping(map: Map) {
distance_km <- map["distance_km"]
frequent_lane_change <- map["frequent_lane_change"]
hard_acceleration <- map["hard_acceleration"]
hard_braking <- map["hard_braking"]
long_driving <- map["long_driving"]
low_speeding <- map["low_speeding"]
max_speed <- map["max_speed"]
sharp_turn <- map["sharp_turn"]
signal_loss <- map["signal_loss"]
speeding <- map["speeding"]
total <- map["total"]
}
}

View File

@ -31,12 +31,15 @@ class GroupMemberListVC: BaseViewController {
requestGroupInfo()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
rootView.unlockVipMaskView.isHidden = AppContextManager.shared.vip > 1
}
private func reactiveAction() {
}
private var selectedRow = 0
private func bindViewModel() {
viewModel.output.sectionedItems
.bind(to: rootView.collectionView.rx.items(dataSource: memberDataSource))
@ -46,24 +49,68 @@ class GroupMemberListVC: BaseViewController {
.bind(to: rootView.drivingEventCV.rx.items(dataSource: drivingDataSource))
.disposed(by: disposeBag)
//
viewModel.output.scheduleSectionedItems
.bind(to: rootView.tableView.rx.items(dataSource: scheduleDataSource))
.disposed(by: disposeBag)
viewModel.output.scheduleSectionedItems
.subscribe(onNext: { [weak self] items in
self?.rootView.updateTableViewHeight(count: items.first?.items.count ?? 0)
}).disposed(by: disposeBag)
// +
rootView.selectedDate
.skip(1)
.subscribe(onNext: { [weak self] date in
self?.rootView.scrollReportToTop()
self?.requestDrivingEvents(for: date)
}).disposed(by: disposeBag)
// selectedDate API
rootView.collectionView.rx.modelSelected(GroupMemberModel.self)
.subscribe(onNext: { [weak self] model in
guard let self = self, let row = self.viewModel.rowOf(userId: model.user_id) else { return }
self.selectedRow = row
guard let self = self else { return }
self.viewModel.memberId = model.user_id
self.rootView.selectedMemberIsSelf = self.viewModel.isCurrentUser(id: model.user_id)
self.rootView.selectedDate.accept(Date())
self.rootView.scrollToToday()
self.rootView.scrollReportToTop()
self.rootView.collectionView.reloadData()
}).disposed(by: disposeBag)
}
///
private func requestDrivingEvents(for date: Date) {
guard !viewModel.memberId.isEmpty else { return }
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
let end: Date
if calendar.isDateInToday(date) {
end = Date() //
} else {
end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
}
requestDrivingEvents(start_time: "\(Int64(start.timeIntervalSince1970 * 1000))",
end_time: "\(Int64(end.timeIntervalSince1970 * 1000))")
requestPlayback(date: "\(Int64(start.timeIntervalSince1970 * 1000))")
}
// MARK: - dataSource
private lazy var scheduleDataSource: RxTableViewSectionedReloadDataSource<ScheduleRecordSection> = {
RxTableViewSectionedReloadDataSource<ScheduleRecordSection> { _, tableView, indexPath, model in
let cell: ScheduleRecordCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(model)
return cell
}
}()
private lazy var memberDataSource: RxCollectionViewSectionedReloadDataSource<GroupMemberListSectionModel> = {
RxCollectionViewSectionedReloadDataSource<GroupMemberListSectionModel> { [weak self] datasource, collectionView, indexPath, model in
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
cell.configure(model: model,
isCurrentUser: self?.viewModel.isCurrentUser(id: model.user_id) ?? false,
isSelected: indexPath.row == (self?.selectedRow ?? 0))
isSelected: model.user_id == self?.viewModel.memberId)
return cell
}
}()
@ -84,13 +131,38 @@ class GroupMemberListVC: BaseViewController {
guard let model = response.model else { return }
self.viewModel.groupModel = model
self.viewModel.loadData(response.list)
//
if let first = self.viewModel.firstMemberId {
self.rootView.selectedMemberIsSelf = self.viewModel.isCurrentUser(id: first)
self.rootView.selectedMemberIsSelf = self.viewModel.isCurrentUser(id: self.viewModel.memberId)
//
if !self.viewModel.memberId.isEmpty {
self.requestDrivingEvents(for: Date())
}
}.disposed(by: disposeBag)
}
//
private func requestDrivingEvents(start_time: String, end_time: String) {
DrivingService.drivingEvents(user_id: viewModel.memberId, start_time: start_time, end_time: end_time).subscribe { response in
guard let model = response.model else { return }
self.rootView.mileageLab.text = String(format: "%.1fkm", model.distance_km)
self.rootView.speedLab.text = String(format: "%.0fkm/h", model.max_speed)
self.viewModel.updateDrivingStats(model)
}.disposed(by: disposeBag)
}
//
private func requestPlayback(date: String) {
let start = CFAbsoluteTimeGetCurrent()
dl.showLoading(text: "行程记录获取中...")
DrivingService.playback(user_id: viewModel.memberId, date: date).subscribe(onNext: { [weak self] response in
let elapsed = CFAbsoluteTimeGetCurrent() - start
print("[playback] total: \(String(format: "%.1f", elapsed))s, items: \(response.list.count)")
self?.dl.dismiss()
self?.viewModel.loadScheduleRecords(response.list)
}, onError: { _ in
self.dl.dismiss()
}).disposed(by: disposeBag)
}
// MARK: - Init
init(groupKey: String) {
viewModel = GroupMemberListVM(groupKey: groupKey)

View File

@ -10,22 +10,6 @@ import RxRelay
import RxDataSources
import ObjectMapper
// MARK: - DrivingStats
struct DrivingStatsData {
let distance_km: Double?
let frequent_lane_change: Int?
let hard_acceleration: Int?
let hard_braking: Int?
let long_driving: Int?
let low_speeding: Int?
let max_speed: Double?
let period: String?
let sharp_turn: Int?
let signal_loss: Int?
let speeding: Int?
let total: Int?
}
struct DrivingEventItem: IdentifiableType, Equatable {
typealias Identity = String
let identity: String
@ -42,6 +26,7 @@ struct DrivingEventItem: IdentifiableType, Equatable {
}
typealias DrivingEventSection = SectionModel<String, DrivingEventItem>
typealias ScheduleRecordSection = SectionModel<String, ScheduleRecordModel>
class GroupMemberListVM {
@ -51,16 +36,19 @@ class GroupMemberListVM {
struct Output {
var sectionedItems: Observable<[GroupMemberListSectionModel]>
var drivingSectionedItems: Observable<[DrivingEventSection]>
var scheduleSectionedItems: Observable<[ScheduleRecordSection]>
}
let output: Output
private var disposeBag = DisposeBag()
var memberId: String = ""
private let sectionedItems = PublishSubject<[GroupMemberListSectionModel]>()
private let drivingItemsRelay = BehaviorRelay<[DrivingEventItem]>(value: GroupMemberListVM.defaultDrivingEvents)
private let scheduleItemsRelay = BehaviorRelay<[ScheduleRecordModel]>(value: [])
private var memberList: [GroupMemberModel] = []
private static let defaultDrivingEvents: [DrivingEventItem] = [
DrivingEventItem(title: "急加速", iconName: "GroupMemberList/1"),
DrivingEventItem(title: "急转向", iconName: "GroupMemberList/3"),
@ -89,30 +77,33 @@ class GroupMemberListVM {
tempmemberList.moveToFirst { $0.user_id == AppContextManager.shared.userId }
tempmemberList.moveToFirst { isGroupOwn(id: $0.user_id) }
memberList = tempmemberList
memberId = memberList.first?.user_id ?? ""
sectionedItems.onNext(memberList.mapSection())
}
/// userId
var firstMemberId: String? { memberList.first?.user_id }
///
func rowOf(userId: String) -> Int? {
memberList.firstIndex(where: { $0.user_id == userId })
}
///
func loadScheduleRecords(_ list: [ScheduleRecordModel]) {
scheduleItemsRelay.accept(list)
}
///
func updateDrivingStats(_ stats: DrivingStatsData) {
func updateDrivingStats(_ stats: DrivingStatsModel) {
let items = Self.defaultDrivingEvents.enumerated().map { i, item in
let count: Int
switch i {
case 0: count = stats.hard_acceleration ?? 0
case 1: count = stats.speeding ?? 0
case 2: count = stats.sharp_turn ?? 0
case 3: count = stats.low_speeding ?? 0
case 4: count = stats.hard_braking ?? 0
case 5: count = stats.frequent_lane_change ?? 0
case 6: count = stats.signal_loss ?? 0
case 7: count = stats.long_driving ?? 0
case 0: count = stats.hard_acceleration
case 1: count = stats.sharp_turn
case 2: count = stats.hard_braking
case 3: count = stats.signal_loss
case 4: count = stats.speeding
case 5: count = stats.low_speeding
case 6: count = stats.frequent_lane_change
case 7: count = stats.long_driving
default: count = 0
}
return DrivingEventItem(title: item.title, iconName: item.iconName, count: count)
@ -124,7 +115,8 @@ class GroupMemberListVM {
self.groupKey = groupKey
output = Output(
sectionedItems: sectionedItems.asObservable(),
drivingSectionedItems: drivingItemsRelay.map { $0.mapSection() }.asObservable()
drivingSectionedItems: drivingItemsRelay.map { $0.mapSection() }.asObservable(),
scheduleSectionedItems: scheduleItemsRelay.map { $0.mapSection() }.asObservable()
)
}
}

View File

@ -166,6 +166,43 @@ class GroupMemberListView: UIView {
return cv
}()
// MARK: -
lazy var unlockVipMaskView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let maskBg = UIImageView(image: UIImage(named: "GroupMemberList/mask_bg"))
view.addSubview(maskBg)
maskBg.layoutChain.edges()
let tipsImg = UIImageView(image: UIImage(named: "GroupMemberList/mask_tips"))
view.addSubview(tipsImg)
tipsImg.layoutChain
.top(83)
.centerX()
.width(283)
.height(194)
let btn = UIButton()
btn.setTitle("立即开通", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
btn.setBackgroundColor(UIColor(hexStr: "#FF7B24"), for: .normal)
btn.cornerRadius = 25
btn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.vipRecharge)
}).disposed(by: disposeBag)
view.addSubview(btn)
btn.layoutChain
.topToBottomOfView(tipsImg, offset: 23)
.centerX()
.width(224)
.height(50)
return view
}()
// MARK: -
lazy var reportView: UIView = {
let view = UIView()
@ -221,6 +258,11 @@ class GroupMemberListView: UIView {
.topToBottomOfView(dateView)
.edges(excludingEdge: .top)
view.addSubview(unlockVipMaskView)
unlockVipMaskView.layoutChain
.topToBottomOfView(dateView)
.edges(excludingEdge: .top)
return view
}()
@ -342,12 +384,7 @@ class GroupMemberListView: UIView {
.rightToLeftOfView(dateNextBtn, offset: -5)
.edgesVertical()
// 4 index 28 3
DispatchQueue.main.async {
let page: CGFloat = 4 // 5 items 28-34
let offset = page * CGFloat(self.daysPerPage) * self.dateItemWidth
self.dateCollectionView.contentOffset.x = offset
}
DispatchQueue.main.async { self.scrollToToday() }
return view
}()
@ -406,6 +443,11 @@ class GroupMemberListView: UIView {
.top(5)
.edgesHorzontal(15)
.height(249)
contentView.addSubview(recordView)
recordView.layoutChain
.topToBottomOfView(drivingAnalysisView, offset: 15)
.edgesHorzontal(15)
.bottom(20)
return view
@ -455,6 +497,76 @@ class GroupMemberListView: UIView {
return cv
}()
// MARK: -
lazy var recordView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.cornerRadius = 10
let titleBg = UIImageView(image: UIImage(named: "GroupMemberList/title_bg"))
view.addSubview(titleBg)
let titleLab = UILabel()
titleLab.text = "行程记录"
titleLab.font = .systemFont(ofSize: 16, weight: .medium)
view.addSubview(titleLab)
titleLab.layoutChain
.top(15)
.centerX()
titleBg.layoutChain
.centerX()
.bottomToView(titleLab, offset: 5)
view.addSubview(tableView)
tableView.layoutChain
.topToBottomOfView(titleBg, offset: 10)
.edgesHorzontal()
.bottom()
view.addSubview(nodataLab)
nodataLab.layoutChain.centerX().centerY()
return view
}()
lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 177
tableView.showsVerticalScrollIndicator = false
tableView.isScrollEnabled = false
tableView.register(ScheduleRecordCell.self)
return tableView
}()
lazy var nodataLab: UILabel = {
let l = UILabel()
l.text = "暂无行程记录"
l.font = .systemFont(ofSize: 12, weight: .regular)
l.textColor = UIColor(hexStr: "#999999")
return l
}()
///
func scrollReportToTop() {
scrollView.setContentOffset(.zero, animated: false)
}
/// 5 items 28-34 3
func scrollToToday() {
let page: CGFloat = 4
let offset = page * CGFloat(daysPerPage) * dateItemWidth
dateCollectionView.contentOffset.x = offset
}
func updateTableViewHeight(count: Int) {
let h = CGFloat(count) * 179
tableView.layoutChain.height(h > 0 ? h : 179)
nodataLab.isHidden = h > 0
}
override func layoutSubviews() {
super.layoutSubviews()
updateArrowVisibility()
@ -547,7 +659,7 @@ class DateCell: UICollectionViewCell {
}
}
// MARK: - GroupMemberListCell
// MARK: - GroupMemberListCell
class GroupMemberListCell: UICollectionViewCell {
func configure(model: GroupMemberModel, isCurrentUser: Bool, isSelected: Bool) {
@ -706,7 +818,7 @@ class GroupMemberListCell: UICollectionViewCell {
}
}
// MARK: - DrivingEventCell
// MARK: - DrivingEventCell
class DrivingEventCell: UICollectionViewCell {
func configure(_ item: DrivingEventItem) {
@ -758,3 +870,254 @@ class DrivingEventCell: UICollectionViewCell {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
// MARK: - ScheduleRecordCells
class ScheduleRecordCell: UITableViewCell {
func configure(_ model: ScheduleRecordModel) {
distanceLab.text = String(format: "%.1fkm", model.distance_km)
speedLab.text = String(format: "最高时速 %.0fkm/h", model.max_speed)
scoreLab.text = model.score.string
dayLab.text = model.start_time.isoStringToCustom(model.start_time, format: "dd") ?? ""
yearMonthLab.text = model.start_time.isoStringToCustom(model.start_time, format: "yyyy.MM") ?? ""
let startTime = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm") ?? ""
let endTime = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm") ?? ""
durationLab.text = "\(startTime)-\(endTime)(\(model.duration_minutes)分钟)"
guard let start_address = model.start_address,
let end_address = model.end_address else { return }
startLab.text = start_address.street
endLab.text = end_address.street
}
private func setupViews() {
contentView.addSubview(dotView)
contentView.addSubview(dayLab)
contentView.addSubview(yearMonthLab)
contentView.addSubview(distanceLab)
contentView.addSubview(durationLab)
contentView.addSubview(detailView)
detailView.addSubview(headerBgView)
detailView.addSubview(speedLab)
detailView.addSubview(scroreTitleLab)
detailView.addSubview(scoreLab)
detailView.addSubview(unitLab)
detailView.addSubview(startTitleLab)
detailView.addSubview(startLab)
detailView.addSubview(endTitleLab)
detailView.addSubview(endLab)
contentView.addSubview(lineView)
dotView.layoutChain
.left(15)
.width(8)
.heightToWidth(1)
dayLab.layoutChain
.top(15)
.leftToRightOfView(dotView, offset: 8)
dotView.layoutChain.centerY(dayLab)
yearMonthLab.layoutChain
.leftToRightOfView(dayLab, offset: 4)
.bottomToView(dayLab, offset: -2)
distanceLab.layoutChain
.leftToRightOfView(yearMonthLab, offset: 8)
.bottomToView(yearMonthLab)
durationLab.layoutChain
.centerY(distanceLab)
.leftToRightOfView(distanceLab, offset: 6)
detailView.layoutChain
.topToBottomOfView(dayLab, offset: 15)
.left(28)
.right(15)
.height(110)
.bottom(15)
headerBgView.layoutChain
.edges(excludingEdge: .bottom)
.height(38)
speedLab.layoutChain
.top(11)
.left(15)
unitLab.layoutChain
.right(10)
.centerY(speedLab)
scoreLab.layoutChain
.rightToLeftOfView(unitLab)
.centerY(speedLab)
scroreTitleLab.layoutChain
.rightToLeftOfView(scoreLab, offset: -3)
.centerY(speedLab)
startTitleLab.layoutChain
.topToBottomOfView(headerBgView, offset: 12)
.left(15)
startLab.layoutChain
.leftToRightOfView(startTitleLab, offset: 2)
.centerY(startTitleLab)
endTitleLab.layoutChain
.topToBottomOfView(startTitleLab, offset: 11)
.leftToView(startTitleLab)
endLab.layoutChain
.leftToRightOfView(endTitleLab, offset: 2)
.centerY(endTitleLab)
lineView.layoutChain
.height(0.5)
.edgesHorzontal(15)
.bottom()
}
lazy var dotView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#16B3FF")
view.cornerRadius = 4
return view
}()
lazy var dayLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 20, weight: .bold)
l.textColor = UIColor(hexStr: "#16B3FF")
return l
}()
lazy var yearMonthLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .bold)
l.textColor = UIColor(hexStr: "#66CDFF")
return l
}()
lazy var distanceLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var durationLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .regular)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var detailView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#F5FBFF")
view.cornerRadius = 10
view.clipsToBounds = true
return view
}()
lazy var headerBgView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#C4E8FF")
return view
}()
lazy var speedLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var scroreTitleLab: UILabel = {
let l = UILabel()
l.text = "安全评分"
l.font = .systemFont(ofSize: 10, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var scoreLab: UILabel = {
let l = UILabel()
l.text = "0"
l.font = .systemFont(ofSize: 24, weight: .medium)
l.textColor = UIColor(hexStr: "#16B3FF")
return l
}()
lazy var unitLab: UILabel = {
let l = UILabel()
l.text = ""
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#16B3FF")
return l
}()
lazy var startTitleLab: UILabel = {
let l = UILabel()
l.text = "起点:"
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#666666")
return l
}()
lazy var startLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var endTitleLab: UILabel = {
let l = UILabel()
l.text = "终点:"
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#666666")
return l
}()
lazy var endLab: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 12, weight: .medium)
l.textColor = UIColor(hexStr: "#333333")
return l
}()
lazy var lineView: UIView = {
let view = UIView()
view.backgroundColor = ThemeManager.shared.color.lineColor
return view
}()
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
setupViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}

View File

@ -0,0 +1,29 @@
//
// ItineraryTraceVC.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import UIKit
class ItineraryTraceVC: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
}
*/
}

View File

@ -0,0 +1,186 @@
//
// ItineraryTraceView.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import UIKit
import RxSwift
import RxCocoa
import AMapNaviKit
class ItineraryTraceView: UIView {
var disposeBag = DisposeBag()
private func setupRx() {
backBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.shared.popOrDismiss()
}).disposed(by: disposeBag)
}
private func setupUI() {
addSubview(mapView)
addSubview(navBgView)
addSubview(navBarView)
navBarView.addSubview(backBtn)
navBarView.addSubview(navTitleLabel)
mapView.layoutChain
.edges()
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.heightToWidth(160/375)
navBarView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
backBtn.layoutChain
.centerY(navTitleLabel)
.left(15)
.width(24).height(24)
navTitleLabel.layoutChain
.top(kStatusBarHeight + 12)
.centerX()
}
// MARK: - Views
lazy var navBgView: UIImageView = {
let iv = UIImageView()
iv.image = UIImage(named: "Common/navBar_bg_2")
iv.contentMode = .scaleAspectFill
return iv
}()
lazy var navBarView: UIView = {
let v = UIView()
v.backgroundColor = .clear
return v
}()
lazy var backBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/back"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 30)
return btn
}()
lazy var navTitleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 18, weight: .medium)
label.textColor = ThemeManager.shared.color.titleAuxColor
label.text = "行程轨迹"
return label
}()
lazy var deleteBtn: UIButton = {
let btn = UIButton()
btn.setImage(UIImage(named: "Common/delete"), for: .normal)
btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 20, bottom: 15, right: 15)
btn.isHidden = true
return btn
}()
lazy var mapView: MAMapView! = {
let mv = MAMapView()
mv.zoomLevel = 14
mv.showsUserLocation = false
mv.showsCompass = false
mv.userTrackingMode = .none
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kNaviHeight) }
return mv
}()
lazy var bottomView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 20
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.addSubview(titleLab)
titleLab.layoutChain
.top(20)
.left(20)
view.addSubview(startTimeLab)
startTimeLab.layoutChain
.topToBottomOfView(titleLab, offset: 8)
.leftToView(titleLab)
let dotView = UIView()
dotView.backgroundColor = UIColor(hexStr: "#16B3FF")
dotView.cornerRadius = 3
view.addSubview(dotView)
dotView.layoutChain
.leftToRightOfView(startTimeLab, offset: 10)
.centerY(startTimeLab)
.width(6)
.heightToWidth(1)
view.addSubview(startAddressLab)
startAddressLab.layoutChain
.centerY(startTimeLab)
.leftToRightOfView(dotView, offset: 12)
view.addSubview(endTimeLab)
endTimeLab.layoutChain
.topToBottomOfView(startTimeLab, offset: 24)
.leftToView(startTimeLab)
return view
}()
/// xxx
lazy var titleLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#0F2846")
label.font = .systemFont(ofSize: 16, weight: .semibold)
return label
}()
lazy var startTimeLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#666666")
label.font = .systemFont(ofSize: 16, weight: .medium)
return label
}()
lazy var startAddressLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#999999")
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
}()
lazy var endTimeLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#666666")
label.font = .systemFont(ofSize: 16, weight: .medium)
return label
}()
lazy var endAddressLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#999999")
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
setupUI()
setupRx()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,254 @@
//
// ScheduleRecordModel.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import ObjectMapper
import RxDataSources
///
struct ScheduleRecordListResponse: BaseModelProtocol {
//
var code: String?
//
var message: String?
//
var date = ""
// ID
var user_id = ""
//
var list: [ScheduleRecordModel] = []
init?(map: Map) {}
mutating func mapping(map: Map) {
code <- map["code"]
message <- map["msg"]
date <- map["date"]
user_id <- map["user_id"]
list <- map["data.trips"]
}
}
struct ScheduleRecordModel: Mappable, Equatable {
var uuid: String = UUID().uuidString
var id: String = ""
///
var start_time: String = ""
///
var end_time: String = ""
///
var distance_km: String = ""
///
var duration_minutes: Int = 0
///
var avg_speed: String = ""
///
var max_speed: Double = 0
///
var score: Int = 0
var start_location: TripLocation?
var end_location: TripLocation?
var start_address: TripAddress?
var end_address: TripAddress?
var trajectory_path: [TrackPoint] = []
var driving_events: [TrackEvent] = []
var stay_points: [StayPoint] = []
var statistics: TripStatistics?
init?(map: Map) {}
mutating func mapping(map: Map) {
id <- map["id"]
start_time <- map["start_time"]
end_time <- map["end_time"]
distance_km <- map["distance_km"]
duration_minutes <- map["duration_minutes"]
avg_speed <- map["avg_speed"]
max_speed <- map["max_speed"]
score <- map["score"]
start_location <- map["start_location"]
end_location <- map["end_location"]
start_address <- map["start_address"]
end_address <- map["end_address"]
trajectory_path <- map["trajectory_path"]
driving_events <- map["driving_events"]
stay_points <- map["stay_points"]
statistics <- map["statistics"]
}
}
extension ScheduleRecordModel: IdentifiableType {
public typealias Identity = String
public var identity: String {
return id
}
}
// MARK: -
struct TripLocation: Mappable, Equatable {
var uuid: String = UUID().uuidString
var lat: Double = 0
var lng: Double = 0
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
}
}
extension TripLocation: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}
struct TripAddress: Mappable, Equatable {
var uuid: String = UUID().uuidString
var formatted_address: String = ""
var country: String = ""
var province: String = ""
var city: String = ""
var district: String = ""
var street: String = ""
init?(map: Map) {}
mutating func mapping(map: Map) {
formatted_address <- map["formatted_address"]
country <- map["country"]
province <- map["province"]
city <- map["city"]
district <- map["district"]
street <- map["street"]
}
}
extension TripAddress: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}
struct TrackPoint: Mappable, Equatable {
var uuid: String = UUID().uuidString
var lat: Double = 0
var lng: Double = 0
var timestamp: Int64 = 0
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
timestamp <- map["timestamp"]
}
}
extension TrackPoint: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}
struct TrackEvent: Mappable, Equatable {
var uuid: String = UUID().uuidString
var type: String = ""
var timestamp: Int64 = 0
var value: Int = 0
init?(map: Map) {}
mutating func mapping(map: Map) {
type <- map["type"]
timestamp <- map["timestamp"]
value <- map["value"]
}
}
extension TrackEvent: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}
struct StayPoint: Mappable, Equatable {
var uuid: String = UUID().uuidString
var lat: Double = 0
var lng: Double = 0
var start_time: Int64 = 0
var end_time: Int64 = 0
var address: String = ""
init?(map: Map) {}
mutating func mapping(map: Map) {
lat <- map["lat"]
lng <- map["lng"]
start_time <- map["start_time"]
end_time <- map["end_time"]
address <- map["address"]
}
}
extension StayPoint: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}
struct TripStatistics: Mappable, Equatable {
var uuid: String = UUID().uuidString
var hard_acceleration: Int = 0
var hard_braking: Int = 0
var sharp_turn: Int = 0
var speeding: Int = 0
var low_speeding: Int = 0
var frequent_lane_change: Int = 0
var signal_loss: Int = 0
var long_driving: Int = 0
init?(map: Map) {}
mutating func mapping(map: Map) {
hard_acceleration <- map["hard_acceleration"]
hard_braking <- map["hard_braking"]
sharp_turn <- map["sharp_turn"]
speeding <- map["speeding"]
low_speeding <- map["low_speeding"]
frequent_lane_change <- map["frequent_lane_change"]
signal_loss <- map["signal_loss"]
long_driving <- map["long_driving"]
}
}
extension TripStatistics: IdentifiableType {
public typealias Identity = String
public var identity: String {
return uuid
}
}

View File

@ -192,6 +192,7 @@ class GroupScheduleView: UIView {
mv.showsUserLocation = false
mv.showsCompass = false
mv.userTrackingMode = .none
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kNaviHeight) }
return mv
}()

View File

@ -54,6 +54,7 @@ class HomeView: UIView {
mv.showsScale = false
mv.isRotateEnabled = false
mv.isRotateCameraEnabled = false
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kStatusBarHeight + 30) }
return mv
}()
#else

View File

@ -87,7 +87,7 @@ class HomeViewController: BaseViewController {
private func startLocationTimer() {
locationTimer?.invalidate()
locationTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
locationTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
guard let self = self, let loc = self.lastLocation else { return }
let coord = loc.coordinate

View File

@ -80,6 +80,11 @@ class SOSView: UIView {
}
})
.disposed(by: disposeBag)
//
addContactView.rx.tapGesture.subscribe(onNext: { _ in
AppRouter.push(Route.emergencyContact)
}).disposed(by: disposeBag)
}
private func setupUI() {

View File

@ -0,0 +1,179 @@
//
// InputEmailPopupView.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import UIKit
import RxSwift
import RxCocoa
class InputEmailPopupView: UIView {
private static let shared = InputEmailPopupView(frame: CGRect(origin: .zero, size: kScreenSize))
var disposeBag = DisposeBag()
private var completion: ((String) -> Void)?
static func show(completion: @escaping ((String) -> Void)) {
guard let superView = kKeyWindow else {
return
}
if InputEmailPopupView.shared.superview != nil {
InputEmailPopupView.shared.removeFromSuperview()
InputEmailPopupView.shared.bgView.frame = .zero
}
InputEmailPopupView.shared.bgView.alpha = 1
InputEmailPopupView.shared.bgView.frame = CGRect(x: 0, y: 0, width: kScreenWidth, height: kScreenHeight)
superView.addSubview(InputEmailPopupView.shared)
superView.bringSubviewToFront(InputEmailPopupView.shared)
UIView.animate(withDuration: 0.25) {
InputEmailPopupView.shared.bgView.alpha = 1
}
InputEmailPopupView.shared.completion = { text in
completion(text)
InputEmailPopupView.dismiss()
}
}
///
static func dismiss() {
guard InputEmailPopupView.shared.superview != nil else { return }
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) {
InputEmailPopupView.shared.bgView.alpha = 0
} completion: { _ in
InputEmailPopupView.shared.removeFromSuperview()
}
}
private func setupRx() {
emailTF.rx.controlEvent(.editingDidEndOnExit)
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
self.emailTF.resignFirstResponder()
})
.disposed(by: disposeBag)
confirmBtn.rx.tap.subscribe(onNext: { _ in
guard let text = self.emailTF.text, String.checkEmailAddressIsValid(text) else {
DLToast.show(text: "请输入正确的邮箱")
return
}
self.completion?(text)
}).disposed(by: disposeBag)
cancelBtn.rx.tap.subscribe(onNext: { _ in
InputEmailPopupView.dismiss()
}).disposed(by: disposeBag)
}
private lazy var bgView: UIView = {
let view = UIView()
view.backgroundColor = .black.withAlphaComponent(0.5)
return view
}()
lazy var infoView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.cornerRadius = 15
return view
}()
lazy var titleLab: UILabel = {
let label = UILabel()
label.text = "添加邮箱"
label.textColor = UIColor(hexStr: "#333333")
label.font = .systemFont(ofSize: 20, weight: .medium)
return label
}()
lazy var textFieldView: UIView = {
let view = UIView()
view.cornerRadius = 10
view.backgroundColor = UIColor(hexStr: "#F5FBFF")
return view
}()
lazy var emailTF: UITextField = {
let textField = UITextField()
textField.font = .systemFont(ofSize: 16, weight: .medium)
textField.placeholder = "请添加邮箱地址"
textField.returnKeyType = .done
return textField
}()
lazy var confirmBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("确定", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
btn.cornerRadius = 25
return btn
}()
lazy var cancelBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("取消", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
btn.backgroundColor = .clear
return btn
}()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
addSubview(bgView)
bgView.addSubview(infoView)
infoView.addSubview(titleLab)
infoView.addSubview(textFieldView)
textFieldView.addSubview(emailTF)
infoView.addSubview(confirmBtn)
infoView.addSubview(cancelBtn)
infoView.layoutChain
.edgesHorzontal(30)
.heightToWidth(296/315)
.centerY()
titleLab.layoutChain
.top(24)
.centerX()
textFieldView.layoutChain
.topToBottomOfView(titleLab, offset: 28)
.edgesHorzontal(20)
.height(62)
emailTF.layoutChain
.edgesHorzontal(15)
.edgesVertical()
confirmBtn.layoutChain
.topToBottomOfView(textFieldView, offset: 46)
.edgesHorzontal(20)
.height(50)
cancelBtn.layoutChain
.topToBottomOfView(confirmBtn)
.edgesHorzontal(20)
.height(50)
setupRx()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -46,6 +46,12 @@ class SignInVC: BaseViewController {
}
}
}).disposed(by: disposeBag)
rootView.emailView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
InputEmailPopupView.show { email in
self?.requestSetEmail(email: email)
}
}).disposed(by: disposeBag)
}
// MARK: - API
@ -58,6 +64,13 @@ class SignInVC: BaseViewController {
}, onError: { _ in }).disposed(by: disposeBag)
}
private func requestSetEmail(email: String) {
DLToast.showLoading()
UserService.setEmail(email: email).subscribe(onNext: { response in
self.rootView.emailLab.text = email
}).disposed(by: disposeBag)
}
init(lastLocation: CLLocation?) {
self.lastLocation = lastLocation
super.init(nibName: nil, bundle: nil)

View File

@ -16,12 +16,19 @@ class SignInView: UIView {
var disposeBag = DisposeBag()
func setupData(_ model: SignInModel) {
emailLab.text = model.email
signInTextImg.image = UIImage(named: model.signInStatus == 1 ? "SignIn/today_text" : "SignIn/signIn_text")
let text = "已连续签到\(model.signCount)"
let fileName = model.signInStatus == 2 ? "sign_in_un_continuous_data" : "sign_in_continuous_data"
if let path = Bundle.main.path(forResource: fileName, ofType: "json") {
self.signInLottieView.animation = LottieAnimation.filepath(path)
self.signInLottieView.play()
}
let dayText = model.signInStatus == 2 ? model.missCount.string : model.signCount.string
let text = model.signInStatus == 2 ? "已有\(model.missCount)天未签到" : "已连续签到\(model.signCount)"
let attr = NSMutableAttributedString(string: text)
let range = (text as NSString).range(of: model.signCount.string)
let range = (text as NSString).range(of: dayText)
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#FF8B39"), range: range)
signInDaysLab.attributedText = attr
}
@ -168,7 +175,7 @@ class SignInView: UIView {
}()
lazy var signInLottieView: LottieAnimationView = {
let view = LottieAnimationView(name: "sign_in_continuous_data")
let view = LottieAnimationView()
view.loopMode = .loop
return view
}()
@ -223,8 +230,8 @@ class SignInView: UIView {
textView.backgroundColor = .clear
textView.isEditable = false
textView.isScrollEnabled = false
textView.isSelectable = false
textView.linkTextAttributes = [:]
textView.isSelectable = true
textView.linkTextAttributes = [.foregroundColor: UIColor(hexStr: "#16B3FF")]
textView.delegate = self
let text = "签到即同意 用户协议 和 隐私政策"
@ -247,8 +254,6 @@ class SignInView: UIView {
backgroundColor = .white
setupUI()
setupRx()
signInLottieView.play()
}
required init?(coder aDecoder: NSCoder) {

View File

@ -217,6 +217,7 @@ class CreateScheduleView: UIView {
mv.showsUserLocation = false
mv.showsCompass = false
mv.userTrackingMode = .none
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kNaviHeight) }
return mv
}()
#endif

View File

@ -85,6 +85,7 @@ class ItineraryDetailView: UIView {
mv.showsUserLocation = false
mv.showsCompass = false
mv.userTrackingMode = .none
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: kNaviHeight) }
return mv
}()

View File

@ -81,6 +81,7 @@ class LocationPickerView: UIView {
mv.zoomLevel = 18
mv.showsUserLocation = false
mv.showsCompass = false
DispatchQueue.main.async { mv.logoCenter = CGPoint(x: mv.bounds.width - 55, y: 25) }
return mv
}()
#endif

View File

@ -0,0 +1,36 @@
//
// DrivingService.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import RxSwift
import Moya
struct DrivingService {
static let disposeBag = DisposeBag()
///
/// - Parameters:
/// - user_id: id
/// - start_time:
/// - end_time:
static func drivingEvents(user_id: String, start_time: String, end_time: String) -> Observable<DrivingStatsResponse> {
let api = DrivingAPI.drivingEvents(user_id: user_id, start_time: start_time, end_time: end_time).multiTarget
return APIProvider.request(token: api)
.map(DrivingStatsResponse.self)
.asObservable()
}
///
/// - Parameters:
/// - user_id: id
/// - date:
static func playback(user_id: String, date: String) -> Observable<ScheduleRecordListResponse> {
let api = DrivingAPI.playback(user_id: user_id, date: date).multiTarget
return APIProvider.request(token: api)
.map(ScheduleRecordListResponse.self)
.asObservable()
}
}

View File

@ -151,4 +151,12 @@ struct UserService {
.map(ResponseModel.self)
.asObservable()
}
///
static func setEmail(email: String) -> Observable<ResponseModel> {
let api = UserAPI.setEmail(email: email).multiTarget
return APIProvider.request(token: api)
.map(ResponseModel.self)
.asObservable()
}
}