367 lines
12 KiB
Swift
367 lines
12 KiB
Swift
//
|
||
// MemberAppUsageView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
import Kingfisher
|
||
|
||
struct MemberAppUsageItemPreview {
|
||
let rank: Int
|
||
let name: String
|
||
let iconURL: String
|
||
let durationText: String
|
||
let progress: CGFloat
|
||
}
|
||
|
||
struct MemberAppUsagePreview {
|
||
let totalTimeText: String
|
||
let segmentRatios: [CGFloat]
|
||
let segmentColors: [UIColor]
|
||
let topApps: [MemberAppUsageItemPreview]
|
||
|
||
init?(topApps: [PhoneUsageAppModel]) {
|
||
let apps = Array(topApps.filter { $0.usageTime > 0 }.prefix(5))
|
||
guard !apps.isEmpty else { return nil }
|
||
|
||
let usageTimes = apps.map { max($0.usageTime, 0) }
|
||
let totalUsageTime = usageTimes.reduce(0, +)
|
||
let colors = Self.segmentColors
|
||
|
||
totalTimeText = Self.durationText(seconds: totalUsageTime)
|
||
segmentRatios = usageTimes.map(CGFloat.init)
|
||
segmentColors = usageTimes.enumerated().map { colors[$0.offset % colors.count] }
|
||
self.topApps = apps.enumerated().map { index, app in
|
||
MemberAppUsageItemPreview(
|
||
rank: index + 1,
|
||
name: app.appName.isEmpty ? "未知应用" : app.appName,
|
||
iconURL: app.appIcon,
|
||
durationText: Self.durationText(seconds: max(app.usageTime, 0)),
|
||
progress: CGFloat(max(app.usageTime, 0)) / CGFloat(24 * 60 * 60)
|
||
)
|
||
}
|
||
}
|
||
|
||
private static let segmentColors = [
|
||
UIColor(hexStr: "#C3ECFF"),
|
||
UIColor(hexStr: "#E3FF9C"),
|
||
UIColor(hexStr: "#FFEAA4"),
|
||
UIColor(hexStr: "#C5FFB7"),
|
||
UIColor(hexStr: "#FFDCF5")
|
||
]
|
||
|
||
private static func durationText(seconds: Int) -> String {
|
||
let hours = seconds / 3600
|
||
let minutes = (seconds % 3600) / 60
|
||
if hours > 0 {
|
||
return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h"
|
||
}
|
||
return "\(minutes)m"
|
||
}
|
||
}
|
||
|
||
/// 成员面板「APP使用记录」:环形图 + TOP5
|
||
final class MemberAppUsageView: UIView {
|
||
|
||
private let titleColor = UIColor(hexStr: "#293445")
|
||
|
||
private let sectionIcon = UIImageView()
|
||
private let sectionTitleLab = UILabel()
|
||
private let cardView = UIView()
|
||
private let donutView = AppUsageDonutView()
|
||
private let totalTimeLab = UILabel()
|
||
private let totalHintLab = UILabel()
|
||
private let usageTitleLab = UILabel()
|
||
private let topTitleLab = UILabel()
|
||
private let topStack = UIStackView()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
configure(with: nil)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(with preview: MemberAppUsagePreview?) {
|
||
topStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
|
||
guard let preview else {
|
||
totalTimeLab.text = ""
|
||
donutView.setSegments(ratios: [], colors: [])
|
||
return
|
||
}
|
||
|
||
totalTimeLab.text = preview.totalTimeText
|
||
donutView.setSegments(ratios: preview.segmentRatios, colors: preview.segmentColors)
|
||
|
||
for item in preview.topApps {
|
||
let row = AppUsageTopRowView()
|
||
row.configure(with: item)
|
||
topStack.addArrangedSubview(row)
|
||
row.layoutChain.height(20)
|
||
}
|
||
}
|
||
|
||
private func setupUI() {
|
||
backgroundColor = .clear
|
||
|
||
sectionIcon.image = UIImage(named: "Home/member_app_usage")
|
||
sectionIcon.contentMode = .scaleAspectFit
|
||
addSubview(sectionIcon)
|
||
sectionIcon.layoutChain
|
||
.top()
|
||
.left(15)
|
||
.width(18)
|
||
.heightToWidth(1)
|
||
|
||
sectionTitleLab.text = "APP使用记录"
|
||
sectionTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||
sectionTitleLab.textColor = titleColor
|
||
addSubview(sectionTitleLab)
|
||
sectionTitleLab.layoutChain
|
||
.leftToRightOfView(sectionIcon, offset: 6)
|
||
.centerY(sectionIcon)
|
||
|
||
cardView.backgroundColor = .white
|
||
cardView.cornerRadius = 16
|
||
addSubview(cardView)
|
||
cardView.layoutChain
|
||
.topToBottomOfView(sectionIcon, offset: 12)
|
||
.edgesHorzontal(15)
|
||
.height(176)
|
||
.bottom()
|
||
|
||
usageTitleLab.attributedText = Self.cardTitle("使用时长")
|
||
cardView.addSubview(usageTitleLab)
|
||
usageTitleLab.layoutChain
|
||
.top(12)
|
||
.left(18)
|
||
|
||
topTitleLab.attributedText = Self.cardTitle("最爱使用TOP5")
|
||
cardView.addSubview(topTitleLab)
|
||
topTitleLab.layoutChain
|
||
.top(12)
|
||
.left(160)
|
||
.right(14)
|
||
|
||
cardView.addSubview(donutView)
|
||
donutView.layoutChain
|
||
.left(18)
|
||
.top(43)
|
||
.width(120)
|
||
.height(120)
|
||
|
||
totalTimeLab.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
totalTimeLab.textColor = titleColor
|
||
totalTimeLab.textAlignment = .center
|
||
cardView.addSubview(totalTimeLab)
|
||
totalTimeLab.layoutChain
|
||
.centerX(donutView)
|
||
.centerY(donutView, offset: -6)
|
||
|
||
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: 2)
|
||
|
||
topStack.axis = .vertical
|
||
topStack.spacing = 6
|
||
topStack.alignment = .fill
|
||
cardView.addSubview(topStack)
|
||
topStack.layoutChain
|
||
.top(43)
|
||
.left(160)
|
||
.right(14)
|
||
}
|
||
|
||
private static func cardTitle(_ title: String) -> NSAttributedString {
|
||
let attributedText = NSMutableAttributedString(
|
||
string: "✦ \(title)",
|
||
attributes: [
|
||
.font: UIFont.systemFont(ofSize: 12, weight: .semibold),
|
||
.foregroundColor: UIColor(hexStr: "#293445")
|
||
]
|
||
)
|
||
attributedText.addAttribute(
|
||
.foregroundColor,
|
||
value: UIColor(hexStr: "#A9F33D"),
|
||
range: NSRange(location: 0, length: 1)
|
||
)
|
||
return attributedText
|
||
}
|
||
}
|
||
|
||
// MARK: - 环形图
|
||
|
||
private final class AppUsageDonutView: UIView {
|
||
|
||
private var segmentLayers: [CAShapeLayer] = []
|
||
private var ratios: [CGFloat] = []
|
||
private var colors: [UIColor] = []
|
||
private let lineWidth: CGFloat = 18
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func setSegments(ratios: [CGFloat], colors: [UIColor]) {
|
||
self.ratios = ratios
|
||
self.colors = colors
|
||
redraw()
|
||
}
|
||
|
||
private func redraw() {
|
||
segmentLayers.forEach { $0.removeFromSuperlayer() }
|
||
segmentLayers.removeAll()
|
||
guard !ratios.isEmpty, bounds.width > 0 else { return }
|
||
|
||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
|
||
var startAngle = -CGFloat.pi / 2
|
||
let total = max(ratios.reduce(0, +), 0.001)
|
||
let segmentGap: CGFloat = ratios.count > 1 ? 0.025 : 0
|
||
|
||
for (i, ratio) in ratios.enumerated() {
|
||
let sweep = (ratio / total) * CGFloat.pi * 2
|
||
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.strokeColor = colors[min(i, colors.count - 1)].cgColor
|
||
layer.fillColor = UIColor.clear.cgColor
|
||
layer.lineWidth = lineWidth
|
||
layer.lineCap = .butt
|
||
self.layer.addSublayer(layer)
|
||
segmentLayers.append(layer)
|
||
startAngle += sweep
|
||
}
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
redraw()
|
||
}
|
||
}
|
||
|
||
// MARK: - TOP 行
|
||
|
||
private final class AppUsageTopRowView: UIView {
|
||
|
||
private var progressRatio: CGFloat = 0
|
||
private var progressWidthConstraint: NSLayoutConstraint?
|
||
private let rankLab = UILabel()
|
||
private let iconView = UIImageView()
|
||
private let nameLab = UILabel()
|
||
private let progressTrack = UIView()
|
||
private let progressFill = UIView()
|
||
private let durationLab = UILabel()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setupUI()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func configure(with item: MemberAppUsageItemPreview) {
|
||
rankLab.text = "\(item.rank)"
|
||
nameLab.text = item.name
|
||
durationLab.text = item.durationText
|
||
iconView.kf.cancelDownloadTask()
|
||
iconView.image = UIImage(named: "LockDistract/app_unknown")
|
||
if let url = URL(string: item.iconURL), !item.iconURL.isEmpty {
|
||
iconView.kf.setImage(with: url, placeholder: iconView.image)
|
||
}
|
||
progressRatio = max(0, min(1, item.progress))
|
||
setNeedsLayout()
|
||
}
|
||
|
||
private func setupUI() {
|
||
rankLab.font = .systemFont(ofSize: 11, weight: .bold)
|
||
rankLab.textColor = UIColor(hexStr: "#353B4F")
|
||
rankLab.textAlignment = .center
|
||
addSubview(rankLab)
|
||
rankLab.layoutChain
|
||
.left()
|
||
.centerY()
|
||
.width(12)
|
||
|
||
iconView.backgroundColor = UIColor(hexStr: "#F4F6F8")
|
||
iconView.contentMode = .scaleAspectFill
|
||
iconView.clipsToBounds = true
|
||
iconView.cornerRadius = 4
|
||
addSubview(iconView)
|
||
iconView.layoutChain
|
||
.leftToRightOfView(rankLab, offset: 4)
|
||
.centerY()
|
||
.width(16)
|
||
.height(16)
|
||
|
||
durationLab.font = .systemFont(ofSize: 10, weight: .regular)
|
||
durationLab.textColor = UIColor(hexStr: "#A8A8A8")
|
||
durationLab.textAlignment = .right
|
||
durationLab.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
addSubview(durationLab)
|
||
durationLab.layoutChain
|
||
.right()
|
||
.centerY()
|
||
.width(40)
|
||
|
||
nameLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||
nameLab.textColor = UIColor(hexStr: "#353B4F")
|
||
nameLab.lineBreakMode = .byTruncatingTail
|
||
addSubview(nameLab)
|
||
nameLab.layoutChain
|
||
.leftToRightOfView(iconView, offset: 5)
|
||
.centerY()
|
||
.width(45)
|
||
|
||
progressTrack.backgroundColor = UIColor(hexStr: "#DDF3FF")
|
||
progressTrack.cornerRadius = 2
|
||
addSubview(progressTrack)
|
||
progressTrack.layoutChain
|
||
.leftToRightOfView(nameLab, offset: 5)
|
||
.rightToLeftOfView(durationLab, offset: -7)
|
||
.centerY()
|
||
.height(4)
|
||
|
||
progressFill.backgroundColor = UIColor(hexStr: "#58C9F7")
|
||
progressFill.cornerRadius = 2
|
||
progressTrack.addSubview(progressFill)
|
||
progressFill.layoutChain
|
||
.left()
|
||
.edgesVertical()
|
||
progressWidthConstraint = progressFill.widthAnchor.constraint(equalTo: progressTrack.widthAnchor, multiplier: 0)
|
||
progressWidthConstraint?.isActive = true
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
progressWidthConstraint?.isActive = false
|
||
progressWidthConstraint = progressFill.widthAnchor.constraint(
|
||
equalTo: progressTrack.widthAnchor,
|
||
multiplier: progressRatio
|
||
)
|
||
progressWidthConstraint?.isActive = true
|
||
}
|
||
}
|