jsdw_ios/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift

623 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PhoneReportDetailView.swift
// QuickLocation
//
import UIKit
import Kingfisher
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"))
let brandLogoImage = UIImageView()
private var brandLogoURL = ""
// 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)
brandLogoImage.contentMode = .scaleAspectFit
brandLogoImage.isHidden = true
deviceImage.addSubview(brandLogoImage)
brandLogoImage.layoutChain.center().width(18).height(18)
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)
updateBrandLogo(urlString: snap.brandIconURL)
screenSection.setSummary(snap.screenTodayText)
unlockSection.setSummary(snap.appUsageTodayText)
screenSection.setBars(snap.screenBars)
unlockSection.setBars(snap.appUsageBars)
}
private func updateBrandLogo(urlString: String) {
brandLogoURL = urlString
brandLogoImage.kf.cancelDownloadTask()
brandLogoImage.image = nil
brandLogoImage.isHidden = true
guard let url = URL(string: urlString), !urlString.isEmpty else {
return
}
brandLogoImage.kf.setImage(with: url) { [weak self] result in
guard let self, self.brandLogoURL == urlString else { return }
switch result {
case .success:
self.brandLogoImage.isHidden = false
case .failure:
self.brandLogoImage.image = nil
self.brandLogoImage.isHidden = true
}
}
}
}
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 chart = PhoneReportBarChartView()
private let summaryColor: UIColor
private let barGradient: (UIColor, UIColor)
private let highlightGradient: (UIColor, UIColor)
private let tipColor: UIColor
private let valueSuffix: String
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)
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)
.right(14, relation: .lessThanOrEqual)
card.addSubview(chart)
chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
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)小时\(mins)" : "\(hours)小时"
}
return "\(mins)分钟"
}
}