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