diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 6b222ad7..4a86c5af 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -259,6 +259,7 @@ 30D74BF42FEB6F5B0050EB2C /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74BF12FEB6F5B0050EB2C /* LocationPickerView.swift */; }; 30D74D1F2FEBB09B0050EB2C /* CreateScheduleVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D74D1E2FEBB09B0050EB2C /* CreateScheduleVM.swift */; }; 30D87CDB2FDFA9EE00E958FD /* MQTTService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87CDA2FDFA9EE00E958FD /* MQTTService.swift */; }; + 55B240013034000100786001 /* MemberWeatherService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B240023034000100786002 /* MemberWeatherService.swift */; }; 30D87CDD2FDFF07500E958FD /* InteractionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87CDC2FDFF07500E958FD /* InteractionView.swift */; }; 30D87CDF2FDFF1A100E958FD /* QuickMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */; }; 30D87D042FE1336300E958FD /* NavigationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D87D012FE1336300E958FD /* NavigationVC.swift */; }; @@ -696,6 +697,7 @@ 30D74BF12FEB6F5B0050EB2C /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = ""; }; 30D74D1E2FEBB09B0050EB2C /* CreateScheduleVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateScheduleVM.swift; sourceTree = ""; }; 30D87CDA2FDFA9EE00E958FD /* MQTTService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTService.swift; sourceTree = ""; }; + 55B240023034000100786002 /* MemberWeatherService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberWeatherService.swift; sourceTree = ""; }; 30D87CDC2FDFF07500E958FD /* InteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionView.swift; sourceTree = ""; }; 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMessageView.swift; sourceTree = ""; }; 30D87D012FE1336300E958FD /* NavigationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationVC.swift; sourceTree = ""; }; @@ -1863,6 +1865,7 @@ isa = PBXGroup; children = ( 30D87CDA2FDFA9EE00E958FD /* MQTTService.swift */, + 55B240023034000100786002 /* MemberWeatherService.swift */, ); path = MQTT; sourceTree = ""; @@ -2673,6 +2676,7 @@ 30B74B412FF2437E00F6744D /* GroupMemberListVC.swift in Sources */, 305A76FD2FCA8C7000227D26 /* EmptyDataSetSource.swift in Sources */, 30D87CDB2FDFA9EE00E958FD /* MQTTService.swift in Sources */, + 55B240013034000100786001 /* MemberWeatherService.swift in Sources */, 30EFF3CD2FDA668A00EB35D4 /* MyProfileView.swift in Sources */, 305A76FE2FCA8C7000227D26 /* EmptyDataSetView.swift in Sources */, 30C4C0192FDBF094009215C1 /* RemoveMemberVC.swift in Sources */, diff --git a/QuickLocation/API/PigeonAPI.swift b/QuickLocation/API/PigeonAPI.swift index 03b623a1..7f8573c2 100644 --- a/QuickLocation/API/PigeonAPI.swift +++ b/QuickLocation/API/PigeonAPI.swift @@ -13,14 +13,14 @@ enum PigeonAPI { /// - toUsers: 收件人加密 id /// - msgType: 2 语音 3 文案 /// - msg: 文案留言,或语音 file id - /// - bgImg: 自定义背景图 file id;图片模版不传 + /// - bgImg: 自定义背景图 file id;图片模版传 0 /// - expireTime: 过期秒数,0 表示服务端默认 7 天 case send( groupKey: String, toUsers: [String], msgType: Int, msg: String, - bgImg: String, + bgImg: Int64, expireTime: Int, extra: [String: Any]? ) @@ -63,7 +63,7 @@ extension PigeonAPI: MultiTargetProtocol { params["to_user"] = toUsers params["msg_type"] = msgType params["msg"] = msg - params["bg_img"] = bgImg.isEmpty ? 0 : bgImg + params["bg_img"] = bgImg params["expire_time"] = expireTime if let extra { params["extra"] = extra diff --git a/QuickLocation/API/UserAPI.swift b/QuickLocation/API/UserAPI.swift index 03cd3383..f633347b 100644 --- a/QuickLocation/API/UserAPI.swift +++ b/QuickLocation/API/UserAPI.swift @@ -24,13 +24,19 @@ enum UserAPI { /// 用户当前状态 case userStatus + + /// 成员今日手机使用数据 + case phoneUsageToday(userId: String, groupKey: String) + + /// 成员手机使用报告 + case phoneUsageReport(userId: String, groupKey: String) /// 用户IM Token case imToken /// 签到信息 case signInInfo - + /// 更换手机号 case changePhone(timestamp: String, phone: String, code: String) @@ -86,6 +92,9 @@ enum UserAPI { /// 关系列表 case relations + + /// 意见反馈 + case feedback(type: String, content: String, contact: String, images: [String]) } extension UserAPI: MultiTargetProtocol { @@ -98,6 +107,10 @@ extension UserAPI: MultiTargetProtocol { return "api/user" case .userStatus: return "mapi/user/status" + case .phoneUsageToday: + return "mapi/phone/usage/today" + case .phoneUsageReport: + return "mapi/phone/usage/report" case .imToken: return "mapi/openim/user/token/get" case .signInInfo: @@ -130,12 +143,14 @@ extension UserAPI: MultiTargetProtocol { return "mapi/user/signin/setemail" case .relations: return "mapi/user/relations" + case .feedback: + return "api/user/feedback" } } var method: Moya.Method { switch self { - case .userInfo, .userStatus, .signInInfo, .notice, .followList, .relations: + case .userInfo, .userStatus, .phoneUsageToday, .phoneUsageReport, .signInInfo, .notice, .followList, .relations: return .get case .changePhone, .setGender: return .put @@ -160,6 +175,20 @@ extension UserAPI: MultiTargetProtocol { case .userStatus: return .requestPlain + + case let .phoneUsageToday(userId, groupKey): + let params: Parameters = [ + "user_id": userId, + "group_key": groupKey + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .phoneUsageReport(userId, groupKey): + let params: Parameters = [ + "user_id": userId, + "group_key": groupKey + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) case .imToken: var params = Parameters() @@ -169,7 +198,7 @@ extension UserAPI: MultiTargetProtocol { case .signInInfo: return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) - + case let .changePhone(timestamp, phone, code): var params = Parameters() params["phone_timestamp"] = timestamp @@ -237,6 +266,15 @@ extension UserAPI: MultiTargetProtocol { case .relations: return .requestPlain + + case let .feedback(type, content, contact, images): + let params: Parameters = [ + "type": type, + "content": content, + "contact": contact, + "images": images + ] + return .requestParameters(parameters: params, encoding: JSONEncoding()) } } } diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/Contents.json new file mode 100644 index 00000000..1fe9a08b --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_alipay@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/catalog_alipay@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/catalog_alipay@2x.png new file mode 100644 index 00000000..071f9133 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_alipay.imageset/catalog_alipay@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/Contents.json new file mode 100644 index 00000000..580dac2e --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_dewu@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/catalog_dewu@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/catalog_dewu@2x.png new file mode 100644 index 00000000..b93ed723 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_dewu.imageset/catalog_dewu@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/Contents.json new file mode 100644 index 00000000..51470b9d --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_doubao@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/catalog_doubao@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/catalog_doubao@2x.png new file mode 100644 index 00000000..d4b0d00e Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_doubao.imageset/catalog_doubao@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/Contents.json new file mode 100644 index 00000000..bbfd5dc5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_douyin@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/catalog_douyin@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/catalog_douyin@2x.png new file mode 100644 index 00000000..85858a35 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin.imageset/catalog_douyin@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/Contents.json new file mode 100644 index 00000000..0fd4d8a0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_douyin_mall@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/catalog_douyin_mall@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/catalog_douyin_mall@2x.png new file mode 100644 index 00000000..36406d32 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_douyin_mall.imageset/catalog_douyin_mall@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/Contents.json new file mode 100644 index 00000000..5a07538b --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_happy_landlord@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/catalog_happy_landlord@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/catalog_happy_landlord@2x.png new file mode 100644 index 00000000..c4e6f7bc Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_happy_landlord.imageset/catalog_happy_landlord@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/Contents.json new file mode 100644 index 00000000..f92c3473 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_hongguo_comic@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/catalog_hongguo_comic@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/catalog_hongguo_comic@2x.png new file mode 100644 index 00000000..dc1a6300 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_comic.imageset/catalog_hongguo_comic@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/Contents.json new file mode 100644 index 00000000..3a322bee --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_hongguo_short_drama@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/catalog_hongguo_short_drama@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/catalog_hongguo_short_drama@2x.png new file mode 100644 index 00000000..34166ab4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_hongguo_short_drama.imageset/catalog_hongguo_short_drama@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/Contents.json new file mode 100644 index 00000000..9540f5ec --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_iqiyi@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/catalog_iqiyi@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/catalog_iqiyi@2x.png new file mode 100644 index 00000000..11f18a87 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_iqiyi.imageset/catalog_iqiyi@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/Contents.json new file mode 100644 index 00000000..083de2ca --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_jcc@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/catalog_jcc@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/catalog_jcc@2x.png new file mode 100644 index 00000000..0193232d Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jcc.imageset/catalog_jcc@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/Contents.json new file mode 100644 index 00000000..0536b785 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_jd@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/catalog_jd@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/catalog_jd@2x.png new file mode 100644 index 00000000..f890af42 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_jd.imageset/catalog_jd@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/Contents.json new file mode 100644 index 00000000..51cb0294 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_kuaishou@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/catalog_kuaishou@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/catalog_kuaishou@2x.png new file mode 100644 index 00000000..bf2b6ebd Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_kuaishou.imageset/catalog_kuaishou@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/Contents.json new file mode 100644 index 00000000..0c2b3254 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_meituan@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/catalog_meituan@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/catalog_meituan@2x.png new file mode 100644 index 00000000..ee8e8b19 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_meituan.imageset/catalog_meituan@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/Contents.json new file mode 100644 index 00000000..1a79e3ff --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_peace_elite@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/catalog_peace_elite@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/catalog_peace_elite@2x.png new file mode 100644 index 00000000..19cb81b9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_peace_elite.imageset/catalog_peace_elite@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/Contents.json new file mode 100644 index 00000000..150d43d4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_qq@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/catalog_qq@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/catalog_qq@2x.png new file mode 100644 index 00000000..52fc6a48 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_qq.imageset/catalog_qq@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/Contents.json new file mode 100644 index 00000000..a83e3bdf --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_taobao@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/catalog_taobao@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/catalog_taobao@2x.png new file mode 100644 index 00000000..8dbd3d73 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_taobao.imageset/catalog_taobao@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/Contents.json new file mode 100644 index 00000000..718dd3fd --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_tomato_novel@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/catalog_tomato_novel@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/catalog_tomato_novel@2x.png new file mode 100644 index 00000000..a1550d2e Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_tomato_novel.imageset/catalog_tomato_novel@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/Contents.json new file mode 100644 index 00000000..ab79a2be --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_wangzhe@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/catalog_wangzhe@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/catalog_wangzhe@2x.png new file mode 100644 index 00000000..c2c41d25 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wangzhe.imageset/catalog_wangzhe@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/Contents.json new file mode 100644 index 00000000..30972e70 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_wechat@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/catalog_wechat@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/catalog_wechat@2x.png new file mode 100644 index 00000000..b2192635 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_wechat.imageset/catalog_wechat@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/Contents.json new file mode 100644 index 00000000..43c7864f --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_weibo@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/catalog_weibo@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/catalog_weibo@2x.png new file mode 100644 index 00000000..5a8b09bf Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_weibo.imageset/catalog_weibo@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/Contents.json new file mode 100644 index 00000000..63c05193 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_xianyu@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/catalog_xianyu@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/catalog_xianyu@2x.png new file mode 100644 index 00000000..e07f0078 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xianyu.imageset/catalog_xianyu@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/Contents.json new file mode 100644 index 00000000..f2ee3656 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_xiaohongshu@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/catalog_xiaohongshu@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/catalog_xiaohongshu@2x.png new file mode 100644 index 00000000..76021df7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_xiaohongshu.imageset/catalog_xiaohongshu@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/Contents.json new file mode 100644 index 00000000..0496360d --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_youku@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/catalog_youku@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/catalog_youku@2x.png new file mode 100644 index 00000000..255d2bb0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_youku.imageset/catalog_youku@2x.png differ diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/Contents.json b/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/Contents.json new file mode 100644 index 00000000..b8e2dcf7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "catalog_zhuanzhuan@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/catalog_zhuanzhuan@2x.png b/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/catalog_zhuanzhuan@2x.png new file mode 100644 index 00000000..b1ae101a Binary files /dev/null and b/QuickLocation/Assets.xcassets/AppRestrict/catalog_zhuanzhuan.imageset/catalog_zhuanzhuan@2x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/Contents.json b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/Contents.json new file mode 100644 index 00000000..be73e68f --- /dev/null +++ b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "permission_location_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "permission_location_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@2x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@2x.png new file mode 100644 index 00000000..ca41c3ad Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@3x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@3x.png new file mode 100644 index 00000000..9cda529b Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_location_icon.imageset/permission_location_icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/Contents.json b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/Contents.json new file mode 100644 index 00000000..f5144ef2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "permission_pair_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "permission_pair_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@2x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@2x.png new file mode 100644 index 00000000..d88e2a01 Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@3x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@3x.png new file mode 100644 index 00000000..d30f9f90 Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_pair_icon.imageset/permission_pair_icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/Contents.json b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/Contents.json new file mode 100644 index 00000000..04a6834d --- /dev/null +++ b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "permission_screen_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "permission_screen_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@2x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@2x.png new file mode 100644 index 00000000..97ef2da7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@3x.png b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@3x.png new file mode 100644 index 00000000..405aa835 Binary files /dev/null and b/QuickLocation/Assets.xcassets/CheckPermission/permission_screen_icon.imageset/permission_screen_icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/Contents.json b/QuickLocation/Assets.xcassets/Explore/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/Contents.json new file mode 100644 index 00000000..79786b3a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bubble_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bubble_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@2x.png b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@2x.png new file mode 100644 index 00000000..ae735555 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@3x.png b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@3x.png new file mode 100644 index 00000000..1a0eaf57 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/bubble_card.imageset/bubble_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/Contents.json new file mode 100644 index 00000000..3c4bace7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "header_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "header_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png new file mode 100644 index 00000000..627eea7e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png new file mode 100644 index 00000000..39623d54 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/header_bg.imageset/header_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/Contents.json new file mode 100644 index 00000000..6a465d10 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lock_distract_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lock_distract_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@2x.png b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@2x.png new file mode 100644 index 00000000..aad0756a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@3x.png b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@3x.png new file mode 100644 index 00000000..62d4074b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/lock_distract_card.imageset/lock_distract_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/Contents.json new file mode 100644 index 00000000..f966c907 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pigeon_message_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pigeon_message_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@2x.png b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@2x.png new file mode 100644 index 00000000..41ae1233 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@3x.png b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@3x.png new file mode 100644 index 00000000..b7295288 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/pigeon_message_card.imageset/pigeon_message_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/Contents.json new file mode 100644 index 00000000..da0a19a3 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "search_location_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "search_location_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@2x.png b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@2x.png new file mode 100644 index 00000000..758fbf8e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@3x.png b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@3x.png new file mode 100644 index 00000000..caee3ed4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/search_location_card.imageset/search_location_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/Contents.json new file mode 100644 index 00000000..d38a4a33 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sign_in_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sign_in_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@2x.png b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@2x.png new file mode 100644 index 00000000..cf8987b5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@3x.png b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@3x.png new file mode 100644 index 00000000..ca92db53 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/sign_in_card.imageset/sign_in_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/Contents.json new file mode 100644 index 00000000..50b65993 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sos_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sos_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@2x.png b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@2x.png new file mode 100644 index 00000000..81a32215 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@3x.png b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@3x.png new file mode 100644 index 00000000..2cefb94f Binary files /dev/null and b/QuickLocation/Assets.xcassets/Explore/sos_card.imageset/sos_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/Contents.json new file mode 100644 index 00000000..e164cead --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "chat_location_map@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "chat_location_map@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@2x.png b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@2x.png new file mode 100644 index 00000000..75830bad Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@3x.png b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@3x.png new file mode 100644 index 00000000..ed653b0b Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/chat_location_map.imageset/chat_location_map@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/Contents.json new file mode 100644 index 00000000..d12da9f5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "create_action_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "create_action_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@2x.png b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@2x.png new file mode 100644 index 00000000..19a78421 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@3x.png b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@3x.png new file mode 100644 index 00000000..c5300a4d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/create_action_card.imageset/create_action_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/Contents.json new file mode 100644 index 00000000..5c2033dd --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "join_action_card@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "join_action_card@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@2x.png b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@2x.png new file mode 100644 index 00000000..bf75b0d7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@3x.png b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@3x.png new file mode 100644 index 00000000..c3df2b55 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/join_action_card.imageset/join_action_card@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@2x.png b/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@2x.png index 6af6e96c..7537c9b0 100644 Binary files a/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@2x.png and b/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@3x.png b/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@3x.png index 309a236a..4d52f1c0 100644 Binary files a/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@3x.png and b/QuickLocation/Assets.xcassets/Group/join_hero_bg.imageset/join_hero_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/message_tick.imageset/Contents.json b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/Contents.json new file mode 100644 index 00000000..81d22b78 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_tick@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_tick@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@2x.png b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@2x.png new file mode 100644 index 00000000..8767e49f Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@3x.png b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@3x.png new file mode 100644 index 00000000..8a87247c Binary files /dev/null and b/QuickLocation/Assets.xcassets/Group/message_tick.imageset/message_tick@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/Contents.json new file mode 100644 index 00000000..3925e635 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mouse@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mouse@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@2x.png b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@2x.png new file mode 100644 index 00000000..55d24136 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@3x.png b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@3x.png new file mode 100644 index 00000000..c0a32b21 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_interaction.imageset/mouse@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@2x.png b/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@2x.png index 1a0ca9bd..ea03309d 100644 Binary files a/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@2x.png and b/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@3x.png b/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@3x.png index bfa643dc..81d489c7 100644 Binary files a/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@3x.png and b/QuickLocation/Assets.xcassets/Home/member_map.imageset/组 48007@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/Contents.json new file mode 100644 index 00000000..8d2b6874 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_cloudy@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_cloudy@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@2x.png new file mode 100644 index 00000000..3b96112f Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@3x.png new file mode 100644 index 00000000..b25b74aa Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_cloudy.imageset/member_weather_cloudy@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/Contents.json new file mode 100644 index 00000000..e948a15b --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_overcast@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_overcast@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@2x.png new file mode 100644 index 00000000..33706cf6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@3x.png new file mode 100644 index 00000000..80faba4a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_overcast.imageset/member_weather_overcast@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/Contents.json new file mode 100644 index 00000000..763613aa --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_rain@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_rain@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@2x.png new file mode 100644 index 00000000..03b189cb Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@3x.png new file mode 100644 index 00000000..ee5b7b22 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_rain.imageset/member_weather_rain@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/Contents.json new file mode 100644 index 00000000..0b90db28 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_snow@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_snow@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@2x.png new file mode 100644 index 00000000..3b533c78 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@3x.png new file mode 100644 index 00000000..5fa0d3d1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_snow.imageset/member_weather_snow@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/Contents.json new file mode 100644 index 00000000..2c1be145 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_sunny@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_sunny@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@2x.png new file mode 100644 index 00000000..753e29ce Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@3x.png new file mode 100644 index 00000000..614f4c09 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_sunny.imageset/member_weather_sunny@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/Contents.json new file mode 100644 index 00000000..64bf6568 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "member_weather_thunderstorm@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "member_weather_thunderstorm@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@2x.png b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@2x.png new file mode 100644 index 00000000..1512deb4 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@3x.png b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@3x.png new file mode 100644 index 00000000..82830257 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_weather_thunderstorm.imageset/member_weather_thunderstorm@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png index 438b3ef5..ac7c8cf0 100644 Binary files a/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png and b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png index e9dda8ef..1e88e0d3 100644 Binary files a/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png and b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png index 73959e72..60d9ee62 100644 Binary files a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png and b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png index c58ab089..20d20d7b 100644 Binary files a/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png and b/QuickLocation/Assets.xcassets/Home/unlock_request.imageset/unlock_request@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/Contents.json new file mode 100644 index 00000000..105d48b7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lock_icon_6@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lock_icon_6@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@2x.png new file mode 100644 index 00000000..7888c74c Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@3x.png new file mode 100644 index 00000000..d97bdcfe Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_6.imageset/lock_icon_6@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/Contents.json new file mode 100644 index 00000000..1a840401 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lock_icon_7@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lock_icon_7@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@2x.png new file mode 100644 index 00000000..72e2a0e9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@3x.png new file mode 100644 index 00000000..8d254b65 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_7.imageset/lock_icon_7@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/Contents.json new file mode 100644 index 00000000..a9a5607e --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_am_i_talkative.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/voice_am_i_talkative.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/voice_am_i_talkative.mp3 new file mode 100644 index 00000000..ef2ad24f Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_am_i_talkative.dataset/voice_am_i_talkative.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/Contents.json new file mode 100644 index 00000000..1cd3ab75 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_baby_dont_be_angry.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/voice_baby_dont_be_angry.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/voice_baby_dont_be_angry.mp3 new file mode 100644 index 00000000..abc5f202 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_baby_dont_be_angry.dataset/voice_baby_dont_be_angry.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/Contents.json new file mode 100644 index 00000000..b800e04d --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_dare_to_compete.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/voice_dare_to_compete.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/voice_dare_to_compete.mp3 new file mode 100644 index 00000000..2639e349 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dare_to_compete.dataset/voice_dare_to_compete.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/Contents.json new file mode 100644 index 00000000..ebff240c --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_dont_you_need_to_work.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/voice_dont_you_need_to_work.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/voice_dont_you_need_to_work.mp3 new file mode 100644 index 00000000..b7d25d60 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_dont_you_need_to_work.dataset/voice_dont_you_need_to_work.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/Contents.json new file mode 100644 index 00000000..247dd7b7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_getting_bold.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/voice_getting_bold.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/voice_getting_bold.mp3 new file mode 100644 index 00000000..56d1d1c6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_getting_bold.dataset/voice_getting_bold.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/Contents.json new file mode 100644 index 00000000..967f8b98 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_good_night_kiss.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/voice_good_night_kiss.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/voice_good_night_kiss.mp3 new file mode 100644 index 00000000..3a8cbfc2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_good_night_kiss.dataset/voice_good_night_kiss.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/Contents.json new file mode 100644 index 00000000..9610085a --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_ignoring_me.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/voice_ignoring_me.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/voice_ignoring_me.mp3 new file mode 100644 index 00000000..b6cc73db Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_ignoring_me.dataset/voice_ignoring_me.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/Contents.json new file mode 100644 index 00000000..39c7c2f3 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_is_my_mic_loud.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/voice_is_my_mic_loud.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/voice_is_my_mic_loud.mp3 new file mode 100644 index 00000000..91f7aff9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_is_my_mic_loud.dataset/voice_is_my_mic_loud.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/Contents.json new file mode 100644 index 00000000..b475eeb7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_love_you.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/voice_love_you.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/voice_love_you.mp3 new file mode 100644 index 00000000..b10fc77b Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_love_you.dataset/voice_love_you.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/Contents.json new file mode 100644 index 00000000..f76dc0c1 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_mosquito_violin.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/voice_mosquito_violin.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/voice_mosquito_violin.mp3 new file mode 100644 index 00000000..a7abbf51 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_mosquito_violin.dataset/voice_mosquito_violin.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/Contents.json new file mode 100644 index 00000000..cb2a1a2a --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_okay.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/voice_okay.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/voice_okay.mp3 new file mode 100644 index 00000000..e1014890 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_okay.dataset/voice_okay.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/Contents.json new file mode 100644 index 00000000..682ea221 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_save_me.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/voice_save_me.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/voice_save_me.mp3 new file mode 100644 index 00000000..80a546f6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_save_me.dataset/voice_save_me.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/Contents.json new file mode 100644 index 00000000..5fb46de2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_sleep_if_you_want.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/voice_sleep_if_you_want.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/voice_sleep_if_you_want.mp3 new file mode 100644 index 00000000..af9542e0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sleep_if_you_want.dataset/voice_sleep_if_you_want.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/Contents.json new file mode 100644 index 00000000..d17619d2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_sorry_im_right.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/voice_sorry_im_right.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/voice_sorry_im_right.mp3 new file mode 100644 index 00000000..230ceada Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_sorry_im_right.dataset/voice_sorry_im_right.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/Contents.json new file mode 100644 index 00000000..a69383d0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_teasing_you_daily.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/voice_teasing_you_daily.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/voice_teasing_you_daily.mp3 new file mode 100644 index 00000000..a9519dc1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_teasing_you_daily.dataset/voice_teasing_you_daily.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/Contents.json new file mode 100644 index 00000000..171c045b --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_thank_you.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/voice_thank_you.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/voice_thank_you.mp3 new file mode 100644 index 00000000..bf323af6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_thank_you.dataset/voice_thank_you.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/Contents.json new file mode 100644 index 00000000..c9692942 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_wake_up_sleepyhead.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/voice_wake_up_sleepyhead.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/voice_wake_up_sleepyhead.mp3 new file mode 100644 index 00000000..e5dfc020 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_wake_up_sleepyhead.dataset/voice_wake_up_sleepyhead.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/Contents.json new file mode 100644 index 00000000..e4810f64 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_what_can_you_do.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/voice_what_can_you_do.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/voice_what_can_you_do.mp3 new file mode 100644 index 00000000..6bb41e0f Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_what_can_you_do.dataset/voice_what_can_you_do.mp3 differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/Contents.json new file mode 100644 index 00000000..d275b246 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "voice_who_can_save_me.mp3", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/voice_who_can_save_me.mp3 b/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/voice_who_can_save_me.mp3 new file mode 100644 index 00000000..5365e463 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/voice_who_can_save_me.dataset/voice_who_can_save_me.mp3 differ diff --git a/QuickLocation/Manager/Account/RelationStore.swift b/QuickLocation/Manager/Account/RelationStore.swift index af98e974..cfe0aa09 100644 --- a/QuickLocation/Manager/Account/RelationStore.swift +++ b/QuickLocation/Manager/Account/RelationStore.swift @@ -3,11 +3,16 @@ // QuickLocation // -import Foundation +import UIKit +import Kingfisher import ObjectMapper import RxSwift import SwiftyUserDefaults +private extension Notification.Name { + static let relationStoreDidUpdate = Notification.Name("relationStoreDidUpdate") +} + struct RelationListResponse: BaseModelProtocol { var code: String? var message: String? @@ -35,7 +40,19 @@ struct RelationModel: Mappable, Equatable { mutating func mapping(map: Map) { name <- map["name"] - type <- (map["type"], kRelationTypeTransform) + type <- (map["const"], kRelationTypeTransform) + if type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + type <- (map["type"], kRelationTypeTransform) + } + if type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + type <- (map["idx"], kRelationTypeTransform) + } + if type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + type <- (map["relation_idx"], kRelationTypeTransform) + } + if type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + type <- (map["id"], kRelationTypeTransform) + } limit <- (map["limit"], kStrTransformInt) icon <- map["icon"] } @@ -108,15 +125,120 @@ final class RelationStore { item(for: idx)?.showsHeart == true } + func iconURL(for idx: String) -> URL? { + guard let icon = item(for: idx)?.icon else { return nil } + return Self.validIconURL(from: icon) + } + + static func validIconURL(from value: String) -> URL? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmedValue), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.host != nil else { return nil } + return url + } + private func replace(_ list: [RelationModel]) { - self.list = list - let json = list.map { $0.toJSON() } + let normalizedList = Self.normalized(list) + self.list = normalizedList + let json = normalizedList.map { $0.toJSON() } Defaults[\.userRelations] = try? JSONSerialization.data(withJSONObject: json) + NotificationCenter.default.post(name: .relationStoreDidUpdate, object: nil) } private func loadDisk() { guard let data = Defaults[\.userRelations], let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } - list = json.compactMap { RelationModel(JSON: $0) } + list = Self.normalized(json.compactMap { RelationModel(JSON: $0) }) + } + + private static func normalized(_ list: [RelationModel]) -> [RelationModel] { + list.enumerated().map { index, model in + guard model.idx.isEmpty else { return model } + var normalizedModel = model + // Legacy relation responses omit an identifier and define it by list order. + normalizedModel.type = String(index + 1) + return normalizedModel + } + } +} + +final class RelationIconImageView: UIImageView { + private var requestVersion = 0 + private var visibilityChanged: ((Bool) -> Void)? + private var relationIdx = "" + private var relationObserver: NSObjectProtocol? + + convenience init() { + self.init(frame: .zero) + } + + override init(frame: CGRect) { + super.init(frame: frame) + contentMode = .scaleAspectFit + isHidden = true + relationObserver = NotificationCenter.default.addObserver( + forName: .relationStoreDidUpdate, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reloadImage() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let relationObserver { + NotificationCenter.default.removeObserver(relationObserver) + } + } + + override var intrinsicContentSize: CGSize { + CGSize(width: 12, height: 12) + } + + func configure( + relationIdx: String, + visibilityChanged: ((Bool) -> Void)? = nil + ) { + self.relationIdx = relationIdx.trimmingCharacters(in: .whitespacesAndNewlines) + self.visibilityChanged = visibilityChanged + reloadImage() + } + + private func reloadImage() { + kf.cancelDownloadTask() + image = nil + isHidden = true + requestVersion += 1 + visibilityChanged?(false) + + guard let url = RelationStore.shared.iconURL(for: relationIdx) else { return } + let version = requestVersion + kf.setImage(with: url) { [weak self] result in + guard let self, self.requestVersion == version else { return } + switch result { + case .success: + self.isHidden = false + self.visibilityChanged?(true) + case .failure: + self.image = nil + self.isHidden = true + self.visibilityChanged?(false) + } + } + } + + func clear() { + kf.cancelDownloadTask() + requestVersion += 1 + relationIdx = "" + visibilityChanged = nil + image = nil + isHidden = true } } diff --git a/QuickLocation/Manager/Account/UserConfigResponse.swift b/QuickLocation/Manager/Account/UserConfigResponse.swift index 017e95ec..0f29950a 100644 --- a/QuickLocation/Manager/Account/UserConfigResponse.swift +++ b/QuickLocation/Manager/Account/UserConfigResponse.swift @@ -99,3 +99,191 @@ struct UserStatusModel: Mappable { return Date(timeIntervalSince1970: seconds) } } + +struct PhoneUsageTodayResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneUsageTodayModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + model <- map["data"] + } +} + +struct PhoneUsageTodayModel: Mappable { + var appCount: Int? + var phoneInfo: PhoneUsageInfoModel? + var stayPoints: [PhoneUsageStayPointModel] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + appCount <- (map["app_count"], kStrTransformInt) + phoneInfo <- map["phone_info"] + stayPoints <- map["stay_points"] + } +} + +struct PhoneUsageReportResponse: BaseModelProtocol { + var code: String? + var message: String? + var model: PhoneUsageReportModel? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + model <- map["data"] + } +} + +struct PhoneUsageReportModel: Mappable { + var phoneInfo: PhoneUsageInfoModel? + var brand: String = "" + var brandIcon: String = "" + var screenUseTimes: [PhoneUsageReportDayModel] = [] + var appUseCounts: [PhoneUsageReportDayModel] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + phoneInfo <- map["phone_info"] + brand <- (map["brand"], kIntTransformStr) + brandIcon <- (map["brand_icon"], kIntTransformStr) + screenUseTimes <- map["screen_use_times"] + appUseCounts <- map["app_use_counts"] + } +} + +struct PhoneUsageReportDayModel: Mappable { + var day: String = "" + var useTime: Int? + var count: Int? + + init?(map: Map) {} + + mutating func mapping(map: Map) { + day <- (map["day"], kIntTransformStr) + useTime <- (map["use_time"], kStrTransformInt) + count <- (map["count"], kStrTransformInt) + } +} + +struct PhoneUsageInfoModel: Mappable { + var brand: String = "" + var model: String = "" + var memoryTotal: String = "" + var memoryUsed: String = "" + var battery: String = "" + var network: String = "" + var brightness: String = "" + var volume: String = "" + var weather: String = "" + var useTime: String = "" + var unlockCount: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + brand <- (map["brand"], kIntTransformStr) + model <- (map["model"], kIntTransformStr) + memoryTotal <- (map["memory_total"], kIntTransformStr) + memoryUsed <- (map["memory_used"], kIntTransformStr) + battery <- (map["battery"], kIntTransformStr) + network <- (map["network"], kIntTransformStr) + brightness <- (map["brightness"], kIntTransformStr) + volume <- (map["volume"], kIntTransformStr) + weather <- (map["weather"], kIntTransformStr) + useTime <- (map["use_time"], kIntTransformStr) + unlockCount <- (map["unlock_count"], kIntTransformStr) + } +} + +struct PhoneUsageStayPointModel: Mappable { + var id: String = "" + var userId: String = "" + var location: PhoneUsageStayLocationModel? + var startTime: String = "" + var endTime: String = "" + var durationMinutes: Int? + var visitCount: Int? + var address: PhoneUsageStayAddressModel? + var createdAt: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + id <- (map["id"], kIntTransformStr) + userId <- (map["user_id"], kIntTransformStr) + location <- map["location"] + startTime <- (map["start_time"], kIntTransformStr) + endTime <- (map["end_time"], kIntTransformStr) + durationMinutes <- (map["duration_minutes"], kStrTransformInt) + visitCount <- (map["visit_count"], kStrTransformInt) + address <- map["address"] + createdAt <- (map["created_at"], kIntTransformStr) + } + + var startDate: Date? { + Self.date(from: startTime) + } + + var resolvedDurationMinutes: Int? { + if let durationMinutes, durationMinutes >= 0 { + return durationMinutes + } + guard let startDate = Self.date(from: startTime), + let endDate = Self.date(from: endTime), + endDate >= startDate else { return nil } + return Int(endDate.timeIntervalSince(startDate) / 60) + } + + private static func date(from value: String) -> Date? { + guard !value.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { + return date + } + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + return standard.date(from: value) + } +} + +struct PhoneUsageStayLocationModel: Mappable { + var latitude: Double = 0 + var longitude: Double = 0 + + init?(map: Map) {} + + mutating func mapping(map: Map) { + latitude <- map["latitude"] + longitude <- map["longitude"] + } +} + +struct PhoneUsageStayAddressModel: Mappable { + var formattedAddress: String = "" + var country: String = "" + var province: String = "" + var city: String = "" + var district: String = "" + var street: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + formattedAddress <- (map["formatted_address"], kIntTransformStr) + country <- (map["country"], kIntTransformStr) + province <- (map["province"], kIntTransformStr) + city <- (map["city"], kIntTransformStr) + district <- (map["district"], kIntTransformStr) + street <- (map["street"], kIntTransformStr) + } +} diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 13f61e64..41e5e075 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -391,8 +391,8 @@ extension AppRouter: AppRouterProtocol { } // MARK: - 意见反馈 - AppRouter.register(Route.feedback) { _, _ in - FeedbackVC() + AppRouter.register(Route.feedback) { _, parameters in + FeedbackVC(contextText: parameters["text"].safeString) } // MARK: - 心情状态设置 diff --git a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift index b546076c..2aaffd4e 100644 --- a/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift +++ b/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift @@ -28,11 +28,19 @@ final class AppRestrictManager { } var selection: FamilyActivitySelection { - get { AppRestrictSharedStore.selection } + get { + let stored = AppRestrictSharedStore.selection + let normalized = applicationOnlySelection(stored) + if !stored.categoryTokens.isEmpty || !stored.webDomainTokens.isEmpty { + AppRestrictSharedStore.selection = normalized + } + return normalized + } set { - AppRestrictSharedStore.selection = newValue + let normalized = applicationOnlySelection(newValue) + AppRestrictSharedStore.selection = normalized // Drop enabled tokens that are no longer in selection - let apps = newValue.applicationTokens + let apps = normalized.applicationTokens AppRestrictSharedStore.enabledTokens = AppRestrictSharedStore.enabledTokens.intersection(apps) refreshMonitoringAndShield() } @@ -57,11 +65,15 @@ final class AppRestrictManager { func mergeSelection(_ incoming: FamilyActivitySelection) { var current = selection current.applicationTokens.formUnion(incoming.applicationTokens) - current.categoryTokens.formUnion(incoming.categoryTokens) - current.webDomainTokens.formUnion(incoming.webDomainTokens) selection = current } + private func applicationOnlySelection(_ source: FamilyActivitySelection) -> FamilyActivitySelection { + var result = FamilyActivitySelection(includeEntireCategory: false) + result.applicationTokens = source.applicationTokens + return result + } + func isEnabled(_ token: ApplicationToken) -> Bool { enabledTokens.contains(token) } diff --git a/QuickLocation/Manager/MQTT/MQTTService.swift b/QuickLocation/Manager/MQTT/MQTTService.swift index 3c68dd8a..63c5c1bf 100644 --- a/QuickLocation/Manager/MQTT/MQTTService.swift +++ b/QuickLocation/Manager/MQTT/MQTTService.swift @@ -9,6 +9,9 @@ import Foundation import CocoaMQTT import UIKit import CoreLocation +import Network +import CoreTelephony +import AVFoundation // MARK: - MQTT 模型 @@ -25,6 +28,8 @@ enum MqttType: String, Codable { case sos = "sos" // 求助 case needTrack = "needtrack" // 追踪上报 case emote = "emote" // 接收表情 + case phone = "phone" // 手机信息上报 + case phoneUsage = "phoneUsage" // 当前 App 使用次数上报 } /// 单点位置 @@ -68,6 +73,76 @@ struct MqttIncomingData: Decodable { let user_id: String? } +/// 手机信息上报数据 +private struct MqttPhoneInfo: Codable { + let brand: String + let model: String + let memory_total: String + let memory_used: String + let battery: String + let network: String + let brightness: String + let volume: String + let weather: String + let use_time: String + let unlock_count: String +} + +private struct MqttPhoneReportData: Codable { + let user_id: String + let group_key: String + let phone: MqttPhoneInfo +} + +private struct MqttPhoneReportPayload: Codable { + let type: String + let data: MqttPhoneReportData + let extra: String +} + +private struct MqttPhoneUsageExtra: Codable { + let count: Int +} + +private struct MqttPhoneUsageItem: Codable { + let day: String + let appName: String + let packageName: String + let usageTime: Int + let extra: MqttPhoneUsageExtra + + enum CodingKeys: String, CodingKey { + case day + case appName = "app_name" + case packageName = "package" + case usageTime = "usage_time" + case extra + } +} + +private struct MqttPhoneUsageData: Codable { + let userId: String + let groupKey: String + let phoneUsage: [MqttPhoneUsageItem] + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case groupKey = "group_key" + case phoneUsage = "phone_usage" + } +} + +private struct MqttPhoneUsagePayload: Codable { + let type: String + let data: MqttPhoneUsageData + let extra: String +} + +private struct MqttPhoneUsageSnapshot: Equatable { + let day: String + let count: Int +} + // MARK: - MQTTService /// MQTT 5.0 服务,管理连接、订阅和消息收发 @@ -96,8 +171,42 @@ final class MQTTService: NSObject { private var userName = "batiao" private var password = "Batiao12B" private var topic = "smartdrive/" - - override private init() {} + + // MARK: - 手机信息上报 + private let phoneReportInterval: TimeInterval = 30 * 60 + private let phoneReportRetryInterval: TimeInterval = 60 + private let phoneNetworkMonitor = PhoneNetworkStatusMonitor() + private var phoneReportGroupKey = "" + private var phoneReportLocation: CLLocation? + private var phoneReportTimer: Timer? + private var lastPhoneReportDate: Date? + private var isPhoneReportInFlight = false + private var phoneReportGeneration = 0 + private var lastObservedPhoneUsage: MqttPhoneUsageSnapshot? + private var pendingPhoneUsage: MqttPhoneUsageSnapshot? + + override private init() { + super.init() + UIDevice.current.isBatteryMonitoringEnabled = true + phoneNetworkMonitor.start() + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appUsageCountDidChange), + name: .unlockCountDidChange, + object: nil + ) + let usage = Self.currentPhoneUsageSnapshot() + lastObservedPhoneUsage = usage + if usage.count > 0 { + pendingPhoneUsage = usage + } + } // MARK: - 连接 func connect() { @@ -136,11 +245,13 @@ final class MQTTService: NSObject { func disconnect() { mqtt?.disconnect() isConnected = false + invalidatePhoneReportTimer() } // MARK: - 切换用户 /// 更新 clientID 并重连(切换用户后调用) func updateClientID(_ newID: String) { + resetPhoneReportState() clientID = newID disconnect() connect() @@ -242,6 +353,252 @@ final class MQTTService: NSObject { ] publish(topic: "\(topic)\(AppContextManager.shared.userId)", message: payload.toJsonString()) } + + // MARK: - 手机信息上报 + + /// 首页提供当前默认圈子和最新定位。切换圈子只更新上下文,不额外触发重复上报。 + func updatePhoneReportContext(groupKey: String?, location: CLLocation?) { + performOnMain { [weak self] in + guard let self else { return } + if let groupKey { + self.phoneReportGroupKey = groupKey + } + if let location, CLLocationCoordinate2DIsValid(location.coordinate) { + self.phoneReportLocation = location + } + self.reportPhoneUsageIfNeeded() + self.reportPhoneIfNeeded() + } + } + + /// MQTT 重连、Timer 和 App 回到前台都通过此入口检查是否到期。 + func reportPhoneIfNeeded(force: Bool = false) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.reportPhoneIfNeeded(force: force) + } + return + } + + guard isConnected, + !isPhoneReportInFlight, + !phoneReportGroupKey.isEmpty, + !AppContextManager.shared.userId.isEmpty else { return } + + if !force, + let lastPhoneReportDate, + Date().timeIntervalSince(lastPhoneReportDate) < phoneReportInterval { + schedulePhoneReportTimer(after: phoneReportInterval - Date().timeIntervalSince(lastPhoneReportDate)) + return + } + + isPhoneReportInFlight = true + phoneReportGeneration += 1 + let generation = phoneReportGeneration + + guard let location = phoneReportLocation else { + finishPhoneReport(weather: "未知", generation: generation) + return + } + + MemberWeatherService.shared.weather(for: location) { [weak self] result in + let weather = (try? result.get().text) ?? "未知" + self?.finishPhoneReport(weather: weather, generation: generation) + } + } + + @objc func applicationDidBecomeActive() { + capturePhoneUsageChange() + reportPhoneUsageIfNeeded() + reportPhoneIfNeeded() + } + + @objc private func appUsageCountDidChange() { + performOnMain { [weak self] in + self?.capturePhoneUsageChange() + } + } + + private func capturePhoneUsageChange() { + let usage = Self.currentPhoneUsageSnapshot() + guard usage != lastObservedPhoneUsage else { return } + lastObservedPhoneUsage = usage + pendingPhoneUsage = usage + reportPhoneUsageIfNeeded() + } + + private func reportPhoneUsageIfNeeded() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.reportPhoneUsageIfNeeded() + } + return + } + + let userId = AppContextManager.shared.userId + let groupKey = phoneReportGroupKey + guard isConnected, + let usage = pendingPhoneUsage, + !userId.isEmpty, + !groupKey.isEmpty else { return } + + let item = MqttPhoneUsageItem( + day: usage.day, + appName: Self.appDisplayName, + packageName: "cn.zuom8.jisuloca", + usageTime: 0, + extra: MqttPhoneUsageExtra(count: usage.count) + ) + let payload = MqttPhoneUsagePayload( + type: MqttType.phoneUsage.rawValue, + data: MqttPhoneUsageData( + userId: userId, + groupKey: groupKey, + phoneUsage: [item] + ), + extra: "" + ) + guard let data = try? JSONEncoder().encode(payload), + let message = String(data: data, encoding: .utf8) else { return } + + let messageId = publish( + topic: "\(topic)\(userId)", + message: message + ) + if messageId >= 0, pendingPhoneUsage == usage { + pendingPhoneUsage = nil + } + } + + private func finishPhoneReport(weather: String, generation: Int) { + performOnMain { [weak self] in + guard let self, + generation == self.phoneReportGeneration else { return } + self.isPhoneReportInFlight = false + + let userId = AppContextManager.shared.userId + let groupKey = self.phoneReportGroupKey + guard self.isConnected, !userId.isEmpty, !groupKey.isEmpty else { return } + + let payload = MqttPhoneReportPayload( + type: MqttType.phone.rawValue, + data: MqttPhoneReportData( + user_id: userId, + group_key: groupKey, + phone: self.collectPhoneInfo(weather: weather) + ), + extra: "" + ) + guard let data = try? JSONEncoder().encode(payload), + let message = String(data: data, encoding: .utf8) else { + self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) + return + } + + let messageId = self.publish( + topic: "\(self.topic)\(userId)", + message: message + ) + if messageId >= 0 { + self.lastPhoneReportDate = Date() + self.schedulePhoneReportTimer(after: self.phoneReportInterval) + } else { + self.schedulePhoneReportTimer(after: self.phoneReportRetryInterval) + } + } + } + + private func collectPhoneInfo(weather: String) -> MqttPhoneInfo { + let storage = Self.storageInfo() + return MqttPhoneInfo( + brand: "iphone", + model: UIDevice.modelName, + memory_total: storage?.total ?? "未知", + memory_used: storage?.used ?? "未知", + battery: UIDevice.batteryPercent.map { "\($0)%" } ?? "未知", + network: phoneNetworkMonitor.statusText, + brightness: "\(Int((UIScreen.main.brightness * 100).rounded()))%", + volume: "\(Int((AVAudioSession.sharedInstance().outputVolume * 100).rounded()))%", + weather: weather, + use_time: "\(max(0, UnlockCountManager.shared.todayScreenTimeSeconds))", + unlock_count: "\(UnlockCountManager.shared.todayCount)" + ) + } + + private func schedulePhoneReportTimer(after interval: TimeInterval) { + invalidatePhoneReportTimer() + let timer = Timer(timeInterval: max(1, interval), repeats: false) { [weak self] _ in + self?.phoneReportTimer = nil + self?.reportPhoneIfNeeded() + } + RunLoop.main.add(timer, forMode: .common) + phoneReportTimer = timer + } + + private func invalidatePhoneReportTimer() { + performOnMain { [weak self] in + self?.phoneReportTimer?.invalidate() + self?.phoneReportTimer = nil + } + } + + private func resetPhoneReportState() { + performOnMain { [weak self] in + guard let self else { return } + self.phoneReportGeneration += 1 + self.phoneReportGroupKey = "" + self.phoneReportLocation = nil + self.lastPhoneReportDate = nil + self.isPhoneReportInFlight = false + self.invalidatePhoneReportTimer() + } + } + + private func performOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + private static func storageInfo() -> (used: String, total: String)? { + let url = URL(fileURLWithPath: NSHomeDirectory()) + let keys: Set = [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey + ] + guard let values = try? url.resourceValues(forKeys: keys), + let total = values.volumeTotalCapacity, + let available = values.volumeAvailableCapacityForImportantUsage, + total > 0 else { return nil } + + let totalBytes = Int64(total) + let usedBytes = max(0, totalBytes - available) + return ( + used: String(format: "%.1fGB", Double(usedBytes) / 1_000_000_000), + total: String(format: "%.0fGB", Double(totalBytes) / 1_000_000_000) + ) + } + + private static func currentPhoneUsageSnapshot() -> MqttPhoneUsageSnapshot { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return MqttPhoneUsageSnapshot( + day: formatter.string(from: Date()), + count: max(0, UnlockCountManager.shared.todayAppUsageCount) + ) + } + + private static var appDisplayName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "极速定位" + } + } // MARK: - CocoaMQTT5Delegate @@ -253,6 +610,8 @@ extension MQTTService: CocoaMQTT5Delegate { // 订阅基础 topic,接收 signIn/join/leave 等非位置消息 subscribe(topic: topic) onConnected?() + reportPhoneUsageIfNeeded() + reportPhoneIfNeeded() } func mqtt5(_ mqtt5: CocoaMQTT5, didPublishMessage message: CocoaMQTT5Message, id: UInt16) { @@ -296,7 +655,69 @@ extension MQTTService: CocoaMQTT5Delegate { func mqtt5DidDisconnect(_ mqtt5: CocoaMQTT5, withError err: Error?) { isConnected = false + invalidatePhoneReportTimer() print("MQTT5 disconnected: \(err?.localizedDescription ?? "")") onDisconnected?() } } + +private final class PhoneNetworkStatusMonitor { + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.quicklocation.mqtt.network") + private let lock = NSLock() + private let telephonyInfo = CTTelephonyNetworkInfo() + private var path: NWPath? + + func start() { + monitor.pathUpdateHandler = { [weak self] path in + self?.lock.lock() + self?.path = path + self?.lock.unlock() + } + monitor.start(queue: queue) + } + + var statusText: String { + lock.lock() + let currentPath = path + lock.unlock() + + guard let currentPath, currentPath.status == .satisfied else { + return "无网络" + } + if currentPath.usesInterfaceType(.wifi) { + return "Wi-Fi" + } + guard currentPath.usesInterfaceType(.cellular) else { + return "无网络" + } + return cellularGeneration + } + + private var cellularGeneration: String { + let technologies = telephonyInfo.serviceCurrentRadioAccessTechnology + .map { Array($0.values) } ?? [] + guard let technology = technologies.first else { return "蜂窝" } + switch technology { + case CTRadioAccessTechnologyNR, CTRadioAccessTechnologyNRNSA: + return "5G" + case CTRadioAccessTechnologyLTE: + return "4G" + case CTRadioAccessTechnologyWCDMA, + CTRadioAccessTechnologyHSDPA, + CTRadioAccessTechnologyHSUPA, + CTRadioAccessTechnologyCDMAEVDORev0, + CTRadioAccessTechnologyCDMAEVDORevA, + CTRadioAccessTechnologyCDMAEVDORevB, + CTRadioAccessTechnologyeHRPD: + return "3G" + case CTRadioAccessTechnologyGPRS, + CTRadioAccessTechnologyEdge, + CTRadioAccessTechnologyCDMA1x: + return "2G" + default: + return "蜂窝" + } + } +} diff --git a/QuickLocation/Manager/MQTT/MemberWeatherService.swift b/QuickLocation/Manager/MQTT/MemberWeatherService.swift new file mode 100644 index 00000000..80898ee3 --- /dev/null +++ b/QuickLocation/Manager/MQTT/MemberWeatherService.swift @@ -0,0 +1,209 @@ +// +// MemberWeatherService.swift +// QuickLocation +// + +import CoreLocation +import Foundation +import WeatherKit + +struct MemberWeatherSnapshot: Equatable { + let text: String + let assetName: String +} + +final class MemberWeatherService { + + static let shared = MemberWeatherService() + + private struct CacheEntry { + let snapshot: MemberWeatherSnapshot + let date: Date + } + + private enum WeatherError: Error { + case invalidLocation + } + + private typealias Completion = (Result) -> Void + + private let cacheDuration: TimeInterval = 15 * 60 + private let stateQueue = DispatchQueue(label: "com.quicklocation.weather.state") + private var cache: [String: CacheEntry] = [:] + private var pendingCompletions: [String: [Completion]] = [:] + + private init() {} + + static func snapshot(reportedText: String) -> MemberWeatherSnapshot { + let text = reportedText.trimmingCharacters(in: .whitespacesAndNewlines) + return MemberWeatherSnapshot( + text: text, + assetName: assetName(reportedText: text) + ) + } + + func weather( + for location: CLLocation, + completion: @escaping (Result) -> Void + ) { + guard CLLocationCoordinate2DIsValid(location.coordinate) else { + DispatchQueue.main.async { + completion(.failure(WeatherError.invalidLocation)) + } + return + } + + let key = Self.cacheKey(for: location.coordinate) + stateQueue.async { [weak self] in + guard let self else { return } + + if let entry = self.cache[key], + Date().timeIntervalSince(entry.date) < self.cacheDuration { + DispatchQueue.main.async { + completion(.success(entry.snapshot)) + } + return + } + + if self.pendingCompletions[key] != nil { + self.pendingCompletions[key]?.append(completion) + return + } + + self.pendingCompletions[key] = [completion] + Task { [weak self] in + do { + let weather = try await WeatherKit.WeatherService.shared.weather( + for: location, + including: .current + ) + let snapshot = Self.snapshot(from: weather) + self?.finish(key: key, result: .success(snapshot)) + } catch { + #if DEBUG + let error = error as NSError + print("WeatherKit request failed [\(error.domain):\(error.code)] at \(key): \(error.localizedDescription)") + #endif + self?.finish(key: key, result: .failure(error)) + } + } + } + } + + private func finish(key: String, result: Result) { + stateQueue.async { [weak self] in + guard let self else { return } + if case let .success(snapshot) = result { + self.cache[key] = CacheEntry(snapshot: snapshot, date: Date()) + } + let completions = self.pendingCompletions.removeValue(forKey: key) ?? [] + DispatchQueue.main.async { + completions.forEach { $0(result) } + } + } + } + + private static func cacheKey(for coordinate: CLLocationCoordinate2D) -> String { + let latitude = Int((coordinate.latitude * 1_000).rounded()) + let longitude = Int((coordinate.longitude * 1_000).rounded()) + return "\(latitude):\(longitude)" + } + + private static func snapshot(from weather: CurrentWeather) -> MemberWeatherSnapshot { + let temperature = Int(weather.temperature.converted(to: .celsius).value.rounded()) + return MemberWeatherSnapshot( + text: "\(conditionText(weather.condition)) \(temperature)°C", + assetName: assetName(weather.condition) + ) + } + + private static func assetName(_ condition: WeatherCondition) -> String { + switch condition { + case .clear, .mostlyClear, .hot: + return "Home/member_weather_sunny" + case .partlyCloudy: + return "Home/member_weather_cloudy" + case .drizzle, .freezingDrizzle, .freezingRain, .hail, .heavyRain, + .rain, .sunShowers: + return "Home/member_weather_rain" + case .isolatedThunderstorms, .scatteredThunderstorms, .strongStorms, + .thunderstorms, .tropicalStorm, .hurricane: + return "Home/member_weather_thunderstorm" + case .blizzard, .blowingSnow, .flurries, .frigid, .heavySnow, + .sleet, .snow, .sunFlurries, .wintryMix: + return "Home/member_weather_snow" + case .blowingDust, .breezy, .cloudy, .foggy, .haze, .mostlyCloudy, + .smoky, .windy: + return "Home/member_weather_overcast" + @unknown default: + return "Home/member_weather_overcast" + } + } + + private static func assetName(reportedText: String) -> String { + guard !reportedText.isEmpty, + !reportedText.contains("未知") else { + return "Home/member_weather_unknown" + } + + if reportedText.containsAny(of: ["雷雨", "雷暴", "强风暴", "热带风暴", "飓风"]) { + return "Home/member_weather_thunderstorm" + } + if reportedText.containsAny(of: ["雨", "冻雨", "冰雹", "阵雨", "太阳雨"]) { + return "Home/member_weather_rain" + } + if reportedText.containsAny(of: ["阵雪", "风雪", "暴风雪", "雪", "严寒"]) { + return "Home/member_weather_snow" + } + if reportedText.contains("晴间多云") { + return "Home/member_weather_cloudy" + } + if reportedText.containsAny(of: ["多云", "阴", "雾", "霾", "扬尘", "烟霾", "大风", "微风"]) { + return "Home/member_weather_overcast" + } + if reportedText.containsAny(of: ["晴", "炎热"]) { + return "Home/member_weather_sunny" + } + return "Home/member_weather_unknown" + } + + private static func conditionText(_ condition: WeatherCondition) -> String { + switch condition { + case .blizzard: return "暴风雪" + case .blowingDust: return "扬尘" + case .blowingSnow: return "风雪" + case .breezy: return "微风" + case .clear, .mostlyClear: return "晴" + case .cloudy, .mostlyCloudy: return "多云" + case .drizzle: return "小雨" + case .flurries: return "阵雪" + case .foggy: return "雾" + case .freezingDrizzle, .freezingRain: return "冻雨" + case .frigid: return "严寒" + case .hail: return "冰雹" + case .heavyRain: return "大雨" + case .heavySnow: return "大雪" + case .hot: return "炎热" + case .hurricane: return "飓风" + case .isolatedThunderstorms, .scatteredThunderstorms, .thunderstorms: return "雷雨" + case .partlyCloudy: return "晴间多云" + case .rain: return "雨" + case .sleet, .wintryMix: return "雨夹雪" + case .smoky: return "烟霾" + case .snow: return "雪" + case .strongStorms: return "强风暴" + case .sunFlurries: return "晴间阵雪" + case .sunShowers: return "太阳雨" + case .tropicalStorm: return "热带风暴" + case .windy: return "大风" + case .haze: return "霾" + @unknown default: return "多云" + } + } +} + +private extension String { + func containsAny(of values: [String]) -> Bool { + values.contains { contains($0) } + } +} diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 19991569..872cf08c 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -86,7 +86,7 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "http://172.16.10.22:9243/" + return "http://172.16.10.20:9243/" } } diff --git a/QuickLocation/Model/PigeonModel.swift b/QuickLocation/Model/PigeonModel.swift index 7b320b10..61575d96 100644 --- a/QuickLocation/Model/PigeonModel.swift +++ b/QuickLocation/Model/PigeonModel.swift @@ -89,21 +89,49 @@ struct PigeonVoiceTemplate: Equatable { let id: String let title: String let duration: TimeInterval + let audioAssetName: String static let templates: [PigeonVoiceTemplate] = [ - PigeonVoiceTemplate(id: "bai_bai_hao_ha", title: "表白好哈", duration: 10), - PigeonVoiceTemplate(id: "bai_bai_hao_ha_2", title: "表白好哈", duration: 10), - PigeonVoiceTemplate(id: "bai_bai_hao_ha_3", title: "表白好哈", duration: 10) + PigeonVoiceTemplate(id: "sorry_im_right", title: "错不起,我对了", duration: 1.881), + PigeonVoiceTemplate(id: "wake_up_sleepyhead", title: "小懒虫,起床啦", duration: 2.821), + PigeonVoiceTemplate(id: "dont_you_need_to_work", title: "你们不用打工吗 一天......", duration: 4.049), + PigeonVoiceTemplate(id: "ignoring_me", title: "不理我 是吧", duration: 3.527), + PigeonVoiceTemplate(id: "who_can_save_me", title: "谁来救救我", duration: 1.228), + PigeonVoiceTemplate(id: "baby_dont_be_angry", title: "宝宝,别生气了", duration: 3.056), + PigeonVoiceTemplate(id: "okay", title: "好的", duration: 2.377), + PigeonVoiceTemplate(id: "thank_you", title: "谢谢你", duration: 0.836), + PigeonVoiceTemplate(id: "good_night_kiss", title: "我先睡了 晚安 么么哒", duration: 2.299), + PigeonVoiceTemplate(id: "sleep_if_you_want", title: "如果你想睡觉就晚安", duration: 5.956), + PigeonVoiceTemplate(id: "getting_bold", title: "我看你胆子真是肥嘟嘟", duration: 2.534), + PigeonVoiceTemplate(id: "mosquito_violin", title: "再不理我我就派蚊子去你耳边拉小提琴", duration: 5.721), + PigeonVoiceTemplate(id: "am_i_talkative", title: "我多嘴问一句我多嘴吗", duration: 6.949), + PigeonVoiceTemplate(id: "is_my_mic_loud", title: "我的麦很炸吗", duration: 3.396), + PigeonVoiceTemplate(id: "love_you", title: "爱你哟", duration: 1.019), + PigeonVoiceTemplate(id: "what_can_you_do", title: "说白了 你有啥实力啊", duration: 3.056), + PigeonVoiceTemplate(id: "save_me", title: "救我救我", duration: 1.489), + PigeonVoiceTemplate(id: "dare_to_compete", title: "敢不敢和我比划比划", duration: 1.646), + PigeonVoiceTemplate(id: "teasing_you_daily", title: "其实每天骚扰你就是我最大的乐趣", duration: 3.448) ] + private init(id: String, title: String, duration: TimeInterval) { + self.id = id + self.title = title + self.duration = duration + audioAssetName = "PigeonMessage/voice_\(id)" + } + + var audioData: Data? { + NSDataAsset(name: audioAssetName)?.data + } + static func resolve(templateId: String) -> PigeonVoiceTemplate? { templates.first { $0.id == templateId } } } struct PigeonTemplateExtra: Mappable { - var media_type: String = "" - var template_id: String = "" + var image_template_id: String = "" + var voice_template_id: String = "" init?(map: Map) {} @@ -129,43 +157,49 @@ struct PigeonTemplateExtra: Mappable { } mutating func mapping(map: Map) { - media_type <- map["media_type"] - template_id <- map["template_id"] + image_template_id <- map["image_template_id"] + voice_template_id <- map["voice_template_id"] - // Read legacy payloads, but always send the canonical two-field shape. - if template_id.isEmpty { - var legacyImageID = "" - legacyImageID <- map["image_template_id"] - if !legacyImageID.isEmpty { - template_id = legacyImageID - media_type = "image" - } else { - var legacyVoiceID = "" - legacyVoiceID <- map["voice_template_id"] - if !legacyVoiceID.isEmpty { - template_id = legacyVoiceID - media_type = "voice" - } + var legacyMediaType = "" + var legacyTemplateID = "" + legacyMediaType <- map["media_type"] + legacyTemplateID <- map["template_id"] + let legacyID = legacyTemplateID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !legacyID.isEmpty else { return } + + switch legacyMediaType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "voice": + if voice_template_id.isEmpty { voice_template_id = legacyID } + case "image": + if image_template_id.isEmpty { image_template_id = legacyID } + default: + if image_template_id.isEmpty, + PigeonLocalTemplate.resolve(templateId: legacyID) != nil { + image_template_id = legacyID + } else if voice_template_id.isEmpty, + PigeonVoiceTemplate.resolve(templateId: legacyID) != nil { + voice_template_id = legacyID } - } else if media_type.isEmpty, - PigeonLocalTemplate.resolve(templateId: template_id) != nil { - media_type = "image" } } - var normalizedMediaType: String { - media_type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + var normalizedImageTemplateID: String { + image_template_id.trimmingCharacters(in: .whitespacesAndNewlines) } - var normalizedTemplateID: String { - template_id.trimmingCharacters(in: .whitespacesAndNewlines) + var normalizedVoiceTemplateID: String { + voice_template_id.trimmingCharacters(in: .whitespacesAndNewlines) } var sendParameters: [String: Any]? { - let mediaType = normalizedMediaType - let templateID = normalizedTemplateID - guard !mediaType.isEmpty, !templateID.isEmpty else { return nil } - return ["media_type": mediaType, "template_id": templateID] + var parameters: [String: Any] = [:] + if !normalizedImageTemplateID.isEmpty { + parameters["image_template_id"] = normalizedImageTemplateID + } + if !normalizedVoiceTemplateID.isEmpty { + parameters["voice_template_id"] = normalizedVoiceTemplateID + } + return parameters.isEmpty ? nil : parameters } } @@ -221,8 +255,7 @@ struct PigeonSentMessage: Mappable { var isVoice: Bool { msg_type == 2 } var localTemplate: PigeonLocalTemplate? { - guard extra?.normalizedMediaType == "image" else { return nil } - let templateId = extra?.normalizedTemplateID ?? "" + let templateId = extra?.normalizedImageTemplateID ?? "" return PigeonLocalTemplate.resolve(templateId: templateId) } @@ -230,6 +263,15 @@ struct PigeonSentMessage: Mappable { localTemplate?.contentImage } + var localVoiceTemplate: PigeonVoiceTemplate? { + let templateId = extra?.normalizedVoiceTemplateID ?? "" + return PigeonVoiceTemplate.resolve(templateId: templateId) + } + + var localVoiceData: Data? { + localVoiceTemplate?.audioData + } + var captionText: String { if isVoice { return "" } let fromMsg = msg.trimmingCharacters(in: .whitespacesAndNewlines) @@ -239,16 +281,29 @@ struct PigeonSentMessage: Mappable { return text_msg } - var mediaURL: URL? { - let value = (isVoice ? msg : bg_img).trimmingCharacters(in: .whitespacesAndNewlines) - let fallback = msg.trimmingCharacters(in: .whitespacesAndNewlines) - let raw = value.lowercased().hasPrefix("http") ? value : fallback + var backgroundURL: URL? { + let background = bg_img.trimmingCharacters(in: .whitespacesAndNewlines) + let legacyImage = isVoice ? "" : msg.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = background.lowercased().hasPrefix("http") ? background : legacyImage guard raw.lowercased().hasPrefix("http"), let url = URL(string: raw) else { return nil } return url } + var audioURL: URL? { + guard isVoice else { return nil } + let raw = msg.trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.lowercased().hasPrefix("http"), let url = URL(string: raw) else { + return nil + } + return url + } + + var mediaURL: URL? { + isVoice ? audioURL : backgroundURL + } + func toHistoryItem() -> PigeonHistoryItem { PigeonHistoryItem( id: message_uuid.isEmpty ? UUID().uuidString : message_uuid, @@ -258,9 +313,10 @@ struct PigeonSentMessage: Mappable { caption: captionText, image: localTemplateImage, mediaURL: mediaURL, + localAudioData: localVoiceData, senderAvatar: from_user?.avatarImage, receiverAvatars: to_user.map(\.avatarImage), - duration: 0 + duration: localVoiceTemplate?.duration ?? 0 ) } diff --git a/QuickLocation/QuickLocation.entitlements b/QuickLocation/QuickLocation.entitlements index 4b2e32da..dd678130 100644 --- a/QuickLocation/QuickLocation.entitlements +++ b/QuickLocation/QuickLocation.entitlements @@ -12,6 +12,8 @@ com.apple.developer.networking.wifi-info + com.apple.developer.weatherkit + com.apple.developer.family-controls com.apple.security.application-groups diff --git a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift index 4aee623a..b2453e13 100644 --- a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift +++ b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift @@ -54,10 +54,18 @@ final class AppRestrictVC: BaseViewController { DLToast.show(text: "需要屏幕使用时间权限") return } - let presenter = FamilyActivityPickerPresenter(selection: AppRestrictManager.shared.selection) + let initialSelection = AppRestrictManager.shared.selection + let presenter = FamilyActivityPickerPresenter(selection: initialSelection) presenter.onComplete = { [weak self] selection in + let addedApplications = selection.applicationTokens + .subtracting(initialSelection.applicationTokens) AppRestrictManager.shared.mergeSelection(selection) self?.reload() + if addedApplications.isEmpty, !selection.categoryTokens.isEmpty { + self?.showCategorySelectionNotice() + } else if !addedApplications.isEmpty { + self?.showApplicationTokens(addedApplications) + } } present(presenter, animated: false) } catch { @@ -66,6 +74,44 @@ final class AppRestrictVC: BaseViewController { } } + private func showCategorySelectionNotice() { + let alert = UIAlertController( + title: "请选择具体应用", + message: "系统不会返回分类“全部”中的具体应用。请展开分类,取消“全部”,再逐个勾选需要配对的应用。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "重新选择", style: .default) { [weak self] _ in + self?.addApps() + }) + present(alert, animated: true) + } + + private func showApplicationTokens(_ tokens: Set) { + let values = tokens.compactMap { token in + AppRestrictTokenCodec.encode(token)?.base64EncodedString() + }.sorted() + guard !values.isEmpty else { + DLToast.show(text: "ApplicationToken 编码失败") + return + } + + let message = values.enumerated().map { index, value in + values.count == 1 ? value : "应用 \(index + 1):\n\(value)" + }.joined(separator: "\n\n") + let alert = UIAlertController( + title: "ApplicationToken", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "复制", style: .default) { _ in + UIPasteboard.general.string = values.joined(separator: "\n") + DLToast.show(text: "Token 已复制") + }) + alert.addAction(UIAlertAction(title: "关闭", style: .cancel)) + present(alert, animated: true) + } + private func openLink(for token: ApplicationToken) { linkingToken = token let vc = SelectActivityVC() diff --git a/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift b/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift index 4c847fbb..cd868c82 100644 --- a/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift +++ b/QuickLocation/Section/AppRestrict/FamilyActivityPickerHost.swift @@ -20,8 +20,23 @@ private struct FamilyActivityPickerSheet: View { } var body: some View { - Color.clear - .familyActivityPicker(isPresented: $isPresented, selection: $selection) + Group { + if #available(iOS 26.2, *) { + Color.clear.familyActivityPicker( + title: "选择应用", + footerText: "请展开分类并勾选具体应用,分类“全部”无法加入配对库", + isPresented: $isPresented, + selection: $selection + ) + } else { + Color.clear.familyActivityPicker( + footerText: "请展开分类并勾选具体应用,分类“全部”无法加入配对库", + isPresented: $isPresented, + selection: $selection + ) + } + } + .environment(\.locale, Locale(identifier: "zh-Hans")) .onChange(of: isPresented) { presented in if !presented { onComplete(selection) @@ -35,7 +50,7 @@ final class FamilyActivityPickerPresenter: UIViewController { private let initial: FamilyActivitySelection var onComplete: ((FamilyActivitySelection) -> Void)? - init(selection: FamilyActivitySelection = FamilyActivitySelection()) { + init(selection: FamilyActivitySelection = FamilyActivitySelection(includeEntireCategory: false)) { self.initial = selection super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen @@ -48,8 +63,11 @@ final class FamilyActivityPickerPresenter: UIViewController { super.viewDidAppear(animated) guard children.isEmpty else { return } let root = FamilyActivityPickerSheet(initial: initial) { [weak self] selection in - self?.onComplete?(selection) - self?.dismiss(animated: false) + guard let self else { return } + let completion = self.onComplete + self.dismiss(animated: false) { + completion?(selection) + } } let host = UIHostingController(rootView: root) host.view.backgroundColor = .clear diff --git a/QuickLocation/Section/AppRestrict/SelectActivityVC.swift b/QuickLocation/Section/AppRestrict/SelectActivityVC.swift index fc08bc7d..a7210f1c 100644 --- a/QuickLocation/Section/AppRestrict/SelectActivityVC.swift +++ b/QuickLocation/Section/AppRestrict/SelectActivityVC.swift @@ -72,6 +72,7 @@ final class SelectActivityVC: BaseViewController { tableView.dataSource = self tableView.delegate = self tableView.rowHeight = 56 + tableView.keyboardDismissMode = .onDrag view.addSubview(tableView) tableView.layoutChain.topToBottomOfView(searchField, offset: 12).edgesHorzontal().bottom() } diff --git a/QuickLocation/Section/AppRestrict/app_catalog.json b/QuickLocation/Section/AppRestrict/app_catalog.json index 9aa03f04..58010ce7 100644 --- a/QuickLocation/Section/AppRestrict/app_catalog.json +++ b/QuickLocation/Section/AppRestrict/app_catalog.json @@ -2,9 +2,27 @@ { "id": "wechat", "name": "微信", - "icon": "Login/wechat", + "icon": "AppRestrict/catalog_wechat", "keywords": ["微信", "wechat", "weixin"] }, + { + "id": "qq", + "name": "QQ", + "icon": "AppRestrict/catalog_qq", + "keywords": ["QQ", "腾讯QQ", "qq", "tencent"] + }, + { + "id": "weibo", + "name": "微博", + "icon": "AppRestrict/catalog_weibo", + "keywords": ["微博", "新浪微博", "weibo", "sina"] + }, + { + "id": "xiaohongshu", + "name": "小红书", + "icon": "AppRestrict/catalog_xiaohongshu", + "keywords": ["小红书", "红薯", "xiaohongshu", "red", "xhs"] + }, { "id": "douyin", "name": "抖音", @@ -12,39 +30,117 @@ "keywords": ["抖音", "douyin", "tiktok"] }, { - "id": "qq", - "name": "QQ", - "icon": "AppRestrict/catalog_qq", - "keywords": ["qq"] + "id": "kuaishou", + "name": "快手", + "icon": "AppRestrict/catalog_kuaishou", + "keywords": ["快手", "kuaishou", "ks"] }, { - "id": "xiaohongshu", - "name": "小红书", - "icon": "AppRestrict/catalog_xiaohongshu", - "keywords": ["小红书", "red", "xhs"] + "id": "iqiyi", + "name": "爱奇艺", + "icon": "AppRestrict/catalog_iqiyi", + "keywords": ["爱奇艺", "奇艺", "iqiyi", "video"] + }, + { + "id": "youku", + "name": "优酷视频", + "icon": "AppRestrict/catalog_youku", + "keywords": ["优酷", "优酷视频", "youku", "video"] + }, + { + "id": "tomato_novel", + "name": "番茄小说", + "icon": "AppRestrict/catalog_tomato_novel", + "keywords": ["番茄小说", "番茄", "tomato", "novel", "阅读"] + }, + { + "id": "hongguo_short_drama", + "name": "红果短剧", + "icon": "AppRestrict/catalog_hongguo_short_drama", + "keywords": ["红果短剧", "红果", "hongguo", "短剧"] + }, + { + "id": "hongguo_comic", + "name": "红果漫剧", + "icon": "AppRestrict/catalog_hongguo_comic", + "keywords": ["红果漫剧", "红果", "hongguo", "漫剧"] }, { "id": "taobao", "name": "淘宝", "icon": "AppRestrict/catalog_taobao", - "keywords": ["淘宝", "taobao"] + "keywords": ["淘宝", "taobao", "购物"] }, { - "id": "netease_music", - "name": "网易云音乐", - "icon": "AppRestrict/catalog_netease", - "keywords": ["网易云", "music"] + "id": "jd", + "name": "京东", + "icon": "AppRestrict/catalog_jd", + "keywords": ["京东", "京东商城", "jd", "jingdong", "购物"] }, { - "id": "kuaishou", - "name": "快手", - "icon": "AppRestrict/catalog_kuaishou", - "keywords": ["快手", "kuaishou"] + "id": "douyin_mall", + "name": "抖音商城", + "icon": "AppRestrict/catalog_douyin_mall", + "keywords": ["抖音商城", "抖音购物", "douyin mall", "shopping"] + }, + { + "id": "meituan", + "name": "美团", + "icon": "AppRestrict/catalog_meituan", + "keywords": ["美团", "外卖", "meituan", "生活"] + }, + { + "id": "alipay", + "name": "支付宝", + "icon": "AppRestrict/catalog_alipay", + "keywords": ["支付宝", "支付", "alipay", "zhifubao"] + }, + { + "id": "xianyu", + "name": "闲鱼", + "icon": "AppRestrict/catalog_xianyu", + "keywords": ["闲鱼", "二手", "xianyu", "闲置"] + }, + { + "id": "zhuanzhuan", + "name": "转转", + "icon": "AppRestrict/catalog_zhuanzhuan", + "keywords": ["转转", "二手", "zhuanzhuan"] + }, + { + "id": "dewu", + "name": "得物", + "icon": "AppRestrict/catalog_dewu", + "keywords": ["得物", "毒", "dewu", "poizon", "购物"] + }, + { + "id": "doubao", + "name": "豆包", + "icon": "AppRestrict/catalog_doubao", + "keywords": ["豆包", "AI助手", "doubao", "ai"] }, { "id": "wangzhe", "name": "王者荣耀", "icon": "AppRestrict/catalog_wangzhe", - "keywords": ["王者", "荣耀"] + "keywords": ["王者荣耀", "王者", "荣耀", "wangzhe", "honor of kings"] + }, + { + "id": "peace_elite", + "name": "和平精英", + "icon": "AppRestrict/catalog_peace_elite", + "keywords": ["和平精英", "和平", "吃鸡", "peace elite", "game"] + }, + { + "id": "jcc", + "name": "金铲铲之战", + "icon": "AppRestrict/catalog_jcc", + "keywords": ["金铲铲之战", "金铲铲", "jcc", "tft", "game"] + }, + { + "id": "happy_landlord", + "name": "腾讯欢乐斗地主", + "icon": "AppRestrict/catalog_happy_landlord", + "keywords": ["腾讯欢乐斗地主", "欢乐斗地主", "斗地主", "landlord", "game"] } ] diff --git a/QuickLocation/Section/Explore/FeatureIntroView.swift b/QuickLocation/Section/Explore/FeatureIntroView.swift index 91ee90ee..82704cf1 100644 --- a/QuickLocation/Section/Explore/FeatureIntroView.swift +++ b/QuickLocation/Section/Explore/FeatureIntroView.swift @@ -6,12 +6,9 @@ import UIKit struct FeatureIntroItem { - let title: String - let subtitle: String - let background: UIColor - let titleColor: UIColor - let subtitleColor: UIColor - let arrowTint: UIColor + let imageName: String + let accessibilityTitle: String + let aspectRatio: CGFloat let action: FeatureIntroAction } @@ -29,167 +26,202 @@ final class FeatureIntroView: UIView { var onTapItem: ((FeatureIntroAction) -> Void)? - private let headerTitleLab = UILabel() - private let headerSubLab = UILabel() - private let sheet = UIView() - private let grid = UIStackView() + private let headerBackgroundView = UIImageView(image: UIImage(named: "Explore/header_bg")) + private let titleContainer = UIStackView() + private let titleLineView = UIView() + private let titleLabel = UILabel() + private let sheetView = UIView() + private let scrollView = UIScrollView() + private let contentView = UIView() + private let gridStack = UIStackView() private let items: [FeatureIntroItem] = [ FeatureIntroItem( - title: "还在吗?", - subtitle: "每日打卡签到 记录美好生活", - background: UIColor(hexStr: "#EDE7FF"), - titleColor: UIColor(hexStr: "#5B4B8A"), - subtitleColor: UIColor(hexStr: "#8B7BB8"), - arrowTint: UIColor(hexStr: "#7B6AAE"), + imageName: "Explore/sign_in_card", + accessibilityTitle: "还在吗", + aspectRatio: 127 / 166, action: .signIn ), FeatureIntroItem( - title: "隐身气泡", - subtitle: "只想做个淡人 默默隐身", - background: UIColor(hexStr: "#D9ECFF"), - titleColor: UIColor(hexStr: "#2F6FAE"), - subtitleColor: UIColor(hexStr: "#6A9BC4"), - arrowTint: UIColor(hexStr: "#3E8FD0"), - action: .createBubble - ), - FeatureIntroItem( - title: "一键锁机", - subtitle: "专注当下 拒绝手机干扰", - background: UIColor(hexStr: "#DDF5E8"), - titleColor: UIColor(hexStr: "#2F8A5B"), - subtitleColor: UIColor(hexStr: "#6AAD88"), - arrowTint: UIColor(hexStr: "#3EAE72"), - action: .lockDistract - ), - FeatureIntroItem( - title: "飞鸽传书", - subtitle: "实时查看位置 守护安全出行", - background: UIColor(hexStr: "#FFF3D6"), - titleColor: UIColor(hexStr: "#A67C2D"), - subtitleColor: UIColor(hexStr: "#C4A46A"), - arrowTint: UIColor(hexStr: "#C9953A"), + imageName: "Explore/pigeon_message_card", + accessibilityTitle: "飞鸽传书", + aspectRatio: 127 / 166, action: .pigeonMessage ), FeatureIntroItem( - title: "查位置", - subtitle: "实时查看位置 守护安全出行", - background: UIColor(hexStr: "#D6F4F2"), - titleColor: UIColor(hexStr: "#2A8A84"), - subtitleColor: UIColor(hexStr: "#66B0AB"), - arrowTint: UIColor(hexStr: "#35A8A0"), + imageName: "Explore/lock_distract_card", + accessibilityTitle: "一键锁机", + aspectRatio: 130 / 166, + action: .lockDistract + ), + FeatureIntroItem( + imageName: "Explore/bubble_card", + accessibilityTitle: "隐身气泡", + aspectRatio: 130 / 166, + action: .createBubble + ), + FeatureIntroItem( + imageName: "Explore/search_location_card", + accessibilityTitle: "查位置", + aspectRatio: 130 / 166, action: .searchLocation ), FeatureIntroItem( - title: "SOS", - subtitle: "紧急求助 一键呼叫", - background: UIColor(hexStr: "#FFE4E8"), - titleColor: UIColor(hexStr: "#D6455D"), - subtitleColor: UIColor(hexStr: "#E28A98"), - arrowTint: UIColor(hexStr: "#E2556C"), + imageName: "Explore/sos_card", + accessibilityTitle: "SOS", + aspectRatio: 130 / 166, action: .sos ) ] override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = UIColor(hexStr: "#F0F1F3") setupUI() } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } private func setupUI() { - headerTitleLab.text = "功能介绍" - headerTitleLab.font = .systemFont(ofSize: 28, weight: .bold) - headerTitleLab.textColor = UIColor(hexStr: "#1F2A44") - addSubview(headerTitleLab) - headerTitleLab.layoutChain.top(kStatusBarHeight + 20).left(20) + backgroundColor = .white - headerSubLab.text = "视频+图片" - headerSubLab.font = .systemFont(ofSize: 28, weight: .bold) - headerSubLab.textColor = UIColor(hexStr: "#1F2A44") - addSubview(headerSubLab) - headerSubLab.layoutChain.topToBottomOfView(headerTitleLab, offset: 2).left(20) + headerBackgroundView.contentMode = .scaleAspectFill + headerBackgroundView.clipsToBounds = true + addSubview(headerBackgroundView) + headerBackgroundView.translatesAutoresizingMaskIntoConstraints = false - sheet.backgroundColor = .white - sheet.layer.cornerRadius = 28 - sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - sheet.clipsToBounds = true - addSubview(sheet) - sheet.layoutChain - .topToBottomOfView(headerSubLab, offset: 24) - .edgesHorzontal() - .bottom() + titleLineView.backgroundColor = UIColor(hexStr: "#253B55") + titleLineView.layer.cornerRadius = 1.5 + titleLineView.translatesAutoresizingMaskIntoConstraints = false - grid.axis = .vertical - grid.spacing = 12 - grid.distribution = .fillEqually - sheet.addSubview(grid) - grid.layoutChain - .top(24) - .edgesHorzontal(16) - .bottom(24 + 70) // leave room for custom tab bar + titleLabel.text = "一键锁机" + titleLabel.textColor = UIColor(hexStr: "#253B55") + titleLabel.font = FontManager.ziHunBianHei(28) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - for row in 0..<3 { + titleContainer.axis = .horizontal + titleContainer.alignment = .center + titleContainer.spacing = 3 + titleContainer.addArrangedSubview(titleLineView) + titleContainer.addArrangedSubview(titleLabel) + addSubview(titleContainer) + titleContainer.translatesAutoresizingMaskIntoConstraints = false + + sheetView.backgroundColor = .white + sheetView.layer.cornerRadius = 30 + sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetView.clipsToBounds = true + addSubview(sheetView) + sheetView.translatesAutoresizingMaskIntoConstraints = false + + scrollView.alwaysBounceVertical = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInset.bottom = 90 + sheetView.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + + scrollView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + gridStack.axis = .vertical + gridStack.alignment = .fill + gridStack.distribution = .fill + gridStack.spacing = 13 + contentView.addSubview(gridStack) + gridStack.translatesAutoresizingMaskIntoConstraints = false + + for rowIndex in 0..<3 { let rowStack = UIStackView() rowStack.axis = .horizontal - rowStack.spacing = 12 + rowStack.alignment = .fill rowStack.distribution = .fillEqually - for col in 0..<2 { - let item = items[row * 2 + col] + rowStack.spacing = 13 + + for columnIndex in 0..<2 { + let itemIndex = rowIndex * 2 + columnIndex + let item = items[itemIndex] let card = FeatureIntroCardView(item: item) + card.tag = itemIndex card.addTarget(self, action: #selector(cardTapped(_:)), for: .touchUpInside) - card.tag = row * 2 + col rowStack.addArrangedSubview(card) + card.heightAnchor.constraint(equalTo: card.widthAnchor, multiplier: item.aspectRatio).isActive = true } - grid.addArrangedSubview(rowStack) + 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), + + 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), + scrollView.bottomAnchor.constraint(equalTo: sheetView.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + gridStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 19), + gridStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), + gridStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), + gridStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) + ]) } @objc private func cardTapped(_ sender: FeatureIntroCardView) { - let idx = sender.tag - guard items.indices.contains(idx) else { return } - onTapItem?(items[idx].action) + guard items.indices.contains(sender.tag) else { return } + onTapItem?(items[sender.tag].action) } } final class FeatureIntroCardView: UIControl { - private let titleLab = UILabel() - private let subLab = UILabel() - private let arrowBtn = UIView() + + private let imageView = UIImageView() init(item: FeatureIntroItem) { super.init(frame: .zero) - backgroundColor = item.background - layer.cornerRadius = 20 - clipsToBounds = true - titleLab.text = item.title - titleLab.font = .systemFont(ofSize: 18, weight: .bold) - titleLab.textColor = item.titleColor - addSubview(titleLab) - titleLab.layoutChain.top(16).left(14).right(14) + isAccessibilityElement = true + accessibilityLabel = item.accessibilityTitle + accessibilityTraits = .button - subLab.text = item.subtitle - subLab.font = .systemFont(ofSize: 11, weight: .medium) - subLab.textColor = item.subtitleColor - subLab.numberOfLines = 2 - addSubview(subLab) - subLab.layoutChain.topToBottomOfView(titleLab, offset: 6).left(14).right(14) + imageView.image = UIImage(named: item.imageName) + imageView.contentMode = .scaleAspectFit + imageView.isUserInteractionEnabled = false + addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false - arrowBtn.backgroundColor = .white - arrowBtn.isUserInteractionEnabled = false - arrowBtn.layer.cornerRadius = 14 - addSubview(arrowBtn) - arrowBtn.layoutChain.left(14).bottom(14).width(28).height(28) - - let arrow = UIImageView(image: UIImage(systemName: "chevron.right")) - arrow.tintColor = item.arrowTint - arrow.contentMode = .scaleAspectFit - arrowBtn.addSubview(arrow) - arrow.layoutChain.center().width(10).height(12) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isHighlighted: Bool { + didSet { + imageView.alpha = isHighlighted ? 0.78 : 1 + } + } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 185ad119..014c3dfe 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -39,6 +39,15 @@ final class GroupChatVC: BaseViewController { private var didSetupKeyboard = false /// 首屏等行高落地后再贴底;用户手滑列表后取消 private var needsInitialBottomScroll = true + /// 在 table reload 前记录列表是否贴底,避免资料刷新把用户拉回底部。 + private var shouldScrollAfterMessageReload = true + /// 非贴底刷新时保留最上方可见消息,防止整表重载改变阅读位置。 + private var pendingMessageReloadAnchor: TableScrollAnchor? + + private struct TableScrollAnchor { + let indexPath: IndexPath + let offsetFromViewportTop: CGFloat + } // MARK: - Init init(groupId: String) { @@ -180,16 +189,27 @@ final class GroupChatVC: BaseViewController { rootView.tableView.rx.setDelegate(self) .disposed(by: disposeBag) - viewModel.output.messages + let messages = viewModel.output.messages .skip(1) - .map { [ChatSectionModel(model: "", items: $0)] } .observe(on: MainScheduler.asyncInstance) + .share() + + messages + .do(onNext: { [weak self] _ in + guard let self else { return } + let tableView = self.rootView.tableView + let isInteracting = tableView.isTracking || tableView.isDragging || tableView.isDecelerating + self.shouldScrollAfterMessageReload = !isInteracting + && (self.needsInitialBottomScroll || self.isNearBottom()) + self.pendingMessageReloadAnchor = self.shouldScrollAfterMessageReload || self.viewModel.suppressAutoScroll + ? nil + : self.captureVisibleAnchor() + }) + .map { [ChatSectionModel(model: "", items: $0)] } .bind(to: rootView.tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) - viewModel.output.messages - .skip(1) - .observe(on: MainScheduler.asyncInstance) + messages .subscribe(onNext: { [weak self] _ in guard let self = self else { return } if self.viewModel.suppressAutoScroll { @@ -198,13 +218,22 @@ final class GroupChatVC: BaseViewController { return } self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory + guard self.shouldScrollAfterMessageReload else { + self.restoreVisibleAnchor(self.pendingMessageReloadAnchor) + self.pendingMessageReloadAnchor = nil + return + } + self.pendingMessageReloadAnchor = nil self.scrollToBottom() if self.needsInitialBottomScroll { DispatchQueue.main.async { [weak self] in - self?.scrollToBottom() + guard let self, self.needsInitialBottomScroll else { return } + self.scrollToBottom() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in - self?.scrollToBottom() + guard let self, self.needsInitialBottomScroll else { return } + self.scrollToBottom() + self.needsInitialBottomScroll = false } } }) @@ -305,10 +334,29 @@ final class GroupChatVC: BaseViewController { return tableView.contentOffset.y >= maxY - threshold } - private func scrollToBottomIfPinned() { - if needsInitialBottomScroll || isNearBottom() { - scrollToBottom() - } + private func captureVisibleAnchor() -> TableScrollAnchor? { + let tableView = rootView.tableView + tableView.layoutIfNeeded() + guard let indexPath = tableView.indexPathsForVisibleRows?.sorted().first else { return nil } + let rowTop = tableView.rectForRow(at: indexPath).minY + return TableScrollAnchor( + indexPath: indexPath, + offsetFromViewportTop: rowTop - tableView.contentOffset.y + ) + } + + private func restoreVisibleAnchor(_ anchor: TableScrollAnchor?) { + guard let anchor else { return } + let tableView = rootView.tableView + guard anchor.indexPath.section < tableView.numberOfSections, + anchor.indexPath.row < tableView.numberOfRows(inSection: anchor.indexPath.section) else { return } + tableView.layoutIfNeeded() + let inset = tableView.adjustedContentInset + let minY = -inset.top + let maxY = max(minY, tableView.contentSize.height - tableView.bounds.height + inset.bottom) + let rowTop = tableView.rectForRow(at: anchor.indexPath).minY + let targetY = min(max(rowTop - anchor.offsetFromViewportTop, minY), maxY) + tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: false) } private func scrollToMessage(clientMsgID: String) { @@ -929,13 +977,6 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } - cell.onNeedRelayout = { [weak self, weak tableView] in - UIView.performWithoutAnimation { - tableView?.beginUpdates() - tableView?.endUpdates() - } - self?.scrollToBottomIfPinned() - } return cell case let .imageReceived(msg): let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -943,13 +984,6 @@ final class GroupChatVC: BaseViewController { cell.onImageTap = { [weak self] in self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) } - cell.onNeedRelayout = { [weak self, weak tableView] in - UIView.performWithoutAnimation { - tableView?.beginUpdates() - tableView?.endUpdates() - } - self?.scrollToBottomIfPinned() - } return cell case let .locationSend(msg): let cell: LocationSendMsgCell = tableView.dequeueReusableCell(for: indexPath) @@ -1004,6 +1038,26 @@ extension GroupChatVC: UITextFieldDelegate, UITableViewDataSource { // MARK: - UITableViewDelegate extension GroupChatVC: UITableViewDelegate { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard tableView === rootView.tableView, + viewModel.currentMessages.indices.contains(indexPath.row) else { return 160 } + + switch viewModel.currentMessages[indexPath.row] { + case let .imageSend(msg), let .imageReceived(msg): + return ChatImageLayout.messageSize(width: msg.imageWidth, height: msg.imageHeight).height + 60 + case .locationSend, .locationReceived: + return 208 + case .emojiSend, .emojiReceived: + return 123 + case .voiceSend, .voiceReceived: + return 99 + case .send, .received: + return 100 + case .notification, .revoked: + return 60 + } + } + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { (cell as? EmojiSendMsgCell)?.playAnimation() (cell as? EmojiReceivedMsgCell)?.playAnimation() diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index 3f41b22b..667643fc 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -11,7 +11,7 @@ import RxCocoa import Lottie import AVFoundation import SwiftDate -import MapKit +import Kingfisher enum VoiceRecordState { case began @@ -40,6 +40,12 @@ enum ChatImageLayout { } return CGSize(width: dw.rounded(), height: dh.rounded()) } + + static func messageSize(width: CGFloat, height: CGFloat) -> CGSize { + guard width > 1, height > 1, width.isFinite, height.isFinite else { return fallback } + // ChatMessage stores the final bubble dimensions produced by displaySize(_:_:). + return CGSize(width: width.rounded(), height: height.rounded()) + } } // MARK: - Message Model @@ -62,6 +68,7 @@ struct ChatMessage { var atNicknames: [String] = [] var isAtAll: Bool = false var isCircleOwner: Bool = false + var relationIdx: String = "" var location: ChatLocationPayload? = nil func with(avatar: UIImage? = nil, @@ -70,6 +77,7 @@ struct ChatMessage { imageUrl: String? = nil, quotePreview: QuotePreview? = nil, isCircleOwner: Bool? = nil, + relationIdx: String? = nil, location: ChatLocationPayload? = nil) -> ChatMessage { ChatMessage( id: id, @@ -90,6 +98,7 @@ struct ChatMessage { atNicknames: atNicknames, isAtAll: isAtAll, isCircleOwner: isCircleOwner ?? self.isCircleOwner, + relationIdx: relationIdx ?? self.relationIdx, location: location ?? self.location ) } @@ -909,36 +918,38 @@ final class ChatSenderNameView: UIView { return label }() - private var ownerWidthConstraint: NSLayoutConstraint! - private var spacingConstraint: NSLayoutConstraint! + private let relationIconView = RelationIconImageView() + private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) - addSubview(ownerLabel) - addSubview(nameLabel) - ownerLabel.translatesAutoresizingMaskIntoConstraints = false - nameLabel.translatesAutoresizingMaskIntoConstraints = false - ownerWidthConstraint = ownerLabel.widthAnchor.constraint(equalToConstant: 0) - spacingConstraint = nameLabel.leadingAnchor.constraint(equalTo: ownerLabel.trailingAnchor) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 6 + stackView.addArrangedSubview(ownerLabel) + stackView.addArrangedSubview(nameLabel) + stackView.addArrangedSubview(relationIconView) + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) NSLayoutConstraint.activate([ - ownerLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - ownerLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), ownerLabel.heightAnchor.constraint(equalToConstant: 20), - ownerWidthConstraint, - spacingConstraint, - nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + relationIconView.widthAnchor.constraint(equalToConstant: 12), + relationIconView.heightAnchor.constraint(equalToConstant: 12), heightAnchor.constraint(equalToConstant: 20) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(name: String, isOwner: Bool) { + func configure(name: String, isOwner: Bool, relationIdx: String) { nameLabel.text = name ownerLabel.isHidden = !isOwner - ownerWidthConstraint.constant = isOwner ? ownerLabel.intrinsicContentSize.width : 0 - spacingConstraint.constant = isOwner ? 6 : 0 + relationIconView.configure(relationIdx: relationIdx) } } @@ -1164,7 +1175,7 @@ class TextSendMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) contentLabel.attributedText = msg.attributedContent(isOutgoing: true) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) @@ -1303,7 +1314,7 @@ class TextReceivedMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) contentLabel.attributedText = msg.attributedContent(isOutgoing: false) let hasQuote = msg.quotePreview != nil quoteBlock.configure(msg.quotePreview) @@ -1546,7 +1557,7 @@ final class EmojiSendMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil @@ -1659,7 +1670,7 @@ final class EmojiReceivedMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let index = Int(msg.content.replacingOccurrences(of: "js_emoji:", with: "")) ?? 0 guard Self.emojiFileNames.indices.contains(index) else { animationName = nil @@ -1834,7 +1845,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl @@ -1878,7 +1889,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let dur = msg.content.int / 1000 durationLabel.text = dur > 0 ? "\(dur)''" : "" voiceUrl = msg.voiceUrl @@ -2025,53 +2036,41 @@ final class ImageReceivedMsgCell: ChatImageMsgCell { class ChatImageMsgCell: UITableViewCell { var menuAnchorView: UIView { photoView } var onImageTap: (() -> Void)? - var onNeedRelayout: (() -> Void)? private let isOutgoing: Bool private var configuredId: String? + private var photoWidthConstraint: NSLayoutConstraint! + private var photoHeightConstraint: NSLayoutConstraint! func configure(_ msg: ChatMessage) { configuredId = msg.id timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) - applyPhotoSize(ChatImageLayout.displaySize(width: msg.imageWidth, height: msg.imageHeight)) - loadPhoto(url: msg.imageUrl, messageId: msg.id, preferred: CGSize(width: msg.imageWidth, height: msg.imageHeight)) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) + applyPhotoSize(ChatImageLayout.messageSize(width: msg.imageWidth, height: msg.imageHeight)) + loadPhoto(url: msg.imageUrl, messageId: msg.id) guard isOutgoing else { return } loadingView.isHidden = !msg.isUploading if msg.isUploading { loadingView.startAnimating() } else { loadingView.stopAnimating() } } - private func loadPhoto(url: String, messageId: String, preferred: CGSize) { + private func loadPhoto(url: String, messageId: String) { + photoView.kf.cancelDownloadTask() guard !url.isEmpty else { photoView.image = nil; return } if let localImage = UIImage(contentsOfFile: url) { photoView.image = localImage - syncPhotoSizeIfNeeded(localImage.size, preferred: preferred) return } photoView.dl.setImage(with: url) { [weak self] image, _ in guard let self, self.configuredId == messageId, let image else { return } - self.syncPhotoSizeIfNeeded(image.size, preferred: preferred) - } - } - - private func syncPhotoSizeIfNeeded(_ imageSize: CGSize, preferred: CGSize) { - let size = ChatImageLayout.displaySize(width: imageSize.width, height: imageSize.height) - let current = preferred.width > 1 && preferred.height > 1 - ? ChatImageLayout.displaySize(width: preferred.width, height: preferred.height) - : ChatImageLayout.fallback - guard abs(size.width - current.width) > 2 || abs(size.height - current.height) > 2 else { return } - applyPhotoSize(size) - let id = configuredId - DispatchQueue.main.async { [weak self] in - guard let self, self.configuredId == id else { return } - self.onNeedRelayout?() + self.photoView.image = image } } private func applyPhotoSize(_ size: CGSize) { - photoView.layoutChain.width(size.width).height(size.height) + photoWidthConstraint.constant = size.width + photoHeightConstraint.constant = size.height } @objc private func onTap() { onImageTap?() } @@ -2141,6 +2140,10 @@ class ChatImageMsgCell: UITableViewCell { timeLabel.layoutChain.top().centerX() senderNameView.translatesAutoresizingMaskIntoConstraints = false + photoView.translatesAutoresizingMaskIntoConstraints = false + photoWidthConstraint = photoView.widthAnchor.constraint(equalToConstant: ChatImageLayout.fallback.width) + photoHeightConstraint = photoView.heightAnchor.constraint(equalToConstant: ChatImageLayout.fallback.height) + NSLayoutConstraint.activate([photoWidthConstraint, photoHeightConstraint]) if isOutgoing { photoView.addSubview(loadingView) @@ -2154,8 +2157,6 @@ class ChatImageMsgCell: UITableViewCell { photoView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView, offset: 0) - .width(ChatImageLayout.fallback.width) - .height(ChatImageLayout.fallback.height) .bottom(10) } else { loadingView.isHidden = true @@ -2168,17 +2169,15 @@ class ChatImageMsgCell: UITableViewCell { photoView.layoutChain .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) - .width(ChatImageLayout.fallback.width) - .height(ChatImageLayout.fallback.height) .bottom(10) } } override func prepareForReuse() { super.prepareForReuse() + photoView.kf.cancelDownloadTask() photoView.image = nil onImageTap = nil - onNeedRelayout = nil configuredId = nil loadingView.stopAnimating() loadingView.isHidden = true @@ -2232,7 +2231,12 @@ class ChatLocationMsgCell: UITableViewCell { view.clipsToBounds = true return view }() - private let mapPreview = ChatLocationMapPreview() + private let mapPreview: UIImageView = { + let imageView = UIImageView(image: UIImage(named: "Group/chat_location_map")) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + return imageView + }() private let titleLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .bold) @@ -2266,11 +2270,10 @@ class ChatLocationMsgCell: UITableViewCell { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil avatarView.image = msg.avatar - senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner) + senderNameView.configure(name: msg.senderName, isOwner: msg.isCircleOwner, relationIdx: msg.relationIdx) let location = msg.location ?? ChatLocationPayload(name: msg.content, address: "", latitude: 0, longitude: 0) titleLabel.text = location.name.isEmpty ? "位置" : location.name addressLabel.text = location.address.isEmpty ? "暂无详细地址" : location.address - mapPreview.configure(location: location) } private func setupUI() { @@ -2282,6 +2285,7 @@ class ChatLocationMsgCell: UITableViewCell { contentView.addSubview(senderNameView) cardView.addSubview(titleLabel) cardView.addSubview(addressLabel) + cardView.addSubview(mapPreview) cardView.addTarget(self, action: #selector(handleLocationTap), for: .touchUpInside) timeLabel.layoutChain.top().centerX() @@ -2297,6 +2301,13 @@ class ChatLocationMsgCell: UITableViewCell { .topToBottomOfView(titleLabel, offset: 8) .edgesHorzontal(10) + mapPreview.layoutChain + .topToBottomOfView(addressLabel, offset: 10) + .left(10) + .right(10) + .bottom(10) + .height(90) + if isOutgoing { avatarView.layoutChain .topToBottomOfView(timeLabel, offset: 10) @@ -2310,7 +2321,7 @@ class ChatLocationMsgCell: UITableViewCell { .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView) .width(250) - .height(148) + .height(158) .bottom(10) } else { avatarView.layoutChain @@ -2325,68 +2336,10 @@ class ChatLocationMsgCell: UITableViewCell { .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .width(250) - .height(148) + .height(158) .bottom(10) } } @objc private func handleLocationTap() { onLocationTap?() } } - -private final class ChatLocationMapPreview: UIView { - private let imageView = UIImageView() - private let pinView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#16B3FF") - view.cornerRadius = 10 - view.borderWidth = 3 - view.borderColor = .white - view.layer.shadowColor = UIColor.black.cgColor - view.layer.shadowOpacity = 0.18 - view.layer.shadowRadius = 3 - view.layer.shadowOffset = CGSize(width: 0, height: 1) - return view - }() - private var requestID = UUID() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = UIColor(hexStr: "#E7F0E9") - clipsToBounds = true - imageView.contentMode = .scaleAspectFill - addSubview(imageView) - addSubview(pinView) - imageView.translatesAutoresizingMaskIntoConstraints = false - pinView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - pinView.centerXAnchor.constraint(equalTo: centerXAnchor), - pinView.centerYAnchor.constraint(equalTo: centerYAnchor), - pinView.widthAnchor.constraint(equalToConstant: 20), - pinView.heightAnchor.constraint(equalToConstant: 20) - ]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func configure(location: ChatLocationPayload) { - let identifier = UUID() - requestID = identifier - imageView.image = nil - guard CLLocationCoordinate2DIsValid(location.coordinate), - abs(location.latitude) > 0.0001 || abs(location.longitude) > 0.0001 else { return } - let options = MKMapSnapshotter.Options() - options.region = MKCoordinateRegion(center: location.coordinate, - latitudinalMeters: 700, - longitudinalMeters: 700) - options.size = CGSize(width: 460, height: 168) - options.scale = UIScreen.main.scale - MKMapSnapshotter(options: options).start(with: .main) { [weak self] snapshot, _ in - guard let self, self.requestID == identifier else { return } - self.imageView.image = snapshot?.image - } - } -} diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index d3eae5f0..a61c8e89 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -47,6 +47,41 @@ struct ChatLocationPayload: Codable, Equatable { let payload = try? JSONDecoder().decode(ChatLocationPayload.self, from: data) { return payload } + + if let description, + let data = description.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + func stringValue(_ keys: [String]) -> String { + for key in keys { + if let value = object[key] as? String, !value.isEmpty { + return value + } + } + return "" + } + + func doubleValue(_ keys: [String], fallback: Double) -> Double { + for key in keys { + if let value = object[key] as? NSNumber { + return value.doubleValue + } + if let value = object[key] as? String, let number = Double(value) { + return number + } + } + return fallback + } + + let legacyName = stringValue(["name", "title", "poi_name", "poiName"]) + let legacyAddress = stringValue(["address", "addr"]) + return ChatLocationPayload( + name: legacyName.isEmpty ? "位置" : legacyName, + address: legacyAddress, + latitude: doubleValue(["latitude", "lat"], fallback: latitude), + longitude: doubleValue(["longitude", "lng", "lon"], fallback: longitude) + ) + } + let parts = (description ?? "").components(separatedBy: "\n") return ChatLocationPayload( name: parts.first ?? "", @@ -163,7 +198,14 @@ final class GroupChatViewModel { if ownerUpdated { message = message.with(isCircleOwner: isOwner) } - guard avatarUpdated || ownerUpdated else { return item } + let relationIdx = message.isSelf + ? "" + : memberList.first(where: { $0.user_id == message.senderId })?.extra.relation_idx ?? "" + let relationUpdated = message.relationIdx != relationIdx + if relationUpdated { + message = message.with(relationIdx: relationIdx) + } + guard avatarUpdated || ownerUpdated || relationUpdated else { return item } didChange = true return ChatSectionItem.with(message) } @@ -774,6 +816,9 @@ final class GroupChatViewModel { atNicknames: atNicknames, isAtAll: isAtAll, isCircleOwner: isCircleOwner(id: sendID), + relationIdx: isSelf + ? "" + : memberList.first(where: { $0.user_id == sendID })?.extra.relation_idx ?? "", location: location ) } diff --git a/QuickLocation/Section/Group/GroupItineraryView.swift b/QuickLocation/Section/Group/GroupItineraryView.swift index 840814a7..187246c5 100644 --- a/QuickLocation/Section/Group/GroupItineraryView.swift +++ b/QuickLocation/Section/Group/GroupItineraryView.swift @@ -27,7 +27,16 @@ final class GroupItineraryView: UIView { tableView.refresh(status: .noMoreData, isEmpty: list.isEmpty) } + func updateMembers(_ members: [GroupMemberModel]) { + relationIdxByUserId = members.reduce(into: [:]) { result, member in + guard !member.user_id.isEmpty else { return } + result[member.user_id] = member.extra.relation_idx + } + tableView.reloadData() + } + private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = [] + private var relationIdxByUserId: [String: String] = [:] private func setupUI() { addSubview(headerRow) @@ -237,7 +246,8 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: GroupItineraryCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(scheduleSections[indexPath.section].items[indexPath.row]) + let model = scheduleSections[indexPath.section].items[indexPath.row] + cell.configure(model, relationIdx: relationIdxByUserId[model.creator_id] ?? "") return cell } @@ -250,10 +260,10 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { // MARK: - GroupItineraryCell final class GroupItineraryCell: UITableViewCell { - func configure(_ model: ScheduleModel) { + func configure(_ model: ScheduleModel, relationIdx: String) { avatarImg.image = model.userIcon nameLab.text = model.nick_name.isEmpty ? "未命名用户" : model.nick_name - followIcon.isHidden = !model.is_follow + relationIcon.configure(relationIdx: relationIdx) let points = Self.orderedPoints(model.points) startLocationLab.text = Self.locationName(points.first) @@ -271,7 +281,7 @@ final class GroupItineraryCell: UITableViewCell { contentView.addSubview(cardView) cardView.addSubview(avatarImg) cardView.addSubview(nameLab) - cardView.addSubview(followIcon) + cardView.addSubview(relationIcon) cardView.addSubview(startMarkerLab) cardView.addSubview(startLocationLab) cardView.addSubview(routeLineView) @@ -295,14 +305,14 @@ final class GroupItineraryCell: UITableViewCell { .leftToRightOfView(avatarImg, offset: 8) .centerY(avatarImg) - followIcon.layoutChain + relationIcon.layoutChain .leftToRightOfView(nameLab, offset: 7) .centerY(nameLab) .width(20) .height(20) - followIcon.translatesAutoresizingMaskIntoConstraints = false - followIcon.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -14).isActive = true + relationIcon.translatesAutoresizingMaskIntoConstraints = false + relationIcon.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -14).isActive = true startMarkerLab.translatesAutoresizingMaskIntoConstraints = false startLocationLab.translatesAutoresizingMaskIntoConstraints = false @@ -384,24 +394,11 @@ final class GroupItineraryCell: UITableViewCell { return label }() - private lazy var followIcon: UIImageView = { - let view = UIImageView(image: Self.followBadgeImage()) - view.contentMode = .scaleAspectFit - return view - }() + private lazy var relationIcon = RelationIconImageView() - private static func followBadgeImage() -> UIImage? { - let size = CGSize(width: 22, height: 18) - UIGraphicsBeginImageContextWithOptions(size, false, 0) - defer { UIGraphicsEndImageContext() } - let configuration = UIImage.SymbolConfiguration(pointSize: 13, weight: .bold) - UIImage(systemName: "heart.fill", withConfiguration: configuration)? - .withTintColor(UIColor(hexStr: "#FF9AC1"), renderingMode: .alwaysOriginal) - .draw(in: CGRect(x: 0, y: 1, width: 15, height: 15)) - UIImage(systemName: "heart.fill", withConfiguration: configuration)? - .withTintColor(UIColor(hexStr: "#FF78AC"), renderingMode: .alwaysOriginal) - .draw(in: CGRect(x: 8, y: 3, width: 14, height: 14)) - return UIGraphicsGetImageFromCurrentImageContext() + override func prepareForReuse() { + super.prepareForReuse() + relationIcon.clear() } private lazy var startMarkerLab: UILabel = makeMarkerLabel(text: "始") diff --git a/QuickLocation/Section/Group/GroupView.swift b/QuickLocation/Section/Group/GroupView.swift index 2b6e9842..cbdcf69e 100644 --- a/QuickLocation/Section/Group/GroupView.swift +++ b/QuickLocation/Section/Group/GroupView.swift @@ -23,6 +23,14 @@ class GroupView: UIView { var disposeBag = DisposeBag() + private let actionCardSpacing: CGFloat = 13 + + private var actionCardHeight: CGFloat { + let availableWidth = UIScreen.main.bounds.width - 32 - actionCardSpacing + let cardWidth = availableWidth / 2 + return cardWidth * 90 / 165 + } + /// 0 = 圈子,1 = 行程 private(set) var currentTopTab: Int = 0 @@ -93,8 +101,8 @@ class GroupView: UIView { messageBtn.layoutChain .right(16) .centerY(circleTabLabel) - .width(28) - .height(28) + .width(32) + .height(32) circlePage.layoutChain .topToBottomOfView(topBar) @@ -114,7 +122,7 @@ class GroupView: UIView { actionButtonsView.layoutChain .top(12) .edgesHorzontal(16) - .height(108) + .height(actionCardHeight) createGroupBtn.layoutChain .left() @@ -124,7 +132,7 @@ class GroupView: UIView { joinGroupBtn.layoutChain .topToView(createGroupBtn) .bottomToView(createGroupBtn) - .leftToRightOfView(createGroupBtn, offset: 12) + .leftToRightOfView(createGroupBtn, offset: actionCardSpacing) .right() .widthToView(createGroupBtn) @@ -156,7 +164,7 @@ class GroupView: UIView { .topToBottomOfView(segmentView) .edgesHorzontal() .bottom() - .height(kScreenHeight - kNaviHeight - 44 - 12 - 108 - 16) + .height(kScreenHeight - kNaviHeight - 44 - 12 - actionCardHeight - 16) segmentContentView.layoutChain .edges() @@ -180,63 +188,6 @@ class GroupView: UIView { hotGroupsCollectionView.layoutChain .top(0).left(0).width(0).height(0) - configureActionCard( - createGroupBtn, - bgColor: UIColor(hexStr: "#D6F3FF"), - title: "创建圈子", - subtitle: "和朋友一起定位玩" - ) - configureActionCard( - joinGroupBtn, - bgColor: UIColor(hexStr: "#E8F8C8"), - title: "加入圈子", - subtitle: "输入邀请码快速加入" - ) - } - - private func configureActionCard(_ card: UIView, bgColor: UIColor, title: String, subtitle: String) { - card.backgroundColor = bgColor - card.cornerRadius = 16 - card.clipsToBounds = true - - let titleLab = UILabel() - titleLab.text = title - titleLab.font = .systemFont(ofSize: 16, weight: .bold) - titleLab.textColor = UIColor(hexStr: "#0F2846") - - let subLab = UILabel() - subLab.text = subtitle - subLab.font = .systemFont(ofSize: 11, weight: .medium) - subLab.textColor = UIColor(hexStr: "#6B7A90") - - let cta = UILabel() - cta.text = "立即体验" - cta.font = .systemFont(ofSize: 12, weight: .semibold) - cta.textColor = UIColor(hexStr: "#0F2846") - cta.textAlignment = .center - cta.backgroundColor = .white - cta.cornerRadius = 12 - cta.clipsToBounds = true - - card.addSubview(titleLab) - card.addSubview(subLab) - card.addSubview(cta) - - titleLab.layoutChain - .top(16) - .left(14) - .right(14) - - subLab.layoutChain - .topToBottomOfView(titleLab, offset: 6) - .left(14) - .right(14) - - cta.layoutChain - .left(14) - .bottom(14) - .width(72) - .height(24) } override func layoutSubviews() { @@ -282,8 +233,16 @@ class GroupView: UIView { lazy var messageBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Home/message"), for: .normal) - btn.isHidden = true + btn.setImage(UIImage(named: "Group/message_tick"), for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + btn.imageEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) + btn.backgroundColor = .white + btn.layer.cornerRadius = 10 + btn.layer.shadowColor = UIColor.black.cgColor + btn.layer.shadowOpacity = 0.08 + btn.layer.shadowOffset = CGSize(width: 0, height: 3) + btn.layer.shadowRadius = 6 + btn.accessibilityLabel = "圈子消息" return btn }() @@ -344,21 +303,25 @@ class GroupView: UIView { return view }() - // MARK: - 创建 / 加入 圈子(色卡,整卡可点) + // MARK: - 创建 / 加入圈子 private lazy var actionButtonsView: UIView = { let v = UIView() v.backgroundColor = .clear return v }() - lazy var createGroupBtn: UIView = { - let v = UIView() + lazy var createGroupBtn: UIImageView = { + let v = UIImageView(image: UIImage(named: "Group/create_action_card")) + v.contentMode = .scaleAspectFill + v.clipsToBounds = true v.isUserInteractionEnabled = true return v }() - lazy var joinGroupBtn: UIView = { - let v = UIView() + lazy var joinGroupBtn: UIImageView = { + let v = UIImageView(image: UIImage(named: "Group/join_action_card")) + v.contentMode = .scaleAspectFill + v.clipsToBounds = true v.isUserInteractionEnabled = true return v }() @@ -512,6 +475,7 @@ class GroupView: UIView { itineraryTabLabel.font = FontManager.youSheBiaoTiHei(isCircle ? 24 : 32) circleTabLabel.textColor = isCircle ? UIColor(hexStr: "#353B4F") : UIColor(hexStr: "#2A3648", alpha: 0.5) itineraryTabLabel.textColor = isCircle ? UIColor(hexStr: "#2A3648", alpha: 0.5) : UIColor(hexStr: "#353B4F") + messageBtn.isHidden = !isCircle } override init(frame: CGRect) { diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index 94616ade..6d7ce91d 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -20,6 +20,7 @@ final class GroupViewController: BaseViewController { /// 行程页当前圈子数据 private var itineraryGroupModel: GroupModel? private var itinerarySchedules: [ScheduleModel] = [] + private var itineraryGroupKey: String = "" override func loadView() { rootView = GroupView(frame: UIScreen.main.bounds) @@ -204,7 +205,13 @@ final class GroupViewController: BaseViewController { self.itineraryGroupModel = model let current = model.groups.first(where: { $0.group_key == model.default_group_key }) self.rootView.itineraryPage.updateGroupName(current?.name ?? "") - self.requestItinerarySchedules(groupKey: model.default_group_key) + let groupKey = model.default_group_key + if self.itineraryGroupKey != groupKey { + self.itineraryGroupKey = groupKey + self.rootView.itineraryPage.updateMembers([]) + } + self.requestItinerarySchedules(groupKey: groupKey) + self.requestItineraryMembers(groupKey: groupKey) }).disposed(by: disposeBag) } @@ -216,13 +223,28 @@ final class GroupViewController: BaseViewController { } ItineraryService.groupScheduleList(groupKey: groupKey) .subscribe(onNext: { [weak self] response in - guard let self = self else { return } + guard let self = self, self.itineraryGroupKey == groupKey else { return } self.itinerarySchedules = response.list self.rootView.itineraryPage.reloadSchedules(response.list) }) .disposed(by: disposeBag) } + private func requestItineraryMembers(groupKey: String) { + guard !groupKey.isEmpty else { + rootView.itineraryPage.updateMembers([]) + return + } + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard let self, + self.itineraryGroupKey == groupKey, + response.isValid(for: groupKey) else { return } + self.rootView.itineraryPage.updateMembers(response.list) + }) + .disposed(by: disposeBag) + } + private func showSwitchGroupPop() { guard let groupModel = itineraryGroupModel else { refreshItineraryPage() diff --git a/QuickLocation/Section/Group/Join/JoinGroupView.swift b/QuickLocation/Section/Group/Join/JoinGroupView.swift index e05daf28..0d61b64a 100644 --- a/QuickLocation/Section/Group/Join/JoinGroupView.swift +++ b/QuickLocation/Section/Group/Join/JoinGroupView.swift @@ -76,7 +76,7 @@ class JoinGroupView: UIView { navBgView.layoutChain .top() .edgesHorzontal() - .heightToWidth(253 / 375) + .heightToWidth(224 / 375) navView.layoutChain .top() @@ -84,7 +84,7 @@ class JoinGroupView: UIView { .height(kNaviHeight) contentCard.layoutChain - .topToBottomOfView(navBgView, offset: -60) + .topToBottomOfView(navBgView, offset: -31) .edgesHorzontal() .bottom() @@ -147,7 +147,7 @@ class JoinGroupView: UIView { scanBtn.layoutChain .top(kStatusBarHeight + 6) .right(18) - .width(32).height(32) + .width(44).height(44) } private func styleNavIconButton(_ button: UIButton) { diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift index 187bf9c7..4404d8db 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -96,8 +96,8 @@ final class MemberInfoVC: BaseViewController { } else { rootView.renderRows([ MemberInfoRow(title: "昵称", value: member?.nick_name ?? "", kind: .text), - MemberInfoRow(title: "备注", value: member?.remark ?? "", kind: .disclosure) { - DLToast.show(text: "敬请期待") + MemberInfoRow(title: "备注", value: member?.remark ?? "", kind: .disclosure) { [weak self] in + self?.editRemark() }, MemberInfoRow(title: "关系", value: relationText(for: member), kind: .disclosure) { [weak self] in self?.editRelation() @@ -180,6 +180,49 @@ final class MemberInfoVC: BaseViewController { RelationStore.shared.name(for: member?.extra.relation_idx ?? "") ?? "未绑定" } + private func editRemark() { + guard let member = members.first(where: { $0.user_id == selectedUserId }) else { return } + let vc = TextInputViewController( + title: "备注", + maxLength: 10, + initialText: member.remark + ) { [weak self] text in + self?.requestEditRemark(text) + } + present(vc, animated: true) + } + + private func requestEditRemark(_ remark: String) { + let trimmed = remark.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let member = members.first(where: { $0.user_id == selectedUserId }) else { return } + let groupKey = groupModel?.default_group_key ?? "" + guard !groupKey.isEmpty else { + DLToast.show(text: "圈子信息缺失") + return + } + + DLToast.showLoading() + GroupService.operate( + opType: "editmateinfo", + requestData: [ + "group_key": groupKey, + "user_id": member.user_id, + "nick_name": trimmed + ] + ).subscribe(onNext: { [weak self] _ in + DLToast.show(text: "修改成功") + guard let self, + let index = self.members.firstIndex(where: { $0.user_id == self.selectedUserId }) else { return } + self.members[index].remark = trimmed + self.rootView.memberCV.reloadData() + self.refreshDetail() + }, onError: { error in + DLToast.dismiss() + DLToast.show(text: error.localizedDescription) + }).disposed(by: disposeBag) + } + private func editRelation() { if RelationStore.shared.list.isEmpty { guard !isLoadingRelations else { return } diff --git a/QuickLocation/Section/Group/MemberInfo/RelationSelectPopVC.swift b/QuickLocation/Section/Group/MemberInfo/RelationSelectPopVC.swift index 490281ea..fe761b28 100644 --- a/QuickLocation/Section/Group/MemberInfo/RelationSelectPopVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/RelationSelectPopVC.swift @@ -4,6 +4,7 @@ // import UIKit +import Kingfisher final class RelationSelectPopVC: DLCustomPopVC { @@ -11,7 +12,7 @@ final class RelationSelectPopVC: DLCustomPopVC { private var selectedIdx: String private let items: [RelationModel] - private var chipButtons: [UIButton] = [] + private var chipControls: [RelationChipControl] = [] init(currentIdx: String, onConfirm: ((String) -> Void)? = nil) { self.selectedIdx = currentIdx @@ -53,23 +54,25 @@ final class RelationSelectPopVC: DLCustomPopVC { let closeBtn = UIButton(type: .custom) closeBtn.setImage(UIImage(named: "MemberInfo/close_circle"), for: .normal) + closeBtn.imageEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) closeBtn.addTarget(self, action: #selector(close), for: .touchUpInside) contentView.addSubview(closeBtn) - closeBtn.layoutChain.right(16).centerY(titleLab).width(28).height(28) + closeBtn.layoutChain.right(6).centerY(titleLab).width(44).height(44) let columns = 5 let rows = max(1, Int(ceil(Double(max(items.count, 1)) / Double(columns)))) - let chipHeight: CGFloat = 36 - let rowSpacing: CGFloat = 8 + let chipHeight: CGFloat = 32 + let rowSpacing: CGFloat = 23 let gridHeight = CGFloat(rows) * chipHeight + CGFloat(max(0, rows - 1)) * rowSpacing let grid = UIStackView() grid.axis = .vertical grid.spacing = rowSpacing grid.distribution = .fillEqually + grid.clipsToBounds = false contentView.addSubview(grid) grid.layoutChain - .topToBottomOfView(titleLab, offset: 24) + .topToBottomOfView(titleLab, offset: 22) .left(16) .right(16) .height(gridHeight) @@ -77,14 +80,15 @@ final class RelationSelectPopVC: DLCustomPopVC { for row in 0.. UIButton { - let button = UIButton(type: .custom) - button.tag = tag - button.layer.cornerRadius = 18 - button.clipsToBounds = true - button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) - button.addTarget(self, action: #selector(tapChip(_:)), for: .touchUpInside) - if item.showsHeart { - let heart = UIImage(named: "Mine/name_heart")?.withRenderingMode(.alwaysOriginal) - button.setImage(heart, for: .normal) - button.setTitle(" \(item.name)", for: .normal) - button.imageView?.contentMode = .scaleAspectFit - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -2, bottom: 0, right: 2) - } else { - button.setTitle(item.name, for: .normal) - } - return button + private func makeChip(_ item: RelationModel, tag: Int) -> RelationChipControl { + let control = RelationChipControl(title: item.name, iconURL: item.icon) + control.tag = tag + control.addTarget(self, action: #selector(tapChip(_:)), for: .touchUpInside) + return control } private func refreshSelection() { - for (index, button) in chipButtons.enumerated() { + for (index, control) in chipControls.enumerated() { guard items.indices.contains(index) else { continue } - let selected = items[index].idx == selectedIdx - button.backgroundColor = selected ? UIColor(hexStr: "#16B3FF") : .white - button.setTitleColor(selected ? .white : UIColor(hexStr: "#293445"), for: .normal) - button.layer.borderWidth = selected ? 0 : 1 - button.layer.borderColor = UIColor(hexStr: "#E8EEF2").cgColor + let itemIdx = items[index].idx + control.isSelected = !itemIdx.isEmpty && itemIdx == selectedIdx } } - @objc private func tapChip(_ sender: UIButton) { + @objc private func tapChip(_ sender: RelationChipControl) { guard items.indices.contains(sender.tag) else { return } - selectedIdx = items[sender.tag].idx + let idx = items[sender.tag].idx + guard !idx.isEmpty else { + DLToast.show(text: "关系数据异常") + return + } + selectedIdx = idx refreshSelection() } @@ -166,3 +160,82 @@ final class RelationSelectPopVC: DLCustomPopVC { dismiss(animated: true) } } + +private final class RelationChipControl: UIControl { + + private let titleLabel = UILabel() + private let relationIconView = UIImageView() + private let iconURL: URL? + private var iconRequestVersion = 0 + + init(title: String, iconURL: String) { + self.iconURL = RelationStore.validIconURL(from: iconURL) + super.init(frame: .zero) + + clipsToBounds = false + layer.cornerRadius = 8 + layer.borderColor = UIColor(hexStr: "#E8E8E8").cgColor + + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textAlignment = .center + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.75 + addSubview(titleLabel) + titleLabel.layoutChain.edgesHorzontal(4).centerY() + + relationIconView.contentMode = .scaleAspectFit + relationIconView.isUserInteractionEnabled = false + relationIconView.isHidden = true + addSubview(relationIconView) + relationIconView.layoutChain.top(-7).centerX().width(22).height(16) + + applyStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isSelected: Bool { + didSet { + applyStyle() + } + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.7 : 1 + } + } + + private func applyStyle() { + backgroundColor = isSelected ? UIColor(hexStr: "#12AEF5") : .white + titleLabel.textColor = isSelected ? .white : UIColor(hexStr: "#293445") + layer.borderWidth = isSelected ? 0 : 1 + updateRelationIcon() + } + + private func updateRelationIcon() { + relationIconView.kf.cancelDownloadTask() + relationIconView.image = nil + relationIconView.isHidden = true + iconRequestVersion += 1 + + guard isSelected, let iconURL else { return } + let requestVersion = iconRequestVersion + relationIconView.kf.setImage(with: iconURL) { [weak self] result in + guard let self, + self.isSelected, + self.iconRequestVersion == requestVersion else { return } + switch result { + case .success: + self.relationIconView.isHidden = false + case .failure: + self.relationIconView.image = nil + self.relationIconView.isHidden = true + } + } + } + +} diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index d89a5977..90291db4 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -30,10 +30,15 @@ class GroupMemberView2: UIView { navBarHeightConstraint?.constant = expanded ? kNaviHeight : 0 } - func setupMemberInfo(_ model: GroupMemberModel, isOwner: Bool) { + func setupMemberInfo( + _ model: GroupMemberModel, + isOwner: Bool, + phoneUsage: PhoneUsageTodayModel? + ) { currentMemberModel = model memberNameLab.text = model.nick_name ownView.isHidden = !isOwner + relationIconView.configure(relationIdx: model.extra.relation_idx) updateMoodBadge(model.mood) statusDotView.backgroundColor = UIColor(hexStr: model.is_online ? "#67EA76" : "#D8D8D8") statusLab.text = model.is_online ? "在线" : "离线" @@ -42,16 +47,16 @@ class GroupMemberView2: UIView { let isCurrentUser = model.user_id == AppContextManager.shared.userId updateDistance(for: model, isCurrentUser: isCurrentUser) - if isCurrentUser { - setupCurrentUserDeviceInfo() - } else { - setupUnknownDeviceInfo(battery: model.battery) - } - refreshMemberReports(for: model) + applyPhoneUsage(phoneUsage) updateRealtimeInteractionVisibility(isCurrentUser: isCurrentUser) refreshRealtimeInteraction() } + func updatePhoneUsage(_ phoneUsage: PhoneUsageTodayModel?, for userId: String) { + guard currentMemberModel?.user_id == userId else { return } + applyPhoneUsage(phoneUsage) + } + func applyEmojiEcho(name: String, for userId: String) { emojiEchoByUserId[userId] = name if currentMemberModel?.user_id == userId { @@ -135,34 +140,40 @@ class GroupMemberView2: UIView { moodImageView.isHidden = image == nil } - private func setupCurrentUserDeviceInfo() { - phoneModelIcon.image = UIImage(named: "Home/member_phone") - phoneModelLab.text = UIDevice.modelName - phoneModelLab.textColor = Self.batteryDefaultTextColor - - networkIcon.image = UIImage(named: "Home/member_wifi") - networkLab.text = Self.localNetworkStatusText() - networkLab.textColor = Self.batteryDefaultTextColor - - applyBatteryDisplay(percent: Self.localBatteryPercent()) + private func applyPhoneUsage(_ usage: PhoneUsageTodayModel?) { + applyDeviceInfo(usage?.phoneInfo) + phoneReportView.configure(with: Self.phoneReportPreview(from: usage)) + applyStayPoints(usage?.stayPoints ?? []) } - private func setupUnknownDeviceInfo(battery: String) { - phoneModelIcon.image = UIImage(named: "Home/member_phone_unknown") - phoneModelLab.text = "***" + private func applyDeviceInfo(_ info: PhoneUsageInfoModel?) { + let model = info?.model.trimmed ?? "" + let hasModel = !model.isEmpty && model != "未知" + phoneModelIcon.image = UIImage(named: hasModel ? "Home/member_phone" : "Home/member_phone_unknown") + phoneModelLab.text = hasModel ? model : "***" phoneModelLab.textColor = Self.batteryDefaultTextColor - networkIcon.image = UIImage(named: "Home/member_network_unknown") - networkLab.text = "***" + let network = info?.network.trimmed ?? "" + let hasNetwork = !network.isEmpty && network != "未知" + networkIcon.image = UIImage(named: hasNetwork ? "Home/member_wifi" : "Home/member_network_unknown") + networkLab.text = hasNetwork ? network : "***" networkLab.textColor = Self.batteryDefaultTextColor - // MQTT / 接口已有电量则展示,否则 unknown - let percent = Int(battery.int) - if percent > 0 { - applyBatteryDisplay(percent: min(percent, 100)) - } else { - applyBatteryUnknown() + applyBatteryDisplay(percent: Self.percent(from: info?.battery)) + + let weather = info?.weather.trimmed ?? "" + guard !weather.isEmpty else { + applyUnknownWeather(text: "***") + return } + let snapshot = MemberWeatherService.snapshot(reportedText: weather) + weatherIcon.image = UIImage(named: snapshot.assetName) + weatherLab.text = snapshot.text + } + + private func applyUnknownWeather(text: String) { + weatherIcon.image = UIImage(named: "Home/member_weather_unknown") + weatherLab.text = text } private func applyBatteryUnknown() { @@ -189,48 +200,78 @@ class GroupMemberView2: UIView { batteryLab.textColor = color } - // MARK: - Local device helpers - - private static func localBatteryPercent() -> Int? { - UIDevice.batteryPercent + private static func percent(from value: String?) -> Int? { + guard let value else { return nil } + let digits = value.filter(\.isNumber) + guard let percent = Int(digits) else { return nil } + return min(max(percent, 0), 100) } - private static func localNetworkStatusText() -> String { - let path = NetworkStatusMonitor.shared.currentPath - if path.status != .satisfied { - return "无网络" - } - if path.usesInterfaceType(.wifi) { - return "Wi-Fi" - } - if path.usesInterfaceType(.cellular) { - return "蜂窝" - } - return "无网络" - } - - private func setupRx() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleUnlockCountDidChange), - name: .unlockCountDidChange, - object: nil + private static func phoneReportPreview(from usage: PhoneUsageTodayModel?) -> MemberPhoneReportPreview? { + guard let usage else { return nil } + let info = usage.phoneInfo + return MemberPhoneReportPreview( + screenTimeSeconds: screenTimeSeconds(from: info?.useTime), + usageCount: usage.appCount, + unlockCount: Int(info?.unlockCount.trimmed ?? "") ) } - @objc private func handleUnlockCountDidChange() { - guard let model = currentMemberModel else { return } - refreshMemberReports(for: model) + private static func screenTimeSeconds(from value: String?) -> Int? { + guard let value else { return nil } + let text = value.trimmed + guard !text.isEmpty else { return nil } + + let hours = firstInteger(in: text, before: "小时") ?? 0 + let minutes: Int + if text.contains("分钟") { + minutes = firstInteger(in: text, before: "分钟") ?? 0 + } else { + minutes = firstInteger(in: text, before: "分") ?? 0 + } + guard text.contains("小时") || text.contains("分") else { return nil } + return max(0, hours * 3600 + minutes * 60) } - private func refreshMemberReports(for model: GroupMemberModel) { - currentMemberModel = model - let isCurrentUser = model.user_id == AppContextManager.shared.userId - if isCurrentUser { - phoneReportView.configure(with: .currentUser()) - } else { - phoneReportView.configure(with: nil) + private static func firstInteger(in text: String, before suffix: String) -> Int? { + guard let range = text.range(of: suffix) else { return nil } + let prefix = text[.. String { + guard let minutes else { return "停留时长未知" } + let value = max(0, minutes) + let hours = value / 60 + let remainingMinutes = value % 60 + if hours > 0, remainingMinutes > 0 { + return "停留\(hours)小时\(remainingMinutes)分" + } + if hours > 0 { + return "停留\(hours)小时" + } + return "停留\(remainingMinutes)分钟" } private func setupUI() { @@ -535,7 +576,7 @@ class GroupMemberView2: UIView { }() lazy var tagView: UIStackView = { - let view = UIStackView(arrangedSubviews: [ownView, moodImageView]) + let view = UIStackView(arrangedSubviews: [ownView, relationIconView, moodImageView]) view.axis = .horizontal view.alignment = .center view.spacing = 5 @@ -549,6 +590,10 @@ class GroupMemberView2: UIView { imageView.isHidden = true return imageView }() + + lazy var relationIconView: RelationIconImageView = { + RelationIconImageView() + }() // 圈主 lazy var ownView: UIView = { @@ -752,9 +797,8 @@ class GroupMemberView2: UIView { let view = UIView() view.backgroundColor = .clear - let icon = UIImageView(image: UIImage(named: "Home/member_weather_unknown")) - view.addSubview(icon) - icon.layoutChain + view.addSubview(weatherIcon) + weatherIcon.layoutChain .top() .centerX() .width(24) @@ -762,11 +806,17 @@ class GroupMemberView2: UIView { view.addSubview(weatherLab) weatherLab.layoutChain - .topToBottomOfView(icon, offset: 5) + .topToBottomOfView(weatherIcon, offset: 5) .centerX() .bottom() return view }() + + lazy var weatherIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "Home/member_weather_unknown")) + view.contentMode = .scaleAspectFit + return view + }() lazy var weatherLab: UILabel = { let label = UILabel() @@ -791,7 +841,7 @@ class GroupMemberView2: UIView { view.backgroundColor = .clear let icon = UIImageView() - icon.image = UIImage(named: "Home/member_today_track") + icon.image = UIImage(named: "Home/member_interaction") view.addSubview(icon) icon.layoutChain .top() @@ -914,7 +964,7 @@ class GroupMemberView2: UIView { .heightToWidth(1) let titleLab = UILabel() - titleLab.text = "今日轨迹" + titleLab.text = "历史轨迹" titleLab.textColor = UIColor(hexStr: "#293445") titleLab.font = .systemFont(ofSize: 16, weight: .bold) view.addSubview(titleLab) @@ -1106,7 +1156,6 @@ class GroupMemberView2: UIView { layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] _ = NetworkStatusMonitor.shared setupUI() - setupRx() } required init?(coder aDecoder: NSCoder) { diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 4b148ffc..9fcc92d6 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -38,6 +38,8 @@ class HomeViewController: BaseViewController { /// 当前选中的成员(用于 GroupMemberListCell 选中态) private var selectedMemberId: String = "" private var inboxMessages: [PigeonSentMessage] = [] + private var phoneUsageCache: [String: PhoneUsageTodayModel] = [:] + private var phoneUsageRequestIDs: [String: UUID] = [:] private let locationManager = CLLocationManager() private var currentHeading: Double = 0 @@ -107,6 +109,7 @@ class HomeViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UnlockCountManager.shared.refreshAuthorizationAndMonitoring() + MQTTService.shared.applicationDidBecomeActive() let manager = AuthorizeManager.manager(type: .locationAlways) rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized @@ -495,11 +498,12 @@ class HomeViewController: BaseViewController { senderName: message.from_user?.displayName ?? "圈子成员", avatar: message.from_user?.avatarImage, image: localTemplateImage, - imageURL: message.isVoice || localTemplateImage != nil ? nil : message.mediaURL, + imageURL: localTemplateImage == nil ? message.backgroundURL : nil, message: message.captionText, kind: message.isVoice ? .voice : .image, - duration: 0, - audioURL: message.isVoice ? message.mediaURL : nil, + duration: message.localVoiceTemplate?.duration ?? 0, + localAudioData: message.localVoiceData, + audioURL: message.audioURL, relationIdx: relationIdx ) } @@ -551,6 +555,10 @@ class HomeViewController: BaseViewController { let isSwitchingGroup = previousGroupKey != nil && previousGroupKey != requestedGroupKey self.viewModel.groupModel = model + MQTTService.shared.updatePhoneReportContext( + groupKey: requestedGroupKey, + location: self.phoneReportLocation + ) NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil) if isDefaultGroup { @@ -558,6 +566,7 @@ class HomeViewController: BaseViewController { } if isSwitchingGroup || requestedGroupKey.isEmpty { + self.clearPhoneUsageState() self.viewModel.clearMembers() self.syncMemberAnnotations([]) self.refreshMQTTSubscriptions([]) @@ -588,6 +597,7 @@ class HomeViewController: BaseViewController { self.syncMemberAnnotations(self.viewModel.memberList) self.refreshMQTTSubscriptions(self.viewModel.memberList) self.updateOnlineCount() + self.requestSelectedMemberPhoneUsage(groupKey: groupKey) }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID, @@ -597,9 +607,69 @@ class HomeViewController: BaseViewController { .disposed(by: disposeBag) } + private var phoneReportLocation: CLLocation? { + if let lastLocation, + CLLocationCoordinate2DIsValid(lastLocation.coordinate) { + return lastLocation + } + guard let latitude = Defaults[\.currentLatitude], + let longitude = Defaults[\.currentLongitude] else { return nil } + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + guard CLLocationCoordinate2DIsValid(coordinate) else { return nil } + return CLLocation(latitude: latitude, longitude: longitude) + } + + private func phoneUsageCacheKey(groupKey: String, userId: String) -> String { + "\(groupKey)|\(userId)" + } + + private func clearPhoneUsageState() { + phoneUsageCache.removeAll() + phoneUsageRequestIDs.removeAll() + if !selectedMemberId.isEmpty { + rootView.groupMemberView.updatePhoneUsage(nil, for: selectedMemberId) + } + } + + private func requestSelectedMemberPhoneUsage(groupKey: String? = nil) { + let resolvedGroupKey = groupKey ?? viewModel.groupModel?.default_group_key ?? "" + requestPhoneUsage(userId: selectedMemberId, groupKey: resolvedGroupKey) + } + + private func requestPhoneUsage(userId: String, groupKey: String) { + guard !userId.isEmpty, !groupKey.isEmpty else { return } + let cacheKey = phoneUsageCacheKey(groupKey: groupKey, userId: userId) + if let cached = phoneUsageCache[cacheKey], selectedMemberId == userId { + rootView.groupMemberView.updatePhoneUsage(cached, for: userId) + } + guard phoneUsageRequestIDs[cacheKey] == nil else { return } + + let requestID = UUID() + phoneUsageRequestIDs[cacheKey] = requestID + UserService.phoneUsageToday(userId: userId, groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + 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 } + + self.phoneUsageCache[cacheKey] = model + guard self.selectedMemberId == userId else { return } + self.rootView.groupMemberView.updatePhoneUsage(model, for: userId) + }, onError: { [weak self] _ in + guard let self, + self.phoneUsageRequestIDs[cacheKey] == requestID else { return } + self.phoneUsageRequestIDs.removeValue(forKey: cacheKey) + }) + .disposed(by: disposeBag) + } + private func requestOperateGroup(groupKey: String) { if viewModel.groupModel?.default_group_key != groupKey { groupInfoRequestID = UUID() + clearPhoneUsageState() viewModel.clearMembers() syncMemberAnnotations([]) refreshMQTTSubscriptions([]) @@ -796,9 +866,12 @@ class HomeViewController: BaseViewController { private func refreshSelectedMemberInfoIfNeeded(userId: String) { guard userId == selectedMemberId, let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return } + let groupKey = viewModel.groupModel?.default_group_key ?? "" + let usage = phoneUsageCache[phoneUsageCacheKey(groupKey: groupKey, userId: userId)] rootView.groupMemberView.setupMemberInfo( model, - isOwner: viewModel.isGroupOwn(id: model.user_id) + isOwner: viewModel.isGroupOwn(id: model.user_id), + phoneUsage: usage ) } @@ -900,6 +973,7 @@ class HomeViewController: BaseViewController { selectedMemberId = listUserId updateSelectedMemberSOSGradient() refreshSelectedMemberInfoIfNeeded(userId: listUserId) + requestSelectedMemberPhoneUsage() memberCV.reloadData() if let idx = viewModel.memberList.firstIndex(where: { $0.user_id == listUserId }) { @@ -1539,6 +1613,10 @@ extension HomeViewController: MAMapViewDelegate { // 地图标注 let isFirstFix = lastLocation == nil lastLocation = location + MQTTService.shared.updatePhoneReportContext( + groupKey: viewModel.groupModel?.default_group_key, + location: location + ) if isFirstFix { reportCurrentLocationIfNeeded() } diff --git a/QuickLocation/Section/Home/MemberPhoneReportView.swift b/QuickLocation/Section/Home/MemberPhoneReportView.swift index 7b43befb..a2c03c0e 100644 --- a/QuickLocation/Section/Home/MemberPhoneReportView.swift +++ b/QuickLocation/Section/Home/MemberPhoneReportView.swift @@ -7,8 +7,8 @@ import UIKit struct MemberPhoneReportPreview { let screenTimeSeconds: Int? - let usageCount: Int - let unlockCount: Int + let usageCount: Int? + let unlockCount: Int? static func currentUser(from manager: UnlockCountManager = .shared) -> MemberPhoneReportPreview { let seconds = manager.todayScreenTimeSeconds @@ -66,8 +66,8 @@ final class MemberPhoneReportView: UIView { func configure(with preview: MemberPhoneReportPreview?) { if let preview { screenTimeBadge.configure(seconds: preview.screenTimeSeconds) - usageValueLab.text = "\(preview.usageCount)" - unlockValueLab.text = "\(preview.unlockCount)" + usageValueLab.text = preview.usageCount.map(String.init) ?? "**" + unlockValueLab.text = preview.unlockCount.map(String.init) ?? "**" } else { screenTimeBadge.configure(seconds: nil) usageValueLab.text = "**" @@ -273,7 +273,7 @@ private final class ScreenTimeBadgeView: UIView { view.removeFromSuperview() } - guard let seconds, seconds > 0 else { + guard let seconds, seconds >= 0 else { contentStack.addArrangedSubview(makeLabel(text: "**", font: numberFont, color: numberColor)) return } diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift index cd0585e7..33ba83be 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift @@ -15,7 +15,11 @@ final class PhoneReportDetailVC: BaseViewController { private var groupModel: GroupModel? init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) { - self.viewModel = PhoneReportDetailViewModel(members: members, selectedUserId: selectedUserId) + self.viewModel = PhoneReportDetailViewModel( + members: members, + selectedUserId: selectedUserId, + groupKey: groupModel?.default_group_key ?? "" + ) self.groupModel = groupModel super.init(nibName: nil, bundle: nil) } @@ -76,12 +80,6 @@ final class PhoneReportDetailVC: BaseViewController { }) .disposed(by: disposeBag) - rootView.screenSection.onModeChanged = { [weak self] mode in - self?.viewModel.screenMode.accept(mode) - } - rootView.unlockSection.onModeChanged = { [weak self] mode in - self?.viewModel.unlockMode.accept(mode) - } } private func switchGroup() { diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift index 8337fbea..fa34a1da 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift @@ -4,6 +4,7 @@ // import UIKit +import Kingfisher final class PhoneReportDetailView: UIView { @@ -62,6 +63,8 @@ final class PhoneReportDetailView: UIView { let storageBarTrack = UIView() let storageBar = UIProgressView(progressViewStyle: .default) let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device")) + let brandLogoImage = UIImageView() + private var brandLogoURL = "" // MARK: Charts let screenSection = PhoneReportChartSectionView( @@ -220,6 +223,11 @@ final class PhoneReportDetailView: UIView { deviceCard.addSubview(deviceImage) deviceImage.layoutChain.right(8).centerY().width(36).height(54) + brandLogoImage.contentMode = .scaleAspectFit + brandLogoImage.isHidden = true + deviceImage.addSubview(brandLogoImage) + brandLogoImage.layoutChain.center().width(18).height(18) + contentView.addSubview(screenSection) screenSection.layoutChain .topToBottomOfView(statusCard, offset: 16) @@ -241,10 +249,33 @@ final class PhoneReportDetailView: UIView { deviceNameLab.text = snap.deviceText storageLab.text = snap.storageText storageBar.progress = Float(snap.storageRatio) + updateBrandLogo(urlString: snap.brandIconURL) screenSection.setSummary(snap.screenTodayText) - unlockSection.setSummary("\(snap.unlockTodayText)次") + unlockSection.setSummary(snap.appUsageTodayText) screenSection.setBars(snap.screenBars) - unlockSection.setBars(snap.unlockBars) + unlockSection.setBars(snap.appUsageBars) + } + + private func updateBrandLogo(urlString: String) { + brandLogoURL = urlString + brandLogoImage.kf.cancelDownloadTask() + brandLogoImage.image = nil + brandLogoImage.isHidden = true + + guard let url = URL(string: urlString), !urlString.isEmpty else { + return + } + + brandLogoImage.kf.setImage(with: url) { [weak self] result in + guard let self, self.brandLogoURL == urlString else { return } + switch result { + case .success: + self.brandLogoImage.isHidden = false + case .failure: + self.brandLogoImage.image = nil + self.brandLogoImage.isHidden = true + } + } } } @@ -351,8 +382,6 @@ final class PhoneReportChartSectionView: UIView { private let titleLab = UILabel() private let summaryPrefixLab = UILabel() private let summaryLab = UILabel() - private let dayBtn = UIButton(type: .system) - private let weekBtn = UIButton(type: .system) private let chart = PhoneReportBarChartView() private let summaryColor: UIColor private let barGradient: (UIColor, UIColor) @@ -360,8 +389,6 @@ final class PhoneReportChartSectionView: UIView { private let tipColor: UIColor private let valueSuffix: String - var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)? - init( title: String, iconName: String, @@ -402,22 +429,6 @@ final class PhoneReportChartSectionView: UIView { card.addSubview(summaryPrefixLab) summaryPrefixLab.layoutChain.top(14).left(14) - configureToggle(dayBtn, title: "天", selected: true) - configureToggle(weekBtn, title: "周", selected: false) - let toggle = UIStackView(arrangedSubviews: [weekBtn, dayBtn]) - toggle.axis = .horizontal - toggle.spacing = 0 - - let toggleBg = UIView() - toggleBg.backgroundColor = UIColor(hexStr: "#EAEAEA") - toggleBg.layer.cornerRadius = 16 - toggleBg.clipsToBounds = true - toggleBg.addSubview(toggle) - toggle.layoutChain.edges(UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3)) - - card.addSubview(toggleBg) - toggleBg.layoutChain.centerY(summaryPrefixLab).right(14).height(32) - summaryLab.font = .systemFont(ofSize: 12, weight: .bold) summaryLab.textColor = summaryColor summaryLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) @@ -425,10 +436,7 @@ final class PhoneReportChartSectionView: UIView { summaryLab.layoutChain .centerY(summaryPrefixLab) .leftToRightOfView(summaryPrefixLab, offset: 4) - .rightToLeftOfView(toggleBg, offset: 8, relation: .lessThanOrEqual) - - dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside) - weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside) + .right(14, relation: .lessThanOrEqual) card.addSubview(chart) chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14) @@ -436,31 +444,6 @@ final class PhoneReportChartSectionView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func configureToggle(_ btn: UIButton, title: String, selected: Bool) { - btn.setTitle(title, for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold) - btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) - applyToggle(btn, selected: selected) - } - - private func applyToggle(_ btn: UIButton, selected: Bool) { - btn.backgroundColor = selected ? .white : .clear - btn.setTitleColor(selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF"), for: .normal) - btn.cornerRadius = 12 - } - - @objc private func tapDay() { - applyToggle(dayBtn, selected: true) - applyToggle(weekBtn, selected: false) - onModeChanged?(.day) - } - - @objc private func tapWeek() { - applyToggle(dayBtn, selected: false) - applyToggle(weekBtn, selected: true) - onModeChanged?(.week) - } - func setSummary(_ text: String) { summaryLab.text = text } @@ -632,8 +615,8 @@ final class PhoneReportBarChartView: UIView { let hours = bar.value / 3600 let mins = (bar.value % 3600) / 60 if hours > 0 { - return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" + return mins > 0 ? "\(hours)小时\(mins)分" : "\(hours)小时" } - return "\(mins)m" + return "\(mins)分钟" } } diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift index 4e7fae57..34a0e5f2 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift @@ -7,19 +7,9 @@ import Foundation import RxSwift import RxCocoa import UIKit -import AVFoundation -import SystemConfiguration.CaptiveNetwork -import NetworkExtension -import CoreLocation -import SwiftyUserDefaults final class PhoneReportDetailViewModel { - enum ChartMode: Equatable { - case day - case week - } - struct ChartBar { let label: String let value: Int @@ -37,192 +27,104 @@ final class PhoneReportDetailViewModel { let deviceText: String let storageText: String let storageRatio: CGFloat + let brandIconURL: String let screenTodayText: String - let unlockTodayText: String - let usageTodayText: String + let appUsageTodayText: String let screenBars: [ChartBar] - let unlockBars: [ChartBar] - - func replacing(wifiText: String? = nil, volumeText: String? = nil) -> StatusSnapshot { - StatusSnapshot( - batteryText: batteryText, - batteryPercent: batteryPercent, - isCharging: isCharging, - estimateText: estimateText, - brightnessText: brightnessText, - volumeText: volumeText ?? self.volumeText, - wifiText: wifiText ?? self.wifiText, - deviceText: deviceText, - storageText: storageText, - storageRatio: storageRatio, - screenTodayText: screenTodayText, - unlockTodayText: unlockTodayText, - usageTodayText: usageTodayText, - screenBars: screenBars, - unlockBars: unlockBars - ) - } + let appUsageBars: [ChartBar] } let members: [GroupMemberModel] let selectedMemberId: BehaviorRelay - let screenMode = BehaviorRelay(value: .day) - let unlockMode = BehaviorRelay(value: .day) let snapshot: BehaviorRelay + private let groupKey: String private let disposeBag = DisposeBag() private let placeholder = "**" - private var volumeObservation: NSKeyValueObservation? + private var reportCache: [String: PhoneUsageReportModel] = [:] + private var requestIDs: [String: UUID] = [:] - init(members: [GroupMemberModel], selectedUserId: String) { - UIDevice.current.isBatteryMonitoringEnabled = true + init(members: [GroupMemberModel], selectedUserId: String, groupKey: String) { self.members = members + self.groupKey = groupKey self.selectedMemberId = BehaviorRelay(value: selectedUserId) self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) - bindInputs() - refresh(userId: selectedUserId) - } - private func bindInputs() { selectedMemberId .asObservable() - .skip(1) + .distinctUntilChanged() .subscribe(onNext: { [weak self] userId in self?.refresh(userId: userId) }) .disposed(by: disposeBag) - - screenMode - .asObservable() - .skip(1) - .subscribe(onNext: { [weak self] _ in - guard let self else { return } - self.refresh(userId: self.selectedMemberId.value) - }) - .disposed(by: disposeBag) - - unlockMode - .asObservable() - .skip(1) - .subscribe(onNext: { [weak self] _ in - guard let self else { return } - self.refresh(userId: self.selectedMemberId.value) - }) - .disposed(by: disposeBag) - - NotificationCenter.default.rx.notification(.unlockCountDidChange) - .subscribe(onNext: { [weak self] _ in - guard let self else { return } - self.refresh(userId: self.selectedMemberId.value) - }) - .disposed(by: disposeBag) - - // 与系统状态栏同步:电量 / 充电状态变化时刷新 - let batteryCenter = NotificationCenter.default - Observable.merge( - batteryCenter.rx.notification(UIDevice.batteryLevelDidChangeNotification), - batteryCenter.rx.notification(UIDevice.batteryStateDidChangeNotification) - ) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] _ in - guard let self else { return } - guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } - self.refresh(userId: self.selectedMemberId.value) - }) - .disposed(by: disposeBag) - - observeSystemVolume() - } - - private func observeSystemVolume() { - let session = AVAudioSession.sharedInstance() - try? session.setActive(true) - volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, _ in - DispatchQueue.main.async { - guard let self else { return } - guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } - let volume = Self.volumeText() ?? self.placeholder - self.snapshot.accept(self.snapshot.value.replacing(volumeText: volume)) - } - } } private func refresh(userId: String) { - let isSelf = userId == AppContextManager.shared.userId - let member = members.first { $0.user_id == userId } + guard !userId.isEmpty, !groupKey.isEmpty else { + snapshot.accept(Self.emptySnapshot()) + return + } - if isSelf { - snapshot.accept(makeSelfSnapshot()) + let cacheKey = "\(groupKey)|\(userId)" + if let cached = reportCache[cacheKey] { + snapshot.accept(makeSnapshot(report: cached)) } else { - snapshot.accept(makeOtherSnapshot(member: member)) - } - } - - private func makeSelfSnapshot() -> StatusSnapshot { - let mgr = UnlockCountManager.shared - let batteryPercent = UIDevice.batteryPercent - let isCharging = UIDevice.isBatteryCharging - let screenSec = mgr.todayScreenTimeSeconds - let unlock = mgr.todayCount - let usage = mgr.todayAppUsageCount - - let batteryText = batteryPercent.map { "\($0)%" } ?? placeholder - let brightnessText = Self.brightnessText() ?? placeholder - let volumeText = Self.volumeText() ?? placeholder - let wifiText = Self.wifiSSID() ?? placeholder - let storageText = Self.storageText() ?? placeholder - let storageRatio = Self.storageRatio() ?? 0 - let screenTodayText = screenSec > 0 ? Self.formatDuration(screenSec) : placeholder - let screenBars = Self.screenBars(from: mgr, mode: screenMode.value) - let unlockBars = Self.unlockBars(unlock: unlock, mode: unlockMode.value) - - // 异步补拉 SSID(iOS 14+),成功后再刷新一次 - Self.fetchWifiSSIDAsync { [weak self] ssid in - guard let self, let ssid, !ssid.isEmpty else { return } - guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } - let current = self.snapshot.value - guard current.wifiText != ssid else { return } - self.snapshot.accept(current.replacing(wifiText: ssid)) + snapshot.accept(Self.emptySnapshot()) } - return StatusSnapshot( - batteryText: batteryText, - batteryPercent: batteryPercent, - isCharging: isCharging, - estimateText: placeholder, - brightnessText: brightnessText, - volumeText: volumeText, - wifiText: wifiText, - deviceText: UIDevice.modelName, - storageText: storageText, - storageRatio: storageRatio, - screenTodayText: screenTodayText, - unlockTodayText: "\(unlock)", - usageTodayText: "\(usage)", - screenBars: screenBars, - unlockBars: unlockBars - ) + guard requestIDs[cacheKey] == nil else { return } + let requestID = UUID() + requestIDs[cacheKey] = requestID + + UserService.phoneUsageReport(userId: userId, groupKey: groupKey) + .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 { return } + + self.reportCache[cacheKey] = report + guard self.selectedMemberId.value == userId else { return } + self.snapshot.accept(self.makeSnapshot(report: report)) + }, onError: { [weak self] _ in + guard let self, self.requestIDs[cacheKey] == requestID else { return } + self.requestIDs.removeValue(forKey: cacheKey) + }) + .disposed(by: disposeBag) } - private func makeOtherSnapshot(member: GroupMemberModel?) -> StatusSnapshot { - let battery = Int(member?.battery.int ?? 0) - let hasBattery = battery > 0 + private func makeSnapshot(report: PhoneUsageReportModel) -> StatusSnapshot { + let info = report.phoneInfo + let storageRatio = Self.storageRatio(used: info?.memoryUsed, total: info?.memoryTotal) + let screenBars = Self.makeBars(from: report.screenUseTimes, value: { $0.useTime }) + let appUsageBars = Self.makeBars(from: report.appUseCounts, value: { $0.count }) + + let todayScreenSeconds = Self.todayValue(in: report.screenUseTimes, value: { $0.useTime }) + ?? Self.nonnegativeInt(from: info?.useTime) + let todayAppUsageCount = Self.todayValue(in: report.appUseCounts, value: { $0.count }) + let screenTodayText: String + if let todayScreenSeconds { + screenTodayText = Self.formatDuration(todayScreenSeconds) + } else { + screenTodayText = placeholder + } + let appUsageTodayText = todayAppUsageCount.map { "\($0)次" } ?? placeholder + return StatusSnapshot( - batteryText: hasBattery ? "\(battery)%" : placeholder, - batteryPercent: hasBattery ? battery : nil, + batteryText: Self.displayText(info?.battery, placeholder: placeholder), + batteryPercent: Self.percent(from: info?.battery), isCharging: false, estimateText: placeholder, - brightnessText: placeholder, - volumeText: placeholder, - wifiText: placeholder, - deviceText: placeholder, - storageText: placeholder, - storageRatio: 0, - screenTodayText: placeholder, - unlockTodayText: placeholder, - usageTodayText: placeholder, - screenBars: [], - unlockBars: [] + brightnessText: Self.displayText(info?.brightness, placeholder: placeholder), + volumeText: Self.displayText(info?.volume, placeholder: placeholder), + wifiText: Self.displayText(info?.network, placeholder: placeholder), + deviceText: Self.displayText(info?.model, placeholder: placeholder), + storageText: Self.storageText(used: info?.memoryUsed, total: info?.memoryTotal, placeholder: placeholder), + storageRatio: storageRatio, + brandIconURL: Self.validHTTPURLString(report.brandIcon) ?? "", + screenTodayText: screenTodayText, + appUsageTodayText: appUsageTodayText, + screenBars: screenBars, + appUsageBars: appUsageBars ) } @@ -238,219 +140,115 @@ final class PhoneReportDetailViewModel { deviceText: "**", storageText: "**", storageRatio: 0, + brandIconURL: "", screenTodayText: "**", - unlockTodayText: "**", - usageTodayText: "**", + appUsageTodayText: "**", screenBars: [], - unlockBars: [] + appUsageBars: [] ) } + private static func makeBars( + from entries: [PhoneUsageReportDayModel], + value: (PhoneUsageReportDayModel) -> Int? + ) -> [ChartBar] { + entries.compactMap { entry -> (Date, Int)? in + guard let date = reportDateFormatter.date(from: entry.day), + let rawValue = value(entry) else { return nil } + return (date, max(0, rawValue)) + } + .sorted { $0.0 < $1.0 } + .map { date, value in + ChartBar( + label: chartDateFormatter.string(from: date), + value: value, + isToday: Calendar.current.isDateInToday(date) + ) + } + } + + private static func todayValue( + in entries: [PhoneUsageReportDayModel], + value: (PhoneUsageReportDayModel) -> Int? + ) -> Int? { + for entry in entries { + guard let date = reportDateFormatter.date(from: entry.day), + Calendar.current.isDateInToday(date), + let rawValue = value(entry) else { continue } + return max(0, rawValue) + } + return nil + } + + private static let reportDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let chartDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone.current + formatter.dateFormat = "M/d" + return formatter + }() + + private static func displayText(_ value: String?, placeholder: String) -> String { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? placeholder : text + } + + private static func nonnegativeInt(from value: String?) -> Int? { + guard let value, + let number = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } + return max(0, number) + } + + private static func percent(from value: String?) -> Int? { + guard let number = firstNumber(in: value) else { return nil } + return max(0, min(100, Int(number.rounded()))) + } + + private static func storageRatio(used: String?, total: String?) -> CGFloat { + guard let usedValue = firstNumber(in: used), + let totalValue = firstNumber(in: total), + totalValue > 0 else { return 0 } + return CGFloat(max(0, min(1, usedValue / totalValue))) + } + + private static func storageText(used: String?, total: String?, placeholder: String) -> String { + let usedText = used?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let totalText = total?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !usedText.isEmpty, !totalText.isEmpty else { return placeholder } + return "\(usedText)/\(totalText)" + } + + private static func firstNumber(in value: String?) -> Double? { + guard let value, + let range = value.range(of: #"\d+(?:\.\d+)?"#, options: .regularExpression) else { return nil } + return Double(value[range]) + } + + private static func validHTTPURLString(_ value: String) -> String? { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: text), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + components.host != nil else { return nil } + return text + } + private static func formatDuration(_ 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" - } - - private static func brightnessText() -> String? { - let value = UIScreen.main.brightness - guard value >= 0 else { return nil } - return "\(Int(value * 100))%" - } - - private static func volumeText() -> String? { - let session = AVAudioSession.sharedInstance() - do { - try session.setActive(true) - } catch { - // 激活失败仍尝试读取当前值 - } - let volume = session.outputVolume - guard volume >= 0 else { return nil } - return "\(Int((volume * 100).rounded()))%" - } - - private static func hasLocationPermissionForWifi() -> Bool { - let status = CLLocationManager.authorizationStatus() - switch status { - case .authorizedAlways, .authorizedWhenInUse: - return true - default: - return false - } - } - - private static func wifiSSID() -> String? { - guard hasLocationPermissionForWifi() else { return nil } - guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil } - for name in interfaces { - if let info = CNCopyCurrentNetworkInfo(name as CFString) as? [String: Any], - let ssid = info[kCNNetworkInfoKeySSID as String] as? String, - !ssid.isEmpty { - return ssid - } - } - return nil - } - - /// iOS 14+ 异步获取当前 Wi‑Fi SSID(需定位权限 + Access Wi‑Fi Information) - private static func fetchWifiSSIDAsync(completion: @escaping (String?) -> Void) { - guard hasLocationPermissionForWifi() else { - completion(nil) - return - } - if #available(iOS 14.0, *) { - NEHotspotNetwork.fetchCurrent { network in - DispatchQueue.main.async { - let ssid = network?.ssid - completion((ssid?.isEmpty == false) ? ssid : nil) - } - } - } else { - completion(wifiSSID()) - } - } - - /// 与「设置 → 通用 → iPhone 存储空间」一致:十进制 GB + ImportantUsage 可用容量 - private static func storageBytes() -> (used: Int64, total: Int64)? { - let url = URL(fileURLWithPath: NSHomeDirectory()) - let keys: Set = [ - .volumeTotalCapacityKey, - .volumeAvailableCapacityForImportantUsageKey - ] - guard - let values = try? url.resourceValues(forKeys: keys), - let total = values.volumeTotalCapacity, - let available = values.volumeAvailableCapacityForImportantUsage, - total > 0 - else { return nil } - let used = max(0, Int64(total) - available) - return (used, Int64(total)) - } - - private static func formatStorageGB(_ bytes: Int64, fractionDigits: Int) -> String { - // Settings 使用十进制(1GB = 1e9),128G 机型会显示约 128GB 而非 119GB - let gb = Double(bytes) / 1_000_000_000 - return String(format: "%.\(fractionDigits)fGB", gb) - } - - private static func storageText() -> String? { - guard let info = storageBytes() else { return nil } - let usedText = formatStorageGB(info.used, fractionDigits: 1) - let totalText = formatStorageGB(info.total, fractionDigits: 0) - return "\(usedText)/\(totalText)" - } - - private static func storageRatio() -> CGFloat? { - guard let info = storageBytes(), info.total > 0 else { return nil } - return CGFloat(Double(info.used) / Double(info.total)) - } - - private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] { - let byDay = Defaults[\.screenTimeByDay] - let todayValue = mgr.todayScreenTimeSeconds - switch mode { - case .day: - return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDaySlash) - case .week: - return weekBars(byDay: byDay, todayValue: todayValue) - } - } - - private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] { - var byDay = Defaults[\.unlockCountByDay] - let todayKey = dayKey(for: Date()) - byDay[todayKey] = unlock - switch mode { - case .day: - return dayBars(byDay: byDay, todayValue: unlock, labelStyle: .monthDay) - case .week: - return weekBars(byDay: byDay, todayValue: unlock) - } - } - - private enum DayLabelStyle { - case monthDaySlash // 08/04 - case monthDay // 8/4 - } - - private static func dayKey(for date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar.current - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) - } - - private static func dayBars( - byDay: [String: Int], - todayValue: Int, - labelStyle: DayLabelStyle - ) -> [ChartBar] { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - let labelFormatter = DateFormatter() - labelFormatter.locale = Locale(identifier: "en_US_POSIX") - switch labelStyle { - case .monthDaySlash: - labelFormatter.dateFormat = "MM/dd" - case .monthDay: - labelFormatter.dateFormat = "M/d" - } - - return (0..<7).map { offset -> ChartBar in - let dayOffset = offset - 6 - let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today - let key = dayKey(for: date) - let isToday = dayOffset == 0 - let value = isToday ? todayValue : (byDay[key] ?? 0) - return ChartBar( - label: labelFormatter.string(from: date), - value: value, - isToday: isToday - ) - } - } - - /// 近 7 周:每周一根柱,值为该周内按日数据之和;label 为周起始日 M/d - private static func weekBars(byDay: [String: Int], todayValue: Int) -> [ChartBar] { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) - guard let currentWeek = calendar.dateInterval(of: .weekOfYear, for: today) else { - return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDay) - } - let labelFormatter = DateFormatter() - labelFormatter.locale = Locale(identifier: "en_US_POSIX") - labelFormatter.dateFormat = "M/d" - let todayKey = dayKey(for: today) - - return (0..<7).map { offset -> ChartBar in - let weekOffset = offset - 6 - let weekStart = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: currentWeek.start) - ?? currentWeek.start - let weekInterval = calendar.dateInterval(of: .weekOfYear, for: weekStart) - ?? DateInterval(start: weekStart, duration: 7 * 24 * 3600) - - var sum = 0 - var cursor = weekInterval.start - while cursor < weekInterval.end { - let key = dayKey(for: cursor) - if key == todayKey { - sum += todayValue - } else { - sum += byDay[key] ?? 0 - } - guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } - cursor = next - } - - return ChartBar( - label: labelFormatter.string(from: weekInterval.start), - value: sum, - isToday: weekOffset == 0 - ) + if hours > 0 { + return minutes > 0 ? "\(hours)小时\(minutes)分" : "\(hours)小时" } + return "\(minutes)分钟" } } diff --git a/QuickLocation/Section/Home/ReceiveMessagePopView.swift b/QuickLocation/Section/Home/ReceiveMessagePopView.swift index 4e9e1f30..7ef8d3a3 100644 --- a/QuickLocation/Section/Home/ReceiveMessagePopView.swift +++ b/QuickLocation/Section/Home/ReceiveMessagePopView.swift @@ -21,6 +21,7 @@ struct ReceiveMessageDisplayItem { let message: String let kind: ReceiveMessageKind let duration: TimeInterval + let localAudioData: Data? let audioURL: URL? let relationIdx: String @@ -33,6 +34,7 @@ struct ReceiveMessageDisplayItem { message: String, kind: ReceiveMessageKind = .image, duration: TimeInterval = 0, + localAudioData: Data? = nil, audioURL: URL? = nil, relationIdx: String = "" ) { @@ -44,6 +46,7 @@ struct ReceiveMessageDisplayItem { self.message = message self.kind = kind self.duration = duration + self.localAudioData = localAudioData self.audioURL = audioURL self.relationIdx = relationIdx } @@ -176,12 +179,7 @@ final class ReceiveMessagePopView: UIView { return label }() - private let relationLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .medium) - label.textColor = UIColor(hexStr: "#FF6B9D") - return label - }() + private let relationIconView = RelationIconImageView() private let nameRow = UIStackView() @@ -297,7 +295,7 @@ final class ReceiveMessagePopView: UIView { nameRow.alignment = .center nameRow.spacing = 4 nameRow.addArrangedSubview(nameLabel) - nameRow.addArrangedSubview(relationLabel) + nameRow.addArrangedSubview(relationIconView) cardBackgroundView.layoutChain .top(38) @@ -392,16 +390,7 @@ final class ReceiveMessagePopView: UIView { } private func applyRelation(_ idx: String) { - if RelationStore.shared.showsHeart(for: idx) { - relationLabel.text = "💕" - relationLabel.isHidden = false - } else if let name = RelationStore.shared.name(for: idx) { - relationLabel.text = name - relationLabel.isHidden = false - } else { - relationLabel.text = nil - relationLabel.isHidden = true - } + relationIconView.configure(relationIdx: idx) } private func animateInitialFlyIn() { @@ -522,13 +511,14 @@ final class ReceiveMessagePopView: UIView { } private func startVoicePlayback() { - if audioPlayer == nil { - prepareAudioPlayer() + if audioPlayer == nil, !prepareAudioPlayer() { + DLToast.show(text: "无法播放") + stopVoicePlayback(reset: true) + return } isVoicePlaying = true playButton.isSelected = true - waveformView.progress = 1 if let player = audioPlayer { if player.currentTime >= player.duration { @@ -566,10 +556,16 @@ final class ReceiveMessagePopView: UIView { } } - private func prepareAudioPlayer() { - guard let url = resolvedAudioURL() else { return } + private func prepareAudioPlayer() -> Bool { do { - let player = try AVAudioPlayer(contentsOf: url) + let player: AVAudioPlayer + if let data = item?.localAudioData { + player = try AVAudioPlayer(data: data) + } else if let url = item?.audioURL { + player = try AVAudioPlayer(contentsOf: url) + } else { + return false + } player.delegate = self player.isMeteringEnabled = true player.prepareToPlay() @@ -577,19 +573,13 @@ final class ReceiveMessagePopView: UIView { if player.duration > 0 { voiceDuration = player.duration } + return true } catch { audioPlayer = nil + return false } } - private func resolvedAudioURL() -> URL? { - if let audioURL = item?.audioURL { - return audioURL - } - return Bundle.main.url(forResource: "sos", withExtension: "mp3") - ?? Bundle.main.url(forResource: "sos", withExtension: "mp3", subdirectory: "sound") - } - private func tickVoicePlayback() { if let player = audioPlayer { voiceElapsed = player.currentTime diff --git a/QuickLocation/Section/Home/SignIn/SignInModel.swift b/QuickLocation/Section/Home/SignIn/SignInModel.swift index 601f3613..74f23050 100644 --- a/QuickLocation/Section/Home/SignIn/SignInModel.swift +++ b/QuickLocation/Section/Home/SignIn/SignInModel.swift @@ -17,12 +17,112 @@ struct SignInInfoResponse: BaseModelProtocol { init?(map: Map) {} mutating func mapping(map: Map) { - code <- map["code"] - message <- map["msg"] + code <- (map["code"], kIntTransformStr) + message <- map["message"] model <- map["data"] } } +struct SignInModel: Mappable, Equatable { + static let calendarDayCount = 30 + + var signedToday = false + var signedDates: [String] = [] + var emails: [String] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + signedToday <- map["signed_today"] + signedDates <- map["sign_in"] + emails <- map["email"] + } + + var primaryEmail: String? { + guard let email = emails.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty else { + return nil + } + return email + } + + func missedDayCount(now: Date = Date(), calendar: Calendar = .current) -> Int { + guard !signedToday else { return 0 } + var calendar = calendar + calendar.timeZone = .current + let today = calendar.startOfDay(for: now) + let signedDateSet = normalizedSignedDateSet + + var missedDays = 0 + for offset in 1...2 { + guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { + break + } + if signedDateSet.contains(Self.apiDateFormatter.string(from: day)) { + break + } + missedDays += 1 + } + return missedDays + } + + func calendarDays(now: Date = Date(), calendar: Calendar = .current) -> [SignInDayItem] { + var calendar = calendar + calendar.timeZone = .current + let today = calendar.startOfDay(for: now) + guard let startDay = calendar.date( + byAdding: .day, + value: -(Self.calendarDayCount - 1), + to: today + ) else { + return [] + } + + let signedDateSet = normalizedSignedDateSet + + return (0.. { + Set(signedDates.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + }) + } + + private static let apiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let monthDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "M.d" + return formatter + }() +} + struct SignInDayItem: Equatable { enum Style: Equatable { case signed @@ -45,212 +145,3 @@ struct SignInDayItem: Equatable { } } } - -struct SignInDayRecord: Mappable, Equatable { - var dateTimestamp: Int = 0 - var dateString: String = "" - var signed: Bool = false - - init?(map: Map) {} - - mutating func mapping(map: Map) { - if let raw = map.JSON["date"] ?? map.JSON["day"] ?? map.JSON["time"] { - applyDate(raw) - } - - var signedFlag: Bool? - signedFlag <- map["signed"] - if let signedFlag { - signed = signedFlag - return - } - - var status = 0 - if map.JSON["status"] != nil { - status <- map["status"] - } else if map.JSON["signInStatus"] != nil { - status <- map["signInStatus"] - } - signed = status == 1 - } - - var resolvedDate: Date? { - if dateTimestamp > 0 { - return SignInModel.date(fromTimestamp: dateTimestamp) - } - if dateString.isEmpty == false { - return SignInModel.date(fromString: dateString) - } - return nil - } - - private mutating func applyDate(_ raw: Any) { - if let number = raw as? NSNumber { - dateTimestamp = number.intValue - return - } - if let value = raw as? Int { - dateTimestamp = value - return - } - if let value = raw as? Double { - dateTimestamp = Int(value) - return - } - if let value = raw as? String { - if let number = Int(value) { - dateTimestamp = number - } else { - dateString = value - } - } - } -} - -struct SignInModel: Mappable, Equatable { - static let calendarDayCount = 30 - - var uuid: String = UUID().uuidString - var missCount: Int = 0 - var alertCount: Int = 0 - var lastTime: Int = 0 - /// 状态 0未签到 1已签到 2已有2天未签到 - var signInStatus: Int = 0 - var signCount: Int = 0 - var email: String = "" - var records: [SignInDayRecord] = [] - - init?(map: Map) {} - - mutating func mapping(map: Map) { - missCount <- map["missCount"] - alertCount <- map["alertCount"] - lastTime <- map["lastTime"] - signInStatus <- map["signInStatus"] - signCount <- map["signCount"] - email <- map["email"] - - records <- map["records"] - if records.isEmpty { - records <- map["signRecords"] - } - if records.isEmpty { - records <- map["days"] - } - } - - func calendarDays(now: Date = Date(), calendar: Calendar = .current) -> [SignInDayItem] { - var calendar = calendar - calendar.timeZone = .current - let today = calendar.startOfDay(for: now) - guard let startDay = calendar.date(byAdding: .day, value: -(Self.calendarDayCount - 1), to: today) else { - return [] - } - - var recordMap: [Date: Bool] = [:] - for record in records { - guard let date = record.resolvedDate else { continue } - recordMap[calendar.startOfDay(for: date)] = record.signed - } - - let lastSignDay: Date? = { - if lastTime > 0 { - return Self.date(fromTimestamp: lastTime).map { calendar.startOfDay(for: $0) } - } - if signInStatus == 1 { - return today - } - return nil - }() - - return (0.. SignInDayItem? in - guard let day = calendar.date(byAdding: .day, value: offset, to: startDay) else { return nil } - let isToday = calendar.isDate(day, inSameDayAs: today) - let signed: Bool - if recordMap.isEmpty == false { - signed = recordMap[day] ?? false - } else { - signed = fallbackSigned( - day: day, - isToday: isToday, - today: today, - lastSignDay: lastSignDay, - calendar: calendar - ) - } - - let style: SignInDayItem.Style - if isToday && signed == false { - style = .todayUnsigned - } else if signed { - style = .signed - } else { - style = .missed - } - - return SignInDayItem( - date: day, - title: isToday ? "今天" : Self.monthDayFormatter.string(from: day), - style: style - ) - } - } - - private func fallbackSigned( - day: Date, - isToday: Bool, - today: Date, - lastSignDay: Date?, - calendar: Calendar - ) -> Bool { - if isToday { - return signInStatus == 1 - } - - if signInStatus == 2, missCount > 0 { - let daysBeforeToday = calendar.dateComponents([.day], from: day, to: today).day ?? -1 - if daysBeforeToday > 0 && daysBeforeToday <= missCount { - return false - } - } - - guard let lastSignDay, signCount > 0 else { return false } - let distance = calendar.dateComponents([.day], from: day, to: lastSignDay).day ?? -1 - return distance >= 0 && distance < signCount - } - - static func date(fromTimestamp timestamp: Int) -> Date? { - guard timestamp > 0 else { return nil } - let seconds = timestamp > 10_000_000_000 - ? TimeInterval(timestamp) / 1000.0 - : TimeInterval(timestamp) - return Date(timeIntervalSince1970: seconds) - } - - static func date(fromString raw: String) -> Date? { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard text.isEmpty == false else { return nil } - for formatter in dateFormatters { - if let date = formatter.date(from: text) { - return date - } - } - return nil - } - - private static let monthDayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "M.d" - return formatter - }() - - private static let dateFormatters: [DateFormatter] = { - ["yyyy-MM-dd", "yyyy/MM/dd", "yyyy-MM-dd HH:mm:ss", "yyyyMMdd", "M.d"].map { format in - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = format - return formatter - } - }() -} diff --git a/QuickLocation/Section/Home/SignIn/SignInVC.swift b/QuickLocation/Section/Home/SignIn/SignInVC.swift index 7c568988..86809744 100644 --- a/QuickLocation/Section/Home/SignIn/SignInVC.swift +++ b/QuickLocation/Section/Home/SignIn/SignInVC.swift @@ -97,7 +97,7 @@ class SignInVC: BaseViewController { DLToast.showLoading() UserService.signInInfo().subscribe(onNext: { [weak self] response in guard let self, let model = response.model else { return } - self.isSignIn = model.signInStatus == 1 + self.isSignIn = model.signedToday self.rootView.setupData(model) }, onError: { [weak self] _ in guard let self else { return } diff --git a/QuickLocation/Section/Home/SignIn/SignInView.swift b/QuickLocation/Section/Home/SignIn/SignInView.swift index f163fe81..da083ea9 100644 --- a/QuickLocation/Section/Home/SignIn/SignInView.swift +++ b/QuickLocation/Section/Home/SignIn/SignInView.swift @@ -46,24 +46,23 @@ class SignInView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFl }() func setupData(_ model: SignInModel) { - let email = model.email.trimmingCharacters(in: .whitespacesAndNewlines) - emailLab.text = email.isEmpty ? "暂未添加" : email - let signed = model.signInStatus == 1 + emailLab.text = model.primaryEmail ?? "暂未添加" heroImg.image = UIImage(named: heroImageName(for: model)) - updateSignButton(signed: signed) + updateSignButton(signed: model.signedToday) calendarDays = model.calendarDays() calendarView.reloadData() scrollCalendarToEnd(animated: false) } private func heroImageName(for model: SignInModel) -> String { - if model.signInStatus == 1 { + if model.signedToday { return "SignIn/hero_bg_signed" } - if model.signInStatus == 2 || model.missCount >= 2 { + let missedDays = model.missedDayCount() + if missedDays >= 2 { return "SignIn/hero_bg_missed_two_days" } - if model.missCount == 1 { + if missedDays == 1 { return "SignIn/hero_bg_missed_one_day" } return "SignIn/hero_bg_unsigned" diff --git a/QuickLocation/Section/LockDistract/LockDistractView.swift b/QuickLocation/Section/LockDistract/LockDistractView.swift index b2b45a23..f6d5e4b4 100644 --- a/QuickLocation/Section/LockDistract/LockDistractView.swift +++ b/QuickLocation/Section/LockDistract/LockDistractView.swift @@ -16,7 +16,9 @@ final class LockDistractView: UIView { "LockDistract/lock_icon_2", "LockDistract/lock_icon_3", "LockDistract/lock_icon_4", - "LockDistract/lock_icon_5" + "LockDistract/lock_icon_5", + "LockDistract/lock_icon_6", + "LockDistract/lock_icon_7" ] private(set) var selectedLockIconIndex = 0 @@ -44,8 +46,30 @@ final class LockDistractView: UIView { } func setGroupName(_ name: String) { - let title = name.isEmpty ? "切换圈子" : " \(name) " - switchGroupBtn.setTitle(title, for: .normal) +// let title = name.isEmpty ? "切换圈子" : " \(name) " +// switchGroupBtn.setTitle(title, for: .normal) + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + var titleAttributes = AttributeContainer() + titleAttributes.font = .systemFont(ofSize: 12, weight: .medium) + + var configuration = switchGroupBtn.configuration ?? UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + trimmedName.isEmpty ? "当前圈子" : trimmedName, + attributes: titleAttributes + ) + configuration.image = UIImage(named: "Home/group_name_icon") + configuration.imagePadding = 3 + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: 6, + bottom: 0, + trailing: 6 + ) + configuration.baseForegroundColor = UIColor(hexStr: "#293445") + switchGroupBtn.configuration = configuration + switchGroupBtn.titleLabel?.lineBreakMode = .byTruncatingTail + switchGroupBtn.sizeToFit() } func configureLocked(_ locked: Bool) { diff --git a/QuickLocation/Section/Login/LoginViewModel.swift b/QuickLocation/Section/Login/LoginViewModel.swift index 8b21bf36..0a765559 100644 --- a/QuickLocation/Section/Login/LoginViewModel.swift +++ b/QuickLocation/Section/Login/LoginViewModel.swift @@ -26,16 +26,17 @@ enum LoginSessionHandler { NotificationCenter.default.post(name: .invalidatePopupQueue, object: nil) Defaults[\.loginToken] = model.token RelationStore.shared.preload() - DLToast.showSuccess(text: "登录成功") { + DLToast.showSuccess(text: "登录成功") + DispatchQueue.main.async { if let userId = model.uid { MQTTService.shared.updateClientID("smartdrive_\(userId)") } - NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) if let nav = AppRouter.shared.navigationController, nav.viewControllers.count > 1 { AppRouter.shared.popOrDismiss() } else { AppDelegate.shared.showMainViewController() } + NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) } }, onError: { error in DLToast.dismiss() diff --git a/QuickLocation/Section/Mine/CheckPermission/CheckPermissionVC.swift b/QuickLocation/Section/Mine/CheckPermission/CheckPermissionVC.swift index 13095dd9..04b53d01 100644 --- a/QuickLocation/Section/Mine/CheckPermission/CheckPermissionVC.swift +++ b/QuickLocation/Section/Mine/CheckPermission/CheckPermissionVC.swift @@ -58,21 +58,21 @@ class CheckPermissionVC: BaseViewController { private func refreshCards() { locationCard.configure( - icon: nil, + icon: UIImage(named: "CheckPermission/permission_location_icon"), title: "定位权限", subtitle: "可以TA分享实时位置", actionTitle: CheckPermissionAuth.isLocationAuthorized ? "已设置" : "去设置", isConfigured: CheckPermissionAuth.isLocationAuthorized ) screenCard.configure( - icon: nil, + icon: UIImage(named: "CheckPermission/permission_screen_icon"), title: "屏幕使用时间限制访问", subtitle: "可以TA分享App使用状况", actionTitle: CheckPermissionAuth.isScreenTimeAuthorized ? "已设置" : "去设置", isConfigured: CheckPermissionAuth.isScreenTimeAuthorized ) pairCard.configure( - icon: UIImage(named: "CheckPermission/pair"), + icon: UIImage(named: "CheckPermission/permission_pair_icon"), title: "设置配对App", subtitle: "可以TA分享App锁定状态", actionTitle: "去设置", @@ -168,8 +168,7 @@ final class CheckPermissionItemCard: UIControl { private let iconWell: UIView = { let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F3F5F8") - view.cornerRadius = 12 + view.backgroundColor = .clear view.isUserInteractionEnabled = false return view }() @@ -256,8 +255,8 @@ final class CheckPermissionItemCard: UIControl { iconView.layoutChain .centerX() .centerY() - .width(31) - .height(21) + .width(36) + .height(36) actionButton.layoutChain .right(16) diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift index 88694913..fc6b630e 100644 --- a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -28,6 +28,16 @@ final class FeedbackVC: BaseViewController { private var selectedImages: [UIImage] = [] private var pendingDraft: FeedbackDraft? private var isSubmitting = false + private let contextText: String + + init(contextText: String = "") { + self.contextText = contextText.trimmingCharacters(in: .whitespacesAndNewlines) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func loadView() { rootView = FeedbackView(frame: UIScreen.main.bounds) @@ -123,20 +133,101 @@ final class FeedbackVC: BaseViewController { category: selectedCategory, detail: detail, images: selectedImages, - contact: rootView.contactTextField.text ?? "" + contact: (rootView.contactTextField.text ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) ) isSubmitting = true rootView.setSubmitting(true) + guard let draft = pendingDraft else { return } + let uploadRequests: [Observable] + do { + uploadRequests = try draft.images.map { image in + guard let data = compressedJPEG(from: image) else { + throw feedbackError("图片处理失败,请重新选择") + } + return UploadService.uploadURL(data, kind: .jpeg, scene: "feedback") + } + } catch { + finishSubmission(error: error) + return + } + + DLToast.showLoading() + let imageURLs = uploadRequests.isEmpty + ? Observable.just([String]()) + : Observable.zip(uploadRequests) + imageURLs + .flatMap { [weak self] urls -> Observable in + guard let self else { return .empty() } + var type = draft.category.rawValue + if !self.contextText.isEmpty { + type += "(\(self.contextText))" + } + return UserService.feedback( + type: type, + content: draft.detail, + contact: draft.contact, + images: urls + ) + } + .subscribe(onNext: { [weak self] _ in + self?.showSuccess() + }, onError: { [weak self] error in + self?.finishSubmission(error: error) + }) + .disposed(by: disposeBag) + } + + private func showSuccess() { + DLToast.dismiss() + let successView = FeedbackSuccessView() successView.show(in: view) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak successView] in - guard let self, let successView else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak successView] in + guard let successView else { return } successView.dismiss { AppRouter.shared.popOrDismiss() } } } + + private func finishSubmission(error: Error) { + DLToast.dismiss() + pendingDraft = nil + isSubmitting = false + rootView.setSubmitting(false) + DLToast.show(text: error.localizedDescription) + } + + private func compressedJPEG(from image: UIImage) -> Data? { + let maxEdge: CGFloat = 1280 + let longest = max(image.size.width, image.size.height) + let output: UIImage + if longest > maxEdge, longest > 0 { + let scale = maxEdge / longest + let size = CGSize( + width: (image.size.width * scale).rounded(), + height: (image.size.height * scale).rounded() + ) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = true + output = UIGraphicsImageRenderer(size: size, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } else { + output = image + } + return output.jpegData(compressionQuality: 0.65) + ?? image.jpegData(compressionQuality: 0.8) + } + + private func feedbackError(_ message: String) -> NSError { + NSError( + domain: "Feedback", + code: -1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } } - - diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index 9ac0c18a..24a3bc9f 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -616,6 +616,7 @@ final class MinePolaroidCell: UICollectionViewCell { private static let swayKey = "minePolaroidSway" private var isOwner = false private var relationIdx: String = "" + private var relationIconAvailable = false private var focusFrameSize = selectedFrameSize private var focusAngle: CGFloat = 0 @@ -624,10 +625,15 @@ final class MinePolaroidCell: UICollectionViewCell { self.relationIdx = model.extra.relation_idx avatarImg.image = model.userIcon ownerTag.isHidden = !isOwner + relationIconView.configure(relationIdx: relationIdx) { [weak self] isVisible in + guard let self else { return } + self.relationIconAvailable = isVisible + self.applyCaption() + } let idx = ((clipColorIndex % Self.clipAssetNames.count) + Self.clipAssetNames.count) % Self.clipAssetNames.count clipImg.image = UIImage(named: Self.clipAssetNames[idx]) stopSway() - applyCaption(visible: true) + applyCaption() } /// 按真实相框尺寸布局,整块 polaroidHost 绕衣夹只做旋转(无非等比 scale) @@ -644,36 +650,12 @@ final class MinePolaroidCell: UICollectionViewCell { } func setSelectedLook(_ selected: Bool, pairMode: Bool = false) { - applyCaption(visible: pairMode || selected) + applyCaption() } - private func applyCaption(visible: Bool) { - let boundName = RelationStore.shared.name(for: relationIdx) - let isHeart = RelationStore.shared.showsHeart(for: relationIdx) - let bound = boundName != nil - - if isOwner { - ownerTag.isHidden = false - heartImg.isHidden = !isHeart - relationLab.isHidden = true - layoutPolaroidHostIfNeeded() - return - } - - ownerTag.isHidden = true - guard visible else { - heartImg.isHidden = true - relationLab.isHidden = true - return - } - if isHeart || !bound { - heartImg.isHidden = false - relationLab.isHidden = true - } else { - heartImg.isHidden = true - relationLab.isHidden = false - relationLab.text = boundName - } + private func applyCaption() { + ownerTag.isHidden = !isOwner + relationIconView.isHidden = !relationIconAvailable layoutPolaroidHostIfNeeded() } @@ -697,11 +679,11 @@ final class MinePolaroidCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() stopSway() - heartImg.isHidden = true - relationLab.isHidden = true + relationIconView.clear() ownerTag.isHidden = true isOwner = false relationIdx = "" + relationIconAvailable = false focusFrameSize = Self.selectedFrameSize focusAngle = 0 polaroidHost.transform = .identity @@ -755,12 +737,16 @@ final class MinePolaroidCell: UICollectionViewCell { } private func layoutCaption(frameWidth fw: CGFloat, midY: CGFloat) { - heartImg.bounds = CGRect(x: 0, y: 0, width: 12, height: 10) - relationLab.sizeToFit() - ownerTag.sizeToFit() + relationIconView.bounds = CGRect(x: 0, y: 0, width: 12, height: 12) + let ownerTagSize = ownerTag.intrinsicContentSize + ownerTag.bounds = CGRect( + x: 0, + y: 0, + width: ceil(ownerTagSize.width), + height: ceil(ownerTagSize.height) + ) var views: [UIView] = [] - if !heartImg.isHidden { views.append(heartImg) } - if !relationLab.isHidden { views.append(relationLab) } + if !relationIconView.isHidden { views.append(relationIconView) } if !ownerTag.isHidden { views.append(ownerTag) } let spacing: CGFloat = 2 let total = views.reduce(CGFloat(0)) { $0 + $1.bounds.width } + spacing * CGFloat(max(0, views.count - 1)) @@ -779,8 +765,7 @@ final class MinePolaroidCell: UICollectionViewCell { polaroidHost.addSubview(swayHost) swayHost.addSubview(frameView) frameView.addSubview(avatarImg) - frameView.addSubview(heartImg) - frameView.addSubview(relationLab) + frameView.addSubview(relationIconView) frameView.addSubview(ownerTag) swayHost.addSubview(clipImg) } @@ -827,25 +812,7 @@ final class MinePolaroidCell: UICollectionViewCell { return iv }() - private lazy var heartImg: UIImageView = { - let iv = UIImageView() - if #available(iOS 13.0, *) { - iv.image = UIImage(systemName: "heart.fill") - iv.tintColor = UIColor(hexStr: "#FF6B9D") - } - iv.contentMode = .scaleAspectFit - iv.isHidden = true - return iv - }() - - private lazy var relationLab: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 9, weight: .semibold) - label.textColor = UIColor(hexStr: "#FF6B9D") - label.textAlignment = .center - label.isHidden = true - return label - }() + private lazy var relationIconView = RelationIconImageView() private lazy var ownerTag: UILabel = { let label = PaddingLabel() @@ -856,6 +823,7 @@ final class MinePolaroidCell: UICollectionViewCell { label.cornerRadius = 4 label.clipsToBounds = true label.textAlignment = .center + label.numberOfLines = 1 label.insets = UIEdgeInsets(top: 1, left: 5, bottom: 1, right: 5) label.isHidden = true return label diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift index 377e1d88..fb0d8975 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift @@ -67,7 +67,7 @@ final class PigeonMessageHistoryVC: BaseViewController { private func togglePlayback(for item: PigeonHistoryItem) { guard item.kind == .voice else { return } - guard item.mediaURL != nil else { + guard item.localAudioData != nil || item.mediaURL != nil else { DLToast.show(text: "无法播放") return } @@ -97,7 +97,15 @@ final class PigeonMessageHistoryVC: BaseViewController { } private func preparePlayer(for item: PigeonHistoryItem) { - guard let url = item.mediaURL else { return } + if let data = item.localAudioData { + startPlayer(data: data, item: item) + return + } + guard let url = item.mediaURL else { + DLToast.show(text: "无法播放") + playingItemId = nil + return + } DLToast.showLoading() URLSession.shared.dataTask(with: url) { [weak self] data, _, error in DispatchQueue.main.async { @@ -108,24 +116,30 @@ final class PigeonMessageHistoryVC: BaseViewController { self.playingItemId = nil return } - do { - let player = try AVAudioPlayer(data: data) - player.prepareToPlay() - self.audioPlayer = player - self.voiceDuration = max(1, player.duration) - self.voiceElapsed = 0 - player.play() - self.isVoicePlaying = true - self.startTimer() - self.refreshVisiblePlayback() - } catch { - DLToast.show(text: "无法播放") - self.playingItemId = nil - } + self.startPlayer(data: data, item: item) } }.resume() } + private func startPlayer(data: Data, item: PigeonHistoryItem) { + guard playingItemId == item.id else { return } + do { + let player = try AVAudioPlayer(data: data) + player.prepareToPlay() + audioPlayer = player + voiceDuration = max(0.01, player.duration) + voiceElapsed = 0 + player.play() + isVoicePlaying = true + startTimer() + refreshVisiblePlayback() + } catch { + DLToast.show(text: "无法播放") + playingItemId = nil + refreshVisiblePlayback() + } + } + private func startTimer() { voiceTimer?.invalidate() let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift index a5b37b3b..a480ae56 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift @@ -19,6 +19,7 @@ struct PigeonHistoryItem { let caption: String let image: UIImage? let mediaURL: URL? + let localAudioData: Data? let senderAvatar: UIImage? let receiverAvatars: [UIImage] let duration: TimeInterval diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 9f1473e9..102d17d4 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -57,10 +57,9 @@ final class PigeonMessageVC: BaseViewController { self?.applySelectedTemplate(template) } rootView.onVoiceTemplateSelected = { [weak self] template in - self?.selectedVoiceTemplate = template - self?.stopPlayback() + self?.applySelectedVoiceTemplate(template) } - rootView.deleteImageButton.addTarget(self, action: #selector(deleteCaption), for: .touchUpInside) + rootView.deleteImageButton.addTarget(self, action: #selector(deleteImageDraft), for: .touchUpInside) rootView.playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) rootView.sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside) rootView.onModeChanged = { [weak self] _ in @@ -226,6 +225,20 @@ final class PigeonMessageVC: BaseViewController { rootView.setSelectedImage(image, selectedTemplateID: template.id) } + private func applySelectedVoiceTemplate(_ template: PigeonVoiceTemplate?) { + if let template, template.audioData == nil { + selectedVoiceTemplate = nil + cleanupAudio(deleteFile: true) + rootView.setVoiceTemplate(nil) + DLToast.show(text: "模板资源不可用") + return + } + + cleanupAudio(deleteFile: true) + selectedVoiceTemplate = template + rootView.setVoiceTemplate(template) + } + private func showCameraDenied() { Permission.openAppSetting( title: "无法使用相机", @@ -233,8 +246,10 @@ final class PigeonMessageVC: BaseViewController { ) } - @objc private func deleteCaption() { - rootView.clearCaption() + @objc private func deleteImageDraft() { + selectedTemplate = nil + selectedImage = nil + rootView.clearImageDraft() } @objc private func handleRecordGesture(_ gesture: UILongPressGestureRecognizer) { @@ -285,6 +300,7 @@ final class PigeonMessageVC: BaseViewController { private func startRecording() { stopPlayback() + selectedVoiceTemplate = nil if let recordURL { try? FileManager.default.removeItem(at: recordURL) } @@ -372,7 +388,6 @@ final class PigeonMessageVC: BaseViewController { } @objc private func togglePlayback() { - guard let recordURL else { return } if let player = audioPlayer { if player.isPlaying { player.pause() @@ -388,7 +403,15 @@ final class PigeonMessageVC: BaseViewController { } do { - let player = try AVAudioPlayer(contentsOf: recordURL) + let player: AVAudioPlayer + if let template = selectedVoiceTemplate, + let data = template.audioData { + player = try AVAudioPlayer(data: data) + } else if let recordURL { + player = try AVAudioPlayer(contentsOf: recordURL) + } else { + return + } player.delegate = self player.isMeteringEnabled = true player.prepareToPlay() @@ -408,7 +431,11 @@ final class PigeonMessageVC: BaseViewController { player.updateMeters() let level = pow(10, CGFloat(player.averagePower(forChannel: 0)) / 38) self.rootView.pushMeterLevel(level) - self.rootView.setVoiceDuration(player.currentTime, hasRecording: true) + self.rootView.setVoiceDuration( + player.currentTime, + totalDuration: player.duration, + hasRecording: true + ) } RunLoop.main.add(timer, forMode: .common) playbackTimer = timer @@ -420,7 +447,13 @@ final class PigeonMessageVC: BaseViewController { audioPlayer?.stop() audioPlayer = nil rootView?.setPlaying(false) - if recordedDuration > 0 { + if let template = selectedVoiceTemplate { + rootView?.setVoiceDuration( + 0, + totalDuration: template.duration, + hasRecording: template.audioData != nil + ) + } else if recordedDuration > 0 { rootView?.setVoiceDuration(recordedDuration, hasRecording: true) } } @@ -447,15 +480,15 @@ final class PigeonMessageVC: BaseViewController { } let msgType = isVoice ? 2 : 3 + let isImageTemplate = selectedTemplate != nil + let isVoiceTemplate = isVoice && selectedVoiceTemplate != nil let caption = rootView.captionText.trimmingCharacters(in: .whitespacesAndNewlines) var extra: [String: Any] = [:] if let selectedTemplate { - extra["media_type"] = selectedTemplate.mediaType - extra["template_id"] = selectedTemplate.id + extra["image_template_id"] = selectedTemplate.id } if isVoice, let selectedVoiceTemplate { - extra["media_type"] = "voice" - extra["template_id"] = selectedVoiceTemplate.id + extra["voice_template_id"] = selectedVoiceTemplate.id } let imageUpload: Observable @@ -484,9 +517,22 @@ final class PigeonMessageVC: BaseViewController { Observable.zip(imageUpload, voiceUpload) .flatMap { [weak self] bgImg, voiceId -> Observable in guard let self else { return .empty() } + let bgImgId: Int64 + if isImageTemplate { + bgImgId = 0 + } else if let uploadedId = Int64(bgImg) { + bgImgId = uploadedId + } else { + let error = NSError( + domain: "PigeonMessage", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "背景图片上传结果异常"] + ) + return .error(error) + } let msg: String if isVoice { - msg = voiceId + msg = isVoiceTemplate ? "0" : voiceId } else { msg = caption } @@ -495,7 +541,7 @@ final class PigeonMessageVC: BaseViewController { toUsers: self.selectedMembers.map(\.user_id), msgType: msgType, msg: msg, - bgImg: bgImg, + bgImg: bgImgId, extra: extra.isEmpty ? nil : extra ) } @@ -564,6 +610,14 @@ extension PigeonMessageVC: AVAudioPlayerDelegate { playbackTimer = nil audioPlayer = nil rootView.setPlaying(false) - rootView.setVoiceDuration(recordedDuration, hasRecording: recordedDuration >= 1) + if let template = selectedVoiceTemplate { + rootView.setVoiceDuration( + 0, + totalDuration: template.duration, + hasRecording: template.audioData != nil + ) + } else { + rootView.setVoiceDuration(recordedDuration, hasRecording: recordedDuration >= 1) + } } } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index 3970bb6d..a3bc0728 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -168,16 +168,21 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { playPauseButton.isEnabled = !isRecording } - func setVoiceDuration(_ seconds: TimeInterval, hasRecording: Bool) { - let maxDuration = Self.maxVoiceDuration + func setVoiceDuration( + _ seconds: TimeInterval, + totalDuration: TimeInterval = PigeonMessageView.maxVoiceDuration, + hasRecording: Bool + ) { + let maxDuration = max(0.01, totalDuration) let value: Int if seconds + 0.05 >= maxDuration { - value = Int(maxDuration) + value = Int(ceil(maxDuration)) } else { - value = min(Int(maxDuration), max(0, Int(seconds.rounded(.down)))) + value = min(Int(ceil(maxDuration)), max(0, Int(seconds.rounded(.down)))) } let currentTime = String(format: "%02d″", value) - let timeText = "\(currentTime)/\(Int(maxDuration))″" + let totalTime = String(format: "%02d″", Int(ceil(maxDuration))) + let timeText = "\(currentTime)/\(totalTime)" let attributedTime = NSMutableAttributedString( string: timeText, attributes: [.foregroundColor: UIColor(hexStr: "#AAAAAA")] @@ -228,9 +233,23 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { } func clearVoiceDraft() { + voiceChipPicker.setSelection(nil) setRecording(isRecording: false) setVoiceDuration(0, hasRecording: false) setPlaying(false) + updateMode() + } + + func setVoiceTemplate(_ template: PigeonVoiceTemplate?) { + voiceChipPicker.setSelection(template) + setRecording(isRecording: false) + setPlaying(false) + setVoiceDuration( + 0, + totalDuration: template?.duration ?? Self.maxVoiceDuration, + hasRecording: template?.audioData != nil + ) + updateMode() } private func setupUI() { @@ -820,6 +839,24 @@ private final class PigeonVoiceChipPickerView: UIView, fatalError("init(coder:) has not been implemented") } + func setSelection(_ template: PigeonVoiceTemplate?) { + selectedTemplate = template + collectionView.reloadData() + guard let template, + let index = templates.firstIndex(where: { $0.id == template.id }) else { + collectionView.setContentOffset( + CGPoint(x: -collectionView.adjustedContentInset.left, y: 0), + animated: false + ) + return + } + collectionView.scrollToItem( + at: IndexPath(item: index + 1, section: 0), + at: .centeredHorizontally, + animated: true + ) + } + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { templates.count + 1 } @@ -874,6 +911,7 @@ private final class PigeonVoiceChipCell: UICollectionViewCell { iconView.layoutChain.left(8).centerY().width(18).height(18) titleLab.font = .systemFont(ofSize: 13, weight: .medium) titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.lineBreakMode = .byTruncatingTail titleLab.layoutChain.leftToRightOfView(iconView, offset: 6).top(8).right(8) subLab.font = .systemFont(ofSize: 11) subLab.textColor = UIColor(hexStr: "#A7ABB2") @@ -894,7 +932,8 @@ private final class PigeonVoiceChipCell: UICollectionViewCell { func configure(template: PigeonVoiceTemplate, selected: Bool) { iconView.image = UIImage(named: "PigeonMessage/voice_chip_play") titleLab.text = template.title - subLab.text = String(format: "%02d:%02d", Int(template.duration) / 60, Int(template.duration) % 60) + let duration = Int(ceil(template.duration)) + subLab.text = String(format: "%02d:%02d", duration / 60, duration % 60) applySelected(selected) } diff --git a/QuickLocation/Service/PigeonService.swift b/QuickLocation/Service/PigeonService.swift index a5174f83..ab47818b 100644 --- a/QuickLocation/Service/PigeonService.swift +++ b/QuickLocation/Service/PigeonService.swift @@ -11,7 +11,7 @@ struct PigeonService { toUsers: [String], msgType: Int, msg: String, - bgImg: String, + bgImg: Int64, extra: [String: Any]? = nil ) -> Observable { let api = PigeonAPI.send( diff --git a/QuickLocation/Service/UploadService.swift b/QuickLocation/Service/UploadService.swift index 65b5bb65..919f01c2 100644 --- a/QuickLocation/Service/UploadService.swift +++ b/QuickLocation/Service/UploadService.swift @@ -32,10 +32,35 @@ enum UploadFileKind { struct UploadService { + private struct UploadedFile { + let id: String + let url: String + } + private static let boundary = "YLQH" private static let jiaMiKey = "857d69d374694c1de46486d3bdaaac43" static func upload(_ data: Data, kind: UploadFileKind, scene: String? = nil) -> Observable { + uploadFile(data, kind: kind, scene: scene) + .map(\.id) + } + + /// 上传后返回服务端提供的可访问地址,供需要提交 URL 的业务使用。 + static func uploadURL(_ data: Data, kind: UploadFileKind, scene: String? = nil) -> Observable { + uploadFile(data, kind: kind, scene: scene) + .map { file in + guard !file.url.isEmpty else { + throw makeError("上传成功但未返回文件地址") + } + return file.url + } + } + + private static func uploadFile( + _ data: Data, + kind: UploadFileKind, + scene: String? + ) -> Observable { Observable.create { observer in do { let request = try makeRequest(fileData: data, kind: kind, scene: scene) @@ -65,11 +90,15 @@ struct UploadService { } let nestedId = json["data"]["id"].stringValue let nestedFileId = json["data"]["file_id"].stringValue + let nestedURL = json["data"]["url"].stringValue + let nestedFileURL = json["data"]["file_url"].stringValue let dataString = json["data"].stringValue let fileId: String? = { if !nestedId.isEmpty { return nestedId } if !nestedFileId.isEmpty { return nestedFileId } if !dataString.isEmpty, json["data"].type != .dictionary { return dataString } + if !nestedURL.isEmpty { return nestedURL } + if !nestedFileURL.isEmpty { return nestedFileURL } return nil }() guard let fileId, !fileId.isEmpty else { @@ -78,8 +107,19 @@ struct UploadService { } return } + let fileURL: String = { + if !nestedURL.isEmpty { return nestedURL } + if !nestedFileURL.isEmpty { return nestedFileURL } + if dataString.hasPrefix("http://") || dataString.hasPrefix("https://") { + return dataString + } + if fileId.hasPrefix("http://") || fileId.hasPrefix("https://") { + return fileId + } + return "" + }() DispatchQueue.main.async { - observer.onNext(fileId) + observer.onNext(UploadedFile(id: fileId, url: fileURL)) observer.onCompleted() } } diff --git a/QuickLocation/Service/UserService.swift b/QuickLocation/Service/UserService.swift index 2ed52ff4..8bcf4e5d 100644 --- a/QuickLocation/Service/UserService.swift +++ b/QuickLocation/Service/UserService.swift @@ -41,6 +41,22 @@ struct UserService { .map(UserStatusResponse.self) .asObservable() } + + /// 指定圈子成员的今日手机使用数据 + static func phoneUsageToday(userId: String, groupKey: String) -> Observable { + let api = UserAPI.phoneUsageToday(userId: userId, groupKey: groupKey).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsageTodayResponse.self) + .asObservable() + } + + /// 指定圈子成员的手机使用报告 + static func phoneUsageReport(userId: String, groupKey: String) -> Observable { + let api = UserAPI.phoneUsageReport(userId: userId, groupKey: groupKey).multiTarget + return APIProvider.request(token: api, handle: false) + .map(PhoneUsageReportResponse.self) + .asObservable() + } static func imToken() -> Observable { let api = UserAPI.imToken.multiTarget @@ -56,7 +72,7 @@ struct UserService { .map(SignInInfoResponse.self) .asObservable() } - + /// 更换手机 static func changePhone(timestamp: String, phone: String, code: String) -> Observable { let api = UserAPI.changePhone(timestamp: timestamp, phone: phone, code: code).multiTarget @@ -195,4 +211,22 @@ struct UserService { .map(RelationListResponse.self) .asObservable() } + + /// 提交意见反馈 + static func feedback( + type: String, + content: String, + contact: String, + images: [String] + ) -> Observable { + let api = UserAPI.feedback( + type: type, + content: content, + contact: contact, + images: images + ).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } }