diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 4a86c5af..c1d77aad 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -327,6 +327,9 @@ 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; }; 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; }; 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */; }; + 55B240013040000100784901 /* AppUsageReportVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B240003040000100784900 /* AppUsageReportVC.swift */; }; + 55B240033040000100784903 /* AppUsageReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B240023040000100784902 /* AppUsageReportView.swift */; }; + 55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B240043040000100784904 /* AppUsageReportViewModel.swift */; }; 55B218A13024A00100784702 /* AboutVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784701 /* AboutVC.swift */; }; 55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; }; 55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.swift */; }; @@ -769,6 +772,9 @@ 55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = ""; }; 55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = ""; }; 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = ""; }; + 55B240003040000100784900 /* AppUsageReportVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportVC.swift; sourceTree = ""; }; + 55B240023040000100784902 /* AppUsageReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportView.swift; sourceTree = ""; }; + 55B240043040000100784904 /* AppUsageReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportViewModel.swift; sourceTree = ""; }; 55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = ""; }; 55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = ""; }; @@ -1324,6 +1330,7 @@ 55B217C43022F0A000784724 /* MemberAppUsageView.swift */, 55B217DD302301000078473C /* TodayTrackDetail */, 55B217DE302301000078473D /* PhoneReportDetail */, + 55B240063040000100784906 /* AppUsageReport */, 30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */, 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */, 30D87CDC2FDFF07500E958FD /* InteractionView.swift */, @@ -2045,6 +2052,16 @@ path = PhoneReportDetail; sourceTree = ""; }; + 55B240063040000100784906 /* AppUsageReport */ = { + isa = PBXGroup; + children = ( + 55B240003040000100784900 /* AppUsageReportVC.swift */, + 55B240023040000100784902 /* AppUsageReportView.swift */, + 55B240043040000100784904 /* AppUsageReportViewModel.swift */, + ); + path = AppUsageReport; + sourceTree = ""; + }; 55B218A13024A00100784710 /* About */ = { isa = PBXGroup; children = ( @@ -2522,6 +2539,9 @@ 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */, 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */, 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */, + 55B240013040000100784901 /* AppUsageReportVC.swift in Sources */, + 55B240033040000100784903 /* AppUsageReportView.swift in Sources */, + 55B240053040000100784905 /* AppUsageReportViewModel.swift in Sources */, 55B218A13024A00100784702 /* AboutVC.swift in Sources */, 55B218A13024A00100784704 /* AboutView.swift in Sources */, 55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */, diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index f633347b..9ab53e04 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -28,6 +28,9 @@ enum UserAPI { /// 成员今日手机使用数据 case phoneUsageToday(userId: String, groupKey: String) + /// 成员 APP 周期使用报告 + case phoneUsagePeriod(userId: String, groupKey: String, period: String) + /// 成员手机使用报告 case phoneUsageReport(userId: String, groupKey: String) @@ -109,6 +112,8 @@ extension UserAPI: MultiTargetProtocol { return "mapi/user/status" case .phoneUsageToday: return "mapi/phone/usage/today" + case .phoneUsagePeriod: + return "mapi/phone/usage/period" case .phoneUsageReport: return "mapi/phone/usage/report" case .imToken: @@ -150,7 +155,7 @@ extension UserAPI: MultiTargetProtocol { var method: Moya.Method { switch self { - case .userInfo, .userStatus, .phoneUsageToday, .phoneUsageReport, .signInInfo, .notice, .followList, .relations: + case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .signInInfo, .notice, .followList, .relations: return .get case .changePhone, .setGender: return .put @@ -183,6 +188,14 @@ extension UserAPI: MultiTargetProtocol { ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + case let .phoneUsagePeriod(userId, groupKey, period): + let params: Parameters = [ + "user_id": userId, + "group_key": groupKey, + "period": period + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + case let .phoneUsageReport(userId, groupKey): let params: Parameters = [ "user_id": userId, diff --git a/QuickLocation/Manager/Account/UserConfigModel.swift b/QuickLocation/Manager/Account/UserConfigModel.swift index 48270d92..7b6439bc 100644 --- a/QuickLocation/Manager/Account/UserConfigModel.swift +++ b/QuickLocation/Manager/Account/UserConfigModel.swift @@ -76,6 +76,9 @@ struct SystemConfigModel: Mappable { /// 聊天界面的警告说明 var chatWarning: String = "" + /// 探索里的banner配置 + var bannerList: [BannerConfigModel] = [] + /// 圈子里的banner var groupBannerList: [String] = [] @@ -103,6 +106,7 @@ struct SystemConfigModel: Mappable { guideDisplay <- map["client.guide_display", nested: false] globalDisplay <- map["client.global_display", nested: false] versionUpgradeModel <- map["client.version.upgrade", nested: false] + bannerList <- map["client.banner.explore", nested: false] } } @@ -205,3 +209,28 @@ struct VersionUpgradeModel: Mappable { version <- map["version"] } } + +struct BannerConfigModel: Mappable { + /// 图片地址 + var img: String = "" + /// url:member跳充值页 url:网页地址 + var url: String = "" + /// app:原生应用内跳转 web:跳转网页 + var type: String = "" + + var enable: Bool = false + /// 备注 + var text: String = "" + + init?(map: Map) { + + } + + mutating func mapping(map: Map) { + img <- map["img"] + url <- map["url"] + type <- map["type"] + enable <- map["enable"] + text <- map["text"] + } +} diff --git a/QuickLocation/Manager/Account/UserConfigResponse.swift b/QuickLocation/Manager/Account/UserConfigResponse.swift index 0f29950a..716c002e 100644 --- a/QuickLocation/Manager/Account/UserConfigResponse.swift +++ b/QuickLocation/Manager/Account/UserConfigResponse.swift @@ -118,6 +118,7 @@ struct PhoneUsageTodayModel: Mappable { var appCount: Int? var phoneInfo: PhoneUsageInfoModel? var stayPoints: [PhoneUsageStayPointModel] = [] + var topApps: [PhoneUsageAppModel] = [] init?(map: Map) {} @@ -125,6 +126,58 @@ struct PhoneUsageTodayModel: Mappable { appCount <- (map["app_count"], kStrTransformInt) phoneInfo <- map["phone_info"] stayPoints <- map["stay_points"] + topApps <- map["top_apps"] + } +} + +struct PhoneUsageAppModel: Mappable { + var package: String = "" + var appName: String = "" + var appIcon: String = "" + var usageTime: Int = 0 + var percentage: Int? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + package <- (map["package"], kIntTransformStr) + appName <- (map["app_name"], kIntTransformStr) + appIcon <- (map["app_icon"], kIntTransformStr) + if appIcon.isEmpty { + appIcon <- (map["icon"], kIntTransformStr) + } + usageTime <- (map["usage_time"], kStrTransformInt) + percentage <- (map["percentage"], kStrTransformInt) + } +} + +struct PhoneUsagePeriodResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneUsagePeriodModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + model <- map["data"] + } +} + +struct PhoneUsagePeriodModel: Mappable { + var favoriteApps: [PhoneUsageAppModel] = [] + var totalUsageTime: Int = 0 + var useTime: String = "" + var unlockCount: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + favoriteApps <- map["favorite_apps"] + totalUsageTime <- (map["total_usage_time"], kStrTransformInt) + useTime <- (map["use_time"], kIntTransformStr) + unlockCount <- (map["unlock_count"], kIntTransformStr) } } diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 41e5e075..d0b1a363 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -79,6 +79,8 @@ enum Route: String { case todayTrackDetail = "todayTrackDetail" /// 手机报告详情 case phoneReportDetail = "phoneReportDetail" + /// APP 使用报告 + case appUsageReport = "appUsageReport" /// 关于我们 case about = "about" /// 意见反馈 @@ -385,6 +387,11 @@ extension AppRouter: AppRouterProtocol { PhoneReportDetailVC(userInfo: parameters) } + // MARK: - APP 使用报告 + AppRouter.register(Route.appUsageReport) { _, parameters in + AppUsageReportVC(userInfo: parameters) + } + // MARK: - 关于我们 AppRouter.register(Route.about) { _, _ in AboutVC() diff --git a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift index 2aaffd4e..f4e5acec 100644 --- a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift +++ b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift @@ -69,7 +69,7 @@ final class AppRestrictManager { } private func applicationOnlySelection(_ source: FamilyActivitySelection) -> FamilyActivitySelection { - var result = FamilyActivitySelection(includeEntireCategory: false) + var result = FamilyActivitySelection(includeEntireCategory: true) result.applicationTokens = source.applicationTokens return result } diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 872cf08c..63469533 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -86,7 +86,7 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "http://172.16.10.20:9243/" + return "http://172.16.10.21:9243/" } } diff --git a/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift b/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift index cd868c82..05260b80 100644 --- a/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift +++ b/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift @@ -50,7 +50,7 @@ final class FamilyActivityPickerPresenter: UIViewController { private let initial: FamilyActivitySelection var onComplete: ((FamilyActivitySelection) -> Void)? - init(selection: FamilyActivitySelection = FamilyActivitySelection(includeEntireCategory: false)) { + init(selection: FamilyActivitySelection = FamilyActivitySelection(includeEntireCategory: true)) { self.initial = selection super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen diff --git a/QuickLocation/Section/Explore/FeatureIntroVC.swift b/QuickLocation/Section/Explore/FeatureIntroVC.swift index 3ee9083e..a5fbefec 100644 --- a/QuickLocation/Section/Explore/FeatureIntroVC.swift +++ b/QuickLocation/Section/Explore/FeatureIntroVC.swift @@ -6,6 +6,7 @@ import UIKit import CoreLocation import SwiftyUserDefaults +import SDCycleScrollView final class FeatureIntroVC: BaseViewController { @@ -13,6 +14,8 @@ final class FeatureIntroVC: BaseViewController { private var rootView: FeatureIntroView! + private var bannerList: [BannerConfigModel] = [] + override func loadView() { rootView = FeatureIntroView(frame: UIScreen.main.bounds) view = rootView @@ -20,10 +23,22 @@ final class FeatureIntroVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() + + rootView.cycleScrollView.delegate = self + rootView.onTapItem = { [weak self] action in self?.handle(action) } } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard let config = AppContextManager.shared.systemConfig else { return } + bannerList = config.bannerList + rootView.cycleScrollView.isHidden = config.bannerList.filter { $0.enable == true }.count == 0 + rootView.cycleScrollView.imageURLStringsGroup = config.bannerList.filter { $0.enable == true }.map { $0.img } + } private func handle(_ action: FeatureIntroAction) { switch action { @@ -54,3 +69,20 @@ final class FeatureIntroVC: BaseViewController { return CLLocation(latitude: lat, longitude: lon) } } + +extension FeatureIntroVC: SDCycleScrollViewDelegate { + func cycleScrollView(_ cycleScrollView: SDCycleScrollView!, didSelectItemAt index: Int) { + let model = bannerList[index] + + switch model.type { + case "app": + if model.url == "member" { + AppRouter.push(Route.vipRecharge) + } + case "web": + AppRouter.push(Route.web, userInfo: ["url": model.url]) + default: + break + } + } +} diff --git a/QuickLocation/Section/Explore/FeatureIntroView.swift b/QuickLocation/Section/Explore/FeatureIntroView.swift index 82704cf1..8042c283 100644 --- a/QuickLocation/Section/Explore/FeatureIntroView.swift +++ b/QuickLocation/Section/Explore/FeatureIntroView.swift @@ -4,6 +4,7 @@ // import UIKit +import SDCycleScrollView struct FeatureIntroItem { let imageName: String @@ -26,7 +27,8 @@ final class FeatureIntroView: UIView { var onTapItem: ((FeatureIntroAction) -> Void)? - private let headerBackgroundView = UIImageView(image: UIImage(named: "Explore/header_bg")) + private let headerBackgroundView = UIView() + private let headerBgImg = UIImageView(image: UIImage(named: "Explore/header_bg")) private let titleContainer = UIStackView() private let titleLineView = UIView() private let titleLabel = UILabel() @@ -86,10 +88,13 @@ final class FeatureIntroView: UIView { private func setupUI() { backgroundColor = .white - headerBackgroundView.contentMode = .scaleAspectFill - headerBackgroundView.clipsToBounds = true addSubview(headerBackgroundView) - headerBackgroundView.translatesAutoresizingMaskIntoConstraints = false + headerBackgroundView.backgroundColor = .clear + + headerBgImg.contentMode = .scaleAspectFill + headerBgImg.clipsToBounds = true + headerBackgroundView.addSubview(headerBgImg) + headerBackgroundView.addSubview(cycleScrollView) titleLineView.backgroundColor = UIColor(hexStr: "#253B55") titleLineView.layer.cornerRadius = 1.5 @@ -150,22 +155,24 @@ final class FeatureIntroView: UIView { gridStack.addArrangedSubview(rowStack) } + headerBackgroundView.layoutChain + .edges(excludingEdge: .bottom) + .heightToWidth(230 / 375) + + headerBgImg.layoutChain.edges() + + cycleScrollView.layoutChain.edges() + + sheetView.layoutChain + .topToBottomOfView(headerBackgroundView, offset: -30) + .edges(excludingEdge: .top) + NSLayoutConstraint.activate([ - headerBackgroundView.topAnchor.constraint(equalTo: topAnchor), - headerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), - headerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), - headerBackgroundView.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 230 / 375), - titleLineView.widthAnchor.constraint(equalToConstant: 24), titleLineView.heightAnchor.constraint(equalToConstant: 3), titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 75), titleContainer.topAnchor.constraint(equalTo: topAnchor, constant: 91), - sheetView.topAnchor.constraint(equalTo: topAnchor, constant: 200), - sheetView.leadingAnchor.constraint(equalTo: leadingAnchor), - sheetView.trailingAnchor.constraint(equalTo: trailingAnchor), - sheetView.bottomAnchor.constraint(equalTo: bottomAnchor), - scrollView.topAnchor.constraint(equalTo: sheetView.topAnchor), scrollView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor), @@ -188,6 +195,23 @@ final class FeatureIntroView: UIView { guard items.indices.contains(sender.tag) else { return } onTapItem?(items[sender.tag].action) } + + // MARK: - 轮播图 + lazy var cycleScrollView: SDCycleScrollView = { + let view = SDCycleScrollView(frame: .zero) + view.backgroundColor = .clear + view.scrollDirection = .horizontal +// view.showPageControl = true + view.bannerImageViewContentMode = .scaleAspectFill + view.autoScrollTimeInterval = 5 +// view.currentPageDotColor = UIColor(hexStr: "#16B3FF") +// view.pageDotColor = UIColor(hexStr: "#7AD6FF").withAlphaComponent(0.4) +// view.pageControlDotSize = CGSize(width: 8, height: 8) + view.clipsToBounds = false +// view.pageControlBottomOffset = -34 + view.isHidden = true + return view + }() } final class FeatureIntroCardView: UIControl { diff --git a/QuickLocation/Section/Home/AppUsageReport/AppUsageReportVC.swift b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportVC.swift new file mode 100644 index 00000000..6028a317 --- /dev/null +++ b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportVC.swift @@ -0,0 +1,96 @@ +// +// AppUsageReportVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import ObjectMapper + +final class AppUsageReportVC: BaseViewController { + + private var rootView: AppUsageReportView! + private let viewModel: AppUsageReportViewModel + + init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) { + viewModel = AppUsageReportViewModel( + members: members, + selectedUserId: selectedUserId, + groupKey: groupModel?.default_group_key ?? "" + ) + super.init(nibName: nil, bundle: nil) + } + + convenience init(userInfo: [String: Any]) { + let membersJson: [[String: Any]] + if let members = userInfo["members"] as? [[String: Any]] { + membersJson = members + } else if let members = userInfo["members"] as? [Any] { + membersJson = members.compactMap { $0 as? [String: Any] } + } else { + membersJson = [] + } + let members = membersJson.compactMap { GroupMemberModel(JSON: $0) } + let selectedUserId = (userInfo["userId"] as? String) ?? members.first?.user_id ?? "" + let groupModel = (userInfo["groupJson"] as? [String: Any]).flatMap { GroupModel(JSON: $0) } + self.init(members: members, selectedUserId: selectedUserId, groupModel: groupModel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + rootView = AppUsageReportView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.memberCV.dataSource = self + rootView.memberCV.delegate = self + bind() + rootView.memberCV.reloadData() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + rootView.onPeriodChanged = { [weak self] period in + self?.viewModel.selectedPeriod.accept(period) + } + viewModel.selectedMemberId + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.rootView.memberCV.reloadData() + }) + .disposed(by: disposeBag) + viewModel.snapshot + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] snapshot in + self?.rootView.apply(snapshot) + }) + .disposed(by: disposeBag) + } +} + +extension AppUsageReportVC: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + viewModel.members.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) + let member = viewModel.members[indexPath.item] + cell.configure( + model: member, + isCurrentUser: member.user_id == AppContextManager.shared.userId, + isSelected: member.user_id == viewModel.selectedMemberId.value + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + viewModel.selectedMemberId.accept(viewModel.members[indexPath.item].user_id) + } +} diff --git a/QuickLocation/Section/Home/AppUsageReport/AppUsageReportView.swift b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportView.swift new file mode 100644 index 00000000..9fae692e --- /dev/null +++ b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportView.swift @@ -0,0 +1,646 @@ +// +// AppUsageReportView.swift +// QuickLocation +// + +import UIKit +import Kingfisher + +final class AppUsageReportView: UIView { + + lazy var navView = BaseNavigationView(title: "APP报告") + let memberCV: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 61, height: 90) + layout.minimumLineSpacing = 12 + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.backgroundColor = .white + view.showsHorizontalScrollIndicator = false + view.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + view.register(GroupMemberListCell.self) + view.applyAppUsageReportCardStyle(cornerRadius: 20) + return view + }() + + var onPeriodChanged: ((AppUsageReportPeriod) -> Void)? + + private let navBgView = UIImageView(image: UIImage(named: "Common/navBar_bg_2")) + private let scrollView = UIScrollView() + private let contentView = UIView() + private let frequentSection = AppUsageFrequentSectionView() + private let durationSection = AppUsageDurationSectionView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#FAFAFA") + setupUI() + bindPeriodControls() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ snapshot: AppUsageReportViewModel.Snapshot) { + frequentSection.setPeriod(snapshot.period) + durationSection.setPeriod(snapshot.period) + frequentSection.configure(apps: snapshot.apps, period: snapshot.period, state: snapshot.state) + durationSection.configure( + apps: snapshot.apps, + totalUsageTime: snapshot.totalUsageTime, + period: snapshot.period, + state: snapshot.state + ) + } + + private func bindPeriodControls() { + let onChange: (AppUsageReportPeriod) -> Void = { [weak self] period in + self?.onPeriodChanged?(period) + } + frequentSection.onPeriodChanged = onChange + durationSection.onPeriodChanged = onChange + } + + private func setupUI() { + addSubview(navBgView) + addSubview(navView) + navBgView.contentMode = .scaleAspectFill + navBgView.layoutChain.edges(excludingEdge: .bottom).heightToWidth(160 / 375) + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + + addSubview(scrollView) + scrollView.showsVerticalScrollIndicator = false + scrollView.layoutChain.topToBottomOfView(navView).edgesHorzontal().bottom() + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + contentView.addSubview(memberCV) + memberCV.layoutChain.top(8).edgesHorzontal(15).height(100) + + contentView.addSubview(frequentSection) + frequentSection.layoutChain.topToBottomOfView(memberCV, offset: 14).edgesHorzontal(15).height(253) + + contentView.addSubview(durationSection) + durationSection.layoutChain + .topToBottomOfView(frequentSection, offset: 14) + .edgesHorzontal(15) + .height(220) + .bottom(24 + kSafeBottomMargin) + } +} + +private final class AppUsageSectionIconView: UIView { + + private let iconView = UIImageView() + + init(symbolName: String) { + super.init(frame: .zero) + backgroundColor = UIColor(hexStr: "#293445") + iconView.image = UIImage( + systemName: symbolName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 9, weight: .bold) + ) + iconView.tintColor = .white + iconView.contentMode = .scaleAspectFit + addSubview(iconView) + iconView.layoutChain.centerX().centerY().width(10).height(10) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } +} + +private enum AppUsageReportStyle { + static func cardTitle(_ title: String) -> NSAttributedString { + let attributedText = NSMutableAttributedString( + string: "✦ \(title)", + attributes: [ + .font: UIFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: UIColor(hexStr: "#293445") + ] + ) + attributedText.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#A9F33D"), + range: NSRange(location: 0, length: 1) + ) + return attributedText + } +} + +private final class AppUsageFrequentSectionView: UIView { + + var onPeriodChanged: ((AppUsageReportPeriod) -> Void)? + + private let titleIcon = AppUsageSectionIconView(symbolName: "heart.fill") + private let titleLab = UILabel() + private let cardView = UIView() + private let cardTitleLab = UILabel() + private let periodControl = AppUsagePeriodControl() + private let rowsStack = UIStackView() + private let statusLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + periodControl.onChanged = { [weak self] period in + self?.onPeriodChanged?(period) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func setPeriod(_ period: AppUsageReportPeriod) { + periodControl.setPeriod(period) + } + + func configure( + apps: [AppUsageReportViewModel.AppItem], + period: AppUsageReportPeriod, + state: AppUsageReportViewModel.ContentState + ) { + rowsStack.arrangedSubviews.forEach { + rowsStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + let statusText = AppUsageReportStatus.text(for: state) + statusLab.text = statusText + statusLab.isHidden = statusText == nil + rowsStack.isHidden = statusText != nil + + guard statusText == nil else { return } + for app in apps { + let row = AppUsageRankRowView() + row.configure(app: app, maximumUsageSeconds: period.maximumUsageSeconds) + row.layoutChain.height(20) + rowsStack.addArrangedSubview(row) + } + } + + private func setupUI() { + addSubview(titleIcon) + titleIcon.layoutChain.top().left().width(18).height(18) + + titleLab.text = "经常使用" + titleLab.font = .systemFont(ofSize: 16, weight: .bold) + titleLab.textColor = UIColor(hexStr: "#293445") + addSubview(titleLab) + titleLab.layoutChain.leftToRightOfView(titleIcon, offset: 7).centerY(titleIcon) + + cardView.backgroundColor = .white + cardView.applyAppUsageReportCardStyle(cornerRadius: 20) + addSubview(cardView) + cardView.layoutChain.topToBottomOfView(titleIcon, offset: 12).edgesHorzontal().bottom() + + cardTitleLab.attributedText = AppUsageReportStyle.cardTitle("最爱使用TOP5") + cardView.addSubview(cardTitleLab) + cardTitleLab.layoutChain.top(22).left(20) + + cardView.addSubview(periodControl) + periodControl.layoutChain.top(12).right(14).width(110).height(32) + + rowsStack.axis = .vertical + rowsStack.spacing = 12 + cardView.addSubview(rowsStack) + rowsStack.layoutChain.top(62).left(20).right(14) + + statusLab.font = .systemFont(ofSize: 14, weight: .medium) + statusLab.textColor = UIColor(hexStr: "#9CA3AF") + statusLab.textAlignment = .center + statusLab.numberOfLines = 0 + cardView.addSubview(statusLab) + statusLab.layoutChain.centerX().centerY(cardView, offset: 16).left(24).right(24) + } + +} + +private final class AppUsageDurationSectionView: UIView { + + var onPeriodChanged: ((AppUsageReportPeriod) -> Void)? + + private let titleIcon = AppUsageSectionIconView(symbolName: "clock.fill") + private let titleLab = UILabel() + private let cardView = UIView() + private let cardTitleLab = UILabel() + private let periodControl = AppUsagePeriodControl() + private let donutView = AppUsageReportDonutView() + private let totalTimeLab = UILabel() + private let totalHintLab = UILabel() + private let legendStack = UIStackView() + private let statusLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + periodControl.onChanged = { [weak self] period in + self?.onPeriodChanged?(period) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:)") } + + func setPeriod(_ period: AppUsageReportPeriod) { + periodControl.setPeriod(period) + } + + func configure( + apps: [AppUsageReportViewModel.AppItem], + totalUsageTime: Int, + period: AppUsageReportPeriod, + state: AppUsageReportViewModel.ContentState + ) { + legendStack.arrangedSubviews.forEach { + legendStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + let statusText = AppUsageReportStatus.text(for: state) + statusLab.text = statusText + statusLab.isHidden = statusText == nil + donutView.isHidden = statusText != nil + totalTimeLab.isHidden = statusText != nil + totalHintLab.isHidden = statusText != nil + legendStack.isHidden = statusText != nil + + guard statusText == nil else { + donutView.setSegments(ratios: [], colors: []) + return + } + + totalTimeLab.text = AppUsageReportFormat.durationText(seconds: totalUsageTime) + donutView.setSegments(ratios: apps.map { CGFloat($0.usageTime) }, colors: apps.map(\.color)) + for app in apps { + let row = AppUsageLegendRowView() + row.configure( + app: app, + progress: CGFloat(app.usageTime) / CGFloat(period.maximumUsageSeconds) + ) + row.layoutChain.height(20) + legendStack.addArrangedSubview(row) + } + } + + private func setupUI() { + addSubview(titleIcon) + titleIcon.layoutChain.top().left().width(18).height(18) + + titleLab.text = "使用时长" + titleLab.font = .systemFont(ofSize: 16, weight: .bold) + titleLab.textColor = UIColor(hexStr: "#293445") + addSubview(titleLab) + titleLab.layoutChain.leftToRightOfView(titleIcon, offset: 7).centerY(titleIcon) + + cardView.backgroundColor = .white + cardView.applyAppUsageReportCardStyle(cornerRadius: 20) + addSubview(cardView) + cardView.layoutChain.topToBottomOfView(titleIcon, offset: 12).edgesHorzontal().bottom() + + cardTitleLab.attributedText = AppUsageReportStyle.cardTitle("App使用详情") + cardView.addSubview(cardTitleLab) + cardTitleLab.layoutChain.top(22).left(20) + + cardView.addSubview(periodControl) + periodControl.layoutChain.top(12).right(14).width(110).height(32) + + cardView.addSubview(donutView) + donutView.layoutChain.left(20).top(56).width(122).height(122) + + totalTimeLab.font = .systemFont(ofSize: 16, weight: .semibold) + totalTimeLab.textColor = UIColor(hexStr: "#293445") + totalTimeLab.textAlignment = .center + cardView.addSubview(totalTimeLab) + totalTimeLab.layoutChain.centerX(donutView).centerY(donutView, offset: -8) + + totalHintLab.text = "总计" + totalHintLab.font = .systemFont(ofSize: 10, weight: .medium) + totalHintLab.textColor = UIColor(hexStr: "#767676") + totalHintLab.textAlignment = .center + cardView.addSubview(totalHintLab) + totalHintLab.layoutChain.centerX(donutView).topToBottomOfView(totalTimeLab, offset: 4) + + legendStack.axis = .vertical + legendStack.spacing = 5 + cardView.addSubview(legendStack) + legendStack.layoutChain + .top(54) + .left(154) + .right(14) + + statusLab.font = .systemFont(ofSize: 14, weight: .medium) + statusLab.textColor = UIColor(hexStr: "#9CA3AF") + statusLab.textAlignment = .center + statusLab.numberOfLines = 0 + cardView.addSubview(statusLab) + statusLab.layoutChain.centerX().centerY(cardView, offset: 18).left(24).right(24) + } +} + +private final class AppUsagePeriodControl: UIView { + + var onChanged: ((AppUsageReportPeriod) -> Void)? + + private let backgroundView = UIView() + private let monthButton = UIButton(type: .custom) + private let weekButton = UIButton(type: .custom) + private var period: AppUsageReportPeriod = .week + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#EEEEEE") + cornerRadius = 16 + setupUI() + setPeriod(.week) + } + + required init?(coder: NSCoder) { fatalError("init(coder:)") } + + func setPeriod(_ period: AppUsageReportPeriod) { + self.period = period + let selectedColor = UIColor(hexStr: "#293445") + let normalColor = UIColor(hexStr: "#8C8C8C") + monthButton.setTitleColor(period == .month ? selectedColor : normalColor, for: .normal) + weekButton.setTitleColor(period == .week ? selectedColor : normalColor, for: .normal) + backgroundView.frame = period == .month + ? CGRect(x: 3, y: 3, width: bounds.width / 2 - 3, height: bounds.height - 6) + : CGRect(x: bounds.width / 2, y: 3, width: bounds.width / 2 - 3, height: bounds.height - 6) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let width = bounds.width / 2 - 4 + backgroundView.frame = period == .month + ? CGRect(x: 3, y: 3, width: width + 1, height: bounds.height - 6) + : CGRect(x: bounds.width / 2, y: 3, width: width + 1, height: bounds.height - 6) + } + + private func setupUI() { + backgroundView.backgroundColor = .white + backgroundView.cornerRadius = 13 + addSubview(backgroundView) + sendSubviewToBack(backgroundView) + + [monthButton, weekButton].forEach { + $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + addSubview($0) + } + monthButton.setTitle("月", for: .normal) + weekButton.setTitle("周", for: .normal) + monthButton.layoutChain.left().top().bottom().widthToView(self, multiplier: 0.5) + weekButton.layoutChain.right().top().bottom().widthToView(self, multiplier: 0.5) + monthButton.addTarget(self, action: #selector(selectMonth), for: .touchUpInside) + weekButton.addTarget(self, action: #selector(selectWeek), for: .touchUpInside) + } + + @objc private func selectMonth() { + guard period != .month else { return } + onChanged?(.month) + } + + @objc private func selectWeek() { + guard period != .week else { return } + onChanged?(.week) + } +} + +private final class AppUsageRankRowView: UIView { + + private var progressRatio: CGFloat = 0 + private var progressConstraint: NSLayoutConstraint? + private let rankLab = UILabel() + private let iconView = UIImageView() + private let nameLab = UILabel() + private let trackView = UIView() + private let fillView = UIView() + private let percentageLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:)") } + + func configure(app: AppUsageReportViewModel.AppItem, maximumUsageSeconds: Int) { + rankLab.text = "\(app.rank)" + nameLab.text = app.name + let periodProgress = min(max(CGFloat(app.usageTime) / CGFloat(maximumUsageSeconds), 0), 1) + percentageLab.text = "\(Int((periodProgress * 100).rounded()))%" + iconView.kf.cancelDownloadTask() + iconView.image = UIImage(named: "LockDistract/app_unknown") + if let url = URL(string: app.iconURL), !app.iconURL.isEmpty { + iconView.kf.setImage(with: url, placeholder: iconView.image) + } + progressRatio = periodProgress + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + progressConstraint?.isActive = false + progressConstraint = fillView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: progressRatio) + progressConstraint?.isActive = true + } + + private func setupUI() { + rankLab.font = .systemFont(ofSize: 12, weight: .bold) + rankLab.textColor = UIColor(hexStr: "#687385") + rankLab.textAlignment = .center + addSubview(rankLab) + rankLab.layoutChain.left().centerY().width(14) + + iconView.backgroundColor = UIColor(hexStr: "#F4F6F8") + iconView.image = UIImage(named: "LockDistract/app_unknown") + iconView.contentMode = .scaleAspectFill + iconView.cornerRadius = 4 + iconView.clipsToBounds = true + addSubview(iconView) + iconView.layoutChain.leftToRightOfView(rankLab, offset: 8).centerY().width(16).height(16) + + nameLab.font = .systemFont(ofSize: 11, weight: .medium) + nameLab.textColor = UIColor(hexStr: "#4B5563") + nameLab.lineBreakMode = .byTruncatingTail + addSubview(nameLab) + nameLab.layoutChain.leftToRightOfView(iconView, offset: 7).centerY().width(62) + + percentageLab.font = .systemFont(ofSize: 10, weight: .regular) + percentageLab.textColor = UIColor(hexStr: "#A8A8A8") + percentageLab.textAlignment = .right + addSubview(percentageLab) + percentageLab.layoutChain.right().centerY().width(34) + + trackView.backgroundColor = UIColor(hexStr: "#DDF3FF") + trackView.cornerRadius = 2 + addSubview(trackView) + trackView.layoutChain.leftToRightOfView(nameLab, offset: 8).rightToLeftOfView(percentageLab, offset: -8).centerY().height(4) + + fillView.backgroundColor = UIColor(hexStr: "#5BC5F7") + fillView.cornerRadius = 2 + trackView.addSubview(fillView) + fillView.layoutChain.left().top().bottom() + } +} + +private final class AppUsageLegendRowView: UIView { + + private var progressRatio: CGFloat = 0 + private var progressConstraint: NSLayoutConstraint? + private let colorDot = UIView() + private let iconView = UIImageView() + private let nameLab = UILabel() + private let trackView = UIView() + private let fillView = UIView() + private let durationLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:)") } + + func configure(app: AppUsageReportViewModel.AppItem, progress: CGFloat) { + colorDot.backgroundColor = app.color + nameLab.text = app.name + durationLab.text = app.durationText + iconView.kf.cancelDownloadTask() + iconView.image = UIImage(named: "LockDistract/app_unknown") + if let url = URL(string: app.iconURL), !app.iconURL.isEmpty { + iconView.kf.setImage(with: url, placeholder: iconView.image) + } + progressRatio = max(0, min(progress, 1)) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + progressConstraint?.isActive = false + progressConstraint = fillView.widthAnchor.constraint(equalTo: trackView.widthAnchor, multiplier: progressRatio) + progressConstraint?.isActive = true + } + + private func setupUI() { + colorDot.cornerRadius = 3 + addSubview(colorDot) + colorDot.layoutChain.left().centerY().width(6).height(6) + + iconView.backgroundColor = UIColor(hexStr: "#F4F6F8") + iconView.image = UIImage(named: "LockDistract/app_unknown") + iconView.contentMode = .scaleAspectFill + iconView.cornerRadius = 4 + iconView.clipsToBounds = true + addSubview(iconView) + iconView.layoutChain.leftToRightOfView(colorDot, offset: 7).centerY().width(16).height(16) + + durationLab.font = .systemFont(ofSize: 10, weight: .regular) + durationLab.textColor = UIColor(hexStr: "#A8A8A8") + durationLab.textAlignment = .right + addSubview(durationLab) + durationLab.layoutChain.right().centerY().width(42) + + nameLab.font = .systemFont(ofSize: 10, weight: .medium) + nameLab.textColor = UIColor(hexStr: "#4B5563") + nameLab.lineBreakMode = .byTruncatingTail + addSubview(nameLab) + nameLab.layoutChain.leftToRightOfView(iconView, offset: 6).centerY().width(42) + + trackView.backgroundColor = UIColor(hexStr: "#DDF3FF") + trackView.cornerRadius = 2 + addSubview(trackView) + trackView.layoutChain.leftToRightOfView(nameLab, offset: 5).rightToLeftOfView(durationLab, offset: -7).centerY().height(4) + + fillView.backgroundColor = UIColor(hexStr: "#5BC5F7") + fillView.cornerRadius = 2 + trackView.addSubview(fillView) + fillView.layoutChain.left().top().bottom() + } +} + +private final class AppUsageReportDonutView: UIView { + + private var segmentLayers: [CAShapeLayer] = [] + private var ratios: [CGFloat] = [] + private var colors: [UIColor] = [] + private let lineWidth: CGFloat = 18 + + func setSegments(ratios: [CGFloat], colors: [UIColor]) { + self.ratios = ratios + self.colors = colors + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + segmentLayers.forEach { $0.removeFromSuperlayer() } + segmentLayers.removeAll() + guard !ratios.isEmpty, !colors.isEmpty, bounds.width > 0 else { return } + + let total = max(ratios.reduce(0, +), 0.001) + let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 + let center = CGPoint(x: bounds.midX, y: bounds.midY) + var startAngle = -CGFloat.pi / 2 + let segmentGap: CGFloat = ratios.count > 1 ? 0.025 : 0 + for (index, ratio) in ratios.enumerated() { + let sweep = ratio / total * 2 * CGFloat.pi + guard sweep > 0.001 else { continue } + let gap = min(segmentGap, sweep * 0.25) + let path = UIBezierPath( + arcCenter: center, + radius: radius, + startAngle: startAngle + gap, + endAngle: startAngle + sweep - gap, + clockwise: true + ) + let layer = CAShapeLayer() + layer.path = path.cgPath + layer.fillColor = UIColor.clear.cgColor + layer.strokeColor = colors[index % colors.count].cgColor + layer.lineWidth = lineWidth + layer.lineCap = .butt + self.layer.addSublayer(layer) + segmentLayers.append(layer) + startAngle += sweep + } + } +} + +private enum AppUsageReportFormat { + static func durationText(seconds: Int) -> String { + let hours = max(seconds, 0) / 3600 + let minutes = (max(seconds, 0) % 3600) / 60 + if hours > 0 { + return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" + } + return "\(minutes)m" + } +} + +private enum AppUsageReportStatus { + static func text(for state: AppUsageReportViewModel.ContentState) -> String? { + switch state { + case .loading: + return "正在加载..." + case .empty: + return "暂无APP使用记录" + case .failure: + return "加载失败,请稍后重试" + case .content: + return nil + } + } +} + +private extension UIView { + func applyAppUsageReportCardStyle(cornerRadius: CGFloat) { + layer.cornerRadius = cornerRadius + layer.masksToBounds = false + layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.08).cgColor + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.shadowOpacity = 1 + layer.shadowRadius = 9 + } +} diff --git a/QuickLocation/Section/Home/AppUsageReport/AppUsageReportViewModel.swift b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportViewModel.swift new file mode 100644 index 00000000..a19564d3 --- /dev/null +++ b/QuickLocation/Section/Home/AppUsageReport/AppUsageReportViewModel.swift @@ -0,0 +1,187 @@ +// +// 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" + } +} diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 90291db4..2323ad56 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -15,6 +15,8 @@ class GroupMemberView2: UIView { private var navBarHeightConstraint: NSLayoutConstraint? private var todayTrackTopToInteraction: NSLayoutConstraint? private var todayTrackTopToMember: NSLayoutConstraint? + private var phoneReportBottomConstraint: NSLayoutConstraint? + private var appUsageBottomConstraint: NSLayoutConstraint? private var currentMemberModel: GroupMemberModel? private var emojiEchoByUserId: [String: String] = [:] private var messageEchoByUserId: [String: String] = [:] @@ -143,9 +145,24 @@ class GroupMemberView2: UIView { private func applyPhoneUsage(_ usage: PhoneUsageTodayModel?) { applyDeviceInfo(usage?.phoneInfo) phoneReportView.configure(with: Self.phoneReportPreview(from: usage)) + let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? []) + appUsageView.configure(with: appUsagePreview) + updateAppUsageVisibility(appUsagePreview != nil) applyStayPoints(usage?.stayPoints ?? []) } + private func updateAppUsageVisibility(_ visible: Bool) { + if visible { + phoneReportBottomConstraint?.isActive = false + appUsageBottomConstraint?.isActive = true + } else { + appUsageBottomConstraint?.isActive = false + phoneReportBottomConstraint?.isActive = true + } + appUsageView.isHidden = !visible + setNeedsLayout() + } + private func applyDeviceInfo(_ info: PhoneUsageInfoModel?) { let model = info?.model.trimmed ?? "" let hasModel = !model.isEmpty && model != "未知" @@ -374,7 +391,21 @@ class GroupMemberView2: UIView { phoneReportView.layoutChain .topToBottomOfView(todayTrackView, offset: 15) .edgesHorzontal() - .bottom(49 + kSafeBottomMargin) + + scrollContentView.addSubview(appUsageView) + appUsageView.layoutChain + .topToBottomOfView(phoneReportView, offset: 15) + .edgesHorzontal() + + phoneReportBottomConstraint = phoneReportView.bottomAnchor.constraint( + equalTo: scrollContentView.bottomAnchor, + constant: -(49 + kSafeBottomMargin) + ) + appUsageBottomConstraint = appUsageView.bottomAnchor.constraint( + equalTo: scrollContentView.bottomAnchor, + constant: -(49 + kSafeBottomMargin) + ) + phoneReportBottomConstraint?.isActive = true return view }() @@ -828,7 +859,6 @@ class GroupMemberView2: UIView { // MARK: - 手机报告 / APP使用记录 lazy var phoneReportView = MemberPhoneReportView() - /// 保留 UI 实现,当前不挂载到 scroll 内容 lazy var appUsageView: MemberAppUsageView = { let view = MemberAppUsageView() view.isHidden = true diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 9fcc92d6..a2567ada 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -292,6 +292,11 @@ class HomeViewController: BaseViewController { rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in self?.openPhoneReportDetail() }).disposed(by: disposeBag) + + // APP 报告二级页 + rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.openAppUsageReport() + }).disposed(by: disposeBag) // 地图回到自己并重新开启自动跟随 rootView.locationView.rx.tapGesture.subscribe { _ in @@ -651,9 +656,14 @@ class HomeViewController: BaseViewController { guard let self, self.phoneUsageRequestIDs[cacheKey] == requestID else { return } self.phoneUsageRequestIDs.removeValue(forKey: cacheKey) - guard self.viewModel.groupModel?.default_group_key == groupKey, - response.code == "0", - let model = response.model else { return } + guard self.viewModel.groupModel?.default_group_key == groupKey else { return } + guard response.code == "0", let model = response.model else { + self.phoneUsageCache.removeValue(forKey: cacheKey) + if self.selectedMemberId == userId { + self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId) + } + return + } self.phoneUsageCache[cacheKey] = model guard self.selectedMemberId == userId else { return } @@ -662,6 +672,11 @@ class HomeViewController: BaseViewController { guard let self, self.phoneUsageRequestIDs[cacheKey] == requestID else { return } self.phoneUsageRequestIDs.removeValue(forKey: cacheKey) + guard self.viewModel.groupModel?.default_group_key == groupKey else { return } + self.phoneUsageCache.removeValue(forKey: cacheKey) + if self.selectedMemberId == userId { + self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId) + } }) .disposed(by: disposeBag) } @@ -965,6 +980,18 @@ class HomeViewController: BaseViewController { AppRouter.push(Route.phoneReportDetail, userInfo: userInfo) } + private func openAppUsageReport() { + let members = viewModel.memberList.map { $0.toJSON() } + var userInfo: [String: Any] = [ + "members": members, + "userId": selectedMemberId + ] + if let group = viewModel.groupModel { + userInfo["groupJson"] = group.toJSON() + } + AppRouter.push(Route.appUsageReport, userInfo: userInfo) + } + /// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板 private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) { let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId diff --git a/QuickLocation/Section/Home/MemberAppUsageView.swift b/QuickLocation/Section/Home/MemberAppUsageView.swift index 3b8de914..6578f70d 100644 --- a/QuickLocation/Section/Home/MemberAppUsageView.swift +++ b/QuickLocation/Section/Home/MemberAppUsageView.swift @@ -4,11 +4,12 @@ // import UIKit +import Kingfisher struct MemberAppUsageItemPreview { let rank: Int let name: String - let tintHex: String + let iconURL: String let durationText: String let progress: CGFloat } @@ -19,23 +20,44 @@ struct MemberAppUsagePreview { let segmentColors: [UIColor] let topApps: [MemberAppUsageItemPreview] - static let mock = MemberAppUsagePreview( - totalTimeText: "24h 60m", - segmentRatios: [0.35, 0.28, 0.18, 0.19], - segmentColors: [ - UIColor(hexStr: "#FF8FB1"), - UIColor(hexStr: "#FFD36E"), - UIColor(hexStr: "#7BE495"), - UIColor(hexStr: "#6EC1FF") - ], - topApps: [ - .init(rank: 1, name: "微信", tintHex: "#07C160", durationText: "1h 45m", progress: 1.0), - .init(rank: 2, name: "抖音", tintHex: "#111111", durationText: "1h 25m", progress: 0.76), - .init(rank: 3, name: "小红书", tintHex: "#FF2442", durationText: "45m", progress: 0.43), - .init(rank: 4, name: "美团", tintHex: "#FFC300", durationText: "30m", progress: 0.29), - .init(rank: 5, name: "Safari", tintHex: "#007AFF", durationText: "18m", progress: 0.17) - ] - ) + init?(topApps: [PhoneUsageAppModel]) { + let apps = Array(topApps.filter { $0.usageTime > 0 }.prefix(5)) + guard !apps.isEmpty else { return nil } + + let usageTimes = apps.map { max($0.usageTime, 0) } + let totalUsageTime = usageTimes.reduce(0, +) + let colors = Self.segmentColors + + totalTimeText = Self.durationText(seconds: totalUsageTime) + segmentRatios = usageTimes.map(CGFloat.init) + segmentColors = usageTimes.enumerated().map { colors[$0.offset % colors.count] } + self.topApps = apps.enumerated().map { index, app in + MemberAppUsageItemPreview( + rank: index + 1, + name: app.appName.isEmpty ? "未知应用" : app.appName, + iconURL: app.appIcon, + durationText: Self.durationText(seconds: max(app.usageTime, 0)), + progress: CGFloat(max(app.usageTime, 0)) / CGFloat(24 * 60 * 60) + ) + } + } + + private static let segmentColors = [ + UIColor(hexStr: "#C3ECFF"), + UIColor(hexStr: "#E3FF9C"), + UIColor(hexStr: "#FFEAA4"), + UIColor(hexStr: "#C5FFB7"), + UIColor(hexStr: "#FFDCF5") + ] + + private static func durationText(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" + } } /// 成员面板「APP使用记录」:环形图 + TOP5 @@ -49,13 +71,14 @@ final class MemberAppUsageView: UIView { private let donutView = AppUsageDonutView() private let totalTimeLab = UILabel() private let totalHintLab = UILabel() + private let usageTitleLab = UILabel() private let topTitleLab = UILabel() private let topStack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) setupUI() - configure(with: .mock) + configure(with: nil) } required init?(coder: NSCoder) { @@ -66,7 +89,7 @@ final class MemberAppUsageView: UIView { topStack.arrangedSubviews.forEach { $0.removeFromSuperview() } guard let preview else { - totalTimeLab.text = "--" + totalTimeLab.text = "" donutView.setSegments(ratios: [], colors: []) return } @@ -78,6 +101,7 @@ final class MemberAppUsageView: UIView { let row = AppUsageTopRowView() row.configure(with: item) topStack.addArrangedSubview(row) + row.layoutChain.height(20) } } @@ -107,17 +131,30 @@ final class MemberAppUsageView: UIView { cardView.layoutChain .topToBottomOfView(sectionIcon, offset: 12) .edgesHorzontal(15) - .height(220) + .height(176) .bottom() + usageTitleLab.attributedText = Self.cardTitle("使用时长") + cardView.addSubview(usageTitleLab) + usageTitleLab.layoutChain + .top(12) + .left(18) + + topTitleLab.attributedText = Self.cardTitle("最爱使用TOP5") + cardView.addSubview(topTitleLab) + topTitleLab.layoutChain + .top(12) + .left(160) + .right(14) + cardView.addSubview(donutView) donutView.layoutChain - .left(12) - .centerY() - .width(110) - .height(110) + .left(18) + .top(43) + .width(120) + .height(120) - totalTimeLab.font = FontManager.youSheBiaoTiHei(14) + totalTimeLab.font = .systemFont(ofSize: 16, weight: .semibold) totalTimeLab.textColor = titleColor totalTimeLab.textAlignment = .center cardView.addSubview(totalTimeLab) @@ -126,7 +163,7 @@ final class MemberAppUsageView: UIView { .centerY(donutView, offset: -6) totalHintLab.text = "今日总计" - totalHintLab.font = .systemFont(ofSize: 9, weight: .medium) + totalHintLab.font = .systemFont(ofSize: 10, weight: .medium) totalHintLab.textColor = UIColor(hexStr: "#767676") totalHintLab.textAlignment = .center cardView.addSubview(totalHintLab) @@ -134,24 +171,30 @@ final class MemberAppUsageView: UIView { .centerX(donutView) .topToBottomOfView(totalTimeLab, offset: 2) - topTitleLab.text = "最爱使用TOP5" - topTitleLab.font = .systemFont(ofSize: 12, weight: .bold) - topTitleLab.textColor = titleColor - cardView.addSubview(topTitleLab) - topTitleLab.layoutChain - .top(14) - .leftToRightOfView(donutView, offset: 16) - .right(12) - topStack.axis = .vertical - topStack.spacing = 10 + topStack.spacing = 6 topStack.alignment = .fill cardView.addSubview(topStack) topStack.layoutChain - .topToBottomOfView(topTitleLab, offset: 10) - .leftToRightOfView(donutView, offset: 16) - .right(12) - .bottom(14) + .top(43) + .left(160) + .right(14) + } + + private static func cardTitle(_ title: String) -> NSAttributedString { + let attributedText = NSMutableAttributedString( + string: "✦ \(title)", + attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold), + .foregroundColor: UIColor(hexStr: "#293445") + ] + ) + attributedText.addAttribute( + .foregroundColor, + value: UIColor(hexStr: "#A9F33D"), + range: NSRange(location: 0, length: 1) + ) + return attributedText } } @@ -162,7 +205,7 @@ private final class AppUsageDonutView: UIView { private var segmentLayers: [CAShapeLayer] = [] private var ratios: [CGFloat] = [] private var colors: [UIColor] = [] - private let lineWidth: CGFloat = 14 + private let lineWidth: CGFloat = 18 override init(frame: CGRect) { super.init(frame: frame) @@ -188,14 +231,16 @@ private final class AppUsageDonutView: UIView { let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 var startAngle = -CGFloat.pi / 2 let total = max(ratios.reduce(0, +), 0.001) + let segmentGap: CGFloat = ratios.count > 1 ? 0.025 : 0 for (i, ratio) in ratios.enumerated() { let sweep = (ratio / total) * CGFloat.pi * 2 guard sweep > 0.001 else { continue } + let gap = min(segmentGap, sweep * 0.25) let path = UIBezierPath(arcCenter: center, radius: radius, - startAngle: startAngle, - endAngle: startAngle + sweep, + startAngle: startAngle + gap, + endAngle: startAngle + sweep - gap, clockwise: true) let layer = CAShapeLayer() layer.path = path.cgPath @@ -222,8 +267,7 @@ private final class AppUsageTopRowView: UIView { private var progressRatio: CGFloat = 0 private var progressWidthConstraint: NSLayoutConstraint? private let rankLab = UILabel() - private let iconView = UIView() - private let iconLabel = UILabel() + private let iconView = UIImageView() private let nameLab = UILabel() private let progressTrack = UIView() private let progressFill = UIView() @@ -242,23 +286,29 @@ private final class AppUsageTopRowView: UIView { rankLab.text = "\(item.rank)" nameLab.text = item.name durationLab.text = item.durationText - iconView.backgroundColor = UIColor(hexStr: item.tintHex) - iconLabel.text = String(item.name.prefix(1)) - progressRatio = max(0.06, min(1, item.progress)) + iconView.kf.cancelDownloadTask() + iconView.image = UIImage(named: "LockDistract/app_unknown") + if let url = URL(string: item.iconURL), !item.iconURL.isEmpty { + iconView.kf.setImage(with: url, placeholder: iconView.image) + } + progressRatio = max(0, min(1, item.progress)) setNeedsLayout() } private func setupUI() { - rankLab.font = .systemFont(ofSize: 10, weight: .bold) + rankLab.font = .systemFont(ofSize: 11, weight: .bold) rankLab.textColor = UIColor(hexStr: "#353B4F") rankLab.textAlignment = .center addSubview(rankLab) rankLab.layoutChain .left() .centerY() - .width(14) + .width(12) - iconView.cornerRadius = 8 + iconView.backgroundColor = UIColor(hexStr: "#F4F6F8") + iconView.contentMode = .scaleAspectFill + iconView.clipsToBounds = true + iconView.cornerRadius = 4 addSubview(iconView) iconView.layoutChain .leftToRightOfView(rankLab, offset: 4) @@ -266,39 +316,35 @@ private final class AppUsageTopRowView: UIView { .width(16) .height(16) - iconLabel.font = .systemFont(ofSize: 9, weight: .bold) - iconLabel.textColor = .white - iconLabel.textAlignment = .center - iconView.addSubview(iconLabel) - iconLabel.layoutChain.centerX().centerY() - - durationLab.font = .systemFont(ofSize: 10, weight: .medium) - durationLab.textColor = UIColor(hexStr: "#767676") + durationLab.font = .systemFont(ofSize: 10, weight: .regular) + durationLab.textColor = UIColor(hexStr: "#A8A8A8") + durationLab.textAlignment = .right durationLab.setContentCompressionResistancePriority(.required, for: .horizontal) addSubview(durationLab) durationLab.layoutChain .right() .centerY() + .width(40) - nameLab.font = .systemFont(ofSize: 11, weight: .medium) + nameLab.font = .systemFont(ofSize: 10, weight: .medium) nameLab.textColor = UIColor(hexStr: "#353B4F") + nameLab.lineBreakMode = .byTruncatingTail addSubview(nameLab) nameLab.layoutChain - .leftToRightOfView(iconView, offset: 6) - .top() - .rightToLeftOfView(durationLab, offset: -6) + .leftToRightOfView(iconView, offset: 5) + .centerY() + .width(45) - progressTrack.backgroundColor = UIColor(hexStr: "#F0F0F0") + progressTrack.backgroundColor = UIColor(hexStr: "#DDF3FF") progressTrack.cornerRadius = 2 addSubview(progressTrack) progressTrack.layoutChain - .leftToView(nameLab) - .rightToView(durationLab) - .topToBottomOfView(nameLab, offset: 4) + .leftToRightOfView(nameLab, offset: 5) + .rightToLeftOfView(durationLab, offset: -7) + .centerY() .height(4) - .bottom() - progressFill.backgroundColor = UIColor(hexStr: "#58EDFF") + progressFill.backgroundColor = UIColor(hexStr: "#58C9F7") progressFill.cornerRadius = 2 progressTrack.addSubview(progressFill) progressFill.layoutChain diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index 8bcf4e5d..9031c484 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -50,6 +50,18 @@ struct UserService { .asObservable() } + /// 指定圈子成员的 APP 周期使用报告 + static func phoneUsagePeriod( + userId: String, + groupKey: String, + period: String + ) -> Observable { + let api = UserAPI.phoneUsagePeriod(userId: userId, groupKey: groupKey, period: period).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsagePeriodResponse.self) + .asObservable() + } + /// 指定圈子成员的手机使用报告 static func phoneUsageReport(userId: String, groupKey: String) -> Observable { let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget