- App使用记录
This commit is contained in:
parent
e0bab657ba
commit
e0e5dc76fd
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,11 +23,23 @@ 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 {
|
||||
case .signIn:
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
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),
|
||||
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([
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -293,6 +293,11 @@ class HomeViewController: BaseViewController {
|
|||
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
|
||||
self.startFollowingCurrentUser()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue