// // 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 statusTitleIcon = UIImageView(image: UIImage(named: "Home/report_phone")) 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 storageBarTrack = UIView() let storageBar = UIProgressView(progressViewStyle: .default) let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device")) // MARK: Charts let screenSection = PhoneReportChartSectionView( title: "屏幕使用时间", iconName: "Home/report_screen", summaryPrefix: "今日使用时长", summaryColor: UIColor(hexStr: "#00ADFE"), barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")), highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")), tipColor: UIColor(hexStr: "#20B6FF") ) let unlockSection = PhoneReportChartSectionView( title: "APP使用次数", iconName: "Home/report_usage", summaryPrefix: "今日使用次数", summaryColor: UIColor(hexStr: "#68C025"), barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")), highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")), tipColor: UIColor(hexStr: "#86E73D"), 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(scrollView) scrollView.showsVerticalScrollIndicator = false scrollView.clipsToBounds = true scrollView.delaysContentTouches = false scrollView.canCancelContentTouches = true scrollView.layoutChain .topToBottomOfView(navView) .edgesHorzontal() .bottom() scrollView.addSubview(contentView) contentView.layoutChain.edges().widthToView(scrollView) contentView.addSubview(memberCV) memberCV.layoutChain .top(8) .edgesHorzontal(15) .height(100) statusTitleIcon.contentMode = .scaleAspectFit contentView.addSubview(statusTitleIcon) statusTitleIcon.layoutChain .topToBottomOfView(memberCV, offset: 12) .left(15) .width(18) .height(18) statusTitleLab.text = "手机状态" statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold) statusTitleLab.textColor = UIColor(hexStr: "#293445") contentView.addSubview(statusTitleLab) statusTitleLab.layoutChain .centerY(statusTitleIcon) .leftToRightOfView(statusTitleIcon, offset: 6) statusCard.backgroundColor = .white statusCard.applyPhoneReportCardStyle(cornerRadius: 20) contentView.addSubview(statusCard) statusCard.layoutChain .topToBottomOfView(statusTitleIcon, 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: "#293445") estimateLab.textAlignment = .center estimateLab.backgroundColor = UIColor(hexStr: "#FAFAFA") estimateLab.cornerRadius = 5 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) storageBarTrack.backgroundColor = UIColor(hexStr: "#E0E0E0") storageBarTrack.layer.cornerRadius = 5 storageBarTrack.clipsToBounds = true deviceCard.addSubview(storageBarTrack) storageBarTrack.layoutChain .topToBottomOfView(storageLab, offset: 6) .left(10) .right(60) .height(10) .bottom(10) storageBar.progressTintColor = UIColor(hexStr: "#16B3FF") storageBar.trackTintColor = UIColor(hexStr: "#16B3FF", alpha: 0.3) storageBarTrack.addSubview(storageBar) storageBar.layoutChain .edgesHorzontal(3) .centerY() 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, isCharging: snap.isCharging) 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(snap.screenTodayText) unlockSection.setSummary("\(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(named: "Home/report_bolt")) 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.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, offset: -2) .width(12) .height(14) } 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, isCharging: Bool = false) { valueLab.text = text bolt.isHidden = false 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, relation: .greaterThanOrEqual).centerY(icon) valueLab.font = .systemFont(ofSize: 16, weight: .medium) valueLab.textColor = UIColor(hexStr: "#293445") valueLab.textAlignment = .center addSubview(valueLab) valueLab.layoutChain.topToBottomOfView(icon, offset: 10).edgesHorzontal(2) } 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 summaryPrefixLab = UILabel() private let summaryLab = UILabel() private let dayBtn = UIButton(type: .system) private let weekBtn = UIButton(type: .system) private let chart = PhoneReportBarChartView() private let summaryColor: UIColor private let barGradient: (UIColor, UIColor) private let highlightGradient: (UIColor, UIColor) private let tipColor: UIColor private let valueSuffix: String var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)? init( title: String, iconName: String, summaryPrefix: String, summaryColor: UIColor, barGradient: (UIColor, UIColor), highlightGradient: (UIColor, UIColor), tipColor: UIColor, valueSuffix: String = "" ) { self.summaryPrefix = summaryPrefix self.summaryColor = summaryColor self.barGradient = barGradient self.highlightGradient = highlightGradient self.tipColor = tipColor self.valueSuffix = valueSuffix super.init(frame: .zero) let titleIcon = UIImageView(image: UIImage(named: iconName)) titleIcon.contentMode = .scaleAspectFit addSubview(titleIcon) titleIcon.layoutChain.top().left().width(18).height(18) titleLab.text = title titleLab.font = .systemFont(ofSize: 16, weight: .bold) titleLab.textColor = UIColor(hexStr: "#293445") addSubview(titleLab) titleLab.layoutChain.centerY(titleIcon).leftToRightOfView(titleIcon, offset: 6) let card = UIView() card.backgroundColor = .white card.applyPhoneReportCardStyle(cornerRadius: 20) addSubview(card) card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180) summaryPrefixLab.text = summaryPrefix summaryPrefixLab.font = .systemFont(ofSize: 12, weight: .medium) summaryPrefixLab.textColor = UIColor(hexStr: "#293445") summaryPrefixLab.setContentCompressionResistancePriority(.required, for: .horizontal) card.addSubview(summaryPrefixLab) summaryPrefixLab.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 let toggleBg = UIView() toggleBg.backgroundColor = UIColor(hexStr: "#EAEAEA") toggleBg.layer.cornerRadius = 16 toggleBg.clipsToBounds = true toggleBg.addSubview(toggle) toggle.layoutChain.edges(UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3)) card.addSubview(toggleBg) toggleBg.layoutChain.centerY(summaryPrefixLab).right(14).height(32) summaryLab.font = .systemFont(ofSize: 12, weight: .bold) summaryLab.textColor = summaryColor summaryLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) card.addSubview(summaryLab) summaryLab.layoutChain .centerY(summaryPrefixLab) .leftToRightOfView(summaryPrefixLab, offset: 4) .rightToLeftOfView(toggleBg, offset: 8, relation: .lessThanOrEqual) dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside) weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside) card.addSubview(chart) chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).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, barGradient: barGradient, highlightGradient: highlightGradient, tipColor: tipColor, suffix: valueSuffix ) } } final class PhoneReportBarChartView: UIView { private var bars: [PhoneReportDetailViewModel.ChartBar] = [] private var barGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) private var highlightGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue) private var tipColor: UIColor = .systemBlue private var suffix = "" private var barLayers: [CALayer] = [] private var labelViews: [UILabel] = [] private var barFrames: [CGRect] = [] private var selectedIndex: Int? private var barSpacing: CGFloat = 0 private var baselineY: CGFloat = 0 private let tipLab = UILabel() private let baseline = UIView() override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = false isUserInteractionEnabled = true baseline.backgroundColor = UIColor(hexStr: "#F2F2F2") addSubview(baseline) tipLab.font = .systemFont(ofSize: 11, weight: .bold) tipLab.textColor = .white tipLab.textAlignment = .center tipLab.cornerRadius = 6 tipLab.clipsToBounds = true tipLab.isHidden = true tipLab.isUserInteractionEnabled = false addSubview(tipLab) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) addGestureRecognizer(tap) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure( bars: [PhoneReportDetailViewModel.ChartBar], barGradient: (UIColor, UIColor), highlightGradient: (UIColor, UIColor), tipColor: UIColor, suffix: String ) { let previous = self.bars self.bars = bars self.barGradient = barGradient self.highlightGradient = highlightGradient self.tipColor = tipColor self.suffix = suffix let dataChanged = previous.count != bars.count || zip(previous, bars).contains { $0.label != $1.label || $0.value != $1.value } if dataChanged || selectedIndex == nil { selectedIndex = bars.firstIndex(where: \.isToday) ?? (bars.isEmpty ? nil : bars.count - 1) } setNeedsLayout() } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: self) guard !barFrames.isEmpty else { return } for (i, frame) in barFrames.enumerated() { let hitX = frame.minX - barSpacing / 2 let hitW = frame.width + barSpacing let hit = CGRect(x: hitX, y: 0, width: hitW, height: max(baselineY, bounds.height)) if hit.contains(point) { guard selectedIndex != i else { return } selectedIndex = i setNeedsLayout() return } } } override func layoutSubviews() { super.layoutSubviews() barLayers.forEach { $0.removeFromSuperlayer() } barLayers.removeAll() labelViews.forEach { $0.removeFromSuperview() } labelViews.removeAll() barFrames.removeAll() tipLab.isHidden = true guard !bars.isEmpty else { baseline.isHidden = true return } baseline.isHidden = false let maxVal = max(bars.map(\.value).max() ?? 1, 1) let count = bars.count let labelH: CGFloat = 16 let chartH = bounds.height - labelH - 24 let barW: CGFloat = 16 barSpacing = count > 1 ? max(0, (bounds.width - barW * CGFloat(count)) / CGFloat(count - 1)) : 0 baselineY = bounds.height - labelH baseline.frame = CGRect(x: 0, y: baselineY - 0.5, width: bounds.width, height: 1) if selectedIndex == nil || selectedIndex! >= count { selectedIndex = bars.firstIndex(where: \.isToday) ?? count - 1 } 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 + barSpacing) let y = baselineY - h let barFrame = CGRect(x: x, y: y, width: barW, height: h) barFrames.append(barFrame) let isSelected = i == selectedIndex let gradient = CAGradientLayer() gradient.frame = barFrame let colors = isSelected ? highlightGradient : barGradient gradient.colors = [colors.0.cgColor, colors.1.cgColor] gradient.startPoint = CGPoint(x: 0.5, y: 0) gradient.endPoint = CGPoint(x: 0.5, y: 1) gradient.cornerRadius = 4 layer.insertSublayer(gradient, below: tipLab.layer) barLayers.append(gradient) 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 - 10, y: bounds.height - labelH, width: barW + 20, height: labelH) insertSubview(lab, belowSubview: tipLab) labelViews.append(lab) if isSelected { tipLab.text = tipText(for: bar) tipLab.backgroundColor = tipColor tipLab.sizeToFit() let tipW = tipLab.bounds.width + 5 + 5 let tipH = tipLab.bounds.height + 4 + 4 // 与柱子水平居中,允许超出 chart 左右边界 tipLab.frame = CGRect( x: x + barW / 2 - tipW / 2, y: max(0, y - tipH - 4), width: tipW, height: tipH ) tipLab.isHidden = false } } bringSubviewToFront(tipLab) } private func tipText(for bar: PhoneReportDetailViewModel.ChartBar) -> String { if suffix == "次" { return "\(bar.value)次" } let hours = bar.value / 3600 let mins = (bar.value % 3600) / 60 if hours > 0 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" } return "\(mins)m" } }