diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 50c0c10a..fd77b196 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -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 = ""; }; 55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = ""; }; 55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = ""; }; + 55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = ""; }; + 55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = ""; }; + 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = ""; }; + 55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = ""; }; + 55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = ""; }; + 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = ""; }; 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = ""; }; 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = ""; }; 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = ""; }; @@ -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 = ""; }; + 55B217DD302301000078473C /* TodayTrackDetail */ = { + isa = PBXGroup; + children = ( + 55B217D13023010000784730 /* TodayTrackDetailVC.swift */, + 55B217D33023010000784732 /* TodayTrackDetailView.swift */, + 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */, + ); + path = TodayTrackDetail; + sourceTree = ""; + }; + 55B217DE302301000078473D /* PhoneReportDetail */ = { + isa = PBXGroup; + children = ( + 55B217D73023010000784736 /* PhoneReportDetailVC.swift */, + 55B217D93023010000784738 /* PhoneReportDetailView.swift */, + 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */, + ); + path = PhoneReportDetail; + sourceTree = ""; + }; 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 */, diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json new file mode 100644 index 00000000..1aa08092 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json @@ -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 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png new file mode 100644 index 00000000..46a7b9f2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png new file mode 100644 index 00000000..d76d27dc Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_device.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/Contents.json new file mode 100644 index 00000000..a57702fa --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/Contents.json @@ -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 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@2x.png b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@2x.png new file mode 100644 index 00000000..54134259 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@3x.png b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@3x.png new file mode 100644 index 00000000..5b19d813 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json new file mode 100644 index 00000000..594bffbc --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json @@ -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 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png new file mode 100644 index 00000000..642c8da0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png new file mode 100644 index 00000000..15f55b59 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json new file mode 100644 index 00000000..8bd9dff4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json @@ -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 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png new file mode 100644 index 00000000..4143cfb1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@3x.png b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@3x.png new file mode 100644 index 00000000..4b771f6b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@3x.png differ diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 10130f56..3bdbc83f 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -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 diff --git a/QuickLocation/QuickLocation.entitlements b/QuickLocation/QuickLocation.entitlements index 778b0546..ca0d937d 100644 --- a/QuickLocation/QuickLocation.entitlements +++ b/QuickLocation/QuickLocation.entitlements @@ -10,5 +10,7 @@ applinks:smartdrive.zuom8.cn + com.apple.developer.networking.wifi-info + diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 7cc3438e..f8f99df4 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -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 diff --git a/QuickLocation/Section/Home/MemberAppUsageView.swift b/QuickLocation/Section/Home/MemberAppUsageView.swift new file mode 100644 index 00000000..3b8de914 --- /dev/null +++ b/QuickLocation/Section/Home/MemberAppUsageView.swift @@ -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 + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift new file mode 100644 index 00000000..3932d235 --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift @@ -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) + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift new file mode 100644 index 00000000..96be3d7c --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift @@ -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 + } + } + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift new file mode 100644 index 00000000..7c92f1d2 --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift @@ -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 + let screenMode = BehaviorRelay(value: .day) + let unlockMode = BehaviorRelay(value: .day) + let snapshot: BehaviorRelay + + 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 + ) + } + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift new file mode 100644 index 00000000..533e8428 --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift @@ -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 + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift new file mode 100644 index 00000000..bec2ea4b --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift @@ -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) + } + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift new file mode 100644 index 00000000..55bf4302 --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift @@ -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 + let selectedDateKey: BehaviorRelay + let stayPoints = BehaviorRelay<[StayPoint]>(value: []) + let trajectoryPoints = BehaviorRelay<[TrackPoint]>(value: []) + let isEmpty = BehaviorRelay(value: true) + let loading = BehaviorRelay(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 + } +}