jsdw_ios/QuickLocation/Section/Home/AppUsageReport/AppUsageReportView.swift

647 lines
23 KiB
Swift

//
// AppUsageReportView.swift
// QuickLocation
//
import UIKit
import Kingfisher
final class AppUsageReportView: UIView {
lazy var navView = BaseNavigationView(title: "APP报告")
let memberCV: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: 61, height: 90)
layout.minimumLineSpacing = 12
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .white
view.showsHorizontalScrollIndicator = false
view.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
view.register(GroupMemberListCell.self)
view.applyAppUsageReportCardStyle(cornerRadius: 20)
return view
}()
var onPeriodChanged: ((AppUsageReportPeriod) -> Void)?
private let navBgView = UIImageView(image: UIImage(named: "Common/navBar_bg_2"))
private let scrollView = UIScrollView()
private let contentView = UIView()
private let frequentSection = AppUsageFrequentSectionView()
private let durationSection = AppUsageDurationSectionView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hexStr: "#FAFAFA")
setupUI()
bindPeriodControls()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ snapshot: AppUsageReportViewModel.Snapshot) {
frequentSection.setPeriod(snapshot.period)
durationSection.setPeriod(snapshot.period)
frequentSection.configure(apps: snapshot.apps, period: snapshot.period, state: snapshot.state)
durationSection.configure(
apps: snapshot.apps,
totalUsageTime: snapshot.totalUsageTime,
period: snapshot.period,
state: snapshot.state
)
}
private func bindPeriodControls() {
let onChange: (AppUsageReportPeriod) -> Void = { [weak self] period in
self?.onPeriodChanged?(period)
}
frequentSection.onPeriodChanged = onChange
durationSection.onPeriodChanged = onChange
}
private func setupUI() {
addSubview(navBgView)
addSubview(navView)
navBgView.contentMode = .scaleAspectFill
navBgView.layoutChain.edges(excludingEdge: .bottom).heightToWidth(160 / 375)
navView.layoutChain.top().edgesHorzontal().height(kNaviHeight)
addSubview(scrollView)
scrollView.showsVerticalScrollIndicator = false
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)
contentView.addSubview(frequentSection)
frequentSection.layoutChain.topToBottomOfView(memberCV, offset: 14).edgesHorzontal(15).height(253)
contentView.addSubview(durationSection)
durationSection.layoutChain
.topToBottomOfView(frequentSection, offset: 14)
.edgesHorzontal(15)
.height(220)
.bottom(24 + kSafeBottomMargin)
}
}
private final class AppUsageSectionIconView: UIView {
private let iconView = UIImageView()
init(symbolName: String) {
super.init(frame: .zero)
backgroundColor = UIColor(hexStr: "#293445")
iconView.image = UIImage(
systemName: symbolName,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 9, weight: .bold)
)
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
addSubview(iconView)
iconView.layoutChain.centerX().centerY().width(10).height(10)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
}
private enum AppUsageReportStyle {
static func cardTitle(_ title: String) -> NSAttributedString {
let attributedText = NSMutableAttributedString(
string: "\(title)",
attributes: [
.font: UIFont.systemFont(ofSize: 13, weight: .semibold),
.foregroundColor: UIColor(hexStr: "#293445")
]
)
attributedText.addAttribute(
.foregroundColor,
value: UIColor(hexStr: "#A9F33D"),
range: NSRange(location: 0, length: 1)
)
return attributedText
}
}
private final class AppUsageFrequentSectionView: UIView {
var onPeriodChanged: ((AppUsageReportPeriod) -> Void)?
private let titleIcon = AppUsageSectionIconView(symbolName: "heart.fill")
private let titleLab = UILabel()
private let cardView = UIView()
private let cardTitleLab = UILabel()
private let periodControl = AppUsagePeriodControl()
private let rowsStack = UIStackView()
private let statusLab = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
periodControl.onChanged = { [weak self] period in
self?.onPeriodChanged?(period)
}
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func setPeriod(_ period: AppUsageReportPeriod) {
periodControl.setPeriod(period)
}
func configure(
apps: [AppUsageReportViewModel.AppItem],
period: AppUsageReportPeriod,
state: AppUsageReportViewModel.ContentState
) {
rowsStack.arrangedSubviews.forEach {
rowsStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let statusText = AppUsageReportStatus.text(for: state)
statusLab.text = statusText
statusLab.isHidden = statusText == nil
rowsStack.isHidden = statusText != nil
guard statusText == nil else { return }
for app in apps {
let row = AppUsageRankRowView()
row.configure(app: app, maximumUsageSeconds: period.maximumUsageSeconds)
row.layoutChain.height(20)
rowsStack.addArrangedSubview(row)
}
}
private func setupUI() {
addSubview(titleIcon)
titleIcon.layoutChain.top().left().width(18).height(18)
titleLab.text = "经常使用"
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
titleLab.textColor = UIColor(hexStr: "#293445")
addSubview(titleLab)
titleLab.layoutChain.leftToRightOfView(titleIcon, offset: 7).centerY(titleIcon)
cardView.backgroundColor = .white
cardView.applyAppUsageReportCardStyle(cornerRadius: 20)
addSubview(cardView)
cardView.layoutChain.topToBottomOfView(titleIcon, offset: 12).edgesHorzontal().bottom()
cardTitleLab.attributedText = AppUsageReportStyle.cardTitle("最爱使用TOP5")
cardView.addSubview(cardTitleLab)
cardTitleLab.layoutChain.top(22).left(20)
cardView.addSubview(periodControl)
periodControl.layoutChain.top(12).right(14).width(110).height(32)
rowsStack.axis = .vertical
rowsStack.spacing = 12
cardView.addSubview(rowsStack)
rowsStack.layoutChain.top(62).left(20).right(14)
statusLab.font = .systemFont(ofSize: 14, weight: .medium)
statusLab.textColor = UIColor(hexStr: "#9CA3AF")
statusLab.textAlignment = .center
statusLab.numberOfLines = 0
cardView.addSubview(statusLab)
statusLab.layoutChain.centerX().centerY(cardView, offset: 16).left(24).right(24)
}
}
private final class AppUsageDurationSectionView: UIView {
var onPeriodChanged: ((AppUsageReportPeriod) -> Void)?
private let titleIcon = AppUsageSectionIconView(symbolName: "clock.fill")
private let titleLab = UILabel()
private let cardView = UIView()
private let cardTitleLab = UILabel()
private let periodControl = AppUsagePeriodControl()
private let donutView = AppUsageReportDonutView()
private let totalTimeLab = UILabel()
private let totalHintLab = UILabel()
private let legendStack = UIStackView()
private let statusLab = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
periodControl.onChanged = { [weak self] period in
self?.onPeriodChanged?(period)
}
}
required init?(coder: NSCoder) { fatalError("init(coder:)") }
func setPeriod(_ period: AppUsageReportPeriod) {
periodControl.setPeriod(period)
}
func configure(
apps: [AppUsageReportViewModel.AppItem],
totalUsageTime: Int,
period: AppUsageReportPeriod,
state: AppUsageReportViewModel.ContentState
) {
legendStack.arrangedSubviews.forEach {
legendStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let statusText = AppUsageReportStatus.text(for: state)
statusLab.text = statusText
statusLab.isHidden = statusText == nil
donutView.isHidden = statusText != nil
totalTimeLab.isHidden = statusText != nil
totalHintLab.isHidden = statusText != nil
legendStack.isHidden = statusText != nil
guard statusText == nil else {
donutView.setSegments(ratios: [], colors: [])
return
}
totalTimeLab.text = AppUsageReportFormat.durationText(seconds: totalUsageTime)
donutView.setSegments(ratios: apps.map { CGFloat($0.usageTime) }, colors: apps.map(\.color))
for app in apps {
let row = AppUsageLegendRowView()
row.configure(
app: app,
progress: CGFloat(app.usageTime) / CGFloat(period.maximumUsageSeconds)
)
row.layoutChain.height(20)
legendStack.addArrangedSubview(row)
}
}
private func setupUI() {
addSubview(titleIcon)
titleIcon.layoutChain.top().left().width(18).height(18)
titleLab.text = "使用时长"
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
titleLab.textColor = UIColor(hexStr: "#293445")
addSubview(titleLab)
titleLab.layoutChain.leftToRightOfView(titleIcon, offset: 7).centerY(titleIcon)
cardView.backgroundColor = .white
cardView.applyAppUsageReportCardStyle(cornerRadius: 20)
addSubview(cardView)
cardView.layoutChain.topToBottomOfView(titleIcon, offset: 12).edgesHorzontal().bottom()
cardTitleLab.attributedText = AppUsageReportStyle.cardTitle("App使用详情")
cardView.addSubview(cardTitleLab)
cardTitleLab.layoutChain.top(22).left(20)
cardView.addSubview(periodControl)
periodControl.layoutChain.top(12).right(14).width(110).height(32)
cardView.addSubview(donutView)
donutView.layoutChain.left(20).top(56).width(122).height(122)
totalTimeLab.font = .systemFont(ofSize: 16, weight: .semibold)
totalTimeLab.textColor = UIColor(hexStr: "#293445")
totalTimeLab.textAlignment = .center
cardView.addSubview(totalTimeLab)
totalTimeLab.layoutChain.centerX(donutView).centerY(donutView, offset: -8)
totalHintLab.text = "总计"
totalHintLab.font = .systemFont(ofSize: 10, weight: .medium)
totalHintLab.textColor = UIColor(hexStr: "#767676")
totalHintLab.textAlignment = .center
cardView.addSubview(totalHintLab)
totalHintLab.layoutChain.centerX(donutView).topToBottomOfView(totalTimeLab, offset: 4)
legendStack.axis = .vertical
legendStack.spacing = 5
cardView.addSubview(legendStack)
legendStack.layoutChain
.top(54)
.left(154)
.right(14)
statusLab.font = .systemFont(ofSize: 14, weight: .medium)
statusLab.textColor = UIColor(hexStr: "#9CA3AF")
statusLab.textAlignment = .center
statusLab.numberOfLines = 0
cardView.addSubview(statusLab)
statusLab.layoutChain.centerX().centerY(cardView, offset: 18).left(24).right(24)
}
}
private final class AppUsagePeriodControl: UIView {
var onChanged: ((AppUsageReportPeriod) -> Void)?
private let backgroundView = UIView()
private let monthButton = UIButton(type: .custom)
private let weekButton = UIButton(type: .custom)
private var period: AppUsageReportPeriod = .week
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hexStr: "#EEEEEE")
cornerRadius = 16
setupUI()
setPeriod(.week)
}
required init?(coder: NSCoder) { fatalError("init(coder:)") }
func setPeriod(_ period: AppUsageReportPeriod) {
self.period = period
let selectedColor = UIColor(hexStr: "#293445")
let normalColor = UIColor(hexStr: "#8C8C8C")
monthButton.setTitleColor(period == .month ? selectedColor : normalColor, for: .normal)
weekButton.setTitleColor(period == .week ? selectedColor : normalColor, for: .normal)
backgroundView.frame = period == .month
? CGRect(x: 3, y: 3, width: bounds.width / 2 - 3, height: bounds.height - 6)
: CGRect(x: bounds.width / 2, y: 3, width: bounds.width / 2 - 3, height: bounds.height - 6)
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
let width = bounds.width / 2 - 4
backgroundView.frame = period == .month
? CGRect(x: 3, y: 3, width: width + 1, height: bounds.height - 6)
: CGRect(x: bounds.width / 2, y: 3, width: width + 1, height: bounds.height - 6)
}
private func setupUI() {
backgroundView.backgroundColor = .white
backgroundView.cornerRadius = 13
addSubview(backgroundView)
sendSubviewToBack(backgroundView)
[monthButton, weekButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
addSubview($0)
}
monthButton.setTitle("", for: .normal)
weekButton.setTitle("", for: .normal)
monthButton.layoutChain.left().top().bottom().widthToView(self, multiplier: 0.5)
weekButton.layoutChain.right().top().bottom().widthToView(self, multiplier: 0.5)
monthButton.addTarget(self, action: #selector(selectMonth), for: .touchUpInside)
weekButton.addTarget(self, action: #selector(selectWeek), for: .touchUpInside)
}
@objc private func selectMonth() {
guard period != .month else { return }
onChanged?(.month)
}
@objc private func selectWeek() {
guard period != .week else { return }
onChanged?(.week)
}
}
private final class AppUsageRankRowView: UIView {
private var progressRatio: CGFloat = 0
private var progressConstraint: NSLayoutConstraint?
private let rankLab = UILabel()
private let iconView = UIImageView()
private let nameLab = UILabel()
private let trackView = UIView()
private let fillView = UIView()
private let percentageLab = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) { fatalError("init(coder:)") }
func configure(app: AppUsageReportViewModel.AppItem, maximumUsageSeconds: Int) {
rankLab.text = "\(app.rank)"
nameLab.text = app.name
let periodProgress = min(max(CGFloat(app.usageTime) / CGFloat(maximumUsageSeconds), 0), 1)
percentageLab.text = "\(Int((periodProgress * 100).rounded()))%"
iconView.kf.cancelDownloadTask()
iconView.image = UIImage(named: "LockDistract/app_unknown")
if let url = URL(string: app.iconURL), !app.iconURL.isEmpty {
iconView.kf.setImage(with: url, placeholder: iconView.image)
}
progressRatio = periodProgress
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
progressConstraint?.isActive = false
progressConstraint = fillView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: progressRatio)
progressConstraint?.isActive = true
}
private func setupUI() {
rankLab.font = .systemFont(ofSize: 12, weight: .bold)
rankLab.textColor = UIColor(hexStr: "#687385")
rankLab.textAlignment = .center
addSubview(rankLab)
rankLab.layoutChain.left().centerY().width(14)
iconView.backgroundColor = UIColor(hexStr: "#F4F6F8")
iconView.image = UIImage(named: "LockDistract/app_unknown")
iconView.contentMode = .scaleAspectFill
iconView.cornerRadius = 4
iconView.clipsToBounds = true
addSubview(iconView)
iconView.layoutChain.leftToRightOfView(rankLab, offset: 8).centerY().width(16).height(16)
nameLab.font = .systemFont(ofSize: 11, weight: .medium)
nameLab.textColor = UIColor(hexStr: "#4B5563")
nameLab.lineBreakMode = .byTruncatingTail
addSubview(nameLab)
nameLab.layoutChain.leftToRightOfView(iconView, offset: 7).centerY().width(62)
percentageLab.font = .systemFont(ofSize: 10, weight: .regular)
percentageLab.textColor = UIColor(hexStr: "#A8A8A8")
percentageLab.textAlignment = .right
addSubview(percentageLab)
percentageLab.layoutChain.right().centerY().width(34)
trackView.backgroundColor = UIColor(hexStr: "#DDF3FF")
trackView.cornerRadius = 2
addSubview(trackView)
trackView.layoutChain.leftToRightOfView(nameLab, offset: 8).rightToLeftOfView(percentageLab, offset: -8).centerY().height(4)
fillView.backgroundColor = UIColor(hexStr: "#5BC5F7")
fillView.cornerRadius = 2
trackView.addSubview(fillView)
fillView.layoutChain.left().top().bottom()
}
}
private final class AppUsageLegendRowView: UIView {
private var progressRatio: CGFloat = 0
private var progressConstraint: NSLayoutConstraint?
private let colorDot = UIView()
private let iconView = UIImageView()
private let nameLab = UILabel()
private let trackView = UIView()
private let fillView = UIView()
private let durationLab = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) { fatalError("init(coder:)") }
func configure(app: AppUsageReportViewModel.AppItem, progress: CGFloat) {
colorDot.backgroundColor = app.color
nameLab.text = app.name
durationLab.text = app.durationText
iconView.kf.cancelDownloadTask()
iconView.image = UIImage(named: "LockDistract/app_unknown")
if let url = URL(string: app.iconURL), !app.iconURL.isEmpty {
iconView.kf.setImage(with: url, placeholder: iconView.image)
}
progressRatio = max(0, min(progress, 1))
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
progressConstraint?.isActive = false
progressConstraint = fillView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: progressRatio)
progressConstraint?.isActive = true
}
private func setupUI() {
colorDot.cornerRadius = 3
addSubview(colorDot)
colorDot.layoutChain.left().centerY().width(6).height(6)
iconView.backgroundColor = UIColor(hexStr: "#F4F6F8")
iconView.image = UIImage(named: "LockDistract/app_unknown")
iconView.contentMode = .scaleAspectFill
iconView.cornerRadius = 4
iconView.clipsToBounds = true
addSubview(iconView)
iconView.layoutChain.leftToRightOfView(colorDot, offset: 7).centerY().width(16).height(16)
durationLab.font = .systemFont(ofSize: 10, weight: .regular)
durationLab.textColor = UIColor(hexStr: "#A8A8A8")
durationLab.textAlignment = .right
addSubview(durationLab)
durationLab.layoutChain.right().centerY().width(42)
nameLab.font = .systemFont(ofSize: 10, weight: .medium)
nameLab.textColor = UIColor(hexStr: "#4B5563")
nameLab.lineBreakMode = .byTruncatingTail
addSubview(nameLab)
nameLab.layoutChain.leftToRightOfView(iconView, offset: 6).centerY().width(42)
trackView.backgroundColor = UIColor(hexStr: "#DDF3FF")
trackView.cornerRadius = 2
addSubview(trackView)
trackView.layoutChain.leftToRightOfView(nameLab, offset: 5).rightToLeftOfView(durationLab, offset: -7).centerY().height(4)
fillView.backgroundColor = UIColor(hexStr: "#5BC5F7")
fillView.cornerRadius = 2
trackView.addSubview(fillView)
fillView.layoutChain.left().top().bottom()
}
}
private final class AppUsageReportDonutView: UIView {
private var segmentLayers: [CAShapeLayer] = []
private var ratios: [CGFloat] = []
private var colors: [UIColor] = []
private let lineWidth: CGFloat = 18
func setSegments(ratios: [CGFloat], colors: [UIColor]) {
self.ratios = ratios
self.colors = colors
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
segmentLayers.forEach { $0.removeFromSuperlayer() }
segmentLayers.removeAll()
guard !ratios.isEmpty, !colors.isEmpty, bounds.width > 0 else { return }
let total = max(ratios.reduce(0, +), 0.001)
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
let center = CGPoint(x: bounds.midX, y: bounds.midY)
var startAngle = -CGFloat.pi / 2
let segmentGap: CGFloat = ratios.count > 1 ? 0.025 : 0
for (index, ratio) in ratios.enumerated() {
let sweep = ratio / total * 2 * CGFloat.pi
guard sweep > 0.001 else { continue }
let gap = min(segmentGap, sweep * 0.25)
let path = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: startAngle + gap,
endAngle: startAngle + sweep - gap,
clockwise: true
)
let layer = CAShapeLayer()
layer.path = path.cgPath
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = colors[index % colors.count].cgColor
layer.lineWidth = lineWidth
layer.lineCap = .butt
self.layer.addSublayer(layer)
segmentLayers.append(layer)
startAngle += sweep
}
}
}
private enum AppUsageReportFormat {
static func durationText(seconds: Int) -> String {
let hours = max(seconds, 0) / 3600
let minutes = (max(seconds, 0) % 3600) / 60
if hours > 0 {
return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h"
}
return "\(minutes)m"
}
}
private enum AppUsageReportStatus {
static func text(for state: AppUsageReportViewModel.ContentState) -> String? {
switch state {
case .loading:
return "正在加载..."
case .empty:
return "暂无APP使用记录"
case .failure:
return "加载失败,请稍后重试"
case .content:
return nil
}
}
}
private extension UIView {
func applyAppUsageReportCardStyle(cornerRadius: CGFloat) {
layer.cornerRadius = cornerRadius
layer.masksToBounds = false
layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.08).cgColor
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowOpacity = 1
layer.shadowRadius = 9
}
}