- 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 */; };
|
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; };
|
||||||
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; };
|
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; };
|
||||||
55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.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 */; };
|
55B218A13024A00100784702 /* AboutVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784701 /* AboutVC.swift */; };
|
||||||
55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; };
|
55B218A13024A00100784704 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784703 /* AboutView.swift */; };
|
||||||
55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784705 /* CancelAccountVC.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
55B218A13024A00100784705 /* CancelAccountVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountVC.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -1324,6 +1330,7 @@
|
||||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||||
55B217DD302301000078473C /* TodayTrackDetail */,
|
55B217DD302301000078473C /* TodayTrackDetail */,
|
||||||
55B217DE302301000078473D /* PhoneReportDetail */,
|
55B217DE302301000078473D /* PhoneReportDetail */,
|
||||||
|
55B240063040000100784906 /* AppUsageReport */,
|
||||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||||
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
||||||
|
|
@ -2045,6 +2052,16 @@
|
||||||
path = PhoneReportDetail;
|
path = PhoneReportDetail;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
55B240063040000100784906 /* AppUsageReport */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
55B240003040000100784900 /* AppUsageReportVC.swift */,
|
||||||
|
55B240023040000100784902 /* AppUsageReportView.swift */,
|
||||||
|
55B240043040000100784904 /* AppUsageReportViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = AppUsageReport;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
55B218A13024A00100784710 /* About */ = {
|
55B218A13024A00100784710 /* About */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -2522,6 +2539,9 @@
|
||||||
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */,
|
55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */,
|
||||||
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */,
|
55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */,
|
||||||
55B217DC302301000078473B /* PhoneReportDetailViewModel.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 */,
|
55B218A13024A00100784702 /* AboutVC.swift in Sources */,
|
||||||
55B218A13024A00100784704 /* AboutView.swift in Sources */,
|
55B218A13024A00100784704 /* AboutView.swift in Sources */,
|
||||||
55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */,
|
55B218A13024A00100784706 /* CancelAccountVC.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ enum UserAPI {
|
||||||
/// 成员今日手机使用数据
|
/// 成员今日手机使用数据
|
||||||
case phoneUsageToday(userId: String, groupKey: String)
|
case phoneUsageToday(userId: String, groupKey: String)
|
||||||
|
|
||||||
|
/// 成员 APP 周期使用报告
|
||||||
|
case phoneUsagePeriod(userId: String, groupKey: String, period: String)
|
||||||
|
|
||||||
/// 成员手机使用报告
|
/// 成员手机使用报告
|
||||||
case phoneUsageReport(userId: String, groupKey: String)
|
case phoneUsageReport(userId: String, groupKey: String)
|
||||||
|
|
||||||
|
|
@ -109,6 +112,8 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
return "mapi/user/status"
|
return "mapi/user/status"
|
||||||
case .phoneUsageToday:
|
case .phoneUsageToday:
|
||||||
return "mapi/phone/usage/today"
|
return "mapi/phone/usage/today"
|
||||||
|
case .phoneUsagePeriod:
|
||||||
|
return "mapi/phone/usage/period"
|
||||||
case .phoneUsageReport:
|
case .phoneUsageReport:
|
||||||
return "mapi/phone/usage/report"
|
return "mapi/phone/usage/report"
|
||||||
case .imToken:
|
case .imToken:
|
||||||
|
|
@ -150,7 +155,7 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .userInfo, .userStatus, .phoneUsageToday, .phoneUsageReport, .signInInfo, .notice, .followList, .relations:
|
case .userInfo, .userStatus, .phoneUsageToday, .phoneUsagePeriod, .phoneUsageReport, .signInInfo, .notice, .followList, .relations:
|
||||||
return .get
|
return .get
|
||||||
case .changePhone, .setGender:
|
case .changePhone, .setGender:
|
||||||
return .put
|
return .put
|
||||||
|
|
@ -183,6 +188,14 @@ extension UserAPI: MultiTargetProtocol {
|
||||||
]
|
]
|
||||||
return .requestParameters(parameters: params, encoding: URLEncoding.queryString)
|
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):
|
case let .phoneUsageReport(userId, groupKey):
|
||||||
let params: Parameters = [
|
let params: Parameters = [
|
||||||
"user_id": userId,
|
"user_id": userId,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ struct SystemConfigModel: Mappable {
|
||||||
/// 聊天界面的警告说明
|
/// 聊天界面的警告说明
|
||||||
var chatWarning: String = ""
|
var chatWarning: String = ""
|
||||||
|
|
||||||
|
/// 探索里的banner配置
|
||||||
|
var bannerList: [BannerConfigModel] = []
|
||||||
|
|
||||||
/// 圈子里的banner
|
/// 圈子里的banner
|
||||||
var groupBannerList: [String] = []
|
var groupBannerList: [String] = []
|
||||||
|
|
||||||
|
|
@ -103,6 +106,7 @@ struct SystemConfigModel: Mappable {
|
||||||
guideDisplay <- map["client.guide_display", nested: false]
|
guideDisplay <- map["client.guide_display", nested: false]
|
||||||
globalDisplay <- map["client.global_display", nested: false]
|
globalDisplay <- map["client.global_display", nested: false]
|
||||||
versionUpgradeModel <- map["client.version.upgrade", 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"]
|
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 appCount: Int?
|
||||||
var phoneInfo: PhoneUsageInfoModel?
|
var phoneInfo: PhoneUsageInfoModel?
|
||||||
var stayPoints: [PhoneUsageStayPointModel] = []
|
var stayPoints: [PhoneUsageStayPointModel] = []
|
||||||
|
var topApps: [PhoneUsageAppModel] = []
|
||||||
|
|
||||||
init?(map: Map) {}
|
init?(map: Map) {}
|
||||||
|
|
||||||
|
|
@ -125,6 +126,58 @@ struct PhoneUsageTodayModel: Mappable {
|
||||||
appCount <- (map["app_count"], kStrTransformInt)
|
appCount <- (map["app_count"], kStrTransformInt)
|
||||||
phoneInfo <- map["phone_info"]
|
phoneInfo <- map["phone_info"]
|
||||||
stayPoints <- map["stay_points"]
|
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 todayTrackDetail = "todayTrackDetail"
|
||||||
/// 手机报告详情
|
/// 手机报告详情
|
||||||
case phoneReportDetail = "phoneReportDetail"
|
case phoneReportDetail = "phoneReportDetail"
|
||||||
|
/// APP 使用报告
|
||||||
|
case appUsageReport = "appUsageReport"
|
||||||
/// 关于我们
|
/// 关于我们
|
||||||
case about = "about"
|
case about = "about"
|
||||||
/// 意见反馈
|
/// 意见反馈
|
||||||
|
|
@ -385,6 +387,11 @@ extension AppRouter: AppRouterProtocol {
|
||||||
PhoneReportDetailVC(userInfo: parameters)
|
PhoneReportDetailVC(userInfo: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - APP 使用报告
|
||||||
|
AppRouter.register(Route.appUsageReport) { _, parameters in
|
||||||
|
AppUsageReportVC(userInfo: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 关于我们
|
// MARK: - 关于我们
|
||||||
AppRouter.register(Route.about) { _, _ in
|
AppRouter.register(Route.about) { _, _ in
|
||||||
AboutVC()
|
AboutVC()
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ final class AppRestrictManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applicationOnlySelection(_ source: FamilyActivitySelection) -> FamilyActivitySelection {
|
private func applicationOnlySelection(_ source: FamilyActivitySelection) -> FamilyActivitySelection {
|
||||||
var result = FamilyActivitySelection(includeEntireCategory: false)
|
var result = FamilyActivitySelection(includeEntireCategory: true)
|
||||||
result.applicationTokens = source.applicationTokens
|
result.applicationTokens = source.applicationTokens
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ extension DefaultsKeys {
|
||||||
case -1: // UAT
|
case -1: // UAT
|
||||||
return "https://jsapi.zuom8.cn/"
|
return "https://jsapi.zuom8.cn/"
|
||||||
default: // SIT
|
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
|
private let initial: FamilyActivitySelection
|
||||||
var onComplete: ((FamilyActivitySelection) -> Void)?
|
var onComplete: ((FamilyActivitySelection) -> Void)?
|
||||||
|
|
||||||
init(selection: FamilyActivitySelection = FamilyActivitySelection(includeEntireCategory: false)) {
|
init(selection: FamilyActivitySelection = FamilyActivitySelection(includeEntireCategory: true)) {
|
||||||
self.initial = selection
|
self.initial = selection
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .overFullScreen
|
modalPresentationStyle = .overFullScreen
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import SwiftyUserDefaults
|
import SwiftyUserDefaults
|
||||||
|
import SDCycleScrollView
|
||||||
|
|
||||||
final class FeatureIntroVC: BaseViewController {
|
final class FeatureIntroVC: BaseViewController {
|
||||||
|
|
||||||
|
|
@ -13,6 +14,8 @@ final class FeatureIntroVC: BaseViewController {
|
||||||
|
|
||||||
private var rootView: FeatureIntroView!
|
private var rootView: FeatureIntroView!
|
||||||
|
|
||||||
|
private var bannerList: [BannerConfigModel] = []
|
||||||
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
rootView = FeatureIntroView(frame: UIScreen.main.bounds)
|
rootView = FeatureIntroView(frame: UIScreen.main.bounds)
|
||||||
view = rootView
|
view = rootView
|
||||||
|
|
@ -20,11 +23,23 @@ final class FeatureIntroVC: BaseViewController {
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
rootView.cycleScrollView.delegate = self
|
||||||
|
|
||||||
rootView.onTapItem = { [weak self] action in
|
rootView.onTapItem = { [weak self] action in
|
||||||
self?.handle(action)
|
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) {
|
private func handle(_ action: FeatureIntroAction) {
|
||||||
switch action {
|
switch action {
|
||||||
case .signIn:
|
case .signIn:
|
||||||
|
|
@ -54,3 +69,20 @@ final class FeatureIntroVC: BaseViewController {
|
||||||
return CLLocation(latitude: lat, longitude: lon)
|
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 UIKit
|
||||||
|
import SDCycleScrollView
|
||||||
|
|
||||||
struct FeatureIntroItem {
|
struct FeatureIntroItem {
|
||||||
let imageName: String
|
let imageName: String
|
||||||
|
|
@ -26,7 +27,8 @@ final class FeatureIntroView: UIView {
|
||||||
|
|
||||||
var onTapItem: ((FeatureIntroAction) -> Void)?
|
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 titleContainer = UIStackView()
|
||||||
private let titleLineView = UIView()
|
private let titleLineView = UIView()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
|
|
@ -86,10 +88,13 @@ final class FeatureIntroView: UIView {
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
backgroundColor = .white
|
backgroundColor = .white
|
||||||
|
|
||||||
headerBackgroundView.contentMode = .scaleAspectFill
|
|
||||||
headerBackgroundView.clipsToBounds = true
|
|
||||||
addSubview(headerBackgroundView)
|
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.backgroundColor = UIColor(hexStr: "#253B55")
|
||||||
titleLineView.layer.cornerRadius = 1.5
|
titleLineView.layer.cornerRadius = 1.5
|
||||||
|
|
@ -150,22 +155,24 @@ final class FeatureIntroView: UIView {
|
||||||
gridStack.addArrangedSubview(rowStack)
|
gridStack.addArrangedSubview(rowStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
headerBackgroundView.layoutChain
|
||||||
headerBackgroundView.topAnchor.constraint(equalTo: topAnchor),
|
.edges(excludingEdge: .bottom)
|
||||||
headerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
.heightToWidth(230 / 375)
|
||||||
headerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
headerBackgroundView.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 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.widthAnchor.constraint(equalToConstant: 24),
|
||||||
titleLineView.heightAnchor.constraint(equalToConstant: 3),
|
titleLineView.heightAnchor.constraint(equalToConstant: 3),
|
||||||
titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 75),
|
titleContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 75),
|
||||||
titleContainer.topAnchor.constraint(equalTo: topAnchor, constant: 91),
|
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.topAnchor.constraint(equalTo: sheetView.topAnchor),
|
||||||
scrollView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor),
|
scrollView.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor),
|
||||||
scrollView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor),
|
scrollView.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor),
|
||||||
|
|
@ -188,6 +195,23 @@ final class FeatureIntroView: UIView {
|
||||||
guard items.indices.contains(sender.tag) else { return }
|
guard items.indices.contains(sender.tag) else { return }
|
||||||
onTapItem?(items[sender.tag].action)
|
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 {
|
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 navBarHeightConstraint: NSLayoutConstraint?
|
||||||
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
||||||
private var todayTrackTopToMember: NSLayoutConstraint?
|
private var todayTrackTopToMember: NSLayoutConstraint?
|
||||||
|
private var phoneReportBottomConstraint: NSLayoutConstraint?
|
||||||
|
private var appUsageBottomConstraint: NSLayoutConstraint?
|
||||||
private var currentMemberModel: GroupMemberModel?
|
private var currentMemberModel: GroupMemberModel?
|
||||||
private var emojiEchoByUserId: [String: String] = [:]
|
private var emojiEchoByUserId: [String: String] = [:]
|
||||||
private var messageEchoByUserId: [String: String] = [:]
|
private var messageEchoByUserId: [String: String] = [:]
|
||||||
|
|
@ -143,9 +145,24 @@ class GroupMemberView2: UIView {
|
||||||
private func applyPhoneUsage(_ usage: PhoneUsageTodayModel?) {
|
private func applyPhoneUsage(_ usage: PhoneUsageTodayModel?) {
|
||||||
applyDeviceInfo(usage?.phoneInfo)
|
applyDeviceInfo(usage?.phoneInfo)
|
||||||
phoneReportView.configure(with: Self.phoneReportPreview(from: usage))
|
phoneReportView.configure(with: Self.phoneReportPreview(from: usage))
|
||||||
|
let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? [])
|
||||||
|
appUsageView.configure(with: appUsagePreview)
|
||||||
|
updateAppUsageVisibility(appUsagePreview != nil)
|
||||||
applyStayPoints(usage?.stayPoints ?? [])
|
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?) {
|
private func applyDeviceInfo(_ info: PhoneUsageInfoModel?) {
|
||||||
let model = info?.model.trimmed ?? ""
|
let model = info?.model.trimmed ?? ""
|
||||||
let hasModel = !model.isEmpty && model != "未知"
|
let hasModel = !model.isEmpty && model != "未知"
|
||||||
|
|
@ -374,7 +391,21 @@ class GroupMemberView2: UIView {
|
||||||
phoneReportView.layoutChain
|
phoneReportView.layoutChain
|
||||||
.topToBottomOfView(todayTrackView, offset: 15)
|
.topToBottomOfView(todayTrackView, offset: 15)
|
||||||
.edgesHorzontal()
|
.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
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
@ -828,7 +859,6 @@ class GroupMemberView2: UIView {
|
||||||
|
|
||||||
// MARK: - 手机报告 / APP使用记录
|
// MARK: - 手机报告 / APP使用记录
|
||||||
lazy var phoneReportView = MemberPhoneReportView()
|
lazy var phoneReportView = MemberPhoneReportView()
|
||||||
/// 保留 UI 实现,当前不挂载到 scroll 内容
|
|
||||||
lazy var appUsageView: MemberAppUsageView = {
|
lazy var appUsageView: MemberAppUsageView = {
|
||||||
let view = MemberAppUsageView()
|
let view = MemberAppUsageView()
|
||||||
view.isHidden = true
|
view.isHidden = true
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,11 @@ class HomeViewController: BaseViewController {
|
||||||
self?.openPhoneReportDetail()
|
self?.openPhoneReportDetail()
|
||||||
}).disposed(by: disposeBag)
|
}).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
|
rootView.locationView.rx.tapGesture.subscribe { _ in
|
||||||
self.startFollowingCurrentUser()
|
self.startFollowingCurrentUser()
|
||||||
|
|
@ -651,9 +656,14 @@ class HomeViewController: BaseViewController {
|
||||||
guard let self,
|
guard let self,
|
||||||
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
|
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
|
||||||
self.phoneUsageRequestIDs.removeValue(forKey: cacheKey)
|
self.phoneUsageRequestIDs.removeValue(forKey: cacheKey)
|
||||||
guard self.viewModel.groupModel?.default_group_key == groupKey,
|
guard self.viewModel.groupModel?.default_group_key == groupKey else { return }
|
||||||
response.code == "0",
|
guard response.code == "0", let model = response.model else {
|
||||||
let model = response.model else { return }
|
self.phoneUsageCache.removeValue(forKey: cacheKey)
|
||||||
|
if self.selectedMemberId == userId {
|
||||||
|
self.rootView.groupMemberView.updatePhoneUsage(nil, for: userId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.phoneUsageCache[cacheKey] = model
|
self.phoneUsageCache[cacheKey] = model
|
||||||
guard self.selectedMemberId == userId else { return }
|
guard self.selectedMemberId == userId else { return }
|
||||||
|
|
@ -662,6 +672,11 @@ class HomeViewController: BaseViewController {
|
||||||
guard let self,
|
guard let self,
|
||||||
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
|
self.phoneUsageRequestIDs[cacheKey] == requestID else { return }
|
||||||
self.phoneUsageRequestIDs.removeValue(forKey: cacheKey)
|
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)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
@ -965,6 +980,18 @@ class HomeViewController: BaseViewController {
|
||||||
AppRouter.push(Route.phoneReportDetail, userInfo: userInfo)
|
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) {
|
private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) {
|
||||||
let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId
|
let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
struct MemberAppUsageItemPreview {
|
struct MemberAppUsageItemPreview {
|
||||||
let rank: Int
|
let rank: Int
|
||||||
let name: String
|
let name: String
|
||||||
let tintHex: String
|
let iconURL: String
|
||||||
let durationText: String
|
let durationText: String
|
||||||
let progress: CGFloat
|
let progress: CGFloat
|
||||||
}
|
}
|
||||||
|
|
@ -19,23 +20,44 @@ struct MemberAppUsagePreview {
|
||||||
let segmentColors: [UIColor]
|
let segmentColors: [UIColor]
|
||||||
let topApps: [MemberAppUsageItemPreview]
|
let topApps: [MemberAppUsageItemPreview]
|
||||||
|
|
||||||
static let mock = MemberAppUsagePreview(
|
init?(topApps: [PhoneUsageAppModel]) {
|
||||||
totalTimeText: "24h 60m",
|
let apps = Array(topApps.filter { $0.usageTime > 0 }.prefix(5))
|
||||||
segmentRatios: [0.35, 0.28, 0.18, 0.19],
|
guard !apps.isEmpty else { return nil }
|
||||||
segmentColors: [
|
|
||||||
UIColor(hexStr: "#FF8FB1"),
|
let usageTimes = apps.map { max($0.usageTime, 0) }
|
||||||
UIColor(hexStr: "#FFD36E"),
|
let totalUsageTime = usageTimes.reduce(0, +)
|
||||||
UIColor(hexStr: "#7BE495"),
|
let colors = Self.segmentColors
|
||||||
UIColor(hexStr: "#6EC1FF")
|
|
||||||
],
|
totalTimeText = Self.durationText(seconds: totalUsageTime)
|
||||||
topApps: [
|
segmentRatios = usageTimes.map(CGFloat.init)
|
||||||
.init(rank: 1, name: "微信", tintHex: "#07C160", durationText: "1h 45m", progress: 1.0),
|
segmentColors = usageTimes.enumerated().map { colors[$0.offset % colors.count] }
|
||||||
.init(rank: 2, name: "抖音", tintHex: "#111111", durationText: "1h 25m", progress: 0.76),
|
self.topApps = apps.enumerated().map { index, app in
|
||||||
.init(rank: 3, name: "小红书", tintHex: "#FF2442", durationText: "45m", progress: 0.43),
|
MemberAppUsageItemPreview(
|
||||||
.init(rank: 4, name: "美团", tintHex: "#FFC300", durationText: "30m", progress: 0.29),
|
rank: index + 1,
|
||||||
.init(rank: 5, name: "Safari", tintHex: "#007AFF", durationText: "18m", progress: 0.17)
|
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
|
/// 成员面板「APP使用记录」:环形图 + TOP5
|
||||||
|
|
@ -49,13 +71,14 @@ final class MemberAppUsageView: UIView {
|
||||||
private let donutView = AppUsageDonutView()
|
private let donutView = AppUsageDonutView()
|
||||||
private let totalTimeLab = UILabel()
|
private let totalTimeLab = UILabel()
|
||||||
private let totalHintLab = UILabel()
|
private let totalHintLab = UILabel()
|
||||||
|
private let usageTitleLab = UILabel()
|
||||||
private let topTitleLab = UILabel()
|
private let topTitleLab = UILabel()
|
||||||
private let topStack = UIStackView()
|
private let topStack = UIStackView()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
setupUI()
|
setupUI()
|
||||||
configure(with: .mock)
|
configure(with: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
|
@ -66,7 +89,7 @@ final class MemberAppUsageView: UIView {
|
||||||
topStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
topStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
guard let preview else {
|
guard let preview else {
|
||||||
totalTimeLab.text = "--"
|
totalTimeLab.text = ""
|
||||||
donutView.setSegments(ratios: [], colors: [])
|
donutView.setSegments(ratios: [], colors: [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +101,7 @@ final class MemberAppUsageView: UIView {
|
||||||
let row = AppUsageTopRowView()
|
let row = AppUsageTopRowView()
|
||||||
row.configure(with: item)
|
row.configure(with: item)
|
||||||
topStack.addArrangedSubview(row)
|
topStack.addArrangedSubview(row)
|
||||||
|
row.layoutChain.height(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,17 +131,30 @@ final class MemberAppUsageView: UIView {
|
||||||
cardView.layoutChain
|
cardView.layoutChain
|
||||||
.topToBottomOfView(sectionIcon, offset: 12)
|
.topToBottomOfView(sectionIcon, offset: 12)
|
||||||
.edgesHorzontal(15)
|
.edgesHorzontal(15)
|
||||||
.height(220)
|
.height(176)
|
||||||
.bottom()
|
.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)
|
cardView.addSubview(donutView)
|
||||||
donutView.layoutChain
|
donutView.layoutChain
|
||||||
.left(12)
|
.left(18)
|
||||||
.centerY()
|
.top(43)
|
||||||
.width(110)
|
.width(120)
|
||||||
.height(110)
|
.height(120)
|
||||||
|
|
||||||
totalTimeLab.font = FontManager.youSheBiaoTiHei(14)
|
totalTimeLab.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
totalTimeLab.textColor = titleColor
|
totalTimeLab.textColor = titleColor
|
||||||
totalTimeLab.textAlignment = .center
|
totalTimeLab.textAlignment = .center
|
||||||
cardView.addSubview(totalTimeLab)
|
cardView.addSubview(totalTimeLab)
|
||||||
|
|
@ -126,7 +163,7 @@ final class MemberAppUsageView: UIView {
|
||||||
.centerY(donutView, offset: -6)
|
.centerY(donutView, offset: -6)
|
||||||
|
|
||||||
totalHintLab.text = "今日总计"
|
totalHintLab.text = "今日总计"
|
||||||
totalHintLab.font = .systemFont(ofSize: 9, weight: .medium)
|
totalHintLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
totalHintLab.textColor = UIColor(hexStr: "#767676")
|
totalHintLab.textColor = UIColor(hexStr: "#767676")
|
||||||
totalHintLab.textAlignment = .center
|
totalHintLab.textAlignment = .center
|
||||||
cardView.addSubview(totalHintLab)
|
cardView.addSubview(totalHintLab)
|
||||||
|
|
@ -134,24 +171,30 @@ final class MemberAppUsageView: UIView {
|
||||||
.centerX(donutView)
|
.centerX(donutView)
|
||||||
.topToBottomOfView(totalTimeLab, offset: 2)
|
.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.axis = .vertical
|
||||||
topStack.spacing = 10
|
topStack.spacing = 6
|
||||||
topStack.alignment = .fill
|
topStack.alignment = .fill
|
||||||
cardView.addSubview(topStack)
|
cardView.addSubview(topStack)
|
||||||
topStack.layoutChain
|
topStack.layoutChain
|
||||||
.topToBottomOfView(topTitleLab, offset: 10)
|
.top(43)
|
||||||
.leftToRightOfView(donutView, offset: 16)
|
.left(160)
|
||||||
.right(12)
|
.right(14)
|
||||||
.bottom(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 segmentLayers: [CAShapeLayer] = []
|
||||||
private var ratios: [CGFloat] = []
|
private var ratios: [CGFloat] = []
|
||||||
private var colors: [UIColor] = []
|
private var colors: [UIColor] = []
|
||||||
private let lineWidth: CGFloat = 14
|
private let lineWidth: CGFloat = 18
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
@ -188,14 +231,16 @@ private final class AppUsageDonutView: UIView {
|
||||||
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
|
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
|
||||||
var startAngle = -CGFloat.pi / 2
|
var startAngle = -CGFloat.pi / 2
|
||||||
let total = max(ratios.reduce(0, +), 0.001)
|
let total = max(ratios.reduce(0, +), 0.001)
|
||||||
|
let segmentGap: CGFloat = ratios.count > 1 ? 0.025 : 0
|
||||||
|
|
||||||
for (i, ratio) in ratios.enumerated() {
|
for (i, ratio) in ratios.enumerated() {
|
||||||
let sweep = (ratio / total) * CGFloat.pi * 2
|
let sweep = (ratio / total) * CGFloat.pi * 2
|
||||||
guard sweep > 0.001 else { continue }
|
guard sweep > 0.001 else { continue }
|
||||||
|
let gap = min(segmentGap, sweep * 0.25)
|
||||||
let path = UIBezierPath(arcCenter: center,
|
let path = UIBezierPath(arcCenter: center,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
startAngle: startAngle,
|
startAngle: startAngle + gap,
|
||||||
endAngle: startAngle + sweep,
|
endAngle: startAngle + sweep - gap,
|
||||||
clockwise: true)
|
clockwise: true)
|
||||||
let layer = CAShapeLayer()
|
let layer = CAShapeLayer()
|
||||||
layer.path = path.cgPath
|
layer.path = path.cgPath
|
||||||
|
|
@ -222,8 +267,7 @@ private final class AppUsageTopRowView: UIView {
|
||||||
private var progressRatio: CGFloat = 0
|
private var progressRatio: CGFloat = 0
|
||||||
private var progressWidthConstraint: NSLayoutConstraint?
|
private var progressWidthConstraint: NSLayoutConstraint?
|
||||||
private let rankLab = UILabel()
|
private let rankLab = UILabel()
|
||||||
private let iconView = UIView()
|
private let iconView = UIImageView()
|
||||||
private let iconLabel = UILabel()
|
|
||||||
private let nameLab = UILabel()
|
private let nameLab = UILabel()
|
||||||
private let progressTrack = UIView()
|
private let progressTrack = UIView()
|
||||||
private let progressFill = UIView()
|
private let progressFill = UIView()
|
||||||
|
|
@ -242,23 +286,29 @@ private final class AppUsageTopRowView: UIView {
|
||||||
rankLab.text = "\(item.rank)"
|
rankLab.text = "\(item.rank)"
|
||||||
nameLab.text = item.name
|
nameLab.text = item.name
|
||||||
durationLab.text = item.durationText
|
durationLab.text = item.durationText
|
||||||
iconView.backgroundColor = UIColor(hexStr: item.tintHex)
|
iconView.kf.cancelDownloadTask()
|
||||||
iconLabel.text = String(item.name.prefix(1))
|
iconView.image = UIImage(named: "LockDistract/app_unknown")
|
||||||
progressRatio = max(0.06, min(1, item.progress))
|
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()
|
setNeedsLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
rankLab.font = .systemFont(ofSize: 10, weight: .bold)
|
rankLab.font = .systemFont(ofSize: 11, weight: .bold)
|
||||||
rankLab.textColor = UIColor(hexStr: "#353B4F")
|
rankLab.textColor = UIColor(hexStr: "#353B4F")
|
||||||
rankLab.textAlignment = .center
|
rankLab.textAlignment = .center
|
||||||
addSubview(rankLab)
|
addSubview(rankLab)
|
||||||
rankLab.layoutChain
|
rankLab.layoutChain
|
||||||
.left()
|
.left()
|
||||||
.centerY()
|
.centerY()
|
||||||
.width(14)
|
.width(12)
|
||||||
|
|
||||||
iconView.cornerRadius = 8
|
iconView.backgroundColor = UIColor(hexStr: "#F4F6F8")
|
||||||
|
iconView.contentMode = .scaleAspectFill
|
||||||
|
iconView.clipsToBounds = true
|
||||||
|
iconView.cornerRadius = 4
|
||||||
addSubview(iconView)
|
addSubview(iconView)
|
||||||
iconView.layoutChain
|
iconView.layoutChain
|
||||||
.leftToRightOfView(rankLab, offset: 4)
|
.leftToRightOfView(rankLab, offset: 4)
|
||||||
|
|
@ -266,39 +316,35 @@ private final class AppUsageTopRowView: UIView {
|
||||||
.width(16)
|
.width(16)
|
||||||
.height(16)
|
.height(16)
|
||||||
|
|
||||||
iconLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
durationLab.font = .systemFont(ofSize: 10, weight: .regular)
|
||||||
iconLabel.textColor = .white
|
durationLab.textColor = UIColor(hexStr: "#A8A8A8")
|
||||||
iconLabel.textAlignment = .center
|
durationLab.textAlignment = .right
|
||||||
iconView.addSubview(iconLabel)
|
|
||||||
iconLabel.layoutChain.centerX().centerY()
|
|
||||||
|
|
||||||
durationLab.font = .systemFont(ofSize: 10, weight: .medium)
|
|
||||||
durationLab.textColor = UIColor(hexStr: "#767676")
|
|
||||||
durationLab.setContentCompressionResistancePriority(.required, for: .horizontal)
|
durationLab.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
addSubview(durationLab)
|
addSubview(durationLab)
|
||||||
durationLab.layoutChain
|
durationLab.layoutChain
|
||||||
.right()
|
.right()
|
||||||
.centerY()
|
.centerY()
|
||||||
|
.width(40)
|
||||||
|
|
||||||
nameLab.font = .systemFont(ofSize: 11, weight: .medium)
|
nameLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||||||
nameLab.textColor = UIColor(hexStr: "#353B4F")
|
nameLab.textColor = UIColor(hexStr: "#353B4F")
|
||||||
|
nameLab.lineBreakMode = .byTruncatingTail
|
||||||
addSubview(nameLab)
|
addSubview(nameLab)
|
||||||
nameLab.layoutChain
|
nameLab.layoutChain
|
||||||
.leftToRightOfView(iconView, offset: 6)
|
.leftToRightOfView(iconView, offset: 5)
|
||||||
.top()
|
.centerY()
|
||||||
.rightToLeftOfView(durationLab, offset: -6)
|
.width(45)
|
||||||
|
|
||||||
progressTrack.backgroundColor = UIColor(hexStr: "#F0F0F0")
|
progressTrack.backgroundColor = UIColor(hexStr: "#DDF3FF")
|
||||||
progressTrack.cornerRadius = 2
|
progressTrack.cornerRadius = 2
|
||||||
addSubview(progressTrack)
|
addSubview(progressTrack)
|
||||||
progressTrack.layoutChain
|
progressTrack.layoutChain
|
||||||
.leftToView(nameLab)
|
.leftToRightOfView(nameLab, offset: 5)
|
||||||
.rightToView(durationLab)
|
.rightToLeftOfView(durationLab, offset: -7)
|
||||||
.topToBottomOfView(nameLab, offset: 4)
|
.centerY()
|
||||||
.height(4)
|
.height(4)
|
||||||
.bottom()
|
|
||||||
|
|
||||||
progressFill.backgroundColor = UIColor(hexStr: "#58EDFF")
|
progressFill.backgroundColor = UIColor(hexStr: "#58C9F7")
|
||||||
progressFill.cornerRadius = 2
|
progressFill.cornerRadius = 2
|
||||||
progressTrack.addSubview(progressFill)
|
progressTrack.addSubview(progressFill)
|
||||||
progressFill.layoutChain
|
progressFill.layoutChain
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,18 @@ struct UserService {
|
||||||
.asObservable()
|
.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> {
|
static func phoneUsageReport(userId: String, groupKey: String) -> Observable<PhoneUsageReportResponse> {
|
||||||
let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget
|
let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue