- 手机报告二级页面

This commit is contained in:
linshujie 2026-08-07 10:58:34 +08:00
parent eccad4b9d3
commit 738d0b710f
23 changed files with 2094 additions and 0 deletions

View File

@ -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 */,

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -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"
} }
@ -349,6 +353,16 @@ extension AppRouter: AppRouterProtocol {
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
PromotionalActivitiesVC() PromotionalActivitiesVC()

View File

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

View File

@ -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 {
@ -206,6 +207,16 @@ class HomeViewController: BaseViewController {
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
if let ann = self.currentUserAnnotation { if let ann = self.currentUserAnnotation {
@ -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

View File

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

View File

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

View File

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

View File

@ -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)
// SSIDiOS 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+ WiFi SSID + Access WiFi 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
)
}
}
}

View File

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

View File

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

View File

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