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

498 lines
18 KiB
Swift

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