// // AppUsageReportViewModel.swift // QuickLocation // import Foundation import RxSwift import RxCocoa import UIKit enum AppUsageReportPeriod: String { case week case month var title: String { switch self { case .week: return "周" case .month: return "月" } } var maximumUsageSeconds: Int { switch self { case .week: return 24 * 7 * 60 * 60 case .month: return 24 * 31 * 60 * 60 } } } final class AppUsageReportViewModel { enum ContentState { case loading case content case empty case failure } struct AppItem { let rank: Int let name: String let iconURL: String let usageTime: Int let percentage: Int let color: UIColor } struct Snapshot { let state: ContentState let period: AppUsageReportPeriod let totalUsageTime: Int let apps: [AppItem] } let members: [GroupMemberModel] let selectedMemberId: BehaviorRelay let selectedPeriod = BehaviorRelay(value: .week) let snapshot = BehaviorRelay(value: .init(state: .loading, period: .week, totalUsageTime: 0, apps: [])) private let groupKey: String private let disposeBag = DisposeBag() private var reportCache: [String: PhoneUsagePeriodModel] = [:] private var requestIDs: [String: UUID] = [:] init(members: [GroupMemberModel], selectedUserId: String, groupKey: String) { self.members = members self.groupKey = groupKey self.selectedMemberId = BehaviorRelay(value: selectedUserId) let selectedUser = selectedMemberId.asObservable() let selectedUsagePeriod = selectedPeriod.asObservable() let selection: Observable<(String, AppUsageReportPeriod)> = Observable.combineLatest( selectedUser, selectedUsagePeriod ) selection .distinctUntilChanged { previous, next in previous.0 == next.0 && previous.1 == next.1 } .subscribe(onNext: { [weak self] selection in self?.refresh(userId: selection.0, period: selection.1) }) .disposed(by: disposeBag) } private func refresh(userId: String, period: AppUsageReportPeriod) { guard !userId.isEmpty, !groupKey.isEmpty else { snapshot.accept(makeSnapshot(state: .empty, period: period, report: nil)) return } let cacheKey = cacheKey(userId: userId, period: period) if let report = reportCache[cacheKey] { snapshot.accept(makeSnapshot(state: report.favoriteApps.isEmpty ? .empty : .content, period: period, report: report)) return } snapshot.accept(makeSnapshot(state: .loading, period: period, report: nil)) guard requestIDs[cacheKey] == nil else { return } let requestID = UUID() requestIDs[cacheKey] = requestID UserService.phoneUsagePeriod(userId: userId, groupKey: groupKey, period: period.rawValue) .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 { self.applyFailureIfCurrent(userId: userId, period: period) return } self.reportCache[cacheKey] = report guard self.selectedMemberId.value == userId, self.selectedPeriod.value == period else { return } self.snapshot.accept( self.makeSnapshot( state: report.favoriteApps.isEmpty ? .empty : .content, period: period, report: report ) ) }, onError: { [weak self] _ in guard let self, self.requestIDs[cacheKey] == requestID else { return } self.requestIDs.removeValue(forKey: cacheKey) self.applyFailureIfCurrent(userId: userId, period: period) }) .disposed(by: disposeBag) } private func applyFailureIfCurrent(userId: String, period: AppUsageReportPeriod) { guard selectedMemberId.value == userId, selectedPeriod.value == period else { return } snapshot.accept(makeSnapshot(state: .failure, period: period, report: nil)) } private func cacheKey(userId: String, period: AppUsageReportPeriod) -> String { "\(groupKey)|\(userId)|\(period.rawValue)" } private func makeSnapshot( state: ContentState, period: AppUsageReportPeriod, report: PhoneUsagePeriodModel? ) -> Snapshot { let rawApps = Array((report?.favoriteApps ?? []).filter { $0.usageTime > 0 }.prefix(5)) let totalUsageTime = max(report?.totalUsageTime ?? 0, 0) let colors = Self.itemColors let apps = rawApps.enumerated().map { index, app in let usageTime = max(app.usageTime, 0) let fallbackPercentage = totalUsageTime > 0 ? Int((Double(usageTime) / Double(totalUsageTime) * 100).rounded()) : 0 let percentage = min(max(app.percentage ?? fallbackPercentage, 0), 100) return AppItem( rank: index + 1, name: app.appName.isEmpty ? "未知应用" : app.appName, iconURL: app.appIcon, usageTime: usageTime, percentage: percentage, color: colors[index % colors.count] ) } let resolvedState: ContentState = apps.isEmpty && state == .content ? .empty : state return Snapshot(state: resolvedState, period: period, totalUsageTime: totalUsageTime, apps: apps) } private static let itemColors = [ UIColor(hexStr: "#C3ECFF"), UIColor(hexStr: "#E3FF9C"), UIColor(hexStr: "#FFEAA4"), UIColor(hexStr: "#C5FFB7"), UIColor(hexStr: "#FFDCF5") ] } extension AppUsageReportViewModel.AppItem { var durationText: String { let hours = usageTime / 3600 let minutes = (usageTime % 3600) / 60 if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } return "\(minutes)m" } }