// // 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 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 refresh(userId: String) { guard !userId.isEmpty, !groupKey.isEmpty else { snapshot.accept(Self.emptySnapshot()) return } let cacheKey = "\(groupKey)|\(userId)" if let cached = reportCache[cacheKey] { snapshot.accept(makeSnapshot(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 { return } self.reportCache[cacheKey] = report guard self.selectedMemberId.value == userId else { return } self.snapshot.accept(self.makeSnapshot(report: report)) }, onError: { [weak self] _ in guard let self, self.requestIDs[cacheKey] == requestID else { return } self.requestIDs.removeValue(forKey: cacheKey) }) .disposed(by: disposeBag) } private func makeSnapshot(report: PhoneUsageReportModel) -> StatusSnapshot { let info = report.phoneInfo let storageRatio = Self.storageRatio(used: info?.memoryUsed, total: info?.memoryTotal) 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: String if let todayScreenSeconds { screenTodayText = Self.formatDuration(todayScreenSeconds) } else { screenTodayText = placeholder } let appUsageTodayText = todayAppUsageCount.map { "\($0)次" } ?? placeholder return StatusSnapshot( batteryText: Self.displayText(info?.battery, placeholder: placeholder), batteryPercent: Self.percent(from: info?.battery), isCharging: false, estimateText: 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: storageRatio, brandIconURL: Self.validHTTPURLString(report.brandIcon) ?? "", screenTodayText: screenTodayText, appUsageTodayText: appUsageTodayText, screenBars: screenBars, appUsageBars: 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 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)分钟" } }