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

619 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
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 storageBarTrack = UIView()
let storageBar = UIProgressView(progressViewStyle: .default)
let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device"))
// MARK: Charts
let screenSection = PhoneReportChartSectionView(
title: "屏幕使用时间",
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: "手机使用次数",
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)
statusTitleLab.text = "手机状态"
statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
statusTitleLab.textColor = UIColor(hexStr: "#293445")
contentView.addSubview(statusTitleLab)
statusTitleLab.layoutChain.topToBottomOfView(memberCV, offset: 12).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: "#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(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, isCharging: Bool = false) {
valueLab.text = text
bolt.isHidden = !isCharging
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,
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)
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)
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"
}
}