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