255 lines
9.5 KiB
Swift
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)分钟"
|
|
}
|
|
}
|