// // 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 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, 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) { 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) 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 UIDevice.current.isBatteryMonitoringEnabled = true let batteryLevel = UIDevice.current.batteryLevel let batteryValid = batteryLevel >= 0 let battery = Int(batteryLevel * 100) let screenSec = mgr.todayScreenTimeSeconds let unlock = mgr.todayCount let usage = mgr.todayAppUsageCount let batteryText = batteryValid ? "\(battery)%" : placeholder let batteryPercent: Int? = batteryValid ? battery : nil 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, estimateText: placeholder, brightnessText: brightnessText, volumeText: volumeText, wifiText: wifiText, deviceText: UIDevice.current.model, 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, 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, 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()) } } private static func storageText() -> String? { guard let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), let total = attrs[.systemSize] as? NSNumber, let free = attrs[.systemFreeSize] as? NSNumber else { return nil } let used = total.doubleValue - free.doubleValue let usedGB = used / 1_073_741_824 let totalGB = total.doubleValue / 1_073_741_824 return String(format: "%.1fGB/%.0fGB", usedGB, totalGB) } private static func storageRatio() -> CGFloat? { guard let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), let total = attrs[.systemSize] as? NSNumber, let free = attrs[.systemFreeSize] as? NSNumber, total.doubleValue > 0 else { return nil } return CGFloat((total.doubleValue - free.doubleValue) / total.doubleValue) } private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" let byDay = Defaults[\.screenTimeByDay] return (0..<7).map { offset -> ChartBar in let dayOffset = offset - 6 let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today let key = formatter.string(from: date) let label = formatter.string(from: date).suffix(5).replacingOccurrences(of: "-", with: "/") let value: Int if dayOffset == 0 { value = mgr.todayScreenTimeSeconds } else { value = byDay[key] ?? 0 } // week mode: keep same day bars for now (UI toggle still works) _ = mode return ChartBar(label: String(label), value: value, isToday: dayOffset == 0) } } private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let formatter = DateFormatter() formatter.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 isToday = dayOffset == 0 _ = mode return ChartBar( label: formatter.string(from: date), value: isToday ? unlock : 0, isToday: isToday ) } } }