jsdw_ios/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel....

255 lines
9.5 KiB
Swift

//
// 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<String>
let snapshot: BehaviorRelay<StatusSnapshot>
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)分钟"
}
}