- 手机报告二级页面
|
|
@ -302,6 +302,12 @@
|
||||||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; };
|
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; };
|
||||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; };
|
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; };
|
||||||
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; };
|
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; };
|
||||||
|
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; };
|
||||||
|
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.swift */; };
|
||||||
|
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */; };
|
||||||
|
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; };
|
||||||
|
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; };
|
||||||
|
55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */; };
|
||||||
55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; };
|
55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; };
|
||||||
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
||||||
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; };
|
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; };
|
||||||
|
|
@ -618,6 +624,12 @@
|
||||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = "<group>"; };
|
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = "<group>"; };
|
||||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = "<group>"; };
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = "<group>"; };
|
||||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = "<group>"; };
|
55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = "<group>"; };
|
||||||
|
55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = "<group>"; };
|
||||||
|
55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = "<group>"; };
|
||||||
|
55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = "<group>"; };
|
55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = "<group>"; };
|
||||||
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
|
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
|
||||||
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -1032,6 +1044,8 @@
|
||||||
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
||||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
||||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||||
|
55B217DD302301000078473C /* TodayTrackDetail */,
|
||||||
|
55B217DE302301000078473D /* PhoneReportDetail */,
|
||||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||||
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
||||||
|
|
@ -1703,6 +1717,26 @@
|
||||||
path = Unlock;
|
path = Unlock;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
55B217DD302301000078473C /* TodayTrackDetail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
55B217D13023010000784730 /* TodayTrackDetailVC.swift */,
|
||||||
|
55B217D33023010000784732 /* TodayTrackDetailView.swift */,
|
||||||
|
55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = TodayTrackDetail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
55B217DE302301000078473D /* PhoneReportDetail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
55B217D73023010000784736 /* PhoneReportDetailVC.swift */,
|
||||||
|
55B217D93023010000784738 /* PhoneReportDetailView.swift */,
|
||||||
|
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = PhoneReportDetail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B07D45692FCECE07570D9B73 /* Pods */ = {
|
B07D45692FCECE07570D9B73 /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -1983,6 +2017,12 @@
|
||||||
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
||||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
||||||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */,
|
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */,
|
||||||
|
55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */,
|
||||||
|
55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */,
|
||||||
|
55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */,
|
||||||
|
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */,
|
||||||
|
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */,
|
||||||
|
55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */,
|
||||||
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
||||||
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
|
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
|
||||||
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,
|
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "phone_brightness@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "phone_brightness@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "phone_device@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "phone_device@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "phone_volume@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "phone_volume@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 921 B |
BIN
QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "phone_wifi@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "phone_wifi@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -75,6 +75,10 @@ enum Route: String {
|
||||||
case groupMemberList = "groupMemberList"
|
case groupMemberList = "groupMemberList"
|
||||||
/// 行程轨迹
|
/// 行程轨迹
|
||||||
case itineraryTrace = "itineraryTrace"
|
case itineraryTrace = "itineraryTrace"
|
||||||
|
/// 今日轨迹详情
|
||||||
|
case todayTrackDetail = "todayTrackDetail"
|
||||||
|
/// 手机报告详情
|
||||||
|
case phoneReportDetail = "phoneReportDetail"
|
||||||
/// 充值优惠弹窗
|
/// 充值优惠弹窗
|
||||||
case promotionalActivities = "promotionalActivities"
|
case promotionalActivities = "promotionalActivities"
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +352,16 @@ extension AppRouter: AppRouterProtocol {
|
||||||
ItineraryTraceVC(scheduleJson: parameters["model"].safeDictionary as! [String : Any],
|
ItineraryTraceVC(scheduleJson: parameters["model"].safeDictionary as! [String : Any],
|
||||||
memberInfo: parameters["memberInfo"].safeDictionary as! [String : Any])
|
memberInfo: parameters["memberInfo"].safeDictionary as! [String : Any])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 今日轨迹详情
|
||||||
|
AppRouter.register(Route.todayTrackDetail) { url, parameters in
|
||||||
|
TodayTrackDetailVC(userInfo: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 手机报告详情
|
||||||
|
AppRouter.register(Route.phoneReportDetail) { url, parameters in
|
||||||
|
PhoneReportDetailVC(userInfo: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 充值优惠弹窗
|
// MARK: - 充值优惠弹窗
|
||||||
AppRouter.register(Route.promotionalActivities) { url, parameters in
|
AppRouter.register(Route.promotionalActivities) { url, parameters in
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,7 @@
|
||||||
<array>
|
<array>
|
||||||
<string>applinks:smartdrive.zuom8.cn</string>
|
<string>applinks:smartdrive.zuom8.cn</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.networking.wifi-info</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import AMapNaviKit
|
||||||
import CocoaMQTT
|
import CocoaMQTT
|
||||||
import Lottie
|
import Lottie
|
||||||
import MarqueeLabel
|
import MarqueeLabel
|
||||||
|
import ObjectMapper
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
class HomeViewController: BaseViewController {
|
class HomeViewController: BaseViewController {
|
||||||
|
|
@ -205,6 +206,16 @@ class HomeViewController: BaseViewController {
|
||||||
rootView.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in
|
rootView.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in
|
||||||
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo])
|
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo])
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
|
// 今日轨迹二级页
|
||||||
|
rootView.groupMemberView.todayTrackView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.openTodayTrackDetail()
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
|
// 手机报告二级页
|
||||||
|
rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.openPhoneReportDetail()
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
// 地图回到自己
|
// 地图回到自己
|
||||||
rootView.locationView.rx.tapGesture.subscribe { _ in
|
rootView.locationView.rx.tapGesture.subscribe { _ in
|
||||||
|
|
@ -564,6 +575,27 @@ class HomeViewController: BaseViewController {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openTodayTrackDetail() {
|
||||||
|
let members = viewModel.memberList.map { $0.toJSON() }
|
||||||
|
let userInfo: [String: Any] = [
|
||||||
|
"members": members,
|
||||||
|
"userId": selectedMemberId
|
||||||
|
]
|
||||||
|
AppRouter.push(Route.todayTrackDetail, userInfo: userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openPhoneReportDetail() {
|
||||||
|
let members = viewModel.memberList.map { $0.toJSON() }
|
||||||
|
var userInfo: [String: Any] = [
|
||||||
|
"members": members,
|
||||||
|
"userId": selectedMemberId
|
||||||
|
]
|
||||||
|
if let group = viewModel.groupModel {
|
||||||
|
userInfo["groupJson"] = group.toJSON()
|
||||||
|
}
|
||||||
|
AppRouter.push(Route.phoneReportDetail, userInfo: userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
/// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板
|
/// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板
|
||||||
private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) {
|
private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) {
|
||||||
let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId
|
let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
//
|
||||||
|
// MemberAppUsageView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct MemberAppUsageItemPreview {
|
||||||
|
let rank: Int
|
||||||
|
let name: String
|
||||||
|
let tintHex: String
|
||||||
|
let durationText: String
|
||||||
|
let progress: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MemberAppUsagePreview {
|
||||||
|
let totalTimeText: String
|
||||||
|
let segmentRatios: [CGFloat]
|
||||||
|
let segmentColors: [UIColor]
|
||||||
|
let topApps: [MemberAppUsageItemPreview]
|
||||||
|
|
||||||
|
static let mock = MemberAppUsagePreview(
|
||||||
|
totalTimeText: "24h 60m",
|
||||||
|
segmentRatios: [0.35, 0.28, 0.18, 0.19],
|
||||||
|
segmentColors: [
|
||||||
|
UIColor(hexStr: "#FF8FB1"),
|
||||||
|
UIColor(hexStr: "#FFD36E"),
|
||||||
|
UIColor(hexStr: "#7BE495"),
|
||||||
|
UIColor(hexStr: "#6EC1FF")
|
||||||
|
],
|
||||||
|
topApps: [
|
||||||
|
.init(rank: 1, name: "微信", tintHex: "#07C160", durationText: "1h 45m", progress: 1.0),
|
||||||
|
.init(rank: 2, name: "抖音", tintHex: "#111111", durationText: "1h 25m", progress: 0.76),
|
||||||
|
.init(rank: 3, name: "小红书", tintHex: "#FF2442", durationText: "45m", progress: 0.43),
|
||||||
|
.init(rank: 4, name: "美团", tintHex: "#FFC300", durationText: "30m", progress: 0.29),
|
||||||
|
.init(rank: 5, name: "Safari", tintHex: "#007AFF", durationText: "18m", progress: 0.17)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成员面板「APP使用记录」:环形图 + TOP5
|
||||||
|
final class MemberAppUsageView: UIView {
|
||||||
|
|
||||||
|
private let titleColor = UIColor(hexStr: "#293445")
|
||||||
|
|
||||||
|
private let sectionIcon = UIImageView()
|
||||||
|
private let sectionTitleLab = UILabel()
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let donutView = AppUsageDonutView()
|
||||||
|
private let totalTimeLab = UILabel()
|
||||||
|
private let totalHintLab = UILabel()
|
||||||
|
private let topTitleLab = UILabel()
|
||||||
|
private let topStack = UIStackView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
configure(with: .mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(with preview: MemberAppUsagePreview?) {
|
||||||
|
topStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
guard let preview else {
|
||||||
|
totalTimeLab.text = "--"
|
||||||
|
donutView.setSegments(ratios: [], colors: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTimeLab.text = preview.totalTimeText
|
||||||
|
donutView.setSegments(ratios: preview.segmentRatios, colors: preview.segmentColors)
|
||||||
|
|
||||||
|
for item in preview.topApps {
|
||||||
|
let row = AppUsageTopRowView()
|
||||||
|
row.configure(with: item)
|
||||||
|
topStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
sectionIcon.image = UIImage(named: "Home/member_app_usage")
|
||||||
|
sectionIcon.contentMode = .scaleAspectFit
|
||||||
|
addSubview(sectionIcon)
|
||||||
|
sectionIcon.layoutChain
|
||||||
|
.top()
|
||||||
|
.left(15)
|
||||||
|
.width(18)
|
||||||
|
.heightToWidth(1)
|
||||||
|
|
||||||
|
sectionTitleLab.text = "APP使用记录"
|
||||||
|
sectionTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
sectionTitleLab.textColor = titleColor
|
||||||
|
addSubview(sectionTitleLab)
|
||||||
|
sectionTitleLab.layoutChain
|
||||||
|
.leftToRightOfView(sectionIcon, offset: 6)
|
||||||
|
.centerY(sectionIcon)
|
||||||
|
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.cornerRadius = 16
|
||||||
|
addSubview(cardView)
|
||||||
|
cardView.layoutChain
|
||||||
|
.topToBottomOfView(sectionIcon, offset: 12)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.height(220)
|
||||||
|
.bottom()
|
||||||
|
|
||||||
|
cardView.addSubview(donutView)
|
||||||
|
donutView.layoutChain
|
||||||
|
.left(12)
|
||||||
|
.centerY()
|
||||||
|
.width(110)
|
||||||
|
.height(110)
|
||||||
|
|
||||||
|
totalTimeLab.font = FontManager.youSheBiaoTiHei(14)
|
||||||
|
totalTimeLab.textColor = titleColor
|
||||||
|
totalTimeLab.textAlignment = .center
|
||||||
|
cardView.addSubview(totalTimeLab)
|
||||||
|
totalTimeLab.layoutChain
|
||||||
|
.centerX(donutView)
|
||||||
|
.centerY(donutView, offset: -6)
|
||||||
|
|
||||||
|
totalHintLab.text = "今日总计"
|
||||||
|
totalHintLab.font = .systemFont(ofSize: 9, weight: .medium)
|
||||||
|
totalHintLab.textColor = UIColor(hexStr: "#767676")
|
||||||
|
totalHintLab.textAlignment = .center
|
||||||
|
cardView.addSubview(totalHintLab)
|
||||||
|
totalHintLab.layoutChain
|
||||||
|
.centerX(donutView)
|
||||||
|
.topToBottomOfView(totalTimeLab, offset: 2)
|
||||||
|
|
||||||
|
topTitleLab.text = "最爱使用TOP5"
|
||||||
|
topTitleLab.font = .systemFont(ofSize: 12, weight: .bold)
|
||||||
|
topTitleLab.textColor = titleColor
|
||||||
|
cardView.addSubview(topTitleLab)
|
||||||
|
topTitleLab.layoutChain
|
||||||
|
.top(14)
|
||||||
|
.leftToRightOfView(donutView, offset: 16)
|
||||||
|
.right(12)
|
||||||
|
|
||||||
|
topStack.axis = .vertical
|
||||||
|
topStack.spacing = 10
|
||||||
|
topStack.alignment = .fill
|
||||||
|
cardView.addSubview(topStack)
|
||||||
|
topStack.layoutChain
|
||||||
|
.topToBottomOfView(topTitleLab, offset: 10)
|
||||||
|
.leftToRightOfView(donutView, offset: 16)
|
||||||
|
.right(12)
|
||||||
|
.bottom(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 环形图
|
||||||
|
|
||||||
|
private final class AppUsageDonutView: UIView {
|
||||||
|
|
||||||
|
private var segmentLayers: [CAShapeLayer] = []
|
||||||
|
private var ratios: [CGFloat] = []
|
||||||
|
private var colors: [UIColor] = []
|
||||||
|
private let lineWidth: CGFloat = 14
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSegments(ratios: [CGFloat], colors: [UIColor]) {
|
||||||
|
self.ratios = ratios
|
||||||
|
self.colors = colors
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func redraw() {
|
||||||
|
segmentLayers.forEach { $0.removeFromSuperlayer() }
|
||||||
|
segmentLayers.removeAll()
|
||||||
|
guard !ratios.isEmpty, bounds.width > 0 else { return }
|
||||||
|
|
||||||
|
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||||
|
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
|
||||||
|
var startAngle = -CGFloat.pi / 2
|
||||||
|
let total = max(ratios.reduce(0, +), 0.001)
|
||||||
|
|
||||||
|
for (i, ratio) in ratios.enumerated() {
|
||||||
|
let sweep = (ratio / total) * CGFloat.pi * 2
|
||||||
|
guard sweep > 0.001 else { continue }
|
||||||
|
let path = UIBezierPath(arcCenter: center,
|
||||||
|
radius: radius,
|
||||||
|
startAngle: startAngle,
|
||||||
|
endAngle: startAngle + sweep,
|
||||||
|
clockwise: true)
|
||||||
|
let layer = CAShapeLayer()
|
||||||
|
layer.path = path.cgPath
|
||||||
|
layer.strokeColor = colors[min(i, colors.count - 1)].cgColor
|
||||||
|
layer.fillColor = UIColor.clear.cgColor
|
||||||
|
layer.lineWidth = lineWidth
|
||||||
|
layer.lineCap = .butt
|
||||||
|
self.layer.addSublayer(layer)
|
||||||
|
segmentLayers.append(layer)
|
||||||
|
startAngle += sweep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TOP 行
|
||||||
|
|
||||||
|
private final class AppUsageTopRowView: UIView {
|
||||||
|
|
||||||
|
private var progressRatio: CGFloat = 0
|
||||||
|
private var progressWidthConstraint: NSLayoutConstraint?
|
||||||
|
private let rankLab = UILabel()
|
||||||
|
private let iconView = UIView()
|
||||||
|
private let iconLabel = UILabel()
|
||||||
|
private let nameLab = UILabel()
|
||||||
|
private let progressTrack = UIView()
|
||||||
|
private let progressFill = UIView()
|
||||||
|
private let durationLab = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(with item: MemberAppUsageItemPreview) {
|
||||||
|
rankLab.text = "\(item.rank)"
|
||||||
|
nameLab.text = item.name
|
||||||
|
durationLab.text = item.durationText
|
||||||
|
iconView.backgroundColor = UIColor(hexStr: item.tintHex)
|
||||||
|
iconLabel.text = String(item.name.prefix(1))
|
||||||
|
progressRatio = max(0.06, min(1, item.progress))
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
rankLab.font = .systemFont(ofSize: 10, weight: .bold)
|
||||||
|
rankLab.textColor = UIColor(hexStr: "#353B4F")
|
||||||
|
rankLab.textAlignment = .center
|
||||||
|
addSubview(rankLab)
|
||||||
|
rankLab.layoutChain
|
||||||
|
.left()
|
||||||
|
.centerY()
|
||||||
|
.width(14)
|
||||||
|
|
||||||
|
iconView.cornerRadius = 8
|
||||||
|
addSubview(iconView)
|
||||||
|
iconView.layoutChain
|
||||||
|
.leftToRightOfView(rankLab, offset: 4)
|
||||||
|
.centerY()
|
||||||
|
.width(16)
|
||||||
|
.height(16)
|
||||||
|
|
||||||
|
iconLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||||
|
iconLabel.textColor = .white
|
||||||
|
iconLabel.textAlignment = .center
|
||||||
|
iconView.addSubview(iconLabel)
|
||||||
|
iconLabel.layoutChain.centerX().centerY()
|
||||||
|
|
||||||
|
durationLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
|
durationLab.textColor = UIColor(hexStr: "#767676")
|
||||||
|
durationLab.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
addSubview(durationLab)
|
||||||
|
durationLab.layoutChain
|
||||||
|
.right()
|
||||||
|
.centerY()
|
||||||
|
|
||||||
|
nameLab.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
nameLab.textColor = UIColor(hexStr: "#353B4F")
|
||||||
|
addSubview(nameLab)
|
||||||
|
nameLab.layoutChain
|
||||||
|
.leftToRightOfView(iconView, offset: 6)
|
||||||
|
.top()
|
||||||
|
.rightToLeftOfView(durationLab, offset: -6)
|
||||||
|
|
||||||
|
progressTrack.backgroundColor = UIColor(hexStr: "#F0F0F0")
|
||||||
|
progressTrack.cornerRadius = 2
|
||||||
|
addSubview(progressTrack)
|
||||||
|
progressTrack.layoutChain
|
||||||
|
.leftToView(nameLab)
|
||||||
|
.rightToView(durationLab)
|
||||||
|
.topToBottomOfView(nameLab, offset: 4)
|
||||||
|
.height(4)
|
||||||
|
.bottom()
|
||||||
|
|
||||||
|
progressFill.backgroundColor = UIColor(hexStr: "#58EDFF")
|
||||||
|
progressFill.cornerRadius = 2
|
||||||
|
progressTrack.addSubview(progressFill)
|
||||||
|
progressFill.layoutChain
|
||||||
|
.left()
|
||||||
|
.edgesVertical()
|
||||||
|
progressWidthConstraint = progressFill.widthAnchor.constraint(equalTo: progressTrack.widthAnchor, multiplier: 0)
|
||||||
|
progressWidthConstraint?.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
progressWidthConstraint?.isActive = false
|
||||||
|
progressWidthConstraint = progressFill.widthAnchor.constraint(
|
||||||
|
equalTo: progressTrack.widthAnchor,
|
||||||
|
multiplier: progressRatio
|
||||||
|
)
|
||||||
|
progressWidthConstraint?.isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
//
|
||||||
|
// PhoneReportDetailVC.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import RxSwift
|
||||||
|
import RxCocoa
|
||||||
|
import ObjectMapper
|
||||||
|
|
||||||
|
final class PhoneReportDetailVC: BaseViewController {
|
||||||
|
|
||||||
|
private var rootView: PhoneReportDetailView!
|
||||||
|
private let viewModel: PhoneReportDetailViewModel
|
||||||
|
private var groupModel: GroupModel?
|
||||||
|
|
||||||
|
init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) {
|
||||||
|
self.viewModel = PhoneReportDetailViewModel(members: members, selectedUserId: selectedUserId)
|
||||||
|
self.groupModel = groupModel
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(userInfo: [String: Any]) {
|
||||||
|
let rawMembers = userInfo["members"]
|
||||||
|
let membersJson: [[String: Any]]
|
||||||
|
if let arr = rawMembers as? [[String: Any]] {
|
||||||
|
membersJson = arr
|
||||||
|
} else if let arr = rawMembers as? [Any] {
|
||||||
|
membersJson = arr.compactMap { $0 as? [String: Any] }
|
||||||
|
} else {
|
||||||
|
membersJson = []
|
||||||
|
}
|
||||||
|
let members = membersJson.compactMap { GroupMemberModel(JSON: $0) }
|
||||||
|
let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? ""
|
||||||
|
let group = (userInfo["groupJson"] as? [String: Any]).flatMap { GroupModel(JSON: $0) }
|
||||||
|
self.init(members: members, selectedUserId: selected, groupModel: group)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
rootView = PhoneReportDetailView(frame: UIScreen.main.bounds)
|
||||||
|
view = rootView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
rootView.memberCV.dataSource = self
|
||||||
|
rootView.memberCV.delegate = self
|
||||||
|
bind()
|
||||||
|
rootView.memberCV.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
rootView.navView.onBackTapped = { [weak self] in
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootView.switchGroupBtn.rx.tap
|
||||||
|
.subscribe(onNext: { [weak self] in
|
||||||
|
self?.switchGroup()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.selectedMemberId
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.rootView.memberCV.reloadData()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.snapshot
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] snap in
|
||||||
|
self?.rootView.apply(snap)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
rootView.screenSection.onModeChanged = { [weak self] mode in
|
||||||
|
self?.viewModel.screenMode.accept(mode)
|
||||||
|
}
|
||||||
|
rootView.unlockSection.onModeChanged = { [weak self] mode in
|
||||||
|
self?.viewModel.unlockMode.accept(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func switchGroup() {
|
||||||
|
guard let groupModel else { return }
|
||||||
|
GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in
|
||||||
|
guard let self, let key = groupKey else { return }
|
||||||
|
GroupService.operate(opType: "setdefault", requestData: ["group_key": key])
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil)
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
})
|
||||||
|
.disposed(by: self.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PhoneReportDetailVC: UICollectionViewDataSource, UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
viewModel.members.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
|
let model = viewModel.members[indexPath.item]
|
||||||
|
let selected = model.user_id == viewModel.selectedMemberId.value
|
||||||
|
let isMe = model.user_id == AppContextManager.shared.userId
|
||||||
|
cell.configure(model: model, isCurrentUser: isMe, isSelected: selected)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let model = viewModel.members[indexPath.item]
|
||||||
|
viewModel.selectedMemberId.accept(model.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,497 @@
|
||||||
|
//
|
||||||
|
// PhoneReportDetailView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class PhoneReportDetailView: UIView {
|
||||||
|
|
||||||
|
lazy var navView: BaseNavigationView = {
|
||||||
|
let nav = BaseNavigationView(title: "手机报告")
|
||||||
|
return nav
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var navBgView: UIImageView = {
|
||||||
|
let iv = UIImageView()
|
||||||
|
iv.image = UIImage(named: "Common/navBar_bg_2")
|
||||||
|
iv.contentMode = .scaleAspectFill
|
||||||
|
return iv
|
||||||
|
}()
|
||||||
|
|
||||||
|
let switchGroupBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.backgroundColor = .white.withAlphaComponent(0.8)
|
||||||
|
btn.setTitle(" 切换圈子 ", for: .normal)
|
||||||
|
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal)
|
||||||
|
btn.cornerRadius = 8
|
||||||
|
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 15, right: 18)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var memberCV: UICollectionView = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.itemSize = CGSize(width: 61, height: 90)
|
||||||
|
layout.minimumLineSpacing = 12
|
||||||
|
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
cv.backgroundColor = .white
|
||||||
|
cv.showsHorizontalScrollIndicator = false
|
||||||
|
cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||||
|
cv.register(GroupMemberListCell.self)
|
||||||
|
cv.applyPhoneReportCardStyle(cornerRadius: 20)
|
||||||
|
return cv
|
||||||
|
}()
|
||||||
|
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
let contentView = UIView()
|
||||||
|
|
||||||
|
// MARK: Status
|
||||||
|
let statusTitleLab = UILabel()
|
||||||
|
let statusCard = UIView()
|
||||||
|
let batteryRing = PhoneBatteryRingView()
|
||||||
|
let estimateLab = UILabel()
|
||||||
|
let brightnessCard = PhoneStatusMiniCard(iconName: "Home/phone_brightness", title: "亮度")
|
||||||
|
let volumeCard = PhoneStatusMiniCard(iconName: "Home/phone_volume", title: "音量")
|
||||||
|
let wifiCard = PhoneStatusMiniCard(iconName: "Home/phone_wifi", title: "WIFI")
|
||||||
|
let deviceCard = UIView()
|
||||||
|
let deviceNameLab = UILabel()
|
||||||
|
let storageLab = UILabel()
|
||||||
|
let storageBar = UIProgressView(progressViewStyle: .default)
|
||||||
|
let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device"))
|
||||||
|
|
||||||
|
// MARK: Charts
|
||||||
|
let screenSection = PhoneReportChartSectionView(
|
||||||
|
title: "屏幕使用时间",
|
||||||
|
summaryPrefix: "今日使用时长",
|
||||||
|
barColor: UIColor(hexStr: "#5EC8FF"),
|
||||||
|
highlightColor: UIColor(hexStr: "#2F9BFF")
|
||||||
|
)
|
||||||
|
let unlockSection = PhoneReportChartSectionView(
|
||||||
|
title: "手机使用次数",
|
||||||
|
summaryPrefix: "今日使用次数",
|
||||||
|
barColor: UIColor(hexStr: "#B7E86A"),
|
||||||
|
highlightColor: UIColor(hexStr: "#83DE00"),
|
||||||
|
valueSuffix: "次"
|
||||||
|
)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
addSubview(navBgView)
|
||||||
|
addSubview(navView)
|
||||||
|
|
||||||
|
navBgView.layoutChain
|
||||||
|
.edges(excludingEdge: .bottom)
|
||||||
|
.heightToWidth(160/375)
|
||||||
|
|
||||||
|
navView.layoutChain.top().edgesHorzontal().height(kNaviHeight)
|
||||||
|
navView.addRightButton(switchGroupBtn)
|
||||||
|
|
||||||
|
addSubview(memberCV)
|
||||||
|
memberCV.layoutChain
|
||||||
|
.topToBottomOfView(navView, offset: 8)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.height(100)
|
||||||
|
|
||||||
|
addSubview(scrollView)
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.layoutChain
|
||||||
|
.topToBottomOfView(memberCV, offset: 12)
|
||||||
|
.edgesHorzontal()
|
||||||
|
.bottom()
|
||||||
|
scrollView.addSubview(contentView)
|
||||||
|
contentView.layoutChain.edges().widthToView(scrollView)
|
||||||
|
|
||||||
|
statusTitleLab.text = "手机状态"
|
||||||
|
statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
statusTitleLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
contentView.addSubview(statusTitleLab)
|
||||||
|
statusTitleLab.layoutChain.top().left(15)
|
||||||
|
|
||||||
|
statusCard.backgroundColor = .white
|
||||||
|
statusCard.applyPhoneReportCardStyle(cornerRadius: 20)
|
||||||
|
contentView.addSubview(statusCard)
|
||||||
|
statusCard.layoutChain
|
||||||
|
.topToBottomOfView(statusTitleLab, offset: 10)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.height(175)
|
||||||
|
|
||||||
|
let leftPanel = UIView()
|
||||||
|
leftPanel.backgroundColor = .clear
|
||||||
|
leftPanel.cornerRadius = 14
|
||||||
|
statusCard.addSubview(leftPanel)
|
||||||
|
leftPanel.layoutChain
|
||||||
|
.edges(excludingEdge: .right)
|
||||||
|
.width(136)
|
||||||
|
|
||||||
|
leftPanel.addSubview(batteryRing)
|
||||||
|
batteryRing.layoutChain.top(23).centerX().width(100).height(100)
|
||||||
|
|
||||||
|
estimateLab.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
estimateLab.textColor = UIColor(hexStr: "#6B7280")
|
||||||
|
estimateLab.textAlignment = .center
|
||||||
|
estimateLab.backgroundColor = UIColor(hexStr: "#E8EEF5")
|
||||||
|
estimateLab.cornerRadius = 10
|
||||||
|
leftPanel.addSubview(estimateLab)
|
||||||
|
estimateLab.layoutChain
|
||||||
|
.topToBottomOfView(batteryRing, offset: 12)
|
||||||
|
.centerX()
|
||||||
|
.height(22)
|
||||||
|
.width(120)
|
||||||
|
|
||||||
|
let rightStack = UIStackView(arrangedSubviews: [brightnessCard, volumeCard, wifiCard])
|
||||||
|
rightStack.axis = .horizontal
|
||||||
|
rightStack.distribution = .fillEqually
|
||||||
|
rightStack.spacing = 8
|
||||||
|
statusCard.addSubview(rightStack)
|
||||||
|
rightStack.layoutChain
|
||||||
|
.top(12)
|
||||||
|
.leftToRightOfView(leftPanel, offset: 10)
|
||||||
|
.right(12)
|
||||||
|
.height(64)
|
||||||
|
|
||||||
|
deviceCard.backgroundColor = UIColor(hexStr: "#F7FBFF")
|
||||||
|
deviceCard.cornerRadius = 12
|
||||||
|
statusCard.addSubview(deviceCard)
|
||||||
|
deviceCard.layoutChain
|
||||||
|
.topToBottomOfView(rightStack, offset: 8)
|
||||||
|
.leftToView(rightStack)
|
||||||
|
.right(12)
|
||||||
|
.bottom(12)
|
||||||
|
|
||||||
|
deviceNameLab.font = .systemFont(ofSize: 13, weight: .bold)
|
||||||
|
deviceNameLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
deviceCard.addSubview(deviceNameLab)
|
||||||
|
deviceNameLab.layoutChain.top(10).left(10).right(60)
|
||||||
|
|
||||||
|
storageLab.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
storageLab.textColor = UIColor(hexStr: "#6B7280")
|
||||||
|
deviceCard.addSubview(storageLab)
|
||||||
|
storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10)
|
||||||
|
|
||||||
|
storageBar.progressTintColor = UIColor(hexStr: "#5EC8FF")
|
||||||
|
storageBar.trackTintColor = UIColor(hexStr: "#E5E7EB")
|
||||||
|
deviceCard.addSubview(storageBar)
|
||||||
|
storageBar.layoutChain
|
||||||
|
.topToBottomOfView(storageLab, offset: 6)
|
||||||
|
.left(10)
|
||||||
|
.right(60)
|
||||||
|
.height(4)
|
||||||
|
.bottom(10)
|
||||||
|
|
||||||
|
deviceImage.contentMode = .scaleAspectFit
|
||||||
|
deviceCard.addSubview(deviceImage)
|
||||||
|
deviceImage.layoutChain.right(8).centerY().width(36).height(54)
|
||||||
|
|
||||||
|
contentView.addSubview(screenSection)
|
||||||
|
screenSection.layoutChain
|
||||||
|
.topToBottomOfView(statusCard, offset: 16)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
|
||||||
|
contentView.addSubview(unlockSection)
|
||||||
|
unlockSection.layoutChain
|
||||||
|
.topToBottomOfView(screenSection, offset: 16)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.bottom(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) {
|
||||||
|
batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText)
|
||||||
|
estimateLab.text = "预计使用\(snap.estimateText)"
|
||||||
|
brightnessCard.setValue(snap.brightnessText)
|
||||||
|
volumeCard.setValue(snap.volumeText)
|
||||||
|
wifiCard.setValue(snap.wifiText)
|
||||||
|
deviceNameLab.text = snap.deviceText
|
||||||
|
storageLab.text = snap.storageText
|
||||||
|
storageBar.progress = Float(snap.storageRatio)
|
||||||
|
screenSection.setSummary("\(screenSection.summaryPrefix) \(snap.screenTodayText)")
|
||||||
|
unlockSection.setSummary("\(unlockSection.summaryPrefix) \(snap.unlockTodayText)次")
|
||||||
|
screenSection.setBars(snap.screenBars)
|
||||||
|
unlockSection.setBars(snap.unlockBars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIView {
|
||||||
|
func applyPhoneReportCardStyle(cornerRadius: CGFloat) {
|
||||||
|
layer.cornerRadius = cornerRadius
|
||||||
|
clipsToBounds = false
|
||||||
|
layer.masksToBounds = false
|
||||||
|
layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.1).cgColor
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
layer.shadowOpacity = 1
|
||||||
|
layer.shadowRadius = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
final class PhoneBatteryRingView: UIView {
|
||||||
|
private let track = CAShapeLayer()
|
||||||
|
private let progress = CAShapeLayer()
|
||||||
|
private let valueLab = UILabel()
|
||||||
|
private let bolt = UIImageView(image: UIImage(systemName: "bolt.fill"))
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
track.fillColor = UIColor.clear.cgColor
|
||||||
|
track.strokeColor = UIColor(hexStr: "#DEFFD8").cgColor
|
||||||
|
track.lineWidth = 11
|
||||||
|
layer.addSublayer(track)
|
||||||
|
progress.fillColor = UIColor.clear.cgColor
|
||||||
|
progress.strokeColor = UIColor(hexStr: "#27DB4B").cgColor
|
||||||
|
progress.lineWidth = 11
|
||||||
|
progress.lineCap = .round
|
||||||
|
progress.strokeEnd = 0
|
||||||
|
layer.addSublayer(progress)
|
||||||
|
|
||||||
|
bolt.tintColor = UIColor(hexStr: "#27DB4B")
|
||||||
|
bolt.contentMode = .scaleAspectFit
|
||||||
|
addSubview(bolt)
|
||||||
|
valueLab.font = .systemFont(ofSize: 16, weight: .heavy)
|
||||||
|
valueLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
valueLab.textAlignment = .center
|
||||||
|
addSubview(valueLab)
|
||||||
|
valueLab.layoutChain.centerX().centerY()
|
||||||
|
bolt.layoutChain.centerY(valueLab).rightToLeftOfView(valueLab)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||||
|
let radius = min(bounds.width, bounds.height) / 2 - 6
|
||||||
|
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: true)
|
||||||
|
track.path = path.cgPath
|
||||||
|
progress.path = path.cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(percent: Int?, text: String) {
|
||||||
|
valueLab.text = text
|
||||||
|
progress.strokeEnd = CGFloat(max(0, min(100, percent ?? 0))) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PhoneStatusMiniCard: UIView {
|
||||||
|
private let icon = UIImageView()
|
||||||
|
private let titleLab = UILabel()
|
||||||
|
private let valueLab = UILabel()
|
||||||
|
|
||||||
|
init(iconName: String, title: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = UIColor(hexStr: "#F7FBFF")
|
||||||
|
cornerRadius = 12
|
||||||
|
icon.image = UIImage(named: iconName)
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
addSubview(icon)
|
||||||
|
icon.layoutChain.top(7).left(5).width(16).height(16)
|
||||||
|
titleLab.text = title
|
||||||
|
titleLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
|
titleLab.textColor = UIColor(hexStr: "#AAAAAA")
|
||||||
|
titleLab.textAlignment = .center
|
||||||
|
addSubview(titleLab)
|
||||||
|
titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5)
|
||||||
|
valueLab.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
valueLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
valueLab.textAlignment = .center
|
||||||
|
addSubview(valueLab)
|
||||||
|
valueLab.layoutChain.topToBottomOfView(icon, offset: 19).edgesHorzontal(2).bottom(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func setValue(_ text: String) {
|
||||||
|
valueLab.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PhoneReportChartSectionView: UIView {
|
||||||
|
let summaryPrefix: String
|
||||||
|
private let titleLab = UILabel()
|
||||||
|
private let summaryLab = UILabel()
|
||||||
|
private let dayBtn = UIButton(type: .system)
|
||||||
|
private let weekBtn = UIButton(type: .system)
|
||||||
|
private let chart = PhoneReportBarChartView()
|
||||||
|
private let barColor: UIColor
|
||||||
|
private let highlightColor: UIColor
|
||||||
|
private let valueSuffix: String
|
||||||
|
|
||||||
|
var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)?
|
||||||
|
|
||||||
|
init(title: String, summaryPrefix: String, barColor: UIColor, highlightColor: UIColor, valueSuffix: String = "") {
|
||||||
|
self.summaryPrefix = summaryPrefix
|
||||||
|
self.barColor = barColor
|
||||||
|
self.highlightColor = highlightColor
|
||||||
|
self.valueSuffix = valueSuffix
|
||||||
|
super.init(frame: .zero)
|
||||||
|
titleLab.text = title
|
||||||
|
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
titleLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
addSubview(titleLab)
|
||||||
|
titleLab.layoutChain.top().left()
|
||||||
|
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = .white
|
||||||
|
card.applyPhoneReportCardStyle(cornerRadius: 20)
|
||||||
|
addSubview(card)
|
||||||
|
card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180)
|
||||||
|
|
||||||
|
summaryLab.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
summaryLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
card.addSubview(summaryLab)
|
||||||
|
summaryLab.layoutChain.top(14).left(14)
|
||||||
|
|
||||||
|
configureToggle(dayBtn, title: "天", selected: true)
|
||||||
|
configureToggle(weekBtn, title: "周", selected: false)
|
||||||
|
let toggle = UIStackView(arrangedSubviews: [weekBtn, dayBtn])
|
||||||
|
toggle.axis = .horizontal
|
||||||
|
toggle.spacing = 0
|
||||||
|
toggle.backgroundColor = UIColor(hexStr: "#F3F4F6")
|
||||||
|
toggle.cornerRadius = 12
|
||||||
|
card.addSubview(toggle)
|
||||||
|
toggle.layoutChain.centerY(summaryLab).right(14).height(28)
|
||||||
|
|
||||||
|
dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside)
|
||||||
|
weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside)
|
||||||
|
|
||||||
|
card.addSubview(chart)
|
||||||
|
chart.layoutChain.topToBottomOfView(summaryLab, offset: 16).edgesHorzontal(14).bottom(14)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
private func configureToggle(_ btn: UIButton, title: String, selected: Bool) {
|
||||||
|
btn.setTitle(title, for: .normal)
|
||||||
|
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
|
||||||
|
btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||||
|
applyToggle(btn, selected: selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyToggle(_ btn: UIButton, selected: Bool) {
|
||||||
|
btn.backgroundColor = selected ? .white : .clear
|
||||||
|
btn.setTitleColor(selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF"), for: .normal)
|
||||||
|
btn.cornerRadius = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tapDay() {
|
||||||
|
applyToggle(dayBtn, selected: true)
|
||||||
|
applyToggle(weekBtn, selected: false)
|
||||||
|
onModeChanged?(.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tapWeek() {
|
||||||
|
applyToggle(dayBtn, selected: false)
|
||||||
|
applyToggle(weekBtn, selected: true)
|
||||||
|
onModeChanged?(.week)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSummary(_ text: String) {
|
||||||
|
summaryLab.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) {
|
||||||
|
chart.configure(bars: bars, color: barColor, highlight: highlightColor, suffix: valueSuffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PhoneReportBarChartView: UIView {
|
||||||
|
private var bars: [PhoneReportDetailViewModel.ChartBar] = []
|
||||||
|
private var color: UIColor = .systemBlue
|
||||||
|
private var highlight: UIColor = .systemBlue
|
||||||
|
private var suffix = ""
|
||||||
|
private var barLayers: [CALayer] = []
|
||||||
|
private var labelViews: [UILabel] = []
|
||||||
|
private var tipLab = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
tipLab.font = .systemFont(ofSize: 11, weight: .bold)
|
||||||
|
tipLab.textColor = .white
|
||||||
|
tipLab.backgroundColor = UIColor(hexStr: "#2F9BFF")
|
||||||
|
tipLab.textAlignment = .center
|
||||||
|
tipLab.cornerRadius = 8
|
||||||
|
tipLab.clipsToBounds = true
|
||||||
|
tipLab.isHidden = true
|
||||||
|
addSubview(tipLab)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func configure(bars: [PhoneReportDetailViewModel.ChartBar], color: UIColor, highlight: UIColor, suffix: String) {
|
||||||
|
self.bars = bars
|
||||||
|
self.color = color
|
||||||
|
self.highlight = highlight
|
||||||
|
self.suffix = suffix
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
barLayers.forEach { $0.removeFromSuperlayer() }
|
||||||
|
barLayers.removeAll()
|
||||||
|
labelViews.forEach { $0.removeFromSuperview() }
|
||||||
|
labelViews.removeAll()
|
||||||
|
tipLab.isHidden = true
|
||||||
|
guard !bars.isEmpty else { return }
|
||||||
|
|
||||||
|
let maxVal = max(bars.map(\.value).max() ?? 1, 1)
|
||||||
|
let count = bars.count
|
||||||
|
let labelH: CGFloat = 16
|
||||||
|
let chartH = bounds.height - labelH - 24
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let barW = max(8, (bounds.width - spacing * CGFloat(count - 1)) / CGFloat(count))
|
||||||
|
|
||||||
|
for (i, bar) in bars.enumerated() {
|
||||||
|
let ratio = CGFloat(bar.value) / CGFloat(maxVal)
|
||||||
|
let h = max(4, chartH * max(0.05, ratio))
|
||||||
|
let x = CGFloat(i) * (barW + spacing)
|
||||||
|
let y = bounds.height - labelH - h
|
||||||
|
let layer = CALayer()
|
||||||
|
layer.frame = CGRect(x: x, y: y, width: barW, height: h)
|
||||||
|
layer.backgroundColor = (bar.isToday ? highlight : color.withAlphaComponent(0.35)).cgColor
|
||||||
|
layer.cornerRadius = 4
|
||||||
|
self.layer.addSublayer(layer)
|
||||||
|
barLayers.append(layer)
|
||||||
|
|
||||||
|
let lab = UILabel()
|
||||||
|
lab.text = bar.label
|
||||||
|
lab.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
|
lab.textColor = UIColor(hexStr: "#9CA3AF")
|
||||||
|
lab.textAlignment = .center
|
||||||
|
lab.frame = CGRect(x: x - 6, y: bounds.height - labelH, width: barW + 12, height: labelH)
|
||||||
|
addSubview(lab)
|
||||||
|
labelViews.append(lab)
|
||||||
|
|
||||||
|
if bar.isToday, bar.value > 0 {
|
||||||
|
let hours = bar.value / 3600
|
||||||
|
let mins = (bar.value % 3600) / 60
|
||||||
|
let text: String
|
||||||
|
if suffix == "次" {
|
||||||
|
text = "\(bar.value)次"
|
||||||
|
} else if hours > 0 {
|
||||||
|
text = mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h"
|
||||||
|
} else {
|
||||||
|
text = "\(mins)m"
|
||||||
|
}
|
||||||
|
tipLab.text = " \(text) "
|
||||||
|
tipLab.sizeToFit()
|
||||||
|
tipLab.frame = CGRect(
|
||||||
|
x: max(0, x + barW / 2 - tipLab.bounds.width / 2 - 6),
|
||||||
|
y: max(0, y - 22),
|
||||||
|
width: tipLab.bounds.width + 12,
|
||||||
|
height: 18
|
||||||
|
)
|
||||||
|
tipLab.backgroundColor = highlight
|
||||||
|
tipLab.isHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
//
|
||||||
|
// PhoneReportDetailViewModel.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RxSwift
|
||||||
|
import RxCocoa
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
import SystemConfiguration.CaptiveNetwork
|
||||||
|
import NetworkExtension
|
||||||
|
import CoreLocation
|
||||||
|
import SwiftyUserDefaults
|
||||||
|
|
||||||
|
final class PhoneReportDetailViewModel {
|
||||||
|
|
||||||
|
enum ChartMode: Equatable {
|
||||||
|
case day
|
||||||
|
case week
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartBar {
|
||||||
|
let label: String
|
||||||
|
let value: Int
|
||||||
|
let isToday: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusSnapshot {
|
||||||
|
let batteryText: String
|
||||||
|
let batteryPercent: Int?
|
||||||
|
let estimateText: String
|
||||||
|
let brightnessText: String
|
||||||
|
let volumeText: String
|
||||||
|
let wifiText: String
|
||||||
|
let deviceText: String
|
||||||
|
let storageText: String
|
||||||
|
let storageRatio: CGFloat
|
||||||
|
let screenTodayText: String
|
||||||
|
let unlockTodayText: String
|
||||||
|
let usageTodayText: String
|
||||||
|
let screenBars: [ChartBar]
|
||||||
|
let unlockBars: [ChartBar]
|
||||||
|
|
||||||
|
func replacing(wifiText: String? = nil, volumeText: String? = nil) -> StatusSnapshot {
|
||||||
|
StatusSnapshot(
|
||||||
|
batteryText: batteryText,
|
||||||
|
batteryPercent: batteryPercent,
|
||||||
|
estimateText: estimateText,
|
||||||
|
brightnessText: brightnessText,
|
||||||
|
volumeText: volumeText ?? self.volumeText,
|
||||||
|
wifiText: wifiText ?? self.wifiText,
|
||||||
|
deviceText: deviceText,
|
||||||
|
storageText: storageText,
|
||||||
|
storageRatio: storageRatio,
|
||||||
|
screenTodayText: screenTodayText,
|
||||||
|
unlockTodayText: unlockTodayText,
|
||||||
|
usageTodayText: usageTodayText,
|
||||||
|
screenBars: screenBars,
|
||||||
|
unlockBars: unlockBars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let members: [GroupMemberModel]
|
||||||
|
let selectedMemberId: BehaviorRelay<String>
|
||||||
|
let screenMode = BehaviorRelay<ChartMode>(value: .day)
|
||||||
|
let unlockMode = BehaviorRelay<ChartMode>(value: .day)
|
||||||
|
let snapshot: BehaviorRelay<StatusSnapshot>
|
||||||
|
|
||||||
|
private let disposeBag = DisposeBag()
|
||||||
|
private let placeholder = "**"
|
||||||
|
private var volumeObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(members: [GroupMemberModel], selectedUserId: String) {
|
||||||
|
self.members = members
|
||||||
|
self.selectedMemberId = BehaviorRelay(value: selectedUserId)
|
||||||
|
self.snapshot = BehaviorRelay(value: Self.emptySnapshot())
|
||||||
|
bindInputs()
|
||||||
|
refresh(userId: selectedUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindInputs() {
|
||||||
|
selectedMemberId
|
||||||
|
.asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe(onNext: { [weak self] userId in
|
||||||
|
self?.refresh(userId: userId)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
screenMode
|
||||||
|
.asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.refresh(userId: self.selectedMemberId.value)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
unlockMode
|
||||||
|
.asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.refresh(userId: self.selectedMemberId.value)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
NotificationCenter.default.rx.notification(.unlockCountDidChange)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.refresh(userId: self.selectedMemberId.value)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
observeSystemVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeSystemVolume() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setActive(true)
|
||||||
|
volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.selectedMemberId.value == AppContextManager.shared.userId else { return }
|
||||||
|
let volume = Self.volumeText() ?? self.placeholder
|
||||||
|
self.snapshot.accept(self.snapshot.value.replacing(volumeText: volume))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh(userId: String) {
|
||||||
|
let isSelf = userId == AppContextManager.shared.userId
|
||||||
|
let member = members.first { $0.user_id == userId }
|
||||||
|
|
||||||
|
if isSelf {
|
||||||
|
snapshot.accept(makeSelfSnapshot())
|
||||||
|
} else {
|
||||||
|
snapshot.accept(makeOtherSnapshot(member: member))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSelfSnapshot() -> StatusSnapshot {
|
||||||
|
let mgr = UnlockCountManager.shared
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
let batteryLevel = UIDevice.current.batteryLevel
|
||||||
|
let batteryValid = batteryLevel >= 0
|
||||||
|
let battery = Int(batteryLevel * 100)
|
||||||
|
let screenSec = mgr.todayScreenTimeSeconds
|
||||||
|
let unlock = mgr.todayCount
|
||||||
|
let usage = mgr.todayAppUsageCount
|
||||||
|
|
||||||
|
let batteryText = batteryValid ? "\(battery)%" : placeholder
|
||||||
|
let batteryPercent: Int? = batteryValid ? battery : nil
|
||||||
|
let brightnessText = Self.brightnessText() ?? placeholder
|
||||||
|
let volumeText = Self.volumeText() ?? placeholder
|
||||||
|
let wifiText = Self.wifiSSID() ?? placeholder
|
||||||
|
let storageText = Self.storageText() ?? placeholder
|
||||||
|
let storageRatio = Self.storageRatio() ?? 0
|
||||||
|
let screenTodayText = screenSec > 0 ? Self.formatDuration(screenSec) : placeholder
|
||||||
|
let screenBars = Self.screenBars(from: mgr, mode: screenMode.value)
|
||||||
|
let unlockBars = Self.unlockBars(unlock: unlock, mode: unlockMode.value)
|
||||||
|
|
||||||
|
// 异步补拉 SSID(iOS 14+),成功后再刷新一次
|
||||||
|
Self.fetchWifiSSIDAsync { [weak self] ssid in
|
||||||
|
guard let self, let ssid, !ssid.isEmpty else { return }
|
||||||
|
guard self.selectedMemberId.value == AppContextManager.shared.userId else { return }
|
||||||
|
let current = self.snapshot.value
|
||||||
|
guard current.wifiText != ssid else { return }
|
||||||
|
self.snapshot.accept(current.replacing(wifiText: ssid))
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusSnapshot(
|
||||||
|
batteryText: batteryText,
|
||||||
|
batteryPercent: batteryPercent,
|
||||||
|
estimateText: placeholder,
|
||||||
|
brightnessText: brightnessText,
|
||||||
|
volumeText: volumeText,
|
||||||
|
wifiText: wifiText,
|
||||||
|
deviceText: UIDevice.current.model,
|
||||||
|
storageText: storageText,
|
||||||
|
storageRatio: storageRatio,
|
||||||
|
screenTodayText: screenTodayText,
|
||||||
|
unlockTodayText: "\(unlock)",
|
||||||
|
usageTodayText: "\(usage)",
|
||||||
|
screenBars: screenBars,
|
||||||
|
unlockBars: unlockBars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOtherSnapshot(member: GroupMemberModel?) -> StatusSnapshot {
|
||||||
|
let battery = Int(member?.battery.int ?? 0)
|
||||||
|
let hasBattery = battery > 0
|
||||||
|
return StatusSnapshot(
|
||||||
|
batteryText: hasBattery ? "\(battery)%" : placeholder,
|
||||||
|
batteryPercent: hasBattery ? battery : nil,
|
||||||
|
estimateText: placeholder,
|
||||||
|
brightnessText: placeholder,
|
||||||
|
volumeText: placeholder,
|
||||||
|
wifiText: placeholder,
|
||||||
|
deviceText: placeholder,
|
||||||
|
storageText: placeholder,
|
||||||
|
storageRatio: 0,
|
||||||
|
screenTodayText: placeholder,
|
||||||
|
unlockTodayText: placeholder,
|
||||||
|
usageTodayText: placeholder,
|
||||||
|
screenBars: [],
|
||||||
|
unlockBars: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func emptySnapshot() -> StatusSnapshot {
|
||||||
|
StatusSnapshot(
|
||||||
|
batteryText: "**",
|
||||||
|
batteryPercent: nil,
|
||||||
|
estimateText: "**",
|
||||||
|
brightnessText: "**",
|
||||||
|
volumeText: "**",
|
||||||
|
wifiText: "**",
|
||||||
|
deviceText: "**",
|
||||||
|
storageText: "**",
|
||||||
|
storageRatio: 0,
|
||||||
|
screenTodayText: "**",
|
||||||
|
unlockTodayText: "**",
|
||||||
|
usageTodayText: "**",
|
||||||
|
screenBars: [],
|
||||||
|
unlockBars: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatDuration(_ seconds: Int) -> String {
|
||||||
|
let hours = seconds / 3600
|
||||||
|
let minutes = (seconds % 3600) / 60
|
||||||
|
if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" }
|
||||||
|
return "\(minutes)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func brightnessText() -> String? {
|
||||||
|
let value = UIScreen.main.brightness
|
||||||
|
guard value >= 0 else { return nil }
|
||||||
|
return "\(Int(value * 100))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func volumeText() -> String? {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setActive(true)
|
||||||
|
} catch {
|
||||||
|
// 激活失败仍尝试读取当前值
|
||||||
|
}
|
||||||
|
let volume = session.outputVolume
|
||||||
|
guard volume >= 0 else { return nil }
|
||||||
|
return "\(Int((volume * 100).rounded()))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasLocationPermissionForWifi() -> Bool {
|
||||||
|
let status = CLLocationManager.authorizationStatus()
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func wifiSSID() -> String? {
|
||||||
|
guard hasLocationPermissionForWifi() else { return nil }
|
||||||
|
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil }
|
||||||
|
for name in interfaces {
|
||||||
|
if let info = CNCopyCurrentNetworkInfo(name as CFString) as? [String: Any],
|
||||||
|
let ssid = info[kCNNetworkInfoKeySSID as String] as? String,
|
||||||
|
!ssid.isEmpty {
|
||||||
|
return ssid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS 14+ 异步获取当前 Wi‑Fi SSID(需定位权限 + Access Wi‑Fi Information)
|
||||||
|
private static func fetchWifiSSIDAsync(completion: @escaping (String?) -> Void) {
|
||||||
|
guard hasLocationPermissionForWifi() else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
NEHotspotNetwork.fetchCurrent { network in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let ssid = network?.ssid
|
||||||
|
completion((ssid?.isEmpty == false) ? ssid : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completion(wifiSSID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storageText() -> String? {
|
||||||
|
guard
|
||||||
|
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
|
||||||
|
let total = attrs[.systemSize] as? NSNumber,
|
||||||
|
let free = attrs[.systemFreeSize] as? NSNumber
|
||||||
|
else { return nil }
|
||||||
|
let used = total.doubleValue - free.doubleValue
|
||||||
|
let usedGB = used / 1_073_741_824
|
||||||
|
let totalGB = total.doubleValue / 1_073_741_824
|
||||||
|
return String(format: "%.1fGB/%.0fGB", usedGB, totalGB)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storageRatio() -> CGFloat? {
|
||||||
|
guard
|
||||||
|
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
|
||||||
|
let total = attrs[.systemSize] as? NSNumber,
|
||||||
|
let free = attrs[.systemFreeSize] as? NSNumber,
|
||||||
|
total.doubleValue > 0
|
||||||
|
else { return nil }
|
||||||
|
return CGFloat((total.doubleValue - free.doubleValue) / total.doubleValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
let byDay = Defaults[\.screenTimeByDay]
|
||||||
|
|
||||||
|
return (0..<7).map { offset -> ChartBar in
|
||||||
|
let dayOffset = offset - 6
|
||||||
|
let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today
|
||||||
|
let key = formatter.string(from: date)
|
||||||
|
let label = formatter.string(from: date).suffix(5).replacingOccurrences(of: "-", with: "/")
|
||||||
|
let value: Int
|
||||||
|
if dayOffset == 0 {
|
||||||
|
value = mgr.todayScreenTimeSeconds
|
||||||
|
} else {
|
||||||
|
value = byDay[key] ?? 0
|
||||||
|
}
|
||||||
|
// week mode: keep same day bars for now (UI toggle still works)
|
||||||
|
_ = mode
|
||||||
|
return ChartBar(label: String(label), value: value, isToday: dayOffset == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "M/d"
|
||||||
|
return (0..<7).map { offset -> ChartBar in
|
||||||
|
let dayOffset = offset - 6
|
||||||
|
let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today
|
||||||
|
let isToday = dayOffset == 0
|
||||||
|
_ = mode
|
||||||
|
return ChartBar(
|
||||||
|
label: formatter.string(from: date),
|
||||||
|
value: isToday ? unlock : 0,
|
||||||
|
isToday: isToday
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
//
|
||||||
|
// TodayTrackDetailVC.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import RxSwift
|
||||||
|
import RxCocoa
|
||||||
|
import AMapNaviKit
|
||||||
|
import SwiftDate
|
||||||
|
import ObjectMapper
|
||||||
|
|
||||||
|
final class TodayTrackDetailVC: BaseViewController {
|
||||||
|
|
||||||
|
private var rootView: TodayTrackDetailView!
|
||||||
|
private let viewModel: TodayTrackDetailViewModel
|
||||||
|
private var routeOverlays: [MAPolyline] = []
|
||||||
|
private var panStartHeight: CGFloat = 0
|
||||||
|
private var stayList: [StayPoint] = []
|
||||||
|
|
||||||
|
init(members: [GroupMemberModel], selectedUserId: String) {
|
||||||
|
self.viewModel = TodayTrackDetailViewModel(members: members, selectedUserId: selectedUserId)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(userInfo: [String: Any]) {
|
||||||
|
let rawMembers = userInfo["members"]
|
||||||
|
let membersJson: [[String: Any]]
|
||||||
|
if let arr = rawMembers as? [[String: Any]] {
|
||||||
|
membersJson = arr
|
||||||
|
} else if let arr = rawMembers as? [Any] {
|
||||||
|
membersJson = arr.compactMap { $0 as? [String: Any] }
|
||||||
|
} else {
|
||||||
|
membersJson = []
|
||||||
|
}
|
||||||
|
let members = membersJson.compactMap { GroupMemberModel(JSON: $0) }
|
||||||
|
let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? ""
|
||||||
|
self.init(members: members, selectedUserId: selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
rootView = TodayTrackDetailView(frame: UIScreen.main.bounds)
|
||||||
|
view = rootView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
rootView.mapView.delegate = self
|
||||||
|
rootView.memberCV.dataSource = self
|
||||||
|
rootView.memberCV.delegate = self
|
||||||
|
rootView.dateCV.dataSource = self
|
||||||
|
rootView.dateCV.delegate = self
|
||||||
|
rootView.timelineTV.dataSource = self
|
||||||
|
rootView.timelineTV.delegate = self
|
||||||
|
bindActions()
|
||||||
|
bindSheetPan()
|
||||||
|
rootView.memberCV.reloadData()
|
||||||
|
rootView.dateCV.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
if isMovingFromParent || isBeingDismissed {
|
||||||
|
rootView.cleanupMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindActions() {
|
||||||
|
rootView.backBtn.rx.tap
|
||||||
|
.subscribe(onNext: { [weak self] in
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.trajectoryPoints
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] points in
|
||||||
|
self?.drawRoute(points)
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.isEmpty
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] empty in
|
||||||
|
self?.rootView.emptyLab.isHidden = !empty
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.stayPoints
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] stays in
|
||||||
|
self?.stayList = stays
|
||||||
|
self?.rootView.timelineTV.reloadData()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.selectedMemberId
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.rootView.memberCV.reloadData()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
viewModel.selectedDateKey
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.rootView.dateCV.reloadData()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindSheetPan() {
|
||||||
|
let pan = UIPanGestureRecognizer()
|
||||||
|
rootView.sheetView.addGestureRecognizer(pan)
|
||||||
|
pan.rx.event.subscribe(onNext: { [weak self] gesture in
|
||||||
|
guard let self, let constraint = self.rootView.sheetHeightConstraint else { return }
|
||||||
|
let minH: CGFloat = 220 + kSafeBottomMargin
|
||||||
|
let maxH: CGFloat = UIScreen.main.bounds.height * 0.7
|
||||||
|
switch gesture.state {
|
||||||
|
case .began:
|
||||||
|
self.panStartHeight = constraint.constant
|
||||||
|
case .changed:
|
||||||
|
let dy = gesture.translation(in: self.rootView).y
|
||||||
|
constraint.constant = min(maxH, max(minH, self.panStartHeight - dy))
|
||||||
|
case .ended, .cancelled:
|
||||||
|
let mid = (minH + maxH) / 2
|
||||||
|
let target = constraint.constant > mid ? maxH : minH + 100
|
||||||
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
constraint.constant = target
|
||||||
|
self.rootView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawRoute(_ points: [TrackPoint]) {
|
||||||
|
rootView.mapView.removeOverlays(routeOverlays)
|
||||||
|
routeOverlays.removeAll()
|
||||||
|
if let anns = rootView.mapView.annotations {
|
||||||
|
rootView.mapView.removeAnnotations(anns)
|
||||||
|
}
|
||||||
|
|
||||||
|
let coords = points
|
||||||
|
.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lng) }
|
||||||
|
.filter { CLLocationCoordinate2DIsValid($0) }
|
||||||
|
guard coords.count >= 2 else {
|
||||||
|
if let first = coords.first {
|
||||||
|
rootView.mapView.setCenter(first, animated: true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutable = coords
|
||||||
|
if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) {
|
||||||
|
rootView.mapView.add(polyline)
|
||||||
|
routeOverlays.append(polyline)
|
||||||
|
let padding = UIEdgeInsets(top: 160, left: 40, bottom: 360, right: 40)
|
||||||
|
rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true)
|
||||||
|
}
|
||||||
|
if let start = coords.first {
|
||||||
|
let ann = MAPointAnnotation()
|
||||||
|
ann.coordinate = start
|
||||||
|
ann.title = "始"
|
||||||
|
rootView.mapView.addAnnotation(ann)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stayTitle(_ stay: StayPoint, isCurrent: Bool) -> String {
|
||||||
|
let addr = stay.address.isEmpty ? "**" : stay.address
|
||||||
|
if isCurrent {
|
||||||
|
return "当前 \(addr)"
|
||||||
|
}
|
||||||
|
let time = Date(timeIntervalSince1970: TimeInterval(stay.start_time)).toFormat("HH:mm")
|
||||||
|
return "\(time) \(addr)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stayTags(_ stay: StayPoint, isCurrent: Bool) -> [String] {
|
||||||
|
let seconds = max(0, Int(stay.end_time - stay.start_time))
|
||||||
|
let hours = seconds / 3600
|
||||||
|
let minutes = (seconds % 3600) / 60
|
||||||
|
let duration: String
|
||||||
|
if hours > 0 {
|
||||||
|
duration = "停留 \(hours)小时\(minutes)分"
|
||||||
|
} else {
|
||||||
|
duration = "停留 \(minutes)分"
|
||||||
|
}
|
||||||
|
if isCurrent {
|
||||||
|
return [duration, "停留中..."]
|
||||||
|
}
|
||||||
|
return [duration]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection / Table
|
||||||
|
|
||||||
|
extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
if collectionView === rootView.memberCV {
|
||||||
|
return viewModel.members.count
|
||||||
|
}
|
||||||
|
return viewModel.dateItems.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
if collectionView === rootView.memberCV {
|
||||||
|
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
|
let model = viewModel.members[indexPath.item]
|
||||||
|
let selected = model.user_id == viewModel.selectedMemberId.value
|
||||||
|
let isMe = model.user_id == AppContextManager.shared.userId
|
||||||
|
cell.configure(model: model, isCurrentUser: isMe, isSelected: selected)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
let cell: TodayTrackDateCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
|
let item = viewModel.dateItems[indexPath.item]
|
||||||
|
let selected = item.key == viewModel.selectedDateKey.value
|
||||||
|
cell.configure(title: item.title, selected: selected)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
if collectionView === rootView.memberCV {
|
||||||
|
let model = viewModel.members[indexPath.item]
|
||||||
|
viewModel.selectedMemberId.accept(model.user_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let item = viewModel.dateItems[indexPath.item]
|
||||||
|
viewModel.selectedDateKey.accept(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TodayTrackDetailVC: UITableViewDataSource, UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
stayList.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell: TodayTrackStayCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
|
let stay = stayList[indexPath.row]
|
||||||
|
let isFirst = indexPath.row == 0
|
||||||
|
let isLast = indexPath.row == stayList.count - 1
|
||||||
|
cell.configure(
|
||||||
|
title: Self.stayTitle(stay, isCurrent: isFirst),
|
||||||
|
tags: Self.stayTags(stay, isCurrent: isFirst),
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
let stay = stayList[indexPath.row]
|
||||||
|
let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng)
|
||||||
|
guard CLLocationCoordinate2DIsValid(coord) else { return }
|
||||||
|
rootView.mapView.setCenter(coord, animated: true)
|
||||||
|
rootView.mapView.setZoomLevel(16, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TodayTrackDetailVC: MAMapViewDelegate {
|
||||||
|
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
|
||||||
|
guard let polyline = overlay as? MAPolyline else { return nil }
|
||||||
|
let renderer = MAPolylineRenderer(polyline: polyline)
|
||||||
|
renderer?.strokeColor = UIColor(hexStr: "#5EC8FF")
|
||||||
|
renderer?.lineWidth = 6
|
||||||
|
return renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
||||||
|
guard !(annotation is MAUserLocation) else { return nil }
|
||||||
|
let id = "todayTrack.ann"
|
||||||
|
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
||||||
|
if view == nil {
|
||||||
|
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
|
||||||
|
}
|
||||||
|
view?.annotation = annotation
|
||||||
|
view?.image = UIImage(systemName: "mappin.circle.fill")
|
||||||
|
view?.centerOffset = CGPoint(x: 0, y: -12)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
//
|
||||||
|
// TodayTrackDetailView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import AMapNaviKit
|
||||||
|
|
||||||
|
final class TodayTrackDetailView: UIView {
|
||||||
|
|
||||||
|
let mapView: MAMapView = {
|
||||||
|
let mv = MAMapView()
|
||||||
|
mv.zoomLevel = 14
|
||||||
|
mv.showsUserLocation = false
|
||||||
|
mv.showsCompass = false
|
||||||
|
mv.showsScale = false
|
||||||
|
return mv
|
||||||
|
}()
|
||||||
|
|
||||||
|
let backBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||||
|
btn.tintColor = UIColor(hexStr: "#293445")
|
||||||
|
btn.backgroundColor = .white
|
||||||
|
btn.cornerRadius = 10
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
let titleLab: UILabel = {
|
||||||
|
let lab = UILabel()
|
||||||
|
lab.text = "今日轨迹"
|
||||||
|
lab.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
lab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
lab.textAlignment = .center
|
||||||
|
return lab
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var memberCV: UICollectionView = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.itemSize = CGSize(width: 61, height: 90)
|
||||||
|
layout.minimumLineSpacing = 12
|
||||||
|
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
cv.backgroundColor = .white
|
||||||
|
cv.showsHorizontalScrollIndicator = false
|
||||||
|
cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||||
|
cv.register(GroupMemberListCell.self)
|
||||||
|
cv.cornerRadius = 16
|
||||||
|
return cv
|
||||||
|
}()
|
||||||
|
|
||||||
|
let sheetView: UIView = {
|
||||||
|
let v = UIView()
|
||||||
|
v.backgroundColor = .white
|
||||||
|
v.layer.cornerRadius = 20
|
||||||
|
v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
v.clipsToBounds = true
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
let handleView: UIView = {
|
||||||
|
let v = UIView()
|
||||||
|
v.backgroundColor = UIColor(hexStr: "#E5E7EB")
|
||||||
|
v.cornerRadius = 2.5
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var dateCV: UICollectionView = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.estimatedItemSize = CGSize(width: 56, height: 32)
|
||||||
|
layout.minimumLineSpacing = 8
|
||||||
|
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
cv.backgroundColor = UIColor(hexStr: "#F3F4F6")
|
||||||
|
cv.showsHorizontalScrollIndicator = false
|
||||||
|
cv.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||||
|
cv.cornerRadius = 18
|
||||||
|
cv.register(TodayTrackDateCell.self)
|
||||||
|
return cv
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var timelineTV: UITableView = {
|
||||||
|
let tv = UITableView(frame: .zero, style: .plain)
|
||||||
|
tv.separatorStyle = .none
|
||||||
|
tv.backgroundColor = .clear
|
||||||
|
tv.showsVerticalScrollIndicator = false
|
||||||
|
tv.register(TodayTrackStayCell.self)
|
||||||
|
tv.rowHeight = UITableView.automaticDimension
|
||||||
|
tv.estimatedRowHeight = 72
|
||||||
|
return tv
|
||||||
|
}()
|
||||||
|
|
||||||
|
let emptyLab: UILabel = {
|
||||||
|
let lab = UILabel()
|
||||||
|
lab.text = "暂无轨迹"
|
||||||
|
lab.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
lab.textColor = UIColor(hexStr: "#9CA3AF")
|
||||||
|
lab.textAlignment = .center
|
||||||
|
lab.isHidden = true
|
||||||
|
return lab
|
||||||
|
}()
|
||||||
|
|
||||||
|
private(set) var sheetHeightConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = UIColor(hexStr: "#F5F7FA")
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
addSubview(mapView)
|
||||||
|
mapView.layoutChain.edges()
|
||||||
|
|
||||||
|
addSubview(backBtn)
|
||||||
|
backBtn.layoutChain
|
||||||
|
.top(kStatusBarHeight + 6)
|
||||||
|
.left(15)
|
||||||
|
.width(36)
|
||||||
|
.height(36)
|
||||||
|
|
||||||
|
addSubview(titleLab)
|
||||||
|
titleLab.layoutChain
|
||||||
|
.centerY(backBtn)
|
||||||
|
.centerX()
|
||||||
|
|
||||||
|
addSubview(memberCV)
|
||||||
|
memberCV.layoutChain
|
||||||
|
.topToBottomOfView(backBtn, offset: 12)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.height(100)
|
||||||
|
|
||||||
|
addSubview(sheetView)
|
||||||
|
let sheetH = sheetView.heightAnchor.constraint(equalToConstant: 320 + kSafeBottomMargin)
|
||||||
|
sheetH.isActive = true
|
||||||
|
sheetHeightConstraint = sheetH
|
||||||
|
sheetView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
sheetView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
sheetView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
sheetView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
sheetView.addSubview(handleView)
|
||||||
|
handleView.layoutChain
|
||||||
|
.top(8)
|
||||||
|
.centerX()
|
||||||
|
.width(36)
|
||||||
|
.height(5)
|
||||||
|
|
||||||
|
sheetView.addSubview(dateCV)
|
||||||
|
dateCV.layoutChain
|
||||||
|
.topToBottomOfView(handleView, offset: 12)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.height(36)
|
||||||
|
|
||||||
|
sheetView.addSubview(timelineTV)
|
||||||
|
timelineTV.layoutChain
|
||||||
|
.topToBottomOfView(dateCV, offset: 12)
|
||||||
|
.edgesHorzontal(15)
|
||||||
|
.bottom(kSafeBottomMargin + 8)
|
||||||
|
|
||||||
|
sheetView.addSubview(emptyLab)
|
||||||
|
emptyLab.layoutChain
|
||||||
|
.centerX()
|
||||||
|
.topToBottomOfView(dateCV, offset: 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupMap() {
|
||||||
|
mapView.removeAnnotations(mapView.annotations)
|
||||||
|
mapView.removeOverlays(mapView.overlays)
|
||||||
|
mapView.delegate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date cell
|
||||||
|
|
||||||
|
final class TodayTrackDateCell: UICollectionViewCell {
|
||||||
|
private let titleLab = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
contentView.cornerRadius = 14
|
||||||
|
titleLab.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
titleLab.textAlignment = .center
|
||||||
|
contentView.addSubview(titleLab)
|
||||||
|
titleLab.layoutChain.edges(UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func configure(title: String, selected: Bool) {
|
||||||
|
titleLab.text = title
|
||||||
|
titleLab.textColor = selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF")
|
||||||
|
contentView.backgroundColor = selected ? .white : .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stay cell
|
||||||
|
|
||||||
|
final class TodayTrackStayCell: UITableViewCell {
|
||||||
|
private let dot = UIView()
|
||||||
|
private let line = UIView()
|
||||||
|
private let titleLab = UILabel()
|
||||||
|
private let tagStack = UIStackView()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
|
||||||
|
dot.backgroundColor = UIColor(hexStr: "#16B3FF")
|
||||||
|
dot.cornerRadius = 5
|
||||||
|
contentView.addSubview(dot)
|
||||||
|
dot.layoutChain.top(8).left().width(10).height(10)
|
||||||
|
|
||||||
|
line.backgroundColor = UIColor(hexStr: "#E5E7EB")
|
||||||
|
contentView.addSubview(line)
|
||||||
|
line.layoutChain
|
||||||
|
.topToBottomOfView(dot, offset: 2)
|
||||||
|
.centerX(dot)
|
||||||
|
.width(2)
|
||||||
|
.bottom()
|
||||||
|
|
||||||
|
titleLab.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
titleLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
titleLab.numberOfLines = 2
|
||||||
|
contentView.addSubview(titleLab)
|
||||||
|
titleLab.layoutChain
|
||||||
|
.top(4)
|
||||||
|
.leftToRightOfView(dot, offset: 12)
|
||||||
|
.right()
|
||||||
|
|
||||||
|
tagStack.axis = .horizontal
|
||||||
|
tagStack.spacing = 8
|
||||||
|
contentView.addSubview(tagStack)
|
||||||
|
tagStack.layoutChain
|
||||||
|
.topToBottomOfView(titleLab, offset: 8)
|
||||||
|
.leftToView(titleLab)
|
||||||
|
.bottom(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func configure(title: String, tags: [String], isFirst: Bool, isLast: Bool) {
|
||||||
|
titleLab.text = title
|
||||||
|
line.isHidden = isLast
|
||||||
|
dot.backgroundColor = isFirst ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#9CA3AF")
|
||||||
|
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
for tag in tags {
|
||||||
|
let lab = UILabel()
|
||||||
|
lab.text = tag
|
||||||
|
lab.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
lab.textColor = UIColor(hexStr: "#6B7280")
|
||||||
|
let wrap = UIView()
|
||||||
|
wrap.backgroundColor = UIColor(hexStr: "#F3F4F6")
|
||||||
|
wrap.cornerRadius = 8
|
||||||
|
wrap.addSubview(lab)
|
||||||
|
lab.layoutChain.edges(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
|
||||||
|
tagStack.addArrangedSubview(wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
//
|
||||||
|
// TodayTrackDetailViewModel.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RxSwift
|
||||||
|
import RxCocoa
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
final class TodayTrackDetailViewModel {
|
||||||
|
|
||||||
|
struct DateItem {
|
||||||
|
let date: Date
|
||||||
|
let title: String
|
||||||
|
let key: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let members: [GroupMemberModel]
|
||||||
|
let selectedMemberId: BehaviorRelay<String>
|
||||||
|
let selectedDateKey: BehaviorRelay<String>
|
||||||
|
let stayPoints = BehaviorRelay<[StayPoint]>(value: [])
|
||||||
|
let trajectoryPoints = BehaviorRelay<[TrackPoint]>(value: [])
|
||||||
|
let isEmpty = BehaviorRelay<Bool>(value: true)
|
||||||
|
let loading = BehaviorRelay<Bool>(value: false)
|
||||||
|
|
||||||
|
private let disposeBag = DisposeBag()
|
||||||
|
let dateItems: [DateItem]
|
||||||
|
|
||||||
|
init(members: [GroupMemberModel], selectedUserId: String, initialDate: Date = Date()) {
|
||||||
|
self.members = members
|
||||||
|
self.dateItems = Self.makeDateItems(around: initialDate)
|
||||||
|
self.selectedMemberId = BehaviorRelay(value: selectedUserId)
|
||||||
|
self.selectedDateKey = BehaviorRelay(value: Self.dayKey(initialDate))
|
||||||
|
bindSelection()
|
||||||
|
reloadPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindSelection() {
|
||||||
|
selectedMemberId
|
||||||
|
.asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.reloadPlayback()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
selectedDateKey
|
||||||
|
.asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.reloadPlayback()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadPlayback() {
|
||||||
|
let userId = selectedMemberId.value
|
||||||
|
let dateKey = selectedDateKey.value
|
||||||
|
loadPlayback(userId: userId, dateKey: dateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPlayback(userId: String, dateKey: String) {
|
||||||
|
if userId.isEmpty {
|
||||||
|
clearPlayback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.accept(true)
|
||||||
|
DrivingService.playback(user_id: userId, date: dateKey)
|
||||||
|
.observe(on: MainScheduler.instance)
|
||||||
|
.subscribe(onNext: { [weak self] response in
|
||||||
|
self?.applyPlayback(response)
|
||||||
|
}, onError: { [weak self] _ in
|
||||||
|
self?.clearPlayback()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPlayback(_ response: ScheduleRecordListResponse) {
|
||||||
|
let trips = response.list
|
||||||
|
let stays = trips.flatMap { $0.stay_points }.sorted { lhs, rhs in
|
||||||
|
lhs.start_time > rhs.start_time
|
||||||
|
}
|
||||||
|
let path = trips.flatMap { $0.trajectory_path }
|
||||||
|
stayPoints.accept(stays)
|
||||||
|
trajectoryPoints.accept(path)
|
||||||
|
isEmpty.accept(stays.isEmpty && path.isEmpty)
|
||||||
|
loading.accept(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearPlayback() {
|
||||||
|
stayPoints.accept([])
|
||||||
|
trajectoryPoints.accept([])
|
||||||
|
isEmpty.accept(true)
|
||||||
|
loading.accept(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dayKey(_ date: Date) -> String {
|
||||||
|
date.toFormat("yyyy-MM-dd")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeDateItems(around date: Date) -> [DateItem] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: date)
|
||||||
|
var items: [DateItem] = []
|
||||||
|
for offset in -4...0 {
|
||||||
|
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let title: String
|
||||||
|
switch offset {
|
||||||
|
case 0:
|
||||||
|
title = "今天"
|
||||||
|
case -1:
|
||||||
|
title = "昨天"
|
||||||
|
default:
|
||||||
|
title = day.toFormat("MM-dd")
|
||||||
|
}
|
||||||
|
items.append(DateItem(date: day, title: title, key: dayKey(day)))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||