- App使用记录

This commit is contained in:
linshujie 2026-08-31 18:32:46 +08:00
parent e0bab657ba
commit e0e5dc76fd
17 changed files with 1315 additions and 93 deletions

View File

@ -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 = "<group>"; };
55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = "<group>"; };
55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = "<group>"; };
55B240003040000100784900 /* AppUsageReportVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportVC.swift; sourceTree = "<group>"; };
55B240023040000100784902 /* AppUsageReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportView.swift; sourceTree = "<group>"; };
55B240043040000100784904 /* AppUsageReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUsageReportViewModel.swift; sourceTree = "<group>"; };
55B218A13024A00100784701 /* AboutVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutVC.swift; sourceTree = "<group>"; };
55B218A13024A00100784703 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
55B240063040000100784906 /* AppUsageReport */ = {
isa = PBXGroup;
children = (
55B240003040000100784900 /* AppUsageReportVC.swift */,
55B240023040000100784902 /* AppUsageReportView.swift */,
55B240043040000100784904 /* AppUsageReportViewModel.swift */,
);
path = AppUsageReport;
sourceTree = "<group>";
};
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 */,

View File

@ -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,

View File

@ -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"]
}
}

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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/"
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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<String>
let selectedPeriod = BehaviorRelay<AppUsageReportPeriod>(value: .week)
let snapshot = BehaviorRelay<Snapshot>(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"
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -50,6 +50,18 @@ struct UserService {
.asObservable()
}
/// APP 使
static func phoneUsagePeriod(
userId: String,
groupKey: String,
period: String
) -> Observable<PhoneUsagePeriodResponse> {
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<PhoneUsageReportResponse> {
let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget