363 lines
13 KiB
Swift
363 lines
13 KiB
Swift
//
|
||
// 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<String>
|
||
let screenMode = BehaviorRelay<ChartMode>(value: .day)
|
||
let unlockMode = BehaviorRelay<ChartMode>(value: .day)
|
||
let snapshot: BehaviorRelay<StatusSnapshot>
|
||
|
||
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
|
||
)
|
||
}
|
||
}
|
||
}
|