// // PhoneReportDetailViewModel.swift // QuickLocation // import Foundation import RxSwift import RxCocoa import UIKit final class PhoneReportDetailViewModel { struct ChartBar { let label: String let value: Int let isToday: Bool } struct StatusSnapshot { let batteryText: String let batteryPercent: Int? let isCharging: Bool let estimateText: String let brightnessText: String let volumeText: String let wifiText: String let deviceText: String let storageText: String let storageRatio: CGFloat let brandIconURL: String let screenTodayText: String let appUsageTodayText: String let screenBars: [ChartBar] let appUsageBars: [ChartBar] } let members: [GroupMemberModel] let selectedMemberId: BehaviorRelay let snapshot: BehaviorRelay private let groupKey: String private let disposeBag = DisposeBag() private var statusObserveBag = DisposeBag() private let placeholder = "**" private var reportCache: [String: PhoneUsageReportModel] = [:] private var requestIDs: [String: UUID] = [:] init(members: [GroupMemberModel], selectedUserId: String, groupKey: String) { self.members = members self.groupKey = groupKey self.selectedMemberId = BehaviorRelay(value: selectedUserId) self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) selectedMemberId .asObservable() .distinctUntilChanged() .subscribe(onNext: { [weak self] userId in self?.refresh(userId: userId) }) .disposed(by: disposeBag) } private func isCurrentUser(_ userId: String) -> Bool { !userId.isEmpty && userId == AppContextManager.shared.userId } private func refresh(userId: String) { let isSelf = isCurrentUser(userId) updateStatusObserving(isSelf: isSelf) guard !userId.isEmpty else { snapshot.accept(Self.emptySnapshot()) return } if groupKey.isEmpty { snapshot.accept(isSelf ? makeLocalStatusSnapshot(report: nil) : Self.emptySnapshot()) return } let cacheKey = "\(groupKey)|\(userId)" let cached = reportCache[cacheKey] if isSelf { snapshot.accept(makeLocalStatusSnapshot(report: cached)) } else if let cached { snapshot.accept(makeRemoteSnapshot(report: cached)) } else { snapshot.accept(Self.emptySnapshot()) } guard requestIDs[cacheKey] == nil else { return } let requestID = UUID() requestIDs[cacheKey] = requestID UserService.phoneUsageReport(userId: userId, groupKey: groupKey) .subscribe(onNext: { [weak self] response in guard let self, self.requestIDs[cacheKey] == requestID else { return } self.requestIDs.removeValue(forKey: cacheKey) guard response.code == "0", let report = response.model else { guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return } self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) return } self.reportCache[cacheKey] = report guard self.selectedMemberId.value == userId else { return } self.snapshot.accept(self.makeSnapshot(report: report, userId: userId)) }, onError: { [weak self] _ in guard let self, self.requestIDs[cacheKey] == requestID else { return } self.requestIDs.removeValue(forKey: cacheKey) guard self.selectedMemberId.value == userId, self.isCurrentUser(userId) else { return } self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) }) .disposed(by: disposeBag) } private func updateStatusObserving(isSelf: Bool) { statusObserveBag = DisposeBag() guard isSelf else { return } PhoneStatusCollector.shared.start() NotificationCenter.default.rx.notification(.phoneStatusDidChange) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] _ in guard let self else { return } let userId = self.selectedMemberId.value guard self.isCurrentUser(userId) else { return } let cacheKey = "\(self.groupKey)|\(userId)" self.snapshot.accept(self.makeLocalStatusSnapshot(report: self.reportCache[cacheKey])) }) .disposed(by: statusObserveBag) } private func makeSnapshot(report: PhoneUsageReportModel, userId: String) -> StatusSnapshot { if isCurrentUser(userId) { return makeLocalStatusSnapshot(report: report) } return makeRemoteSnapshot(report: report) } private func makeLocalStatusSnapshot(report: PhoneUsageReportModel?) -> StatusSnapshot { let local = PhoneStatusCollector.shared.snapshot() let charts = chartFields(from: report) return StatusSnapshot( batteryText: local.batteryPercent == nil ? placeholder : local.batteryText, batteryPercent: local.batteryPercent, isCharging: local.isCharging, estimateText: Self.estimatedUsageText( batteryPercent: local.batteryPercent, placeholder: placeholder ), brightnessText: local.brightnessText, volumeText: local.volumeText, wifiText: local.network, deviceText: local.model, storageText: local.memoryUsed == nil ? placeholder : local.storageText, storageRatio: local.storageRatio, brandIconURL: Self.validHTTPURLString(report?.brandIcon ?? "") ?? "", screenTodayText: charts.screenTodayText, appUsageTodayText: charts.appUsageTodayText, screenBars: charts.screenBars, appUsageBars: charts.appUsageBars ) } private func makeRemoteSnapshot(report: PhoneUsageReportModel) -> StatusSnapshot { let info = report.phoneInfo let charts = chartFields(from: report) let batteryPercent = Self.percent(from: info?.battery) return StatusSnapshot( batteryText: Self.displayText(info?.battery, placeholder: placeholder), batteryPercent: batteryPercent, isCharging: false, estimateText: Self.estimatedUsageText( batteryPercent: batteryPercent, placeholder: placeholder ), brightnessText: Self.displayText(info?.brightness, placeholder: placeholder), volumeText: Self.displayText(info?.volume, placeholder: placeholder), wifiText: Self.displayText(info?.network, placeholder: placeholder), deviceText: Self.displayText(info?.model, placeholder: placeholder), storageText: Self.storageText(used: info?.memoryUsed, total: info?.memoryTotal, placeholder: placeholder), storageRatio: Self.storageRatio(used: info?.memoryUsed, total: info?.memoryTotal), brandIconURL: Self.validHTTPURLString(report.brandIcon) ?? "", screenTodayText: charts.screenTodayText, appUsageTodayText: charts.appUsageTodayText, screenBars: charts.screenBars, appUsageBars: charts.appUsageBars ) } private func chartFields(from report: PhoneUsageReportModel?) -> ( screenTodayText: String, appUsageTodayText: String, screenBars: [ChartBar], appUsageBars: [ChartBar] ) { guard let report else { return (placeholder, placeholder, [], []) } let info = report.phoneInfo let screenBars = Self.makeBars(from: report.screenUseTimes, value: { $0.useTime }) let appUsageBars = Self.makeBars(from: report.appUseCounts, value: { $0.count }) let todayScreenSeconds = Self.todayValue(in: report.screenUseTimes, value: { $0.useTime }) ?? Self.nonnegativeInt(from: info?.useTime) let todayAppUsageCount = Self.todayValue(in: report.appUseCounts, value: { $0.count }) let screenTodayText = todayScreenSeconds.map { Self.formatDuration($0) } ?? placeholder let appUsageTodayText = todayAppUsageCount.map { "\($0)次" } ?? placeholder return (screenTodayText, appUsageTodayText, screenBars, appUsageBars) } private static func emptySnapshot() -> StatusSnapshot { StatusSnapshot( batteryText: "**", batteryPercent: nil, isCharging: false, estimateText: "**", brightnessText: "**", volumeText: "**", wifiText: "**", deviceText: "**", storageText: "**", storageRatio: 0, brandIconURL: "", screenTodayText: "**", appUsageTodayText: "**", screenBars: [], appUsageBars: [] ) } private static func makeBars( from entries: [PhoneUsageReportDayModel], value: (PhoneUsageReportDayModel) -> Int? ) -> [ChartBar] { entries.compactMap { entry -> (Date, Int)? in guard let date = reportDateFormatter.date(from: entry.day), let rawValue = value(entry) else { return nil } return (date, max(0, rawValue)) } .sorted { $0.0 < $1.0 } .map { date, value in ChartBar( label: chartDateFormatter.string(from: date), value: value, isToday: Calendar.current.isDateInToday(date) ) } } private static func todayValue( in entries: [PhoneUsageReportDayModel], value: (PhoneUsageReportDayModel) -> Int? ) -> Int? { for entry in entries { guard let date = reportDateFormatter.date(from: entry.day), Calendar.current.isDateInToday(date), let rawValue = value(entry) else { continue } return max(0, rawValue) } return nil } private static let reportDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .gregorian) formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" return formatter }() private static let chartDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .gregorian) formatter.timeZone = TimeZone.current formatter.dateFormat = "M/d" return formatter }() private static func displayText(_ value: String?, placeholder: String) -> String { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? placeholder : text } private static func nonnegativeInt(from value: String?) -> Int? { guard let value, let number = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } return max(0, number) } private static func percent(from value: String?) -> Int? { guard let number = firstNumber(in: value) else { return nil } return max(0, min(100, Int(number.rounded()))) } private static func estimatedUsageText(batteryPercent: Int?, placeholder: String) -> String { guard let batteryPercent else { return placeholder } let estimatedMinutes = batteryPercent * 9 let hours = max(1, Int((Double(estimatedMinutes) / 60).rounded())) return "\(hours)小时" } private static func storageRatio(used: String?, total: String?) -> CGFloat { guard let usedValue = firstNumber(in: used), let totalValue = firstNumber(in: total), totalValue > 0 else { return 0 } return CGFloat(max(0, min(1, usedValue / totalValue))) } private static func storageText(used: String?, total: String?, placeholder: String) -> String { let usedText = used?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let totalText = total?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !usedText.isEmpty, !totalText.isEmpty else { return placeholder } return "\(usedText)/\(totalText)" } private static func firstNumber(in value: String?) -> Double? { guard let value, let range = value.range(of: #"\d+(?:\.\d+)?"#, options: .regularExpression) else { return nil } return Double(value[range]) } private static func validHTTPURLString(_ value: String) -> String? { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) guard let components = URLComponents(string: text), let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https", components.host != nil else { return nil } return text } private static func formatDuration(_ seconds: Int) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 if hours > 0 { return minutes > 0 ? "\(hours)小时\(minutes)分" : "\(hours)小时" } return "\(minutes)分钟" } }