- 手机报告二级页面
|
|
@ -302,6 +302,12 @@
|
|||
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; };
|
||||
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.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 */; };
|
||||
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -1032,6 +1044,8 @@
|
|||
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||
55B217DD302301000078473C /* TodayTrackDetail */,
|
||||
55B217DE302301000078473D /* PhoneReportDetail */,
|
||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
||||
|
|
@ -1703,6 +1717,26 @@
|
|||
path = Unlock;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -1983,6 +2017,12 @@
|
|||
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
||||
55B217C53022F0A000784725 /* MemberAppUsageView.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 */,
|
||||
30DC185A2FD11E7A0041DCD1 /* WebOperations.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 itineraryTrace = "itineraryTrace"
|
||||
/// 今日轨迹详情
|
||||
case todayTrackDetail = "todayTrackDetail"
|
||||
/// 手机报告详情
|
||||
case phoneReportDetail = "phoneReportDetail"
|
||||
/// 充值优惠弹窗
|
||||
case promotionalActivities = "promotionalActivities"
|
||||
}
|
||||
|
|
@ -348,6 +352,16 @@ extension AppRouter: AppRouterProtocol {
|
|||
ItineraryTraceVC(scheduleJson: parameters["model"].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: - 充值优惠弹窗
|
||||
AppRouter.register(Route.promotionalActivities) { url, parameters in
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<array>
|
||||
<string>applinks:smartdrive.zuom8.cn</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import AMapNaviKit
|
|||
import CocoaMQTT
|
||||
import Lottie
|
||||
import MarqueeLabel
|
||||
import ObjectMapper
|
||||
#endif
|
||||
|
||||
class HomeViewController: BaseViewController {
|
||||
|
|
@ -205,6 +206,16 @@ class HomeViewController: BaseViewController {
|
|||
rootView.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in
|
||||
AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo])
|
||||
}).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
|
||||
|
|
@ -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) {
|
||||
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
|
||||
}
|
||||
}
|
||||