// // PhoneReportDetailViewModel.swift // QuickLocation // import Foundation import RxSwift import RxCocoa import UIKit import AVFoundation import SystemConfiguration.CaptiveNetwork import NetworkExtension import CoreLocation import SwiftyUserDefaults final class PhoneReportDetailViewModel { enum ChartMode: Equatable { case day case week } 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 screenTodayText: String let unlockTodayText: String let usageTodayText: String let screenBars: [ChartBar] let unlockBars: [ChartBar] func replacing(wifiText: String? = nil, volumeText: String? = nil) -> StatusSnapshot { StatusSnapshot( batteryText: batteryText, batteryPercent: batteryPercent, isCharging: isCharging, estimateText: estimateText, brightnessText: brightnessText, volumeText: volumeText ?? self.volumeText, wifiText: wifiText ?? self.wifiText, deviceText: deviceText, storageText: storageText, storageRatio: storageRatio, screenTodayText: screenTodayText, unlockTodayText: unlockTodayText, usageTodayText: usageTodayText, screenBars: screenBars, unlockBars: unlockBars ) } } let members: [GroupMemberModel] let selectedMemberId: BehaviorRelay let screenMode = BehaviorRelay(value: .day) let unlockMode = BehaviorRelay(value: .day) let snapshot: BehaviorRelay private let disposeBag = DisposeBag() private let placeholder = "**" private var volumeObservation: NSKeyValueObservation? init(members: [GroupMemberModel], selectedUserId: String) { UIDevice.current.isBatteryMonitoringEnabled = true self.members = members self.selectedMemberId = BehaviorRelay(value: selectedUserId) self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) bindInputs() refresh(userId: selectedUserId) } private func bindInputs() { selectedMemberId .asObservable() .skip(1) .subscribe(onNext: { [weak self] userId in self?.refresh(userId: userId) }) .disposed(by: disposeBag) screenMode .asObservable() .skip(1) .subscribe(onNext: { [weak self] _ in guard let self else { return } self.refresh(userId: self.selectedMemberId.value) }) .disposed(by: disposeBag) unlockMode .asObservable() .skip(1) .subscribe(onNext: { [weak self] _ in guard let self else { return } self.refresh(userId: self.selectedMemberId.value) }) .disposed(by: disposeBag) NotificationCenter.default.rx.notification(.unlockCountDidChange) .subscribe(onNext: { [weak self] _ in guard let self else { return } self.refresh(userId: self.selectedMemberId.value) }) .disposed(by: disposeBag) // 与系统状态栏同步:电量 / 充电状态变化时刷新 let batteryCenter = NotificationCenter.default Observable.merge( batteryCenter.rx.notification(UIDevice.batteryLevelDidChangeNotification), batteryCenter.rx.notification(UIDevice.batteryStateDidChangeNotification) ) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] _ in guard let self else { return } guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } self.refresh(userId: self.selectedMemberId.value) }) .disposed(by: disposeBag) observeSystemVolume() } private func observeSystemVolume() { let session = AVAudioSession.sharedInstance() try? session.setActive(true) volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, _ in DispatchQueue.main.async { guard let self else { return } guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } let volume = Self.volumeText() ?? self.placeholder self.snapshot.accept(self.snapshot.value.replacing(volumeText: volume)) } } } private func refresh(userId: String) { let isSelf = userId == AppContextManager.shared.userId let member = members.first { $0.user_id == userId } if isSelf { snapshot.accept(makeSelfSnapshot()) } else { snapshot.accept(makeOtherSnapshot(member: member)) } } private func makeSelfSnapshot() -> StatusSnapshot { let mgr = UnlockCountManager.shared let batteryPercent = UIDevice.batteryPercent let isCharging = UIDevice.isBatteryCharging let screenSec = mgr.todayScreenTimeSeconds let unlock = mgr.todayCount let usage = mgr.todayAppUsageCount let batteryText = batteryPercent.map { "\($0)%" } ?? placeholder let brightnessText = Self.brightnessText() ?? placeholder let volumeText = Self.volumeText() ?? placeholder let wifiText = Self.wifiSSID() ?? placeholder let storageText = Self.storageText() ?? placeholder let storageRatio = Self.storageRatio() ?? 0 let screenTodayText = screenSec > 0 ? Self.formatDuration(screenSec) : placeholder let screenBars = Self.screenBars(from: mgr, mode: screenMode.value) let unlockBars = Self.unlockBars(unlock: unlock, mode: unlockMode.value) // 异步补拉 SSID(iOS 14+),成功后再刷新一次 Self.fetchWifiSSIDAsync { [weak self] ssid in guard let self, let ssid, !ssid.isEmpty else { return } guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } let current = self.snapshot.value guard current.wifiText != ssid else { return } self.snapshot.accept(current.replacing(wifiText: ssid)) } return StatusSnapshot( batteryText: batteryText, batteryPercent: batteryPercent, isCharging: isCharging, estimateText: placeholder, brightnessText: brightnessText, volumeText: volumeText, wifiText: wifiText, deviceText: UIDevice.modelName, storageText: storageText, storageRatio: storageRatio, screenTodayText: screenTodayText, unlockTodayText: "\(unlock)", usageTodayText: "\(usage)", screenBars: screenBars, unlockBars: unlockBars ) } private func makeOtherSnapshot(member: GroupMemberModel?) -> StatusSnapshot { let battery = Int(member?.battery.int ?? 0) let hasBattery = battery > 0 return StatusSnapshot( batteryText: hasBattery ? "\(battery)%" : placeholder, batteryPercent: hasBattery ? battery : nil, isCharging: false, estimateText: placeholder, brightnessText: placeholder, volumeText: placeholder, wifiText: placeholder, deviceText: placeholder, storageText: placeholder, storageRatio: 0, screenTodayText: placeholder, unlockTodayText: placeholder, usageTodayText: placeholder, screenBars: [], unlockBars: [] ) } private static func emptySnapshot() -> StatusSnapshot { StatusSnapshot( batteryText: "**", batteryPercent: nil, isCharging: false, estimateText: "**", brightnessText: "**", volumeText: "**", wifiText: "**", deviceText: "**", storageText: "**", storageRatio: 0, screenTodayText: "**", unlockTodayText: "**", usageTodayText: "**", screenBars: [], unlockBars: [] ) } private static func formatDuration(_ 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" } private static func brightnessText() -> String? { let value = UIScreen.main.brightness guard value >= 0 else { return nil } return "\(Int(value * 100))%" } private static func volumeText() -> String? { let session = AVAudioSession.sharedInstance() do { try session.setActive(true) } catch { // 激活失败仍尝试读取当前值 } let volume = session.outputVolume guard volume >= 0 else { return nil } return "\(Int((volume * 100).rounded()))%" } private static func hasLocationPermissionForWifi() -> Bool { let status = CLLocationManager.authorizationStatus() switch status { case .authorizedAlways, .authorizedWhenInUse: return true default: return false } } private static func wifiSSID() -> String? { guard hasLocationPermissionForWifi() else { return nil } guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil } for name in interfaces { if let info = CNCopyCurrentNetworkInfo(name as CFString) as? [String: Any], let ssid = info[kCNNetworkInfoKeySSID as String] as? String, !ssid.isEmpty { return ssid } } return nil } /// iOS 14+ 异步获取当前 Wi‑Fi SSID(需定位权限 + Access Wi‑Fi Information) private static func fetchWifiSSIDAsync(completion: @escaping (String?) -> Void) { guard hasLocationPermissionForWifi() else { completion(nil) return } if #available(iOS 14.0, *) { NEHotspotNetwork.fetchCurrent { network in DispatchQueue.main.async { let ssid = network?.ssid completion((ssid?.isEmpty == false) ? ssid : nil) } } } else { completion(wifiSSID()) } } /// 与「设置 → 通用 → iPhone 存储空间」一致:十进制 GB + ImportantUsage 可用容量 private static func storageBytes() -> (used: Int64, total: Int64)? { let url = URL(fileURLWithPath: NSHomeDirectory()) let keys: Set = [ .volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey ] guard let values = try? url.resourceValues(forKeys: keys), let total = values.volumeTotalCapacity, let available = values.volumeAvailableCapacityForImportantUsage, total > 0 else { return nil } let used = max(0, Int64(total) - available) return (used, Int64(total)) } private static func formatStorageGB(_ bytes: Int64, fractionDigits: Int) -> String { // Settings 使用十进制(1GB = 1e9),128G 机型会显示约 128GB 而非 119GB let gb = Double(bytes) / 1_000_000_000 return String(format: "%.\(fractionDigits)fGB", gb) } private static func storageText() -> String? { guard let info = storageBytes() else { return nil } let usedText = formatStorageGB(info.used, fractionDigits: 1) let totalText = formatStorageGB(info.total, fractionDigits: 0) return "\(usedText)/\(totalText)" } private static func storageRatio() -> CGFloat? { guard let info = storageBytes(), info.total > 0 else { return nil } return CGFloat(Double(info.used) / Double(info.total)) } private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] { let byDay = Defaults[\.screenTimeByDay] let todayValue = mgr.todayScreenTimeSeconds switch mode { case .day: return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDaySlash) case .week: return weekBars(byDay: byDay, todayValue: todayValue) } } private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] { var byDay = Defaults[\.unlockCountByDay] let todayKey = dayKey(for: Date()) byDay[todayKey] = unlock switch mode { case .day: return dayBars(byDay: byDay, todayValue: unlock, labelStyle: .monthDay) case .week: return weekBars(byDay: byDay, todayValue: unlock) } } private enum DayLabelStyle { case monthDaySlash // 08/04 case monthDay // 8/4 } private static func dayKey(for date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar.current formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } private static func dayBars( byDay: [String: Int], todayValue: Int, labelStyle: DayLabelStyle ) -> [ChartBar] { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let labelFormatter = DateFormatter() labelFormatter.locale = Locale(identifier: "en_US_POSIX") switch labelStyle { case .monthDaySlash: labelFormatter.dateFormat = "MM/dd" case .monthDay: labelFormatter.dateFormat = "M/d" } return (0..<7).map { offset -> ChartBar in let dayOffset = offset - 6 let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today let key = dayKey(for: date) let isToday = dayOffset == 0 let value = isToday ? todayValue : (byDay[key] ?? 0) return ChartBar( label: labelFormatter.string(from: date), value: value, isToday: isToday ) } } /// 近 7 周:每周一根柱,值为该周内按日数据之和;label 为周起始日 M/d private static func weekBars(byDay: [String: Int], todayValue: Int) -> [ChartBar] { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) guard let currentWeek = calendar.dateInterval(of: .weekOfYear, for: today) else { return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDay) } let labelFormatter = DateFormatter() labelFormatter.locale = Locale(identifier: "en_US_POSIX") labelFormatter.dateFormat = "M/d" let todayKey = dayKey(for: today) return (0..<7).map { offset -> ChartBar in let weekOffset = offset - 6 let weekStart = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: currentWeek.start) ?? currentWeek.start let weekInterval = calendar.dateInterval(of: .weekOfYear, for: weekStart) ?? DateInterval(start: weekStart, duration: 7 * 24 * 3600) var sum = 0 var cursor = weekInterval.start while cursor < weekInterval.end { let key = dayKey(for: cursor) if key == todayKey { sum += todayValue } else { sum += byDay[key] ?? 0 } guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = next } return ChartBar( label: labelFormatter.string(from: weekInterval.start), value: sum, isToday: weekOffset == 0 ) } } }