diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 3b1f0ae4..7de71c27 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -255,7 +255,6 @@ 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 */; }; - 55C1A0113034101000786011 /* PhoneStatusCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C1A0103034101000786010 /* PhoneStatusCollector.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 */; }; @@ -315,7 +314,6 @@ 55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.swift */; }; 55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C03022F0A000784720 /* MinePhotoWallView.swift */; }; 55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */; }; - 55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C1A0203034102000786020 /* PrivilegeDeniedOverlay.swift */; }; 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; }; 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; }; 55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; }; @@ -379,6 +377,8 @@ 55B261010000000100786601 /* PrivacySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B261000000000100786600 /* PrivacySettingsViewController.swift */; }; 55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; }; 55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; }; + 55C1A0113034101000786011 /* PhoneStatusCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C1A0103034101000786010 /* PhoneStatusCollector.swift */; }; + 55C1A0213034102000786021 /* PrivilegeDeniedOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C1A0203034102000786020 /* PrivilegeDeniedOverlay.swift */; }; 55C219C13024C00100784731 /* SearchLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */; }; 55C4C0242FDB91C8009215C2 /* CheckPermissionGuideVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C4C0232FDB91C8009215C2 /* CheckPermissionGuideVC.swift */; }; 60BEE25E61F3DE24976A7A47 /* ManagedSettingsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FE44DF1C46BDA7B98BC7859 /* ManagedSettingsUI.framework */; }; @@ -706,7 +706,6 @@ 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 = ""; }; - 55C1A0103034101000786010 /* PhoneStatusCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneStatusCollector.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 = ""; }; @@ -770,7 +769,6 @@ 55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = ""; }; 55B217C03022F0A000784720 /* MinePhotoWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinePhotoWallView.swift; sourceTree = ""; }; 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = ""; }; - 55C1A0203034102000786020 /* PrivilegeDeniedOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivilegeDeniedOverlay.swift; sourceTree = ""; }; 55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = ""; }; 55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = ""; }; 55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = ""; }; @@ -834,6 +832,8 @@ 55B261000000000100786600 /* PrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewController.swift; sourceTree = ""; }; 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = ""; }; 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = ""; }; + 55C1A0103034101000786010 /* PhoneStatusCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneStatusCollector.swift; sourceTree = ""; }; + 55C1A0203034102000786020 /* PrivilegeDeniedOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivilegeDeniedOverlay.swift; sourceTree = ""; }; 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationHeaderView.swift; sourceTree = ""; }; 55C4C0232FDB91C8009215C2 /* CheckPermissionGuideVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckPermissionGuideVC.swift; sourceTree = ""; }; 5E5F5DC8694A001BF0C47147 /* PairGuideStepsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairGuideStepsView.swift; path = AppRestrict/PairGuideStepsView.swift; sourceTree = ""; }; @@ -2937,7 +2937,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; @@ -2992,7 +2992,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; @@ -3225,7 +3225,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83; ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)"; diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/Contents.json new file mode 100644 index 00000000..1c3875c5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "vip_required@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "vip_required@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@2x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@2x.png new file mode 100644 index 00000000..bddcc0e3 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@3x.png b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@3x.png new file mode 100644 index 00000000..1a85d451 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/HistoryTrack/vip_required.imageset/vip_required@3x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/Contents.json b/QuickLocation/Assets.xcassets/MemberVipRequired/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/MemberVipRequired/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/Contents.json b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/Contents.json new file mode 100644 index 00000000..4be8d4e9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app_usage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app_usage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@2x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@2x.png new file mode 100644 index 00000000..f31df810 Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@2x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@3x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@3x.png new file mode 100644 index 00000000..d3f92deb Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/app_usage.imageset/app_usage@3x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/Contents.json b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/Contents.json new file mode 100644 index 00000000..083fa343 --- /dev/null +++ b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "phone_report@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "phone_report@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@2x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@2x.png new file mode 100644 index 00000000..500a39af Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@2x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@3x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@3x.png new file mode 100644 index 00000000..cfcccd21 Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/phone_report.imageset/phone_report@3x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/Contents.json b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/Contents.json new file mode 100644 index 00000000..adfee5fa --- /dev/null +++ b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "today_track@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "today_track@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@2x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@2x.png new file mode 100644 index 00000000..045a8200 Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@2x.png differ diff --git a/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@3x.png b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@3x.png new file mode 100644 index 00000000..5b54a324 Binary files /dev/null and b/QuickLocation/Assets.xcassets/MemberVipRequired/today_track.imageset/today_track@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/Contents.json new file mode 100644 index 00000000..afbb3108 --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "crown@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "crown@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@2x.png b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@2x.png new file mode 100644 index 00000000..aac3c219 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@3x.png b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@3x.png new file mode 100644 index 00000000..6cdddf78 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/crown.imageset/crown@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/Contents.json new file mode 100644 index 00000000..044f4fb9 --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "limited_offer@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "limited_offer@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@2x.png b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@2x.png new file mode 100644 index 00000000..bbe03d28 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@3x.png b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@3x.png new file mode 100644 index 00000000..5bf00817 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/limited_offer.imageset/limited_offer@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/Contents.json new file mode 100644 index 00000000..1fe3c6b0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "media_locked_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "media_locked_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@2x.png b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@2x.png new file mode 100644 index 00000000..d26b009e Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@3x.png b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@3x.png new file mode 100644 index 00000000..caf0e4de Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/media_locked_bg.imageset/media_locked_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/Contents.json new file mode 100644 index 00000000..ef02356a --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "narrow_button_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "narrow_button_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@2x.png b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@2x.png new file mode 100644 index 00000000..03e861eb Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@3x.png b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@3x.png new file mode 100644 index 00000000..d4e14d2c Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/narrow_button_bg.imageset/narrow_button_bg@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/Contents.json new file mode 100644 index 00000000..27666dff --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "realtime_location@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "realtime_location@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@2x.png b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@2x.png new file mode 100644 index 00000000..24995a4e Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@3x.png b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@3x.png new file mode 100644 index 00000000..afa882d5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/realtime_location.imageset/realtime_location@3x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/Contents.json b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/Contents.json new file mode 100644 index 00000000..cd8c1585 --- /dev/null +++ b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "wide_button_bg@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "wide_button_bg@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@2x.png b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@2x.png new file mode 100644 index 00000000..5f8d3628 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@2x.png differ diff --git a/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@3x.png b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@3x.png new file mode 100644 index 00000000..ea8d7489 Binary files /dev/null and b/QuickLocation/Assets.xcassets/VipFeature/wide_button_bg.imageset/wide_button_bg@3x.png differ diff --git a/QuickLocation/Manager/Account/AppContextManager.swift b/QuickLocation/Manager/Account/AppContextManager.swift index e4df4462..10f59056 100644 --- a/QuickLocation/Manager/Account/AppContextManager.swift +++ b/QuickLocation/Manager/Account/AppContextManager.swift @@ -15,6 +15,7 @@ class AppContextManager: NSObject { var systemConfig: SystemConfigModel? var imToken: String? + var imTokenUserId: String? var account: UserConfigModel? { if let loginAccount = loginAccount { @@ -129,6 +130,8 @@ class AppContextManager: NSObject { Defaults[\.userConfig] = nil Defaults[\.defaultGroupKey] = "" loginAccount = nil + imToken = nil + imTokenUserId = nil } } diff --git a/QuickLocation/Manager/App/ApiManager.swift b/QuickLocation/Manager/App/ApiManager.swift index 8cc28cc0..3e07a123 100644 --- a/QuickLocation/Manager/App/ApiManager.swift +++ b/QuickLocation/Manager/App/ApiManager.swift @@ -127,7 +127,11 @@ extension ApiManager { DLToast.show(text: combineMessage) } } - return .failure(handleError(with: code, domain: "Data Error", message: combineMessage, data: response)) + return .failure(handleError(with: code, + domain: "Data Error", + message: combineMessage, + isShowTip: code != GatewayStatusCode.needVip.rawValue, + data: response)) case let .failure(error): let message = error.gatewayMessage ?? "请检查网络连接" if error.innerError?.code == NSURLErrorCancelled { @@ -181,10 +185,15 @@ extension ApiManager { // MARK: - 需开通VIP private func handleNeedVip(_ msg: String) { + let message = msg + .split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" MainAsync { - ConfirmPopVC.showAlert(title: "温馨提示", message: msg, confirmText: "去开通", confirmBlock: { + UIViewController.showConfirmPop(title: "温馨提示", message: message, confirmText: "去开通", confirmBlock: { AppRouter.push(Route.vipRecharge) - }) + }, cancelText: "取消") } } } diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index 6ff0fbc9..938c1a82 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -92,6 +92,7 @@ struct GroupUsersResponse: BaseModelProtocol { var message: String? var model: GroupInfoModel? var list: [GroupMemberModel] = [] + var vip = GroupMemberVip() var reviewCount: Int = 0 init?(map: Map) {} @@ -101,6 +102,7 @@ struct GroupUsersResponse: BaseModelProtocol { message <- map["message"] model <- map["data.group"] list <- map["data.mates"] + vip <- map["data.vip"] reviewCount <- (map["data.reviewCount"], kStrTransformInt) if reviewCount == 0 { reviewCount <- (map["data.review_count"], kStrTransformInt) @@ -286,6 +288,27 @@ struct GroupPrivilege: Mappable, Equatable { } } +struct GroupMemberVip: Mappable, Equatable { + var app_usage_record: Bool? + var phone_report: Bool? + var trip_analysis_others: Bool? + var app_lock: Bool? + var pigeon_mail: Bool? + var view_phone: Bool? + + init() {} + init?(map: Map) {} + + mutating func mapping(map: Map) { + app_usage_record <- (map["app_usage_record"], kOptionalFlexibleBool) + phone_report <- (map["phone_report"], kOptionalFlexibleBool) + trip_analysis_others <- (map["trip_analysis_others"], kOptionalFlexibleBool) + app_lock <- (map["app_lock"], kOptionalFlexibleBool) + pigeon_mail <- (map["pigeon_mail"], kOptionalFlexibleBool) + view_phone <- (map["view_phone"], kOptionalFlexibleBool) + } +} + private let kFlexibleBool = TransformOf(fromJSON: { value in switch value { case let value as Bool: @@ -304,6 +327,31 @@ private let kFlexibleBool = TransformOf(fromJSON: { value in value }) +private let kOptionalFlexibleBool = TransformOf(fromJSON: { value in + switch value { + case let value as Bool: + return value + case let value as NSNumber: + return value.boolValue + case let value as Int: + return value != 0 + case let value as String: + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch text { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return nil + } + default: + return nil + } +}, toJSON: { value in + value +}) + extension UIImageView { @MainActor func setGroupIcon(_ model: GroupInfoModel) { @@ -424,7 +472,13 @@ struct GroupMemberModel: Mappable, Equatable { /// 会员等级 1普通 2白银 3永久 4钻石 var level: Int = 1 + var shouldShowVipAvatarFrame: Bool { + AppContextManager.shared.systemConfig?.isIntercept != true && level != 1 + } var vipIcon: UIImage { + guard AppContextManager.shared.systemConfig?.isIntercept != true else { + return UIImage() + } switch level { case 2: // 白银 return UIImage(named: "VipIcon/2") ?? UIImage() diff --git a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift index 75a3e33b..36207973 100644 --- a/QuickLocation/Section/AppRestrict/AppRestrictVC.swift +++ b/QuickLocation/Section/AppRestrict/AppRestrictVC.swift @@ -135,7 +135,7 @@ final class AppRestrictVC: BaseViewController { .subscribe(onNext: { _ in DLToast.show(text: "配对成功") }, onError: { error in - DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) + DLToast.show(error) }) .disposed(by: disposeBag) } @@ -278,7 +278,7 @@ final class AppRestrictVC: BaseViewController { DLToast.show(text: "删除成功") }, onError: { [weak self] error in self?.deletingTokenStrings.remove(tokenString) - DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) + DLToast.show(error) }) .disposed(by: UserService.disposeBag) } diff --git a/QuickLocation/Section/Explore/FeatureIntroVC.swift b/QuickLocation/Section/Explore/FeatureIntroVC.swift index 165e3f6b..06577a45 100644 --- a/QuickLocation/Section/Explore/FeatureIntroVC.swift +++ b/QuickLocation/Section/Explore/FeatureIntroVC.swift @@ -35,7 +35,8 @@ final class FeatureIntroVC: BaseViewController { super.viewDidAppear(animated) rootView.reloadFeatureItems() - guard let config = AppContextManager.shared.systemConfig else { return } + guard AppContextManager.shared.systemConfig?.isIntercept == false, + let config = AppContextManager.shared.systemConfig else { return } bannerList = config.bannerList rootView.cycleScrollView.isHidden = config.bannerList.filter { $0.enable == true }.count == 0 rootView.cycleScrollView.imageURLStringsGroup = config.bannerList.filter { $0.enable == true }.map { $0.img } diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift index 03a54c2d..3e774699 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift @@ -96,7 +96,7 @@ class CreateGroupViewModel { self?.groupType = response.list[0].type self?.groupTemplates.onNext(response.list) }, onError: { error in - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -135,7 +135,7 @@ class CreateGroupViewModel { CreateGroupVipPopView.show() } else { - DLToast.show(text: error.localizedDescription) + DLToast.show(error) } }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index a49b1e4c..a712594d 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -96,16 +96,7 @@ final class GroupChatVC: BaseViewController { setupKeyboard() IQKeyboardManager.shared.isEnabled = false IQKeyboardManager.shared.resignOnTouchOutside = false - - // 非圈主 会员权益拦截 -// let isCircleOwner = GroupOwnerMatcher.matches( -// userId: AppContextManager.shared.userId, -// groupKey: viewModel.groupId -// ) -// let canSend = isCircleOwner || AppContextManager.shared.vip > 1 -// rootView.disableIMView.isHidden = canSend -// rootView.quickActionBar.isUserInteractionEnabled = canSend -// rootView.quickActionBar.alpha = canSend ? 1 : 0.35 + rootView.updateVipMediaAccess(isUnlocked: isVipUnlocked) } override func viewWillDisappear(_ animated: Bool) { @@ -581,6 +572,22 @@ final class GroupChatVC: BaseViewController { } // MARK: - Actions + private var isVipUnlocked: Bool { + guard AppContextManager.shared.systemConfig?.isIntercept == false else { + return false + } + + return AppContextManager.shared.vip > 1 + } + + private func requireVipForMedia() -> Bool { + guard isVipUnlocked else { + AppRouter.push(Route.vipRecharge) + return false + } + return true + } + private func reactiveAction() { rootView.backBtn.rx.tap .subscribe(onNext: { _ in @@ -630,7 +637,7 @@ final class GroupChatVC: BaseViewController { rootView.galleryBtn.rx.tap .subscribe(onNext: { [weak self] in - guard let self = self else { return } + guard let self, self.requireVipForMedia() else { return } self.rootView.dismissAllPanels() self.showAlbum() }) @@ -638,8 +645,9 @@ final class GroupChatVC: BaseViewController { rootView.cameraBtn.rx.tap .subscribe(onNext: { [weak self] in - self?.rootView.dismissAllPanels() - self?.showCamera() + guard let self, self.requireVipForMedia() else { return } + self.rootView.dismissAllPanels() + self.showCamera() }) .disposed(by: disposeBag) @@ -699,16 +707,19 @@ final class GroupChatVC: BaseViewController { // MARK: - 显示相册 private func showAlbum() { + guard requireVipForMedia() else { return } let picker = PhotoPickerVC(maxCount: 9) picker.onConfirm = { [weak self] images in + guard let self, self.requireVipForMedia() else { return } for image in images { - self?.sendImageMessage(image) + self.sendImageMessage(image) } } present(picker, animated: true) } private func showCamera() { + guard requireVipForMedia() else { return } guard UIImagePickerController.isSourceTypeAvailable(.camera) else { DLToast.showError(text: "当前设备不支持拍照") return @@ -748,6 +759,11 @@ final class GroupChatVC: BaseViewController { private var currentVoiceHover: VoiceRecordView.HoverTarget = .none private func enterVoiceInputMode() { + guard isVipUnlocked else { + rootView.dismissAllPanels() + rootView.setVoiceInputMode(true) + return + } let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .authorized: @@ -831,6 +847,7 @@ final class GroupChatVC: BaseViewController { } private func startVoiceRecording() { + guard requireVipForMedia() else { return } guard !isVoiceRecording else { return } AudioServicesPlaySystemSound(1519) currentVoiceHover = .none @@ -945,28 +962,30 @@ final class GroupChatVC: BaseViewController { } private func sendVoiceMessage(url: URL, durationMs: Int) { - let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs) - viewModel.cacheMessage(msg) - OIMManager.manager.sendMessage(msg, - recvID: "", - groupID: viewModel.groupId, - offlinePushInfo: nil, - onSuccess: { [weak self] returnedMsg in + guard requireVipForMedia() else { + deleteRecordFile() + rootView.voiceRecordView.reset() + return + } + GroupIMService.shared.sendGroupMessage(groupId: viewModel.groupId, + makeMessage: { + OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs) + }, + onPrepared: { [weak self] message in + self?.viewModel.cacheMessage(message) + }, + onSuccess: { [weak self] sentMessage in DispatchQueue.main.async { guard let self else { return } - let sentMessage = returnedMsg ?? msg - if sentMessage.soundElem == nil { - sentMessage.soundElem = msg.soundElem - } sentMessage.soundElem?.soundPath = url.path self.viewModel.onReceiveMessage(sentMessage) } }, - onProgress: nil as OIMNumberCallback?, - onFailure: { [weak self] code, errMsg in + onFailure: { [weak self] code, errMsg in print("Voice send failed: \(code) \(errMsg ?? "")") DispatchQueue.main.async { - self?.dl.show(text: (errMsg?.isEmpty == false ? errMsg : "语音发送失败,请重试") ?? "语音发送失败,请重试") + let text = GroupIMService.shared.displaySendError(errMsg, fallback: "语音发送失败,请重试") + self?.dl.show(text: text) } }) } @@ -1014,7 +1033,7 @@ final class GroupChatVC: BaseViewController { }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID else { return } if showError { - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) } }) .disposed(by: disposeBag) @@ -1186,6 +1205,7 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { extension GroupChatVC { private func sendImageMessage(_ image: UIImage) { + guard requireVipForMedia() else { return } let sendImage = image.cgImage != nil ? image.fixOrientation() : image guard let data = sendImage.jpegData(compressionQuality: 0.8) else { return } let dir = NSTemporaryDirectory() @@ -1195,39 +1215,38 @@ extension GroupChatVC { let displaySize = ChatImageLayout.displaySize(width: sendImage.size.width, height: sendImage.size.height) - let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) - // 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重 - let localId = msg.clientMsgID ?? UUID().uuidString - viewModel.cacheMessage(msg) - - // 立即显示本地图片(带 loading) - let localMsg = ChatMessage( - id: localId, - isSelf: true, - senderId: AppContextManager.shared.userId, - avatar: viewModel.getUserAvatar(id: AppContextManager.shared.userId), - headPic: viewModel.getUserHeadPic(id: AppContextManager.shared.userId), - senderName: AppContextManager.shared.name, - content: "", - voiceUrl: "", - imageUrl: fileURL.path, - imageWidth: displaySize.width, - imageHeight: displaySize.height, - timestamp: Date().timeIntervalSince1970, - showTime: false, - isUploading: true - ) - viewModel.appendLocalMessage(.imageSend(localMsg)) - OIMManager.manager.sendMessage(msg, - recvID: "", - groupID: viewModel.groupId, - offlinePushInfo: nil, - onSuccess: { [weak self] returnedMsg in + var localId = "" + GroupIMService.shared.sendGroupMessage(groupId: viewModel.groupId, + makeMessage: { + OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) + }, + onPrepared: { [weak self] message in + guard let self else { return } + localId = message.clientMsgID ?? UUID().uuidString + self.viewModel.cacheMessage(message) + let localMessage = ChatMessage( + id: localId, + isSelf: true, + senderId: AppContextManager.shared.userId, + avatar: self.viewModel.getUserAvatar(id: AppContextManager.shared.userId), + headPic: self.viewModel.getUserHeadPic(id: AppContextManager.shared.userId), + senderName: AppContextManager.shared.name, + content: "", + voiceUrl: "", + imageUrl: fileURL.path, + imageWidth: displaySize.width, + imageHeight: displaySize.height, + timestamp: Date().timeIntervalSince1970, + showTime: false, + isUploading: true + ) + self.viewModel.appendLocalMessage(.imageSend(localMessage)) + }, + onSuccess: { [weak self] sentMessage in // 服务端返回后,更新本地消息为服务端图片URL,去掉loading - // 注意:returnedMsg 是 SDK 回填服务端数据后的新 OIMMessageInfo,包含完整 URL - let networkUrl = returnedMsg?.pictureElem?.sourcePicture?.url - ?? returnedMsg?.pictureElem?.bigPicture?.url - ?? returnedMsg?.pictureElem?.sourcePath + let networkUrl = sentMessage.pictureElem?.sourcePicture?.url + ?? sentMessage.pictureElem?.bigPicture?.url + ?? sentMessage.pictureElem?.sourcePath ?? "" self?.viewModel.updateLocalMessage(id: localId) { chatMsg in // 仅在服务端有URL时才替换,否则保留本地路径让图片仍可见 @@ -1238,13 +1257,16 @@ extension GroupChatVC { } try? FileManager.default.removeItem(at: fileURL) }, - onProgress: nil as OIMNumberCallback?, - onFailure: { [weak self] code, errMsg in + onFailure: { [weak self] code, errMsg in print("Image send failed: \(code) \(errMsg ?? "")") // 发送失败,隐藏 loading self?.viewModel.updateLocalMessage(id: localId) { chatMsg in chatMsg.isUploading = false } + DispatchQueue.main.async { + let text = GroupIMService.shared.displaySendError(errMsg, fallback: "图片发送失败,请重试") + self?.dl.show(text: text) + } }) } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index d8dd0803..7f507b81 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -161,6 +161,7 @@ class GroupChatView: UIView { /// 收起所有浮层面板 private var isVoiceInputMode = false + private var isVipMediaUnlocked = true func setVoiceInputMode(_ enabled: Bool) { isVoiceInputMode = enabled @@ -171,9 +172,22 @@ class GroupChatView: UIView { refreshComposer() } + func updateVipMediaAccess(isUnlocked: Bool) { + isVipMediaUnlocked = isUnlocked + let lockedBackground = isUnlocked ? nil : UIImage(named: "VipFeature/media_locked_bg") + galleryBtn.setBackgroundImage(lockedBackground, for: .normal) + cameraBtn.setBackgroundImage(lockedBackground, for: .normal) + galleryBtn.backgroundColor = isUnlocked ? .white : .clear + cameraBtn.backgroundColor = isUnlocked ? .white : .clear + voiceBtn.setImage(UIImage(named: "IM/voice_input"), for: .normal) + textField.placeholder = "发消息或者按住说话" + refreshComposer() + } + func refreshComposer() { let hasText = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if isVoiceInputMode { + updateHoldSpeakAppearance() textField.isHidden = true emojiBtn.isHidden = true voiceBtn.isHidden = true @@ -191,6 +205,22 @@ class GroupChatView: UIView { layoutIfNeeded() } + private func updateHoldSpeakAppearance() { + var configuration = UIButton.Configuration.plain() + configuration.title = "按住 说话" + configuration.image = isVipMediaUnlocked ? nil : UIImage(named: "VipFeature/crown") + configuration.imagePlacement = .leading + configuration.imagePadding = isVipMediaUnlocked ? 0 : 10 + configuration.contentInsets = .zero + configuration.baseForegroundColor = UIColor(hexStr: "#293445") + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = .systemFont(ofSize: 16, weight: .medium) + return attributes + } + holdSpeakBtn.configuration = configuration + } + func dismissAllPanels(excludeTextField: Bool = false) { let needsReset = !emojiPanelView.isHidden || textField.isFirstResponder diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index 09e1f5b6..f772bd6e 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -333,65 +333,62 @@ final class GroupChatViewModel { let atAll = pendingAtAll let mentions = pendingMentions - let msg: OIMMessageInfo - if atAll { - msg = OIMMessageInfo.createText(atAllMessage: trimmed, - displayText: "@所有人", - message: quote) - } else if !mentions.isEmpty { - let ids = mentions.map(\.userId) - let infos: [OIMAtInfo] = mentions.map { m in - let info = OIMAtInfo() - info.atUserID = m.userId - info.groupNickname = m.nickname - return info - } - msg = OIMMessageInfo.createText(atMessage: trimmed, - atUsersID: ids, - atUsersInfo: infos, - message: quote) - } else if let quote { - msg = OIMMessageInfo.createQuoteMessage(trimmed, message: quote) - } else { - msg = OIMMessageInfo.createTextMessage(trimmed) - } - clearPendingQuote() clearPendingMentions() - OIMManager.manager.sendMessage(msg, - recvID: "", - groupID: groupId, - offlinePushInfo: nil, - onSuccess: { [weak self] _ in - self?.cacheMessage(msg) - self?.appendMessage(msg) + GroupIMService.shared.sendGroupMessage(groupId: groupId, + makeMessage: { + if atAll { + return OIMMessageInfo.createText(atAllMessage: trimmed, + displayText: "@所有人", + message: quote) + } + if !mentions.isEmpty { + let ids = mentions.map(\.userId) + let infos: [OIMAtInfo] = mentions.map { mention in + let info = OIMAtInfo() + info.atUserID = mention.userId + info.groupNickname = mention.nickname + return info + } + return OIMMessageInfo.createText(atMessage: trimmed, + atUsersID: ids, + atUsersInfo: infos, + message: quote) + } + if let quote { + return OIMMessageInfo.createQuoteMessage(trimmed, message: quote) + } + return OIMMessageInfo.createTextMessage(trimmed) }, - onProgress: nil as OIMNumberCallback?, - onFailure: { code, errMsg in + onSuccess: { [weak self] message in + self?.cacheMessage(message) + self?.appendMessage(message) + }, + onFailure: { code, errMsg in print("send failed: \(code) \(errMsg ?? "")") DispatchQueue.main.async { - DLToast.showError(text: errMsg ?? "发送失败") + let text = GroupIMService.shared.displaySendError(errMsg, fallback: "消息发送失败,请重试") + DLToast.showError(text: text) } }) } func sendLocation(_ location: ChatLocationPayload) { - let msg = OIMMessageInfo.createLocationMessage(location.encodedDescription, - latitude: location.latitude, - longitude: location.longitude) - OIMManager.manager.sendMessage(msg, - recvID: "", - groupID: groupId, - offlinePushInfo: nil, - onSuccess: { [weak self] _ in - self?.cacheMessage(msg) - self?.appendMessage(msg) + GroupIMService.shared.sendGroupMessage(groupId: groupId, + makeMessage: { + OIMMessageInfo.createLocationMessage(location.encodedDescription, + latitude: location.latitude, + longitude: location.longitude) }, - onProgress: nil as OIMNumberCallback?, - onFailure: { code, errMsg in + onSuccess: { [weak self] message in + self?.cacheMessage(message) + self?.appendMessage(message) + }, + onFailure: { code, errMsg in DispatchQueue.main.async { - DLToast.showError(text: errMsg ?? "位置发送失败(\(code))") + let text = GroupIMService.shared.displaySendError(errMsg, fallback: "位置发送失败,请重试") + DLToast.showError(text: text) } }) } diff --git a/QuickLocation/Section/Group/GroupIMService.swift b/QuickLocation/Section/Group/GroupIMService.swift index b4acf878..b40b57d3 100644 --- a/QuickLocation/Section/Group/GroupIMService.swift +++ b/QuickLocation/Section/Group/GroupIMService.swift @@ -15,8 +15,12 @@ final class GroupIMService { private var isInited = false private var isLogining = false + private var isLoggingOut = false + private var isSessionInvalidated = false + private var loginGeneration = 0 /// 登录进行中时排队的回调,登录结束统一回调,避免并发重复登录 - private var pendingLoginCompletions: [(Bool) -> Void] = [] + private var pendingLoginCompletions: [(userId: String, completion: (Bool) -> Void)] = [] + private var pendingLogoutCompletions: [(Bool) -> Void] = [] private let disposeBag = DisposeBag() private init() {} @@ -34,85 +38,273 @@ final class GroupIMService { onConnecting: {}, onConnectFailure: { _, _ in }, onConnectSuccess: {}, - onKickedOffline: {}, - onUserTokenExpired: {}, - onUserTokenInvalid: { _ in }) + onKickedOffline: { + GroupIMService.shared.invalidateSession() + }, + onUserTokenExpired: { + GroupIMService.shared.invalidateSession() + }, + onUserTokenInvalid: { _ in + GroupIMService.shared.invalidateSession() + }) } // MARK: - Login /// 确保已登录(幂等):已登录直接回调,否则拉 token 再登录;登录中则排队等待 func ensureLogin(completion: @escaping (Bool) -> Void) { - login(completion: completion) + runOnMain { [weak self] in + self?.login(completion: completion) + } } - func login(completion: @escaping (Bool) -> Void) { - if OIMManager.manager.getLoginStatus() == .logged { - completion(true) - return - } + private func login(completion: @escaping (Bool) -> Void) { if AppContextManager.shared.isGuest { completion(false) return } - let userId = AppContextManager.shared.userId + let userId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) guard !userId.isEmpty else { completion(false) return } - pendingLoginCompletions.append(completion) - guard !isLogining else { return } - isLogining = true - - let existing = AppContextManager.shared.imToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !existing.isEmpty { - performSDKLogin(userId: userId, token: existing) + if !isSessionInvalidated, + OIMManager.manager.getLoginStatus() == .logged, + OIMManager.manager.getLoginUserID() == userId { + completion(true) return } + pendingLoginCompletions.append((userId, completion)) + startLoginIfNeeded() + } + + private func startLoginIfNeeded() { + guard !isLogining, !isLoggingOut, !pendingLoginCompletions.isEmpty else { return } + + let userId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !AppContextManager.shared.isGuest, !userId.isEmpty else { + let callbacks = pendingLoginCompletions + pendingLoginCompletions.removeAll() + callbacks.forEach { $0.completion(false) } + return + } + + let staleCompletions = pendingLoginCompletions.filter { $0.userId != userId } + pendingLoginCompletions.removeAll { $0.userId != userId } + staleCompletions.forEach { $0.completion(false) } + guard !pendingLoginCompletions.isEmpty else { return } + + let status = OIMManager.manager.getLoginStatus() + if status == .logged { + if !isSessionInvalidated, OIMManager.manager.getLoginUserID() == userId { + finishLogin(success: true, userId: userId) + } else { + clearCachedToken() + loginGeneration += 1 + beginSDKLogout() + } + return + } + if status == .logging { + clearCachedToken() + loginGeneration += 1 + beginSDKLogout() + return + } + + isLogining = true + loginGeneration += 1 + let generation = loginGeneration + + let existing = AppContextManager.shared.imToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !existing.isEmpty, AppContextManager.shared.imTokenUserId == userId { + performSDKLogin(userId: userId, token: existing, generation: generation, canRefreshToken: true) + return + } + clearCachedToken() + requestTokenAndLogin(userId: userId, generation: generation) + } + + private func requestTokenAndLogin(userId: String, generation: Int) { UserService.imToken() .subscribe(onNext: { [weak self] response in guard let self else { return } - guard let data = response.data, - let token = data["token"] as? String, - !token.isEmpty else { - self.finishLogin(success: false) + guard self.loginGeneration == generation else { return } + guard AppContextManager.shared.userId == userId else { + self.finishLogin(success: false, userId: userId) return } - AppContextManager.shared.imToken = token - self.performSDKLogin(userId: userId, token: token) + guard let data = response.data, + let token = data["token"] as? String, + !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + self.finishLogin(success: false, userId: userId) + return + } + let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + AppContextManager.shared.imToken = normalizedToken + AppContextManager.shared.imTokenUserId = userId + self.performSDKLogin(userId: userId, + token: normalizedToken, + generation: generation, + canRefreshToken: false) }, onError: { [weak self] _ in - self?.finishLogin(success: false) + guard let self, self.loginGeneration == generation else { return } + self.finishLogin(success: false, userId: userId) }) .disposed(by: disposeBag) } - private func performSDKLogin(userId: String, token: String) { + private func performSDKLogin(userId: String, + token: String, + generation: Int, + canRefreshToken: Bool) { OIMManager.manager.login(userId, token: token) { [weak self] _ in - self?.finishLogin(success: true) + guard let self, self.loginGeneration == generation else { return } + let isCurrentUser = AppContextManager.shared.userId == userId + && OIMManager.manager.getLoginUserID() == userId + self.isSessionInvalidated = !isCurrentUser + self.finishLogin(success: isCurrentUser, userId: userId) } onFailure: { [weak self] code, msg in + guard let self, self.loginGeneration == generation else { return } print("OpenIM login failed: \(code) \(msg ?? "")") - self?.finishLogin(success: false) + if canRefreshToken, AppContextManager.shared.userId == userId { + self.clearCachedToken() + self.requestTokenAndLogin(userId: userId, generation: generation) + } else { + self.finishLogin(success: false, userId: userId) + } } } - private func finishLogin(success: Bool) { + private func finishLogin(success: Bool, userId: String) { isLogining = false - let callbacks = pendingLoginCompletions - pendingLoginCompletions.removeAll() - callbacks.forEach { $0(success) } + let callbacks = pendingLoginCompletions.filter { $0.userId == userId } + pendingLoginCompletions.removeAll { $0.userId == userId } + callbacks.forEach { $0.completion(success) } + startLoginIfNeeded() } // MARK: - Logout func logout(completion: ((Bool) -> Void)? = nil) { + runOnMain { [weak self] in + guard let self else { return } + self.clearCachedToken() + self.isSessionInvalidated = true + self.loginGeneration += 1 + self.isLogining = false + let loginCallbacks = self.pendingLoginCompletions + self.pendingLoginCompletions.removeAll() + loginCallbacks.forEach { $0.completion(false) } + if let completion { + self.pendingLogoutCompletions.append(completion) + } + self.beginSDKLogout() + } + } + + private func beginSDKLogout() { + guard !isLoggingOut else { return } + guard OIMManager.manager.getLoginStatus() != .logout else { + finishLogout(success: true) + return + } + isLoggingOut = true OIMManager.manager.logoutWith(onSuccess: { (_: String?) in - completion?(true) - }, onFailure: { (code: Int, msg: String?) in + GroupIMService.shared.finishLogout(success: true) + }, onFailure: { [weak self] (code: Int, msg: String?) in print("OpenIM logout failed: \(code) \(msg ?? "")") - completion?(false) + self?.finishLogout(success: false) }) } + private func finishLogout(success: Bool) { + isLoggingOut = false + let callbacks = pendingLogoutCompletions + pendingLogoutCompletions.removeAll() + callbacks.forEach { $0(success) } + if success { + startLoginIfNeeded() + } else { + let loginCallbacks = pendingLoginCompletions + pendingLoginCompletions.removeAll() + loginCallbacks.forEach { $0.completion(false) } + } + } + + private func clearCachedToken() { + AppContextManager.shared.imToken = nil + AppContextManager.shared.imTokenUserId = nil + } + + private func invalidateSession() { + runOnMain { [weak self] in + self?.isSessionInvalidated = true + self?.clearCachedToken() + } + } + + private func runOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } + + // MARK: - Send + func sendGroupMessage(groupId: String, + makeMessage: @escaping () -> OIMMessageInfo, + onPrepared: ((OIMMessageInfo) -> Void)? = nil, + onSuccess: ((OIMMessageInfo) -> Void)?, + onProgress: ((Int) -> Void)? = nil, + onFailure: ((Int, String?) -> Void)?) { + let normalizedGroupId = groupId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedGroupId.isEmpty else { + onFailure?(-1, "圈子信息无效") + return + } + + ensureLogin { success in + let userId = AppContextManager.shared.userId + guard success, + OIMManager.manager.getLoginStatus() == .logged, + OIMManager.manager.getLoginUserID() == userId else { + onFailure?(-1, "消息服务连接失败,请重试") + return + } + let message = makeMessage() + guard !(message.clientMsgID ?? "").isEmpty else { + onFailure?(-1, "消息创建失败,请重试") + return + } + onPrepared?(message) + OIMManager.manager.sendMessage(message, + recvID: "", + groupID: normalizedGroupId, + offlinePushInfo: nil, + onSuccess: { returnedMessage in + onSuccess?(returnedMessage ?? message) + }, + onProgress: { progress in + onProgress?(progress) + }, + onFailure: { code, errorMessage in + onFailure?(code, errorMessage) + }) + } + } + + func displaySendError(_ message: String?, fallback: String) -> String { + let text = message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { return fallback } + let lowercased = text.lowercased() + if lowercased.contains("json") || lowercased.contains("unexpected end") { + return fallback + } + return text + } + // MARK: - Get Joined Groups func getJoinedGroups(completion: @escaping ([OIMGroupInfo]) -> Void) { OIMManager.manager.getJoinedGroupListWith(onSuccess: { groups in diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift index abaf5928..233938d9 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListVC.swift @@ -86,7 +86,7 @@ final class GroupMemberListVC: BaseViewController { self.viewModel.loadData(group: group, members: response.list) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift index f9ac040f..ba5829f2 100644 --- a/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift +++ b/QuickLocation/Section/Group/GroupMemberList/GroupMemberListView.swift @@ -264,7 +264,7 @@ final class GroupMemberListCell: UICollectionViewCell { nameLabel.text = model.showName nameLabel.textColor = UIColor(hexStr: isCurrentUser ? "#16B3FF" : "#0F2846") selectionImageView.isHidden = !isSelected - vipBackgroundView.isHidden = model.level == 1 + vipBackgroundView.isHidden = !model.shouldShowVipAvatarFrame let isSOS = model.status == 1 updateSOSBlink(isSOS) diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift index 546ab8c8..c9e20b8b 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift @@ -176,7 +176,7 @@ class GroupSettingVC: BaseViewController { self.requestReviewBadge() }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index e70aa01b..acae4557 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -24,6 +24,7 @@ final class GroupViewController: BaseViewController { private var groupRefreshWorkItem: DispatchWorkItem? private var groupInfoRequestID = UUID() private var hasAppeared = false + private var isEnteringGroupChat = false override func loadView() { rootView = GroupView(frame: UIScreen.main.bounds) @@ -184,10 +185,10 @@ final class GroupViewController: BaseViewController { rootView.createdTableView.rx.modelSelected(GroupCellData.self).asObservable(), rootView.joinedTableView.rx.modelSelected(GroupCellData.self).asObservable() ) - .subscribe(onNext: { data in + .subscribe(onNext: { [weak self] data in let groupId = data.group.group_key guard !groupId.isEmpty else { return } - AppRouter.push(Route.groupChat, userInfo: ["groupId": groupId]) + self?.openGroupChat(groupId: groupId) }) .disposed(by: disposeBag) @@ -215,6 +216,24 @@ final class GroupViewController: BaseViewController { .disposed(by: disposeBag) } + private func openGroupChat(groupId: String) { + guard !isEnteringGroupChat else { return } + isEnteringGroupChat = true + DLToast.showLoading() + GroupIMService.shared.ensureLogin { [weak self] success in + DispatchQueue.main.async { + guard let self else { return } + self.isEnteringGroupChat = false + DLToast.dismiss() + guard success else { + DLToast.showError(text: "消息服务登录失败,请重试") + return + } + AppRouter.push(Route.groupChat, userInfo: ["groupId": groupId]) + } + } + } + private func bindItineraryPage() { let page = rootView.itineraryPage diff --git a/QuickLocation/Section/Group/Mailbox/MailboxDetailViewController.swift b/QuickLocation/Section/Group/Mailbox/MailboxDetailViewController.swift index 85748ecf..4adffd07 100644 --- a/QuickLocation/Section/Group/Mailbox/MailboxDetailViewController.swift +++ b/QuickLocation/Section/Group/Mailbox/MailboxDetailViewController.swift @@ -78,7 +78,7 @@ final class MailboxDetailViewController: UIViewController { DLToast.dismiss() self?.isLoading = false self?.detailView.setControlsEnabled(true) - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/Mailbox/MailboxViewController.swift b/QuickLocation/Section/Group/Mailbox/MailboxViewController.swift index 4e69af7b..3ff2d55c 100644 --- a/QuickLocation/Section/Group/Mailbox/MailboxViewController.swift +++ b/QuickLocation/Section/Group/Mailbox/MailboxViewController.swift @@ -187,7 +187,7 @@ final class MailboxViewController: BaseViewController { self.updateContentState(showLoading: false) self.loadPendingCategoryIfNeeded() }, onError: { [weak self] error in - self?.finishLoadingWithError(message: error.localizedDescription) + self?.finishLoadingWithError(error: error) }) .disposed(by: disposeBag) } @@ -228,6 +228,13 @@ final class MailboxViewController: BaseViewController { DLToast.showError(text: message) } + private func finishLoadingWithError(error: Error) { + isLoading = false + finishRefreshControls() + updateContentState(showLoading: false) + DLToast.showError(error) + } + private func finishRefreshControls() { rootView.tableView.mj_header?.endRefreshing() if reachedEnd { @@ -281,7 +288,7 @@ final class MailboxViewController: BaseViewController { self.present(detailController, animated: true) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -343,7 +350,7 @@ final class MailboxViewController: BaseViewController { DLToast.showSuccess(text: "已清空") }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift index e155d937..b3b11f5b 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -230,7 +230,7 @@ final class MemberInfoVC: BaseViewController { self.refreshDetail() }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -248,7 +248,7 @@ final class MemberInfoVC: BaseViewController { case .success: self.presentRelationPicker() case let .failure(error): - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) } } } @@ -291,7 +291,7 @@ final class MemberInfoVC: BaseViewController { self.refreshDetail() }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -310,7 +310,7 @@ final class MemberInfoVC: BaseViewController { self?.syncUserConfig() }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -324,7 +324,7 @@ final class MemberInfoVC: BaseViewController { self?.syncUserConfig() }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -340,7 +340,7 @@ final class MemberInfoVC: BaseViewController { self?.syncUserConfig() }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift index 4d9240a3..c572428f 100644 --- a/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift +++ b/QuickLocation/Section/Group/RemoveMember/RemoveMemberVC.swift @@ -115,7 +115,7 @@ class RemoveMemberVC: BaseViewController { self.viewModel.loadData(response.list) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift index 11cb28b0..4a5675a6 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift @@ -25,6 +25,7 @@ final class CreateBubbleSetupView: UIView { private let subtitleLab = UILabel() private let titleLab = UILabel() private let hourPicker = BubbleHourPickerView() + private let vipOfferImageView = UIImageView(image: UIImage(named: "VipFeature/limited_offer")) private let sheetOverlay = UIView() private let sheetPanel = UIView() @@ -83,6 +84,7 @@ final class CreateBubbleSetupView: UIView { addSubview(subtitleLab) addSubview(hourPicker) addSubview(confirmBtn) + addSubview(vipOfferImageView) heroView.showsOuterDecorBubbles = false heroView.layoutChain @@ -113,6 +115,25 @@ final class CreateBubbleSetupView: UIView { confirmBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) confirmBtn.cornerRadius = 20 confirmBtn.layoutChain.left(30).right(30).bottom(34).height(56) + + vipOfferImageView.contentMode = .scaleAspectFit + vipOfferImageView.isHidden = true + vipOfferImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + vipOfferImageView.trailingAnchor.constraint(equalTo: confirmBtn.trailingAnchor, constant: -6), + vipOfferImageView.bottomAnchor.constraint(equalTo: confirmBtn.topAnchor, constant: 10), + vipOfferImageView.widthAnchor.constraint(equalToConstant: 94), + vipOfferImageView.heightAnchor.constraint(equalToConstant: 32) + ]) + } + + func updateVipAccess(isUnlocked: Bool) { + confirmBtn.setTitle(isUnlocked ? "确定" : "开通会员 解锁功能", for: .normal) + confirmBtn.setBackgroundImage( + UIImage(named: isUnlocked ? "Common/button_bg_2" : "VipFeature/wide_button_bg"), + for: .normal + ) + vipOfferImageView.isHidden = isUnlocked } private func setupSheet() { diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift index 20bf91b6..36ad1e62 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleVC.swift @@ -26,6 +26,7 @@ class CreateBubbleVC: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + rootView.updateVipAccess(isUnlocked: isVipUnlocked) requestUserStatus() } @@ -48,6 +49,10 @@ class CreateBubbleVC: BaseViewController { rootView.setupView.confirmBtn.rx.tap .subscribe(onNext: { [weak self] in guard let self else { return } + guard self.isVipUnlocked else { + AppRouter.push(Route.vipRecharge) + return + } let hours = self.rootView.setupView.selectedHour.value let message = self.rootView.setupView.buildMessage(for: hours) self.rootView.setupView.showNotifySheet(message: message) @@ -80,18 +85,21 @@ class CreateBubbleVC: BaseViewController { } private func submitCreate() { - guard let systemConfig = AppContextManager.shared.systemConfig, systemConfig.isIntercept == false else { - let hours = rootView.setupView.selectedHour.value - requestSetBubble(enable: true, keep_time: hours) + guard isVipUnlocked else { + rootView.setupView.dismissNotifySheet() + AppRouter.push(Route.vipRecharge) return } + let hours = rootView.setupView.selectedHour.value + requestSetBubble(enable: true, keep_time: hours) + } - if AppContextManager.shared.vip > 1 { - let hours = rootView.setupView.selectedHour.value - requestSetBubble(enable: true, keep_time: hours) - } else { - CreateBubblePopView.show() + private var isVipUnlocked: Bool { + guard AppContextManager.shared.systemConfig?.isIntercept == false else { + return false } + + return AppContextManager.shared.vip > 1 } private func restoreCachedBubbleIfNeeded() { @@ -119,7 +127,7 @@ class CreateBubbleVC: BaseViewController { self.applyUserStatus(model) }, onError: { [weak self] error in guard let self, requestVersion == self.statusRequestVersion else { return } - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift index 35ca0d89..0b132dc8 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleView.swift @@ -90,4 +90,8 @@ class CreateBubbleView: UIView { func updateNavTitleForSheet(_ visible: Bool) { navView.titleLabel.text = visible ? "通知您的圈子" : "设置气泡时间" } + + func updateVipAccess(isUnlocked: Bool) { + setupView.updateVipAccess(isUnlocked: isUnlocked) + } } diff --git a/QuickLocation/Section/Home/GroupMemberView.swift b/QuickLocation/Section/Home/GroupMemberView.swift index e4be1bbf..44ea7819 100644 --- a/QuickLocation/Section/Home/GroupMemberView.swift +++ b/QuickLocation/Section/Home/GroupMemberView.swift @@ -158,6 +158,7 @@ class GroupMemberCell: UITableViewCell { ownView.isHidden = !isOwn avaterImgView.setHeadPic(model.displayHeadPic) vipIcon.image = model.vipIcon + vipIcon.isHidden = !model.shouldShowVipAvatarFrame nameLab.text = model.showName // 位置 locationLab.text = "在 " + model.lastLocation diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 0e83b608..6fd867bb 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -10,14 +10,25 @@ import Network import CoreLocation import SwiftyUserDefaults +enum MemberFeatureAccessState: Equatable { + case available + case vipRequired + case privilegeDenied +} + class GroupMemberView2: UIView { private var navBarHeightConstraint: NSLayoutConstraint? private var todayTrackTopToInteraction: NSLayoutConstraint? private var todayTrackTopToMember: NSLayoutConstraint? + private var appUsageTopToPhoneReportConstraint: NSLayoutConstraint? + private var appUsageTopToTodayTrackConstraint: NSLayoutConstraint? + private var todayTrackBottomConstraint: NSLayoutConstraint? private var phoneReportBottomConstraint: NSLayoutConstraint? private var appUsageBottomConstraint: NSLayoutConstraint? + private var isAppUsageVisible = false private var currentMemberModel: GroupMemberModel? + private var currentGroupVip = GroupMemberVip() private var emojiEchoByUserId: [String: String] = [:] private var messageEchoByUserId: [String: String] = [:] private var phoneStatusObserver: NSObjectProtocol? @@ -26,6 +37,7 @@ class GroupMemberView2: UIView { private var localWeatherSnapshotKey: String? private var localWeatherUpdatedAt: Date? private var pendingWeatherLocationKey: String? + private(set) var isTodayTrackVipRequired = false var onCoupleMemberTap: ((GroupMemberModel) -> Void)? private static let weatherRefreshInterval: TimeInterval = 15 * 60 @@ -43,9 +55,11 @@ class GroupMemberView2: UIView { func setupMemberInfo( _ model: GroupMemberModel, isOwner: Bool, - phoneUsage: PhoneUsageTodayModel? + phoneUsage: PhoneUsageTodayModel?, + groupVip: GroupMemberVip ) { currentMemberModel = model + currentGroupVip = groupVip memberNameLab.text = model.showName ownView.isHidden = !isOwner relationIconView.configure(relationIdx: model.extra.relation_idx) @@ -64,9 +78,7 @@ class GroupMemberView2: UIView { } applyPhoneUsage(phoneUsage) - if isCurrentUser { - applyLocalDeviceInfo() - startLocalPhoneStatusObserving() + if isCurrentUser, !isPhoneDeviceVipRequired { requestLocalWeather() } updateRealtimeInteractionVisibility(isCurrentUser: isCurrentUser) @@ -75,6 +87,7 @@ class GroupMemberView2: UIView { func clearMemberInfo() { currentMemberModel = nil + currentGroupVip = GroupMemberVip() memberNameLab.text = "" ownView.isHidden = true relationIconView.configure(relationIdx: "") @@ -92,16 +105,23 @@ class GroupMemberView2: UIView { func updatePhoneUsage(_ phoneUsage: PhoneUsageTodayModel?, for userId: String) { guard currentMemberModel?.user_id == userId else { return } applyPhoneUsage(phoneUsage) - if Self.isCurrentUser(userId) { + if Self.isCurrentUser(userId), !isPhoneDeviceVipRequired { applyLocalDeviceInfo() } } func refreshLocalDeviceInfoIfNeeded() { - guard isShowingCurrentUser else { return } + guard isShowingCurrentUser, !isPhoneDeviceVipRequired else { return } applyLocalDeviceInfo() } + func refreshInterceptState() { + memberCV.reloadData() + coupleLeftSlot.refreshVipVisibility() + coupleRightSlot.refreshVipVisibility() + updateFeatureVisibilityLayout() + } + func applyEmojiEcho(name: String, for userId: String) { emojiEchoByUserId[userId] = name if currentMemberModel?.user_id == userId { @@ -186,7 +206,9 @@ class GroupMemberView2: UIView { } private func applyPhoneUsage(_ usage: PhoneUsageTodayModel?) { - if isShowingCurrentUser { + if isPhoneDeviceVipRequired { + stopLocalPhoneStatusObserving() + } else if isShowingCurrentUser { applyLocalDeviceInfo() startLocalPhoneStatusObserving() } else { @@ -197,51 +219,95 @@ class GroupMemberView2: UIView { applyDeviceInfo(usage?.phoneInfo) } } + applyPhoneDeviceAccessState() if isShowingOfflineOther { phoneReportView.configure(with: nil) appUsageView.configure(with: nil) - applyPrivilegeMasks(appUsagePreview: nil) + applyFeatureAccessStates(appUsagePreview: nil) applyOfflineStayPoints() return } phoneReportView.configure(with: Self.phoneReportPreview(from: usage)) let appUsagePreview = MemberAppUsagePreview(topApps: usage?.topApps ?? []) appUsageView.configure(with: appUsagePreview) - applyPrivilegeMasks(appUsagePreview: appUsagePreview) + applyFeatureAccessStates(appUsagePreview: appUsagePreview) applyStayPoints(usage?.stayPoints ?? []) } var isPhoneReportDenied: Bool { phoneReportView.isDenied } var isAppUsageDenied: Bool { appUsageView.isDenied } + var isPhoneReportVipRequired: Bool { phoneReportView.isVipRequired } + var isAppUsageVipRequired: Bool { appUsageView.isVipRequired } var onPhoneReportRemind: (() -> Void)? var onAppUsageRemind: (() -> Void)? - private func applyPrivilegeMasks(appUsagePreview: MemberAppUsagePreview? = nil) { + private func applyFeatureAccessStates(appUsagePreview: MemberAppUsagePreview?) { guard currentMemberModel != nil else { - phoneReportView.setDenied(false) - appUsageView.setDenied(false) + isTodayTrackVipRequired = false + todayTrackVipView.isHidden = true + phoneReportView.setAccessState(.available) + appUsageView.setAccessState(.available) updateAppUsageVisibility(appUsagePreview != nil) return } let isSelf = isShowingCurrentUser - let privilege = currentMemberModel?.extra.privilege ?? GroupPrivilege() - let phoneDenied = !isSelf && !privilege.phone_report - let usageDenied = !isSelf && !privilege.app_usage_record - phoneReportView.setDenied(phoneDenied) + let memberPrivilege = currentMemberModel?.extra.privilege ?? GroupPrivilege() + let phoneState = Self.accessState( + vipAllowed: currentGroupVip.phone_report, + privilegeAllowed: isSelf || memberPrivilege.phone_report + ) + let appUsageState = Self.accessState( + vipAllowed: currentGroupVip.app_usage_record, + privilegeAllowed: isSelf || memberPrivilege.app_usage_record + ) + + isTodayTrackVipRequired = !isSelf && currentGroupVip.trip_analysis_others == false + todayTrackVipView.isHidden = !isTodayTrackVipRequired + phoneReportView.setAccessState(phoneState) phoneReportView.onDeniedAction = { [weak self] in self?.onPhoneReportRemind?() } - appUsageView.setDenied(usageDenied) + appUsageView.setAccessState(appUsageState) appUsageView.onDeniedAction = { [weak self] in self?.onAppUsageRemind?() } - let showUsage = appUsagePreview != nil || usageDenied - if appUsagePreview == nil, usageDenied { + let showUsage = appUsagePreview != nil || appUsageState != .available + if appUsagePreview == nil, appUsageState != .available { appUsageView.configure(with: nil) } updateAppUsageVisibility(showUsage) } + private static func accessState( + vipAllowed: Bool?, + privilegeAllowed: Bool + ) -> MemberFeatureAccessState { + if vipAllowed == false { + return .vipRequired + } + if !privilegeAllowed { + return .privilegeDenied + } + return .available + } + private var isShowingCurrentUser: Bool { Self.isCurrentUser(currentMemberModel?.user_id) } + private var isPhoneDeviceVipRequired: Bool { + currentMemberModel != nil && !isShowingCurrentUser && currentGroupVip.view_phone == false + } + + private func applyPhoneDeviceAccessState() { + let requiresVip = isPhoneDeviceVipRequired + phoneModelView.isHidden = requiresVip + batteryView.isHidden = requiresVip + networkView.isHidden = requiresVip + weatherView.isHidden = requiresVip + phoneDeviceVipButton.isHidden = !requiresVip + } + + @objc private func handlePhoneDeviceVipTap() { + AppRouter.push(Route.vipRecharge) + } + private static func isCurrentUser(_ userId: String?) -> Bool { let memberId = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let selfId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -280,7 +346,7 @@ class GroupMemberView2: UIView { object: nil, queue: .main ) { [weak self] _ in - guard let self, self.isShowingCurrentUser else { return } + guard let self, self.isShowingCurrentUser, !self.isPhoneDeviceVipRequired else { return } self.applyLocalDeviceInfo() } } @@ -309,6 +375,7 @@ class GroupMemberView2: UIView { } private func requestLocalWeather() { + guard !isPhoneDeviceVipRequired else { return } guard let latitude = Defaults[\.currentLatitude], let longitude = Defaults[\.currentLongitude] else { if localWeatherSnapshot == nil { @@ -342,7 +409,7 @@ class GroupMemberView2: UIView { MemberWeatherService.shared.weather(for: location) { [weak self] result in guard let self, self.weatherRequestID == requestID else { return } self.pendingWeatherLocationKey = nil - guard self.isShowingCurrentUser else { return } + guard self.isShowingCurrentUser, !self.isPhoneDeviceVipRequired else { return } switch result { case .success(let snapshot): self.localWeatherSnapshot = snapshot @@ -369,14 +436,20 @@ class GroupMemberView2: UIView { } private func updateAppUsageVisibility(_ visible: Bool) { - if visible { - phoneReportBottomConstraint?.isActive = false - appUsageBottomConstraint?.isActive = true - } else { - appUsageBottomConstraint?.isActive = false - phoneReportBottomConstraint?.isActive = true - } + isAppUsageVisible = visible appUsageView.isHidden = !visible + updateFeatureVisibilityLayout() + } + + private func updateFeatureVisibilityLayout() { + let hidesPhoneReport = AppContextManager.shared.systemConfig?.isIntercept == true + phoneReportView.isHidden = hidesPhoneReport + + appUsageTopToPhoneReportConstraint?.isActive = !hidesPhoneReport + appUsageTopToTodayTrackConstraint?.isActive = hidesPhoneReport + phoneReportBottomConstraint?.isActive = !hidesPhoneReport && !isAppUsageVisible + appUsageBottomConstraint?.isActive = isAppUsageVisible + todayTrackBottomConstraint?.isActive = hidesPhoneReport && !isAppUsageVisible setNeedsLayout() } @@ -594,8 +667,16 @@ class GroupMemberView2: UIView { scrollContentView.addSubview(appUsageView) appUsageView.layoutChain - .topToBottomOfView(phoneReportView, offset: 15) .edgesHorzontal() + appUsageTopToPhoneReportConstraint = appUsageView.topAnchor.constraint( + equalTo: phoneReportView.bottomAnchor, + constant: 15 + ) + appUsageTopToPhoneReportConstraint?.isActive = true + appUsageTopToTodayTrackConstraint = appUsageView.topAnchor.constraint( + equalTo: todayTrackView.bottomAnchor, + constant: 15 + ) phoneReportBottomConstraint = phoneReportView.bottomAnchor.constraint( equalTo: scrollContentView.bottomAnchor, @@ -605,6 +686,10 @@ class GroupMemberView2: UIView { equalTo: scrollContentView.bottomAnchor, constant: -(49 + kSafeBottomMargin) ) + todayTrackBottomConstraint = todayTrackView.bottomAnchor.constraint( + equalTo: scrollContentView.bottomAnchor, + constant: -(49 + kSafeBottomMargin) + ) phoneReportBottomConstraint?.isActive = true return view @@ -864,6 +949,7 @@ class GroupMemberView2: UIView { view.addSubview(batteryView) view.addSubview(networkView) view.addSubview(weatherView) + view.addSubview(phoneDeviceVipButton) phoneModelView.layoutChain .topToBottomOfView(locationLab, offset: 20) @@ -884,6 +970,12 @@ class GroupMemberView2: UIView { .leftToRightOfView(networkView) .right() .widthToView(phoneModelView) + + phoneDeviceVipButton.layoutChain + .centerX() + .centerY(phoneModelView) + .width(174) + .height(34) return view }() @@ -1146,6 +1238,17 @@ class GroupMemberView2: UIView { label.font = .systemFont(ofSize: 12, weight: .medium) return label }() + + private lazy var phoneDeviceVipButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("开通会员 解锁功能", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = FontManager.boboBold(14) + button.setBackgroundImage(UIImage(named: "VipFeature/narrow_button_bg"), for: .normal) + button.isHidden = true + button.addTarget(self, action: #selector(handlePhoneDeviceVipTap), for: .touchUpInside) + return button + }() // MARK: - 手机报告 / APP使用记录 lazy var phoneReportView = MemberPhoneReportView() @@ -1359,9 +1462,28 @@ class GroupMemberView2: UIView { stayNodataView.layoutChain .topToBottomOfView(stayTxtLab, offset: 14) .leftToView(stayTxtLab) + + view.addSubview(todayTrackVipView) + todayTrackVipView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + todayTrackVipView.topAnchor.constraint(equalTo: infoView.topAnchor), + todayTrackVipView.leadingAnchor.constraint(equalTo: infoView.leadingAnchor), + todayTrackVipView.trailingAnchor.constraint(equalTo: infoView.trailingAnchor), + todayTrackVipView.bottomAnchor.constraint(equalTo: infoView.bottomAnchor) + ]) return view }() + + private lazy var todayTrackVipView: UIImageView = { + let view = UIImageView(image: UIImage(named: "MemberVipRequired/today_track")) + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.cornerRadius = 16 + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() lazy var stayNumLab: UILabel = { let label = UILabel() @@ -1507,7 +1629,11 @@ private final class CoupleMemberSlot: UIControl { avatarImageView.setHeadPic(model?.displayHeadPic) avatarImageView.layer.borderColor = UIColor(hexStr: isSelected ? "#FFD43B" : "#FFFFFF").cgColor nameLabel.text = model?.showName - vipImageView.isHidden = model?.level == 1 || model == nil + refreshVipVisibility() + } + + func refreshVipVisibility() { + vipImageView.isHidden = member?.shouldShowVipAvatarFrame != true } private func setupUI() { diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 09a18d24..4d38ce70 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -330,7 +330,12 @@ class HomeViewController: BaseViewController { // 今日轨迹二级页 rootView.groupMemberView.todayTrackView.rx.tapGesture.subscribe(onNext: { [weak self] _ in - self?.openTodayTrackDetail() + guard let self else { return } + if self.rootView.groupMemberView.isTodayTrackVipRequired { + AppRouter.push(Route.vipRecharge) + } else { + self.openTodayTrackDetail() + } }).disposed(by: disposeBag) // 实时互动 @@ -344,13 +349,23 @@ class HomeViewController: BaseViewController { // 手机报告二级页 rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in - guard let self, !self.rootView.groupMemberView.isPhoneReportDenied else { return } + guard let self else { return } + if self.rootView.groupMemberView.isPhoneReportVipRequired { + AppRouter.push(Route.vipRecharge) + return + } + guard !self.rootView.groupMemberView.isPhoneReportDenied else { return } self.openPhoneReportDetail() }).disposed(by: disposeBag) // APP 报告二级页 rootView.groupMemberView.appUsageView.rx.tapGesture.subscribe(onNext: { [weak self] _ in - guard let self, !self.rootView.groupMemberView.isAppUsageDenied else { return } + guard let self else { return } + if self.rootView.groupMemberView.isAppUsageVipRequired { + AppRouter.push(Route.vipRecharge) + return + } + guard !self.rootView.groupMemberView.isAppUsageDenied else { return } self.openAppUsageReport() }).disposed(by: disposeBag) @@ -379,6 +394,7 @@ class HomeViewController: BaseViewController { guard let self = self, let model = response.model else { return } Defaults[\.loginToken] = model.token AppContextManager.shared.systemConfig = model.config + self.rootView.groupMemberView.refreshInterceptState() // rootView.searchLottieView.isHidden = model.config?.isIntercept == true self.requestUserInfo { [weak self] in self?.requestGroupInfo() @@ -595,7 +611,7 @@ class HomeViewController: BaseViewController { done(true) }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) done(false) }) .disposed(by: disposeBag) @@ -660,7 +676,7 @@ class HomeViewController: BaseViewController { }, onError: { [weak self] error in guard let self, self.groupInfoRequestID == requestID else { return } if showError { - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) @@ -833,7 +849,7 @@ class HomeViewController: BaseViewController { return } - self.viewModel.updateMembers(response.list) + self.viewModel.updateGroupUsers(response.list, vip: response.vip) // 成员接口是气泡结束的权威来源:status=0 时重新显示地图点。 self.syncMemberAnnotations(self.viewModel.memberList) self.refreshMQTTSubscriptions(self.viewModel.memberList) @@ -855,7 +871,7 @@ class HomeViewController: BaseViewController { self.groupInfoRequestID == requestID, self.viewModel.groupModel?.default_group_key == groupKey else { return } if showError { - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) } if retryAllowed { self.scheduleGroupDataRetry(changes: changes) @@ -945,7 +961,7 @@ class HomeViewController: BaseViewController { GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) .subscribe(onNext: { _ in }, onError: { error in - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -1168,7 +1184,8 @@ class HomeViewController: BaseViewController { rootView.groupMemberView.setupMemberInfo( model, isOwner: viewModel.isGroupOwn(id: model.user_id), - phoneUsage: usage + phoneUsage: usage, + groupVip: viewModel.groupVip ) } @@ -1424,7 +1441,7 @@ extension HomeViewController { self?.queryUnlockRequests() done(true) }, onError: { error in - DLToast.show(text: error.gatewayMessage ?? "解锁失败") + DLToast.show(error, fallbackText: "解锁失败") done(false) }) .disposed(by: disposeBag) diff --git a/QuickLocation/Section/Home/HomeViewModel.swift b/QuickLocation/Section/Home/HomeViewModel.swift index 71411cd9..a2483ca5 100644 --- a/QuickLocation/Section/Home/HomeViewModel.swift +++ b/QuickLocation/Section/Home/HomeViewModel.swift @@ -87,6 +87,7 @@ class HomeViewModel { } private(set) var memberList: [GroupMemberModel] = [] + private(set) var groupVip = GroupMemberVip() func markMemberInBubble(userId: String) { updateMemberStatus(userId: userId, status: 2) @@ -112,7 +113,13 @@ class HomeViewModel { sectionedItems.onNext(memberList.mapSection()) } + func updateGroupUsers(_ list: [GroupMemberModel], vip: GroupMemberVip) { + groupVip = vip + updateMembers(list) + } + func clearMembers() { + groupVip = GroupMemberVip() updateMembers([]) } diff --git a/QuickLocation/Section/Home/LockedAppPopView.swift b/QuickLocation/Section/Home/LockedAppPopView.swift index 0e4687aa..72e060ef 100644 --- a/QuickLocation/Section/Home/LockedAppPopView.swift +++ b/QuickLocation/Section/Home/LockedAppPopView.swift @@ -339,7 +339,7 @@ final class LockedAppPopView: UIView { DLToast.show(text: "已向圈主发送解锁请求") Self.dismiss() }, onError: { error in - DLToast.show(text: error.gatewayMessage ?? "发送失败") + DLToast.show(error, fallbackText: "发送失败") }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/MemberAppUsageView.swift b/QuickLocation/Section/Home/MemberAppUsageView.swift index cf31fc0f..ec5606bd 100644 --- a/QuickLocation/Section/Home/MemberAppUsageView.swift +++ b/QuickLocation/Section/Home/MemberAppUsageView.swift @@ -74,8 +74,10 @@ final class MemberAppUsageView: UIView { private let usageTitleLab = UILabel() private let topTitleLab = UILabel() private let topStack = UIStackView() + private let vipRequiredView = UIImageView() private let deniedOverlay = PrivilegeDeniedOverlay() private(set) var isDenied = false + private(set) var isVipRequired = false var onDeniedAction: (() -> Void)? override init(frame: CGRect) { @@ -88,8 +90,9 @@ final class MemberAppUsageView: UIView { fatalError("init(coder:) has not been implemented") } - func setDenied(_ denied: Bool) { - isDenied = denied + func setAccessState(_ state: MemberFeatureAccessState) { + isDenied = state == .privilegeDenied + isVipRequired = state == .vipRequired deniedOverlay.configure( title: "TA未开启设置", message: "无法查看app使用记录", @@ -97,9 +100,13 @@ final class MemberAppUsageView: UIView { imageName: "PrivilegeDenied/app_usage" ) deniedOverlay.onAction = { [weak self] in self?.onDeniedAction?() } - deniedOverlay.setVisible(denied) + deniedOverlay.setVisible(isDenied) deniedOverlay.layer.cornerRadius = cardView.layer.cornerRadius - gestureRecognizers?.forEach { $0.isEnabled = !denied } + vipRequiredView.isHidden = !isVipRequired + [usageTitleLab, topTitleLab, donutView, totalTimeLab, totalHintLab, topStack].forEach { + $0.isHidden = isVipRequired + } + gestureRecognizers?.forEach { $0.isEnabled = !isDenied } } func configure(with preview: MemberAppUsagePreview?) { @@ -200,6 +207,14 @@ final class MemberAppUsageView: UIView { .left(160) .right(14) + vipRequiredView.image = UIImage(named: "MemberVipRequired/app_usage") + vipRequiredView.contentMode = .scaleAspectFill + vipRequiredView.clipsToBounds = true + vipRequiredView.isHidden = true + vipRequiredView.isUserInteractionEnabled = false + cardView.addSubview(vipRequiredView) + vipRequiredView.layoutChain.edges() + cardView.bringSubviewToFront(deniedOverlay) } diff --git a/QuickLocation/Section/Home/MemberPhoneReportView.swift b/QuickLocation/Section/Home/MemberPhoneReportView.swift index d58b44ad..1b9eeca1 100644 --- a/QuickLocation/Section/Home/MemberPhoneReportView.swift +++ b/QuickLocation/Section/Home/MemberPhoneReportView.swift @@ -52,8 +52,10 @@ final class MemberPhoneReportView: UIView { private let unlockIcon = UIImageView() private let unlockTitleLab = UILabel() private let unlockValueLab = UILabel() + private let vipRequiredView = UIImageView() private let deniedOverlay = PrivilegeDeniedOverlay() private(set) var isDenied = false + private(set) var isVipRequired = false var onDeniedAction: (() -> Void)? override init(frame: CGRect) { @@ -66,8 +68,9 @@ final class MemberPhoneReportView: UIView { fatalError("init(coder:) has not been implemented") } - func setDenied(_ denied: Bool) { - isDenied = denied + func setAccessState(_ state: MemberFeatureAccessState) { + isDenied = state == .privilegeDenied + isVipRequired = state == .vipRequired deniedOverlay.configure( title: "TA未开启设置", message: "无法查看手机报告", @@ -75,9 +78,12 @@ final class MemberPhoneReportView: UIView { imageName: "PrivilegeDenied/phone_report" ) deniedOverlay.onAction = { [weak self] in self?.onDeniedAction?() } - deniedOverlay.setVisible(denied) + deniedOverlay.setVisible(isDenied) deniedOverlay.layer.cornerRadius = cardView.layer.cornerRadius - gestureRecognizers?.forEach { $0.isEnabled = !denied } + vipRequiredView.isHidden = !isVipRequired + leftPanel.isHidden = isVipRequired + rightPanel.isHidden = isVipRequired + gestureRecognizers?.forEach { $0.isEnabled = !isDenied } } func configure(with preview: MemberPhoneReportPreview?) { @@ -259,6 +265,14 @@ final class MemberPhoneReportView: UIView { .leftToRightOfView(unlockValueLab, offset: 3) .bottomToView(unlockValueLab, offset: -4) + vipRequiredView.image = UIImage(named: "MemberVipRequired/phone_report") + vipRequiredView.contentMode = .scaleAspectFill + vipRequiredView.clipsToBounds = true + vipRequiredView.isHidden = true + vipRequiredView.isUserInteractionEnabled = false + cardView.addSubview(vipRequiredView) + vipRequiredView.layoutChain.edges() + cardView.bringSubviewToFront(deniedOverlay) } } diff --git a/QuickLocation/Section/Home/SOS/SOSListViewController.swift b/QuickLocation/Section/Home/SOS/SOSListViewController.swift index b5f43344..82f3408b 100644 --- a/QuickLocation/Section/Home/SOS/SOSListViewController.swift +++ b/QuickLocation/Section/Home/SOS/SOSListViewController.swift @@ -137,7 +137,7 @@ final class SOSListViewController: BaseViewController { self.rootView.tableView.reloadData() }, onError: { [weak self] error in self?.finishLoading() - DLToast.showError(text: error.gatewayMessage ?? error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/SOS/SOSViewController.swift b/QuickLocation/Section/Home/SOS/SOSViewController.swift index da1070a5..2cef329c 100644 --- a/QuickLocation/Section/Home/SOS/SOSViewController.swift +++ b/QuickLocation/Section/Home/SOS/SOSViewController.swift @@ -83,7 +83,7 @@ final class SOSViewController: BaseViewController { self.rootView.setState(model.status == 1 ? .active : .idle) }, onError: { [weak self] error in guard let self, requestVersion == self.statusRequestVersion else { return } - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift index bfed7086..d3ec9131 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift @@ -43,14 +43,20 @@ class SearchLocationResultVC: BaseViewController { private func applyResultPresentation() { handleCode() let isVip = isVipUnlocked() - rootView.vipMaskView.isHidden = isVip + rootView.updateVipAccess(isUnlocked: isVip) if !isVip { - rootView.actionBtn.setTitle("开通会员", for: .normal) + rootView.actionBtn.setTitle("开通会员 解锁功能", for: .normal) rootView.actionBtn.isHidden = false } } private func reactiveAction() { + rootView.vipMaskView.rx.tap + .subscribe(onNext: { + AppRouter.push(Route.vipRecharge) + }) + .disposed(by: disposeBag) + rootView.actionBtn.rx.tap.subscribe(onNext: { _ in guard self.isVipUnlocked() else { AppRouter.push(Route.vipRecharge) diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift index 857db5eb..d0d8d022 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift @@ -9,6 +9,7 @@ import Lottie class SearchLocationResultView: UIView { let headerView = SearchLocationHeaderView() + private var vipResultHeightConstraint: NSLayoutConstraint? private func setupUI() { addSubview(navBgView) @@ -44,6 +45,17 @@ class SearchLocationResultView: UIView { .height(showInviteRow ? 72 : 0) } + func updateVipAccess(isUnlocked: Bool) { + normalResultContentView.isHidden = !isUnlocked + vipMaskView.isHidden = isUnlocked + vipOfferImageView.isHidden = isUnlocked + vipResultHeightConstraint?.isActive = !isUnlocked + actionBtn.setBackgroundImage( + UIImage(named: isUnlocked ? "Common/button_bg_2" : "VipFeature/wide_button_bg"), + for: .normal + ) + } + lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") @@ -73,6 +85,7 @@ class SearchLocationResultView: UIView { resultCard.layoutChain .topToBottomOfView(headerView, offset: 20) .edgesHorzontal(15) + vipResultHeightConstraint = resultCard.heightAnchor.constraint(equalToConstant: 158) contentView.addSubview(actionBtn) actionBtn.layoutChain @@ -81,6 +94,15 @@ class SearchLocationResultView: UIView { .height(56) .bottom(kSafeBottomMargin + 30) + contentView.addSubview(vipOfferImageView) + vipOfferImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + vipOfferImageView.trailingAnchor.constraint(equalTo: actionBtn.trailingAnchor, constant: -6), + vipOfferImageView.bottomAnchor.constraint(equalTo: actionBtn.topAnchor, constant: 10), + vipOfferImageView.widthAnchor.constraint(equalToConstant: 94), + vipOfferImageView.heightAnchor.constraint(equalToConstant: 32) + ]) + return view }() @@ -89,8 +111,25 @@ class SearchLocationResultView: UIView { card.backgroundColor = UIColor(hexStr: "#EFF9FF") card.cornerRadius = 16 + card.addSubview(resultStackView) + resultStackView.layoutChain.edges() + resultStackView.addArrangedSubview(normalResultContentView) + + card.addSubview(vipMaskView) + vipMaskView.layoutChain.edges() + return card + }() + + private lazy var resultStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + return stackView + }() + + private lazy var normalResultContentView: UIView = { + let contentView = UIView() let locationRow = UIView() - card.addSubview(locationRow) + contentView.addSubview(locationRow) let pinIcon = UIImageView(image: UIImage(named: "Home/member_location")) pinIcon.contentMode = .scaleAspectFit @@ -115,7 +154,7 @@ class SearchLocationResultView: UIView { let pillContainer = UIView() pillContainer.backgroundColor = .white pillContainer.cornerRadius = 14 - card.addSubview(pillContainer) + contentView.addSubview(pillContainer) pillContainer.addSubview(statusPillLab) statusPillLab.layoutChain.edgesHorzontal(58).edgesVertical(13) pillContainer.layoutChain @@ -123,30 +162,27 @@ class SearchLocationResultView: UIView { .centerX() .height(48) - card.addSubview(disclaimerIcon) + contentView.addSubview(disclaimerIcon) disclaimerIcon.layoutChain .topToBottomOfView(pillContainer, offset: 14) .left(24) .width(12) .height(12) - card.addSubview(disclaimerLab) + contentView.addSubview(disclaimerLab) disclaimerLab.layoutChain .topToBottomOfView(pillContainer, offset: 11) .leftToRightOfView(disclaimerIcon, offset: 3) .right(23) - card.addSubview(inviteView) + contentView.addSubview(inviteView) inviteView.layoutChain .topToBottomOfView(disclaimerLab, offset: 0) .edgesHorzontal(12) .height(0) .bottom(16) - card.addSubview(vipMaskView) - vipMaskView.layoutChain.edges() - - return card + return contentView }() lazy var phoneAreaLab: UILabel = { @@ -197,6 +233,13 @@ class SearchLocationResultView: UIView { return btn }() + private lazy var vipOfferImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "VipFeature/limited_offer")) + view.contentMode = .scaleAspectFit + view.isHidden = true + return view + }() + var inviteBtn: UIButton { actionBtn } lazy var inviteView: UIView = { @@ -240,10 +283,9 @@ class SearchLocationResultView: UIView { return btn }() - lazy var vipMaskView: UIImageView = { - let view = UIImageView() - view.image = UIImage(named: "SearchLocation/vip_mask") - view.contentMode = .scaleAspectFill + lazy var vipMaskView: UIButton = { + let view = UIButton(type: .custom) + view.setBackgroundImage(UIImage(named: "VipFeature/realtime_location"), for: .normal) view.isHidden = true return view }() diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift index 72f891b4..73191533 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift @@ -84,6 +84,11 @@ final class TodayTrackDetailVC: BaseViewController { } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.start() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let width = rootView.dateCV.bounds.width @@ -125,6 +130,12 @@ final class TodayTrackDetailVC: BaseViewController { }) .disposed(by: disposeBag) + rootView.vipRequiredBtn.rx.tap + .subscribe(onNext: { + AppRouter.push(Route.vipRecharge) + }) + .disposed(by: disposeBag) + viewModel.displayedTrips .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] trips in @@ -135,7 +146,22 @@ final class TodayTrackDetailVC: BaseViewController { viewModel.isEmpty .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] empty in - self?.rootView.emptyLab.isHidden = !empty + guard let self, !self.viewModel.isVipRequired.value else { return } + self.rootView.emptyLab.isHidden = !empty + }) + .disposed(by: disposeBag) + + viewModel.isVipRequired + .observe(on: MainScheduler.instance) + .distinctUntilChanged() + .subscribe(onNext: { [weak self] required in + guard let self else { return } + self.stopPlayback(resetProgress: true) + self.rootView.setVipRequired(required) + self.updateSheetHeight(isVipRequired: required) + if !required { + self.rootView.emptyLab.isHidden = !self.viewModel.isEmpty.value + } }) .disposed(by: disposeBag) @@ -174,7 +200,14 @@ final class TodayTrackDetailVC: BaseViewController { }) .disposed(by: disposeBag) - viewModel.start() + } + + private func updateSheetHeight(isVipRequired: Bool) { + guard let constraint = rootView.sheetHeightConstraint else { return } + let minHeight: CGFloat = 200 + kSafeBottomMargin + let maxHeight: CGFloat = UIScreen.main.bounds.height * 0.7 + let vipHeight: CGFloat = UIScreen.main.bounds.height * 0.47 + constraint.constant = isVipRequired ? min(maxHeight, max(minHeight, vipHeight)) : minHeight } private func bindSheetPan() { diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift index 91589403..806c3790 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift @@ -150,6 +150,14 @@ final class TodayTrackDetailView: UIView { return lab }() + let vipRequiredBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Home/HistoryTrack/vip_required"), for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + btn.isHidden = true + return btn + }() + private(set) var sheetHeightConstraint: NSLayoutConstraint? override init(frame: CGRect) { @@ -263,6 +271,20 @@ final class TodayTrackDetailView: UIView { emptyLab.layoutChain .centerX() .topToBottomOfView(dateBar, offset: 40) + + sheetView.addSubview(vipRequiredBtn) + vipRequiredBtn.layoutChain + .topToBottomOfView(dateBar, offset: 28) + .centerX() + .width(303) + .height(139) + } + + func setVipRequired(_ required: Bool) { + vipRequiredBtn.isHidden = !required + timelineTV.isHidden = required + emptyLab.isHidden = true + playbackBar.isHidden = required } func cleanupMap() { diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift index bf54888e..e3f37a92 100644 --- a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift @@ -23,6 +23,7 @@ final class TodayTrackDetailViewModel { let selectedTripId = BehaviorRelay(value: nil) let displayedTrips = BehaviorRelay<[ScheduleRecordModel]>(value: []) let isEmpty = BehaviorRelay(value: true) + let isVipRequired = BehaviorRelay(value: false) let loading = BehaviorRelay(value: false) private let disposeBag = DisposeBag() @@ -75,6 +76,7 @@ final class TodayTrackDetailViewModel { selectedTripId.accept(nil) trips.accept([]) displayedTrips.accept([]) + isVipRequired.accept(false) isEmpty.accept(false) loadTrips(userId: userId, dateKey: dateKey) } @@ -93,14 +95,19 @@ final class TodayTrackDetailViewModel { .subscribe(onNext: { [weak self] response in guard let self, self.requestToken == token else { return } self.applyTrips(response.trips) - }, onError: { [weak self] _ in + }, onError: { [weak self] error in guard let self, self.requestToken == token else { return } + if error.gatewayStatusCode == 22000 { + self.applyVipRequired() + return + } self.applyTrips([]) }) .disposed(by: disposeBag) } private func applyTrips(_ list: [ScheduleRecordModel]) { + isVipRequired.accept(false) let sorted = list.sorted { $0.start_time < $1.start_time } trips.accept(sorted) if selectedTripId.value == nil || !sorted.contains(where: { $0.id == selectedTripId.value }) { @@ -110,6 +117,15 @@ final class TodayTrackDetailViewModel { loading.accept(false) } + private func applyVipRequired() { + selectedTripId.accept(nil) + trips.accept([]) + displayedTrips.accept([]) + isEmpty.accept(false) + isVipRequired.accept(true) + loading.accept(false) + } + private func applyDisplayedTrips() { let all = trips.value if let id = selectedTripId.value, let one = all.first(where: { $0.id == id }) { diff --git a/QuickLocation/Section/LockDistract/LockDistractVC.swift b/QuickLocation/Section/LockDistract/LockDistractVC.swift index f755cb5a..ee78245e 100644 --- a/QuickLocation/Section/LockDistract/LockDistractVC.swift +++ b/QuickLocation/Section/LockDistract/LockDistractVC.swift @@ -13,10 +13,12 @@ final class LockDistractVC: BaseViewController { private var groupModel: GroupModel? private var ownedGroups: [GroupInfoModel] = [] private var activeGroupKey: String = "" + private var groupVip = GroupMemberVip() private var members: [GroupMemberModel] = [] private var selectedIndex = 0 private var lockAppsDisposable: Disposable? private let lockStateSubscriptionOwner = UUID() + private var hasAppeared = false override var isNavigationBarHidden: Bool { true } @@ -29,9 +31,18 @@ final class LockDistractVC: BaseViewController { super.viewDidLoad() view.backgroundColor = UIColor(hexStr: "#FAFAFA") bind() + applyGroupVip(groupVip) loadGroupMembers() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if hasAppeared { + refreshGroupVip() + } + hasAppeared = true + } + @MainActor deinit { MQTTService.shared.updateLockStateSubscriptions( owner: lockStateSubscriptionOwner, @@ -145,6 +156,7 @@ final class LockDistractVC: BaseViewController { private func loadUsers(for groupKey: String) { guard !groupKey.isEmpty else { + applyGroupVip(GroupMemberVip()) applyMembers([]) return } @@ -154,9 +166,11 @@ final class LockDistractVC: BaseViewController { DLToast.showError(text: response.message ?? "获取圈子成员失败") return } - self?.applyMembers(response.list) + guard let self, self.activeGroupKey == groupKey else { return } + self.applyGroupVip(response.vip) + self.applyMembers(response.list) }, onError: { error in - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -173,6 +187,28 @@ final class LockDistractVC: BaseViewController { loadLockableApps() } + private var isAppLockVipUnlocked: Bool { + groupVip.app_lock == true + } + + private func applyGroupVip(_ vip: GroupMemberVip) { + groupVip = vip + rootView.updateVipAccess(isUnlocked: isAppLockVipUnlocked) + } + + private func refreshGroupVip() { + let groupKey = activeGroupKey + guard !groupKey.isEmpty else { return } + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard let self, + self.activeGroupKey == groupKey, + response.isValid(for: groupKey) else { return } + self.applyGroupVip(response.vip) + }, onError: { _ in }) + .disposed(by: disposeBag) + } + private func stepMember(_ delta: Int) { guard !members.isEmpty else { return } let count = members.count @@ -258,7 +294,7 @@ final class LockDistractVC: BaseViewController { self.rootView.configureLockableApps([]) self.rootView.configureLocked(false) self.rootView.setTodayLockCount(0) - DLToast.show(text: error.gatewayMessage ?? "获取可锁应用失败") + DLToast.show(error, fallbackText: "获取可锁应用失败") }) lockAppsDisposable?.disposed(by: disposeBag) } @@ -290,10 +326,11 @@ final class LockDistractVC: BaseViewController { } DLToast.showLoading() UserService.requestPhoneUnlock(os: os, groupKey: activeGroupKey, tokens: tokens) - .subscribe(onNext: { _ in + .subscribe(onNext: { [weak self] _ in DLToast.show(text: "已向圈主发送解锁请求") + self?.refreshGroupVip() }, onError: { error in - DLToast.show(text: error.gatewayMessage ?? "发送失败") + DLToast.show(error, fallbackText: "发送失败") }) .disposed(by: disposeBag) } @@ -322,14 +359,19 @@ final class LockDistractVC: BaseViewController { self?.rootView.lockBtn.isEnabled = true DLToast.show(text: "已解锁") self?.loadLockableApps() + self?.refreshGroupVip() }, onError: { [weak self] error in self?.rootView.lockBtn.isEnabled = true - DLToast.show(text: error.gatewayMessage ?? "解锁失败") + DLToast.show(error, fallbackText: "解锁失败") }) .disposed(by: disposeBag) } private func performPrimaryAction() { + if rootView.primaryAction == .lock, !isAppLockVipUnlocked { + AppRouter.push(Route.vipRecharge) + return + } switch rootView.primaryAction { case .requestUnlock: requestUnlockForLockedApps() @@ -343,6 +385,10 @@ final class LockDistractVC: BaseViewController { } private func lockSelectedApps() { + guard isAppLockVipUnlocked else { + AppRouter.push(Route.vipRecharge) + return + } guard let member = selectedMember, !activeGroupKey.isEmpty else { DLToast.show(text: "请选择要锁定的成员") return @@ -368,9 +414,10 @@ final class LockDistractVC: BaseViewController { self?.rootView.lockBtn.isEnabled = true DLToast.show(text: "锁定成功") self?.loadLockableApps() + self?.refreshGroupVip() }, onError: { [weak self] error in self?.rootView.lockBtn.isEnabled = true - DLToast.show(text: error.gatewayMessage ?? "锁定失败") + DLToast.show(error, fallbackText: "锁定失败") }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/LockDistract/LockDistractView.swift b/QuickLocation/Section/LockDistract/LockDistractView.swift index c3012dde..92821aba 100644 --- a/QuickLocation/Section/LockDistract/LockDistractView.swift +++ b/QuickLocation/Section/LockDistract/LockDistractView.swift @@ -35,6 +35,7 @@ final class LockDistractView: UIView { private(set) var isLocked = false private(set) var isCurrentUser = false private(set) var primaryAction: LockDistractPrimaryAction = .lock + private(set) var isVipUnlocked = true private let copyMaxLength = 20 private var lockBtnHeightConstraint: NSLayoutConstraint? private var bottomStackToAppsConstraint: NSLayoutConstraint? @@ -110,6 +111,11 @@ final class LockDistractView: UIView { appCollectionView.reloadData() } + func updateVipAccess(isUnlocked: Bool) { + isVipUnlocked = isUnlocked + applyLockedState() + } + var selectedLockApps: [PhoneLockAppItem] { lockableApps.enumerated().compactMap { index, app in selectedAppIndexes.contains(index) ? app : nil @@ -167,16 +173,23 @@ final class LockDistractView: UIView { appCollectionView.reloadData() lockBtn.isHidden = lockedByOthers lockBtnHeightConstraint?.constant = lockedByOthers ? 0 : 56 + vipOfferImageView.isHidden = lockedByOthers || primaryAction != .lock || isVipUnlocked switch primaryAction { case .lock: tipLab.text = "锁定功能需要在对方开启权限情况下才能使用哟~" - lockBtn.setTitle("锁定", for: .normal) + lockBtn.setTitle(isVipUnlocked ? "锁定" : "开通会员 解锁功能", for: .normal) + lockBtn.setBackgroundImage( + UIImage(named: isVipUnlocked ? "Common/button_bg_2" : "VipFeature/wide_button_bg"), + for: .normal + ) case .requestUnlock: tipLab.text = "已被其他圈主锁定,请先申请解锁哟~" lockBtn.setTitle("请求解锁", for: .normal) + lockBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) case .unlock: tipLab.text = "TA已经被你锁定,解锁后才能重新设置哟~" lockBtn.setTitle("解锁", for: .normal) + lockBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) case .hidden: tipLab.text = "该成员的APP已被其他圈主锁定~" } @@ -195,6 +208,7 @@ final class LockDistractView: UIView { addSubview(footerBar) footerBar.addSubview(tipRow) footerBar.addSubview(lockBtn) + footerBar.addSubview(vipOfferImageView) contentView.addSubview(navBgView) contentView.addSubview(statsRow) @@ -230,6 +244,14 @@ final class LockDistractView: UIView { .bottomToTopOfView(lockBtn, offset: -8) .top(10) + vipOfferImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + vipOfferImageView.trailingAnchor.constraint(equalTo: lockBtn.trailingAnchor, constant: -6), + vipOfferImageView.bottomAnchor.constraint(equalTo: lockBtn.topAnchor, constant: 10), + vipOfferImageView.widthAnchor.constraint(equalToConstant: 94), + vipOfferImageView.heightAnchor.constraint(equalToConstant: 32) + ]) + scrollView.layoutChain .top() .edgesHorzontal() @@ -705,6 +727,13 @@ final class LockDistractView: UIView { return btn }() + lazy var vipOfferImageView: UIImageView = { + let view = UIImageView(image: UIImage(named: "VipFeature/limited_offer")) + view.contentMode = .scaleAspectFit + view.isHidden = true + return view + }() + private func makeSectionTitle(_ text: String) -> UIView { let row = UIView() let star = UIImageView(image: UIImage(named: "LockDistract/section_star")) diff --git a/QuickLocation/Section/Login/LoginView.swift b/QuickLocation/Section/Login/LoginView.swift index 86a9b38a..8344608e 100644 --- a/QuickLocation/Section/Login/LoginView.swift +++ b/QuickLocation/Section/Login/LoginView.swift @@ -85,9 +85,11 @@ class LoginView: UIView { addSubview(rightSeparator) addSubview(agreementView) - agreementView.addSubview(checkBox) + addSubview(checkBox) + agreementView.addSubview(agreementTV) agreementView.addSubview(agreementLabel) +// agreementView.addSubview(checkBox) bgMaskImage.layoutChain.edges(excludingEdge: .bottom).heightToWidth(320/375) @@ -176,15 +178,13 @@ class LoginView: UIView { agreementLabel.layoutChain.edges() checkBox.layoutChain - .left() - .centerY() + .rightToLeftOfView(agreementView, offset: 0) + .centerY(agreementView) .width(12) .height(12) agreementTV.layoutChain - .leftToRightOfView(checkBox, offset: 0) - .right() - .top() + .edges() phoneLoginBtn.layoutChain .bottom(kSafeBottomMargin + 30) @@ -457,7 +457,7 @@ class LoginView: UIView { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Login/checkbox"), for: .normal) btn.setImage(UIImage(named: "Login/selected"), for: .selected) - btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 100, bottom: 100, right: 20) + btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 150, bottom: 150, right: 50) return btn }() diff --git a/QuickLocation/Section/Login/LoginViewController.swift b/QuickLocation/Section/Login/LoginViewController.swift index 1b961aca..1ed06bab 100644 --- a/QuickLocation/Section/Login/LoginViewController.swift +++ b/QuickLocation/Section/Login/LoginViewController.swift @@ -66,8 +66,8 @@ class LoginViewController: BaseViewController { return } - guard let phone = self.rootView.phoneInputTF.text, !phone.isEmpty else { - DLToast.showError(text: "请输入手机号码") + guard let phone = self.rootView.phoneInputTF.text, !phone.isEmpty, phone.count == 11 else { + DLToast.showError(text: "请输入正确手机号码") return } diff --git a/QuickLocation/Section/Login/OneTapLoginView.swift b/QuickLocation/Section/Login/OneTapLoginView.swift index 59bc8a03..fa80dd05 100644 --- a/QuickLocation/Section/Login/OneTapLoginView.swift +++ b/QuickLocation/Section/Login/OneTapLoginView.swift @@ -66,7 +66,8 @@ class OneTapLoginView: UIView { inputContainerView.addSubview(loginButton) addSubview(agreementView) - agreementView.addSubview(checkBox) + addSubview(checkBox) +// agreementView.addSubview(checkBox) agreementView.addSubview(agreementLabel) agreementView.addSubview(agreementTV) @@ -107,8 +108,8 @@ class OneTapLoginView: UIView { .edgesHorzontal(30) checkBox.layoutChain - .left() - .top(10) + .rightToLeftOfView(agreementView, offset: 0) + .centerY(agreementView) .width(12) .height(12) @@ -119,10 +120,7 @@ class OneTapLoginView: UIView { .bottom() agreementTV.layoutChain - .leftToRightOfView(checkBox, offset: 0) - .right() - .top() - .bottom() + .edges() } override init(frame: CGRect) { diff --git a/QuickLocation/Section/Mine/Account/AccountVC.swift b/QuickLocation/Section/Mine/Account/AccountVC.swift index 6dca3222..18bf5b16 100644 --- a/QuickLocation/Section/Mine/Account/AccountVC.swift +++ b/QuickLocation/Section/Mine/Account/AccountVC.swift @@ -122,7 +122,7 @@ class AccountVC: BaseViewController { NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -140,7 +140,7 @@ class AccountVC: BaseViewController { NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } @@ -156,7 +156,7 @@ class AccountVC: BaseViewController { NotificationCenter.default.post(name: .RefreshUserConfigNotification, object: nil) }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift index 61fd8f49..e091cb77 100644 --- a/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift +++ b/QuickLocation/Section/Mine/Feedback/FeedbackVC.swift @@ -194,7 +194,7 @@ final class FeedbackVC: BaseViewController { pendingDraft = nil isSubmitting = false rootView.setSubmitting(false) - DLToast.show(text: error.localizedDescription) + DLToast.show(error) } private func compressedJPEG(from image: UIImage) -> Data? { diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index aa3f3775..732af8c6 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -127,7 +127,7 @@ final class MineViewController: BaseViewController { groupInfo: response.model ) }, onError: { error in - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift b/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift index ba4f75f1..12e7af52 100644 --- a/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift +++ b/QuickLocation/Section/Mine/MoodStatus/MoodStatusVC.swift @@ -79,7 +79,7 @@ final class MoodStatusVC: BaseViewController { } }, onError: { [weak self] error in guard let self, requestVersion == self.statusRequestVersion else { return } - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift index 40723101..511ffb4d 100644 --- a/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift +++ b/QuickLocation/Section/Mine/MyProfile/AvatarIconListVC.swift @@ -84,7 +84,7 @@ class AvatarIconListVC: BaseViewController { self.rootView.setPreviewImage(image) self.onSelectIcon?(url) }, onError: { error in - DLToast.show(text: error.gatewayMessage ?? "更换失败") + DLToast.show(error, fallbackText: "更换失败") }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift b/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift index abaf6ccc..935f5849 100644 --- a/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift +++ b/QuickLocation/Section/Mine/PrivacySettings/PrivacySettingsViewController.swift @@ -93,7 +93,7 @@ final class PrivacySettingsViewController: BaseViewController { }, onError: { [weak self] error in self?.groups = [] self?.rootView.configure(groups: [], selectedGroupKey: "") - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -127,7 +127,7 @@ final class PrivacySettingsViewController: BaseViewController { }, onError: { [weak self] error in self?.rootView.setSettingsEnabled(!(self?.groups.isEmpty ?? true)) self?.applySelectedPrivilege() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -242,7 +242,7 @@ final class PrivacySettingsViewController: BaseViewController { self.isSubmitting = false self.rootView.setSettingsEnabled(!self.groups.isEmpty) self.revertPrivilege(groupKey: groupKey, previous: previous) - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 579465a1..7d7f4600 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -13,6 +13,7 @@ final class PigeonMessageVC: BaseViewController { private var rootView: PigeonMessageView! private var groupModel: GroupModel? private var activeGroupKey: String = "" + private var groupVip = GroupMemberVip() private var members: [GroupMemberModel] = [] private var selectedMembers: [GroupMemberModel] = [] private var selectedImage: UIImage? @@ -26,6 +27,7 @@ final class PigeonMessageVC: BaseViewController { private var playbackTimer: Timer? private var recordedDuration: TimeInterval = 0 private var isPressingRecord = false + private var hasAppeared = false override func loadView() { rootView = PigeonMessageView(frame: UIScreen.main.bounds) @@ -36,9 +38,18 @@ final class PigeonMessageVC: BaseViewController { super.viewDidLoad() bindActions() setupRecordingGesture() + applyGroupVip(groupVip) loadMembers() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if hasAppeared { + refreshGroupVip() + } + hasAppeared = true + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) guard isMovingFromParent || navigationController?.isBeingDismissed == true else { return } @@ -102,13 +113,14 @@ final class PigeonMessageVC: BaseViewController { self.loadUsers(for: self.activeGroupKey, showLoading: false) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }).disposed(by: disposeBag) } private func loadUsers(for groupKey: String, showLoading: Bool) { guard !groupKey.isEmpty else { DLToast.dismiss() + applyGroupVip(GroupMemberVip()) applyMembers([]) return } @@ -120,10 +132,12 @@ final class PigeonMessageVC: BaseViewController { DLToast.showError(text: response.message ?? "获取圈子成员失败") return } - self?.applyMembers(response.list) + guard let self, self.activeGroupKey == groupKey else { return } + self.applyGroupVip(response.vip) + self.applyMembers(response.list) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }) .disposed(by: disposeBag) } @@ -134,6 +148,28 @@ final class PigeonMessageVC: BaseViewController { rootView.configureMembers(members, selectedIds: []) } + private var isPigeonVipUnlocked: Bool { + groupVip.pigeon_mail == true + } + + private func applyGroupVip(_ vip: GroupMemberVip) { + groupVip = vip + rootView.updateVipAccess(isUnlocked: isPigeonVipUnlocked) + } + + private func refreshGroupVip() { + let groupKey = activeGroupKey + guard !groupKey.isEmpty else { return } + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard let self, + self.activeGroupKey == groupKey, + response.isValid(for: groupKey) else { return } + self.applyGroupVip(response.vip) + }, onError: { _ in }) + .disposed(by: disposeBag) + } + private func updateCurrentGroupDisplay() { let groupName = groupModel?.groups.first { $0.group_key == activeGroupKey @@ -158,7 +194,7 @@ final class PigeonMessageVC: BaseViewController { self.presentGroupPicker(model) }, onError: { error in DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) + DLToast.showError(error) }).disposed(by: disposeBag) } @@ -480,6 +516,10 @@ final class PigeonMessageVC: BaseViewController { } @objc private func sendMessage() { + guard isPigeonVipUnlocked else { + AppRouter.push(Route.vipRecharge) + return + } if let wait = pigeonSendWaitSeconds() { DLToast.show(text: "发送过于频繁,请\(Self.formatWaitDuration(wait))后再试") return @@ -580,10 +620,11 @@ final class PigeonMessageVC: BaseViewController { self.cleanupAudio(deleteFile: true) self.rootView.clearVoiceDraft() self.recordPigeonSendSuccess() + self.refreshGroupVip() DLToast.show(text: "发送成功") }, onError: { error in DLToast.dismiss() - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index 49949528..f3e176ef 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -82,6 +82,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { private let recordingHintLabel = UILabel() private let voiceTimeLabel = UILabel() private let waveformView = PigeonWaveformView() + private let vipOfferImageView = UIImageView(image: UIImage(named: "VipFeature/limited_offer")) private var selectedImageHeightConstraint: NSLayoutConstraint? private var isRecording = false @@ -252,6 +253,15 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { updateMode() } + func updateVipAccess(isUnlocked: Bool) { + sendButton.setTitle(isUnlocked ? "发送" : "开通会员 解锁功能", for: .normal) + sendButton.setBackgroundImage( + UIImage(named: isUnlocked ? "Common/button_bg_2" : "VipFeature/wide_button_bg"), + for: .normal + ) + vipOfferImageView.isHidden = isUnlocked + } + private func setupUI() { let headerView = PigeonHeaderGradientView() addSubview(headerView) @@ -384,6 +394,17 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { .topToBottomOfView(modeContainer, offset: 12) .edgesHorzontal(15) .bottom(16) + + vipOfferImageView.contentMode = .scaleAspectFit + vipOfferImageView.isHidden = true + addSubview(vipOfferImageView) + vipOfferImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + vipOfferImageView.trailingAnchor.constraint(equalTo: sendButton.trailingAnchor, constant: -6), + vipOfferImageView.bottomAnchor.constraint(equalTo: sendButton.topAnchor, constant: 10), + vipOfferImageView.widthAnchor.constraint(equalToConstant: 94), + vipOfferImageView.heightAnchor.constraint(equalToConstant: 32) + ]) } private func setupRecipientCard(_ card: UIView) { diff --git a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift index 879165ab..25087ed7 100644 --- a/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift +++ b/QuickLocation/Section/Schedule/CreateSchedule/CreateScheduleVC.swift @@ -118,7 +118,7 @@ class CreateScheduleVC: BaseViewController, MAMapViewDelegate { CreateScheduleVipPopView.show() } else { - self.dl.show(text: error.localizedDescription) + self.dl.show(error) } }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Web/Controller/WebViewController.swift b/QuickLocation/Section/Web/Controller/WebViewController.swift index 4645cccc..520375ea 100644 --- a/QuickLocation/Section/Web/Controller/WebViewController.swift +++ b/QuickLocation/Section/Web/Controller/WebViewController.swift @@ -495,7 +495,7 @@ class FullscreenWebView: WKWebView { @objc private func image(_ image: UIImage, didFinishSavingWithError error: NSError?, context: UnsafeRawPointer) { if let error = error { - error.isShowErrorTips ? DLToast.showError(text: error.localizedDescription) : () + DLToast.showError(error) } else { DLToast.showSuccess(text: "oapp_Success".localizedString) } diff --git a/QuickLocation/Service/GroupService.swift b/QuickLocation/Service/GroupService.swift index 6e634c01..c0c45def 100644 --- a/QuickLocation/Service/GroupService.swift +++ b/QuickLocation/Service/GroupService.swift @@ -267,7 +267,7 @@ struct GroupService { } DLToast.show(text: "已提醒") }, onError: { error in - DLToast.show(text: error.localizedDescription) + DLToast.show(error) }) .disposed(by: disposeBag) } diff --git a/QuickLocation/UIKit/Alert/DLToast.swift b/QuickLocation/UIKit/Alert/DLToast.swift index ca865c07..183d6ccd 100644 --- a/QuickLocation/UIKit/Alert/DLToast.swift +++ b/QuickLocation/UIKit/Alert/DLToast.swift @@ -299,6 +299,15 @@ public class DLToast { completion: (() -> Void)? = nil) { show(text: text, icon: UIImage(named: "fail"), in: view, completion: completion) } + + public static func showError(_ error: Error, + in view: UIView? = nil, + completion: (() -> Void)? = nil) { + guard error.isShowErrorTips else { return } + showError(text: error.gatewayMessage ?? error.localizedDescription, + in: view, + completion: completion) + } /// 显示警告信息 /// - Parameters: @@ -337,6 +346,16 @@ public class DLToast { toastView.show(in: superView) } } + + public static func show(_ error: Error, + fallbackText: String? = nil, + in view: UIView? = nil, + completion: (() -> Void)? = nil) { + guard error.isShowErrorTips else { return } + show(text: error.gatewayMessage ?? fallbackText ?? error.localizedDescription, + in: view, + completion: completion) + } /// 隐藏toast /// - Parameters: @@ -384,6 +403,11 @@ extension DLWrapper where Base: UIView { completion: (() -> Void)? = nil) { DLToast.showError(text: text, in: base, completion: completion) } + + public func showError(_ error: Error, + completion: (() -> Void)? = nil) { + DLToast.showError(error, in: base, completion: completion) + } /// 显示警告信息 /// - Parameters: @@ -408,6 +432,12 @@ extension DLWrapper where Base: UIView { completion: (() -> Void)? = nil) { DLToast.show(text: text, icon: icon, config: config, style: style, in: base, completion: completion) } + + public func show(_ error: Error, + fallbackText: String? = nil, + completion: (() -> Void)? = nil) { + DLToast.show(error, fallbackText: fallbackText, in: base, completion: completion) + } /// 隐藏toast /// - Parameters: @@ -445,6 +475,11 @@ extension DLWrapper where Base: UIViewController { completion: (() -> Void)? = nil) { DLToast.showError(text: text, in: base.view, completion: completion) } + + public func showError(_ error: Error, + completion: (() -> Void)? = nil) { + DLToast.showError(error, in: base.view, completion: completion) + } /// 显示警告信息 /// - Parameters: @@ -469,6 +504,12 @@ extension DLWrapper where Base: UIViewController { completion: (() -> Void)? = nil) { DLToast.show(text: text, icon: icon, config: config, style: style, in: base.view, completion: completion) } + + public func show(_ error: Error, + fallbackText: String? = nil, + completion: (() -> Void)? = nil) { + DLToast.show(error, fallbackText: fallbackText, in: base.view, completion: completion) + } /// 隐藏toast /// - Parameters: