457 lines
16 KiB
Swift
457 lines
16 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 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<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) {
|
||
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<URLResourceKey> = [
|
||
.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
|
||
)
|
||
}
|
||
}
|
||
}
|