diff --git a/.gitignore b/.gitignore index 6b7678fd..73d65739 100644 --- a/.gitignore +++ b/.gitignore @@ -45,96 +45,6 @@ npm-debug.log *.png.orig *.jpg.orig screenshot/ -.opencode/skills/README.md -.opencode/skills/ask-matt/SKILL.md -.opencode/skills/ask-matt/agents/openai.yaml -.opencode/skills/code-review/SKILL.md -.opencode/skills/code-review/agents/openai.yaml -.opencode/skills/codebase-design/DEEPENING.md -.opencode/skills/codebase-design/DESIGN-IT-TWICE.md -.opencode/skills/codebase-design/SKILL.md -.opencode/skills/codebase-design/agents/openai.yaml -.opencode/skills/diagnosing-bugs/SKILL.md -.opencode/skills/diagnosing-bugs/agents/openai.yaml -.opencode/skills/diagnosing-bugs/scripts/hitl-loop.template.sh -.opencode/skills/domain-modeling/ADR-FORMAT.md -.opencode/skills/domain-modeling/CONTEXT-FORMAT.md -.opencode/skills/domain-modeling/SKILL.md -.opencode/skills/domain-modeling/agents/openai.yaml -.opencode/skills/grill-me/SKILL.md -.opencode/skills/grill-me/agents/openai.yaml -.opencode/skills/grill-with-docs/SKILL.md -.opencode/skills/grill-with-docs/agents/openai.yaml -.opencode/skills/grilling/SKILL.md -.opencode/skills/grilling/agents/openai.yaml -.opencode/skills/handoff/SKILL.md -.opencode/skills/handoff/agents/openai.yaml -.opencode/skills/implement/SKILL.md -.opencode/skills/implement/agents/openai.yaml -.opencode/skills/improve-codebase-architecture/HTML-REPORT.md -.opencode/skills/improve-codebase-architecture/SKILL.md -.opencode/skills/improve-codebase-architecture/agents/openai.yaml -.opencode/skills/prototype/LOGIC.md -.opencode/skills/prototype/SKILL.md -.opencode/skills/prototype/UI.md -.opencode/skills/prototype/agents/openai.yaml -.opencode/skills/research/SKILL.md -.opencode/skills/research/agents/openai.yaml -.opencode/skills/resolving-merge-conflicts/SKILL.md -.opencode/skills/resolving-merge-conflicts/agents/openai.yaml -.opencode/skills/setup-matt-pocock-skills/domain.md -.opencode/skills/setup-matt-pocock-skills/issue-tracker-github.md -.opencode/skills/setup-matt-pocock-skills/issue-tracker-gitlab.md -.opencode/skills/setup-matt-pocock-skills/issue-tracker-local.md -.opencode/skills/setup-matt-pocock-skills/SKILL.md -.opencode/skills/setup-matt-pocock-skills/triage-labels.md -.opencode/skills/setup-matt-pocock-skills/agents/openai.yaml -.opencode/skills/tdd/mocking.md -.opencode/skills/tdd/SKILL.md -.opencode/skills/tdd/tests.md -.opencode/skills/tdd/agents/openai.yaml -.opencode/skills/teach/GLOSSARY-FORMAT.md -.opencode/skills/teach/LEARNING-RECORD-FORMAT.md -.opencode/skills/teach/MISSION-FORMAT.md -.opencode/skills/teach/RESOURCES-FORMAT.md -.opencode/skills/teach/SKILL.md -.opencode/skills/teach/agents/openai.yaml -.opencode/skills/to-spec/SKILL.md -.opencode/skills/to-spec/agents/openai.yaml -.opencode/skills/to-tickets/SKILL.md -.opencode/skills/to-tickets/agents/openai.yaml -.opencode/skills/triage/AGENT-BRIEF.md -.opencode/skills/triage/OUT-OF-SCOPE.md -.opencode/skills/triage/SKILL.md -.opencode/skills/triage/agents/openai.yaml -.opencode/skills/wayfinder/SKILL.md -.opencode/skills/wayfinder/agents/openai.yaml -.opencode/skills/writing-great-skills/GLOSSARY.md -.opencode/skills/writing-great-skills/SKILL.md -.opencode/skills/writing-great-skills/agents/openai.yaml -.cursor/commands/opsx-apply.md -.cursor/commands/opsx-archive.md -.cursor/commands/opsx-explore.md -.cursor/commands/opsx-propose.md -.cursor/commands/opsx-sync.md -.cursor/commands/opsx-update.md -.cursor/skills/openspec-apply-change/SKILL.md -.cursor/skills/openspec-archive-change/SKILL.md -.cursor/skills/openspec-explore/SKILL.md -.cursor/skills/openspec-propose/SKILL.md -.cursor/skills/openspec-sync-specs/SKILL.md -.cursor/skills/openspec-update-change/SKILL.md -.opencode/commands/opsx-apply.md -.opencode/commands/opsx-archive.md -.opencode/commands/opsx-explore.md -.opencode/commands/opsx-propose.md -.opencode/commands/opsx-sync.md -.opencode/commands/opsx-update.md -.opencode/skills/openspec-apply-change/SKILL.md -.opencode/skills/openspec-archive-change/SKILL.md -.opencode/skills/openspec-explore/SKILL.md -.opencode/skills/openspec-propose/SKILL.md -.opencode/skills/openspec-sync-specs/SKILL.md -.opencode/skills/openspec-update-change/SKILL.md -openspec/config.yaml -openspec/CONVENTIONS.md +.opencode/ +.cursor/ +openspec/ diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 09eb7392..50c0c10a 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -96,7 +96,6 @@ 305A76DE2FCA8C7000227D26 /* ObservableType+ObjectMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76202FCA8C7000227D26 /* ObservableType+ObjectMapper.swift */; }; 305A76DF2FCA8C7000227D26 /* Single+ObjectMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76212FCA8C7000227D26 /* Single+ObjectMapper.swift */; }; 305A76E02FCA8C7000227D26 /* GroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76232FCA8C7000227D26 /* GroupView.swift */; }; - 55B217B13022F0A000784711 /* GroupItineraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217B03022F0A000784710 /* GroupItineraryView.swift */; }; 305A76E12FCA8C7000227D26 /* GroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76242FCA8C7000227D26 /* GroupViewController.swift */; }; 305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; }; 305A76E32FCA8C7000227D26 /* GroupMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76272FCA8C7000227D26 /* GroupMemberView.swift */; }; @@ -298,6 +297,11 @@ 55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B2179230218CE100784774 /* GroupMemberView2.swift */; }; 55B217953022DD6B00784774 /* 荆南波波黑-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B217943022DD6B00784774 /* 荆南波波黑-Bold.ttf */; }; 55B217A13022F0A000784702 /* FontManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A03022F0A000784701 /* FontManager.swift */; }; + 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 */; }; + 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217C43022F0A000784724 /* MemberAppUsageView.swift */; }; + 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D03022F0A000784726 /* UnlockCountManager.swift */; }; 55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; }; 55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; }; 55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; }; @@ -399,7 +403,6 @@ 305A76202FCA8C7000227D26 /* ObservableType+ObjectMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableType+ObjectMapper.swift"; sourceTree = ""; }; 305A76212FCA8C7000227D26 /* Single+ObjectMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Single+ObjectMapper.swift"; sourceTree = ""; }; 305A76232FCA8C7000227D26 /* GroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupView.swift; sourceTree = ""; }; - 55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = ""; }; 305A76242FCA8C7000227D26 /* GroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewController.swift; sourceTree = ""; }; 305A76252FCA8C7000227D26 /* GroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewModel.swift; sourceTree = ""; }; 305A76272FCA8C7000227D26 /* GroupMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberView.swift; sourceTree = ""; }; @@ -610,6 +613,11 @@ 55B2179230218CE100784774 /* GroupMemberView2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberView2.swift; sourceTree = ""; }; 55B217943022DD6B00784774 /* 荆南波波黑-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "荆南波波黑-Bold.ttf"; sourceTree = ""; }; 55B217A03022F0A000784701 /* FontManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontManager.swift; sourceTree = ""; }; + 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 = ""; }; + 55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = ""; }; + 55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = ""; }; 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; 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 = ""; }; @@ -928,6 +936,7 @@ 305A75262FCA8C7000227D26 /* Money */, 305A75282FCA8C7000227D26 /* Notification */, 305A752A2FCA8C7000227D26 /* Theme */, + 55B217D23022F0A000784728 /* Unlock */, 305A752C2FCA8C7000227D26 /* URL */, 30D87CD52FDF9F1900E958FD /* MQTT */, 30C6666A2FFB7C3000E62B25 /* IAPManager */, @@ -1021,6 +1030,8 @@ 305A76282FCA8C7000227D26 /* HomeView.swift */, 55B2179030217D6600784774 /* HomeView2.swift */, 55B2179230218CE100784774 /* GroupMemberView2.swift */, + 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */, + 55B217C43022F0A000784724 /* MemberAppUsageView.swift */, 30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */, 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */, 30D87CDC2FDFF07500E958FD /* InteractionView.swift */, @@ -1071,6 +1082,7 @@ 305A76372FCA8C7000227D26 /* MineViewController.swift */, 305A76382FCA8C7000227D26 /* MineViewModel.swift */, 305A76362FCA8C7000227D26 /* MineView.swift */, + 55B217C03022F0A000784720 /* MinePhotoWallView.swift */, 30EFF3CB2FDA667A00EB35D4 /* MyProfile */, 30EFF3BC2FD9585200EB35D4 /* Account */, 30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */, @@ -1683,6 +1695,14 @@ path = Font; sourceTree = ""; }; + 55B217D23022F0A000784728 /* Unlock */ = { + isa = PBXGroup; + children = ( + 55B217D03022F0A000784726 /* UnlockCountManager.swift */, + ); + path = Unlock; + sourceTree = ""; + }; B07D45692FCECE07570D9B73 /* Pods */ = { isa = PBXGroup; children = ( @@ -1961,6 +1981,8 @@ 30BF300C2FED09BA00D9CB52 /* ScheduleDetailView.swift in Sources */, 305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */, 55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */, + 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */, + 55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, 30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */, 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */, @@ -1992,6 +2014,7 @@ 305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */, 305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */, 305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */, + 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */, 305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */, 305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */, 305A798C2FCAB99300227D26 /* HomeViewModel.swift in Sources */, @@ -2055,6 +2078,7 @@ 305A76ED2FCA8C7000227D26 /* MemberAnnotationView.swift in Sources */, 30BAB8532FCD337C00C33B5C /* GroupService.swift in Sources */, 305A76EE2FCA8C7000227D26 /* MineView.swift in Sources */, + 55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */, 305A76EF2FCA8C7000227D26 /* MineViewController.swift in Sources */, 30A87A5E2FEE71A50095E7C6 /* CreateBubbleView.swift in Sources */, 30D74ABF2FEA67F30050EB2C /* CreateScheduleView.swift in Sources */, diff --git a/QuickLocation/AppDelegate.swift b/QuickLocation/AppDelegate.swift index c57ea5dd..698f9128 100644 --- a/QuickLocation/AppDelegate.swift +++ b/QuickLocation/AppDelegate.swift @@ -21,6 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + UnlockCountManager.shared.start(launchOptions: launchOptions) FontManager.registerCustomFonts() setupLocation() ApiManager.shared.setup() diff --git a/QuickLocation/Assets.xcassets/Common/back.imageset/back@2x.png b/QuickLocation/Assets.xcassets/Common/back.imageset/back@2x.png index 2e36c2ca..85681381 100644 Binary files a/QuickLocation/Assets.xcassets/Common/back.imageset/back@2x.png and b/QuickLocation/Assets.xcassets/Common/back.imageset/back@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Common/back.imageset/back@3x.png b/QuickLocation/Assets.xcassets/Common/back.imageset/back@3x.png index 854c1646..55442614 100644 Binary files a/QuickLocation/Assets.xcassets/Common/back.imageset/back@3x.png and b/QuickLocation/Assets.xcassets/Common/back.imageset/back@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/Contents.json new file mode 100644 index 00000000..459214f7 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_phone_report@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_phone_report@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@2x.png b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@2x.png new file mode 100644 index 00000000..0d4082af Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@3x.png b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@3x.png new file mode 100644 index 00000000..8f20f18d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/Contents.json new file mode 100644 index 00000000..e0f6e865 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_daily_unlock@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_daily_unlock@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@2x.png b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@2x.png new file mode 100644 index 00000000..9a35be28 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@3x.png b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@3x.png new file mode 100644 index 00000000..ddf41c23 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/Contents.json new file mode 100644 index 00000000..712671e0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_app_usage@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_app_usage@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@2x.png b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@2x.png new file mode 100644 index 00000000..542712a1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@3x.png b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@3x.png new file mode 100644 index 00000000..16400a4c Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/Contents.json new file mode 100644 index 00000000..fd3dcff5 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_report_clock@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_report_clock@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@2x.png b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@2x.png new file mode 100644 index 00000000..7c9de47a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@3x.png b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@3x.png new file mode 100644 index 00000000..7bca7e2d Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/Contents.json new file mode 100644 index 00000000..09ac5321 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "组 48025@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "组 48025@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@2x.png b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@2x.png new file mode 100644 index 00000000..ecda71a8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@3x.png b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@3x.png new file mode 100644 index 00000000..e3da5b53 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/Contents.json new file mode 100644 index 00000000..d87a79a4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "组 48027@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "组 48027@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@2x.png b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@2x.png new file mode 100644 index 00000000..64648288 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@3x.png b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@3x.png new file mode 100644 index 00000000..5fe1ee76 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/Contents.json new file mode 100644 index 00000000..35d7e6a8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_usage_count@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_usage_count@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@2x.png b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@2x.png new file mode 100644 index 00000000..dc01a8bc Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@3x.png b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@3x.png new file mode 100644 index 00000000..faa36979 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@2x.png b/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@2x.png index dc2c628d..98823358 100644 Binary files a/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@2x.png and b/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@3x.png b/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@3x.png index dc2c628d..4c16315e 100644 Binary files a/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@3x.png and b/QuickLocation/Assets.xcassets/Mine/arrow_right.imageset/arrow_right@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/Contents.json new file mode 100644 index 00000000..13830385 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "clothespin@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "clothespin@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@2x.png b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@2x.png new file mode 100644 index 00000000..f66b38a5 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@3x.png b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@3x.png new file mode 100644 index 00000000..343f808a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin.imageset/clothespin@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/Contents.json new file mode 100644 index 00000000..b7ea57c0 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "clothespin_blue@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "clothespin_blue@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@2x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@2x.png new file mode 100644 index 00000000..f869eea1 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@3x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@3x.png new file mode 100644 index 00000000..054ded10 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/Contents.json new file mode 100644 index 00000000..5e7f70c6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "clothespin_green@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "clothespin_green@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@2x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@2x.png new file mode 100644 index 00000000..b598d87e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@3x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@3x.png new file mode 100644 index 00000000..d67cb39c Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/Contents.json new file mode 100644 index 00000000..ca44a694 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "clothespin_pink@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "clothespin_pink@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@2x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@2x.png new file mode 100644 index 00000000..fa0826ea Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@3x.png b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@3x.png new file mode 100644 index 00000000..768c4281 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/customer_service.imageset/customer_service@2x.png b/QuickLocation/Assets.xcassets/Mine/customer_service.imageset/customer_service@2x.png index c2a55286..4dee7073 100644 Binary files a/QuickLocation/Assets.xcassets/Mine/customer_service.imageset/customer_service@2x.png and b/QuickLocation/Assets.xcassets/Mine/customer_service.imageset/customer_service@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png new file mode 100644 index 00000000..c64e235e Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png new file mode 100644 index 00000000..23223358 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/menu_about.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@2x.png new file mode 100644 index 00000000..4ec987ee Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@3x.png new file mode 100644 index 00000000..810eb3db Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@2x.png new file mode 100644 index 00000000..2abb2551 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@3x.png new file mode 100644 index 00000000..0612c1dd Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_permission.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@2x.png new file mode 100644 index 00000000..01b1de42 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@3x.png new file mode 100644 index 00000000..9099e7b6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_privacy.imageset/icon@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/Contents.json new file mode 100644 index 00000000..6402a56a --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@2x.png b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@2x.png new file mode 100644 index 00000000..3e4150a0 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@3x.png b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@3x.png new file mode 100644 index 00000000..3201386a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/quick_status.imageset/icon@3x.png differ diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index 67aca406..b93ac3f3 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -23,6 +23,24 @@ extension DefaultsKeys { var isShowGuide: DefaultsKey { .init("isShowGuide", defaultValue: true) } /// 气泡持续时间 var bubbleKeepTimes: DefaultsKey { .init("bubbleKeepTimes") } + /// 解锁统计:今日次数 + var unlockCountToday: DefaultsKey { .init("unlockCountToday", defaultValue: 0) } + /// 解锁统计:累计次数 + var unlockCountTotal: DefaultsKey { .init("unlockCountTotal", defaultValue: 0) } + /// 解锁统计:日期 yyyy-MM-dd + var unlockCountDate: DefaultsKey { .init("unlockCountDate", defaultValue: "") } + /// 上次锁屏态(杀进程补计) + var unlockWasLocked: DefaultsKey { .init("unlockWasLocked", defaultValue: false) } + var unlockLastCountedAt: DefaultsKey { .init("unlockLastCountedAt") } + var unlockLastHeartbeatAt: DefaultsKey { .init("unlockLastHeartbeatAt") } + var unlockLastLatitude: DefaultsKey { .init("unlockLastLatitude") } + var unlockLastLongitude: DefaultsKey { .init("unlockLastLongitude") } + /// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd + var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) } + /// 手机报告:进行中的解锁 session 起点(持久化,杀进程可续) + var screenSessionStartAt: DefaultsKey { .init("screenSessionStartAt") } + /// 手机报告:本 App 今日进入前台次数 + var appUsageToday: DefaultsKey { .init("appUsageToday", defaultValue: 0) } } /// 通知常量 @@ -43,6 +61,8 @@ extension Notification.Name { static let resumePopupQueue = Notification.Name("resumePopupQueue") /// 终止弹窗队列(登录成功后) static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue") + /// 本机解锁次数变化 + static let unlockCountDidChange = Notification.Name("unlockCountDidChange") } diff --git a/QuickLocation/Manager/Unlock/UnlockCountManager.swift b/QuickLocation/Manager/Unlock/UnlockCountManager.swift index 719939aa..c6edee4c 100644 --- a/QuickLocation/Manager/Unlock/UnlockCountManager.swift +++ b/QuickLocation/Manager/Unlock/UnlockCountManager.swift @@ -31,6 +31,8 @@ final class UnlockCountManager: NSObject { private var lastGeofenceCenter: CLLocationCoordinate2D? private var lastKnownLocation: CLLocation? private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid + /// 当前解锁 session 起点(锁屏结算屏幕时长) + private var screenSessionStart: Date? private override init() { super.init() @@ -74,6 +76,23 @@ final class UnlockCountManager: NSObject { return Defaults[\.unlockCountTotal] } + /// 今日屏幕使用时长(秒),含当前解锁 session 的今日部分 + var todayScreenTimeSeconds: Int { + rollDayIfNeeded() + let today = todayString() + var total = Defaults[\.screenTimeByDay][today] ?? 0 + if let start = effectiveScreenSessionStart { + total += screenTimeSeconds(from: start, to: Date(), onDay: today) + } + return total + } + + /// 本 App 今日进入前台次数 + var todayAppUsageCount: Int { + rollDayIfNeeded() + return Defaults[\.appUsageToday] + } + var hasAlwaysLocationAuthorization: Bool { authorizationStatus() == .authorizedAlways } @@ -95,6 +114,7 @@ final class UnlockCountManager: NSObject { } didStart = true rollDayIfNeeded() + restoreScreenSessionIfNeeded() let available = UIApplication.shared.isProtectedDataAvailable isSessionLocked = !available @@ -140,6 +160,9 @@ final class UnlockCountManager: NSObject { restartLocationMonitoringIfPossible(reassertContinuous: true) handleNonActiveLaunchIfNeeded(launchOptions) reconcileUnlockIfNeeded(reason: "coldStartAfterLock") + if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked { + startScreenSession() + } Defaults[\.unlockLastHeartbeatAt] = Date() } @@ -162,13 +185,120 @@ final class UnlockCountManager: NSObject { @discardableResult private func rollDayIfNeeded() -> Bool { + migrateScreenTimeStorageIfNeeded() let today = todayString() - if Defaults[\.unlockCountDate] != today { - Defaults[\.unlockCountDate] = today - Defaults[\.unlockCountToday] = 0 - return true + guard Defaults[\.unlockCountDate] != today else { return false } + + flushScreenSession() + + Defaults[\.unlockCountDate] = today + Defaults[\.unlockCountToday] = 0 + Defaults[\.appUsageToday] = 0 + return true + } + + private var effectiveScreenSessionStart: Date? { + screenSessionStart ?? Defaults[\.screenSessionStartAt] + } + + private func dayString(for date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private func migrateScreenTimeStorageIfNeeded() { + var byDay = Defaults[\.screenTimeByDay] + guard byDay.isEmpty else { return } + + let legacyKey = "screenTimeTodaySeconds" + let legacyToday = UserDefaults.standard.integer(forKey: legacyKey) + if legacyToday > 0 { + let dateKey = Defaults[\.unlockCountDate].isEmpty ? todayString() : Defaults[\.unlockCountDate] + byDay[dateKey] = legacyToday + Defaults[\.screenTimeByDay] = byDay } - return false + } + + private func addScreenTime(from start: Date, to end: Date) { + guard end > start else { return } + var cursor = start + let calendar = Calendar.current + var byDay = Defaults[\.screenTimeByDay] + + while cursor < end { + let dayStart = calendar.startOfDay(for: cursor) + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { break } + let segmentEnd = min(end, nextDay) + let seconds = max(0, Int(segmentEnd.timeIntervalSince(cursor))) + if seconds > 0 { + let key = dayString(for: cursor) + byDay[key, default: 0] += seconds + } + cursor = segmentEnd + } + + Defaults[\.screenTimeByDay] = byDay + } + + private func screenTimeSeconds(from start: Date, to end: Date, onDay dayKey: String) -> Int { + guard end > start else { return 0 } + let calendar = Calendar.current + guard + let dayStart = date(fromDayKey: dayKey), + let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) + else { return 0 } + + let segmentStart = max(start, dayStart) + let segmentEnd = min(end, nextDay) + guard segmentEnd > segmentStart else { return 0 } + return max(0, Int(segmentEnd.timeIntervalSince(segmentStart))) + } + + private func date(fromDayKey key: String) -> Date? { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: key) + } + + private func restoreScreenSessionIfNeeded() { + guard UIApplication.shared.isProtectedDataAvailable else { return } + guard screenSessionStart == nil, let persisted = Defaults[\.screenSessionStartAt] else { return } + screenSessionStart = persisted + } + + private func startScreenSession() { + guard UIApplication.shared.isProtectedDataAvailable else { return } + rollDayIfNeeded() + if screenSessionStart == nil { + if let persisted = Defaults[\.screenSessionStartAt] { + screenSessionStart = persisted + } else { + screenSessionStart = Date() + Defaults[\.screenSessionStartAt] = screenSessionStart + } + } + } + + private func flushScreenSession() { + guard let start = effectiveScreenSessionStart else { return } + addScreenTime(from: start, to: Date()) + screenSessionStart = nil + Defaults[\.screenSessionStartAt] = nil + notifyChange() + } + + private func recordAppUsage() { + rollDayIfNeeded() + Defaults[\.appUsageToday] = Defaults[\.appUsageToday] + 1 + notifyChange() + debugLog("appUsage today=\(Defaults[\.appUsageToday])") } // MARK: - Counting @@ -183,12 +313,14 @@ final class UnlockCountManager: NSObject { isSessionLocked = false Defaults[\.unlockLastHeartbeatAt] = now stopProtectedDataPolling() + startScreenSession() notifyChange() print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])") } private func markLocked() { + flushScreenSession() isSessionLocked = true hasSeenLockInSession = true Defaults[\.unlockWasLocked] = true @@ -344,6 +476,7 @@ final class UnlockCountManager: NSObject { debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") endBootBackgroundTask() reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") + recordAppUsage() restartLocationMonitoringIfPossible(reassertContinuous: true) Defaults[\.unlockLastHeartbeatAt] = Date() } diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index f8a78899..1c779092 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -189,6 +189,10 @@ struct GroupMemberModel: Mappable, Equatable { } /// 状态:0、无状态 1、SOS 2、气泡等 var status: Int = 0 + /// 入圈天数(若服务端下发) + var join_days: Int = 0 + /// 入圈时间戳毫秒(若服务端下发) + var join_time: Int64 = 0 /// 是否在线 var is_online: Bool = false /// 设备电量 @@ -235,6 +239,8 @@ struct GroupMemberModel: Mappable, Equatable { is_online <- map["is_online"] last_active_time <- map["last_active_time"] status <- map["status"] + join_days <- (map["join_days"], kStrTransformInt) + join_time <- map["join_time"] } } diff --git a/QuickLocation/Section/Group/GroupItineraryView.swift b/QuickLocation/Section/Group/GroupItineraryView.swift index 43d89ff3..7870d53b 100644 --- a/QuickLocation/Section/Group/GroupItineraryView.swift +++ b/QuickLocation/Section/Group/GroupItineraryView.swift @@ -21,83 +21,23 @@ final class GroupItineraryView: UIView { groupNameLab.text = name.isEmpty ? " " : name } - func reloadMembers(_ members: [GroupMemberModel], selectedId: String, groupKey: String = "") { - memberList = Self.sortedMemberList(members, groupKey: groupKey) - // 保留有效选中;无效则用传入 selectedId → 自己 → 第一项(对齐首页) - if memberList.contains(where: { $0.user_id == selectedMemberId }) { - // keep selectedMemberId - } else if memberList.contains(where: { $0.user_id == selectedId }) { - selectedMemberId = selectedId - } else if let me = memberList.first(where: { $0.user_id == AppContextManager.shared.userId }) { - selectedMemberId = me.user_id - } else { - selectedMemberId = memberList.first?.user_id ?? "" - } - memberCV.reloadData() - scrollToSelectedMember(animated: false) - } - func reloadSchedules(_ list: [ScheduleModel]) { scheduleSections = Self.groupByDay(list) tableView.reloadData() emptyLab.isHidden = !list.isEmpty } - private var memberList: [GroupMemberModel] = [] - private var selectedMemberId: String = "" - private var scheduleSections: [(title: String, items: [ScheduleModel])] = [] - - /// 与 HomeViewModel / GroupMemberView2 一致:圈主 -> 自己 -> 在线;同优先级保持接口原始顺序 - private static func sortedMemberList(_ list: [GroupMemberModel], groupKey: String) -> [GroupMemberModel] { - list.enumerated() - .sorted { lhs, rhs in - let lhsPriority = memberSortPriority(lhs.element, groupKey: groupKey) - let rhsPriority = memberSortPriority(rhs.element, groupKey: groupKey) - if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } - return lhs.offset < rhs.offset - } - .map(\.element) - } - - private static func memberSortPriority(_ member: GroupMemberModel, groupKey: String) -> Int { - if !groupKey.isEmpty, groupKey.contains(member.user_id) { return 0 } - if member.user_id == AppContextManager.shared.userId { return 1 } - if member.is_online { return 2 } - return 3 - } - - private func scrollToSelectedMember(animated: Bool) { - guard !selectedMemberId.isEmpty, - let idx = memberList.firstIndex(where: { $0.user_id == selectedMemberId }) else { return } - let indexPath = IndexPath(item: idx, section: 0) - DispatchQueue.main.async { [weak self] in - guard let self = self, - self.memberCV.numberOfSections > 0, - self.memberCV.numberOfItems(inSection: 0) > idx else { return } - self.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) - } - } + private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = [] private func setupUI() { addSubview(headerRow) headerRow.addSubview(groupIcon) headerRow.addSubview(groupNameLab) headerRow.addSubview(changeGroupBtn) - - let memberView = UIView() - memberView.backgroundColor = .white - memberView.cornerRadius = 20 - memberView.addSubview(memberCV) - addSubview(memberView) - - memberView.layoutChain - .topToBottomOfView(headerRow, offset: 12) - .edgesHorzontal(15) - .height(100) - addSubview(tableView) addSubview(emptyLab) - + addSubview(createBtn) + headerRow.layoutChain .top(8) .edgesHorzontal(16) @@ -119,53 +59,65 @@ final class GroupItineraryView: UIView { .centerY() .rightToLeftOfView(changeGroupBtn, offset: -8, relation: .lessThanOrEqual) - memberCV.layoutChain - .edges(all: 5, excludingEdge: .bottom) - .bottom() - tableView.layoutChain - .topToBottomOfView(memberCV, offset: 8) + .topToBottomOfView(headerRow, offset: 12) .edgesHorzontal() .bottom() emptyLab.layoutChain .centerX() .centerY(tableView) + + createBtn.layoutChain + .bottom(kSafeBottomMargin + 102) + .right(10) + .width(60) + .heightToWidth(1) } private func setupRx() { + createBtn.rx.tap.subscribe(onNext: { _ in + AppRouter.push(Route.createSchedule) + }).disposed(by: disposeBag) + changeGroupBtn.rx.tap .subscribe(onNext: { [weak self] in self?.onSwitchGroup?() }) .disposed(by: disposeBag) } - private static func groupByDay(_ list: [ScheduleModel]) -> [(title: String, items: [ScheduleModel])] { + private static func groupByDay(_ list: [ScheduleModel]) -> [(dayText: String, dateText: String, items: [ScheduleModel])] { let sorted = list.sorted { $0.timestamp > $1.timestamp } var map: [String: [ScheduleModel]] = [:] var order: [String] = [] + var dayDateMap: [String: (dayText: String, dateText: String)] = [:] let fmt = DateFormatter() fmt.locale = Locale(identifier: "zh_CN") let calendar = Calendar.current for item in sorted { let date = Date(timeIntervalSince1970: TimeInterval(item.timestamp) / 1000) fmt.dateFormat = "M月d日" - let day = fmt.string(from: date) - let title: String + let dateText = fmt.string(from: date) + let dayText: String if calendar.isDateInToday(date) { - title = "今天 \(day)" + dayText = "今天" } else if calendar.isDateInYesterday(date) { - title = "昨天 \(day)" + dayText = "昨天" } else { - fmt.dateFormat = "EEEE M月d日" - title = fmt.string(from: date) + fmt.dateFormat = "EEEE" + dayText = fmt.string(from: date) } - if map[title] == nil { - order.append(title) - map[title] = [] + let key = "\(dayText) \(dateText)" + if map[key] == nil { + order.append(key) + dayDateMap[key] = (dayText, dateText) + map[key] = [] } - map[title]?.append(item) + map[key]?.append(item) + } + return order.map { key in + let pair = dayDateMap[key] ?? ("", "") + return (pair.dayText, pair.dateText, map[key] ?? []) } - return order.map { ($0, map[$0] ?? []) } } lazy var headerRow: UIView = { @@ -198,24 +150,9 @@ final class GroupItineraryView: UIView { return btn }() - lazy var memberCV: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.itemSize = CGSize(width: 61, height: 95) - layout.minimumLineSpacing = 15 - layout.sectionInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - layout.scrollDirection = .horizontal - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.register(GroupMemberListCell.self) - cv.dataSource = self - cv.delegate = self - return cv - }() - lazy var tableView: UITableView = { let tv = UITableView(frame: .zero, style: .grouped) - tv.backgroundColor = .clear + tv.backgroundColor = UIColor(hexStr: "#FAFAFA") tv.separatorStyle = .none tv.showsVerticalScrollIndicator = false tv.register(GroupItineraryCell.self) @@ -239,10 +176,16 @@ final class GroupItineraryView: UIView { label.isHidden = true return label }() + + lazy var createBtn: UIButton = { + let btn = UIButton() + btn.setImage(UIImage(named: "Schedule/create"), for: .normal) + return btn + }() override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = UIColor(hexStr: "#FAFAFA") + backgroundColor = .clear setupUI() setupRx() } @@ -252,30 +195,6 @@ final class GroupItineraryView: UIView { } } -// MARK: - UICollectionView -extension GroupItineraryView: UICollectionViewDataSource, UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - memberList.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) - let model = memberList[indexPath.item] - cell.configure( - model: model, - isCurrentUser: model.user_id == AppContextManager.shared.userId, - isSelected: model.user_id == selectedMemberId - ) - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedMemberId = memberList[indexPath.item].user_id - memberCV.reloadData() - scrollToSelectedMember(animated: true) - } -} - // MARK: - UITableView extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { @@ -289,10 +208,27 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let header = UIView() header.backgroundColor = .clear + let sectionData = scheduleSections[section] let label = UILabel() - label.text = scheduleSections[section].title - label.font = .systemFont(ofSize: 13, weight: .semibold) - label.textColor = UIColor(hexStr: "#353B4F") + let color = UIColor(hexStr: "#353B4F") + let attr = NSMutableAttributedString() + attr.append(NSAttributedString( + string: sectionData.dayText, + attributes: [ + .font: UIFont.systemFont(ofSize: 16, weight: .bold), + .foregroundColor: color + ] + )) + if !sectionData.dateText.isEmpty { + attr.append(NSAttributedString( + string: " \(sectionData.dateText)", + attributes: [ + .font: UIFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: color + ] + )) + } + label.attributedText = attr header.addSubview(label) label.layoutChain.left(16).centerY() return header @@ -340,9 +276,9 @@ final class GroupItineraryCell: UITableViewCell { let date = Date(timeIntervalSince1970: TimeInterval(model.timestamp) / 1000) let ongoing = Calendar.current.isDateInToday(date) || date > Date() statusLab.text = ongoing ? "进行中" : "已完成" - statusLab.textColor = ongoing ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#999999") + statusLab.textColor = ongoing ? UIColor(hexStr: "#00B0C6") : UIColor(hexStr: "#999999") statusLab.backgroundColor = ongoing - ? UIColor(hexStr: "#E3F6FF") + ? UIColor(hexStr: "#DCFCFF") : UIColor(hexStr: "#F0F0F0") let tag = model.groups.first?.group_name ?? "" @@ -420,43 +356,45 @@ final class GroupItineraryCell: UITableViewCell { private lazy var avatarImg: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill - iv.cornerRadius = 18 + iv.cornerRadius = 10 iv.clipsToBounds = true return iv }() private lazy var nameLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .semibold) + label.font = .systemFont(ofSize: 14, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") return label }() private lazy var statusLab: PaddingLabel = { let label = PaddingLabel() - label.font = .systemFont(ofSize: 11, weight: .medium) + label.font = .systemFont(ofSize: 12, weight: .medium) label.textAlignment = .center - label.cornerRadius = 6 + label.cornerRadius = 4 label.clipsToBounds = true label.insets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8) + label.borderWidth = 1 + label.borderColor = UIColor(hexStr: "#84E2ED") return label }() private lazy var summaryLab: UILabel = { let label = UILabel() label.text = "今天停留了" - label.font = .systemFont(ofSize: 14, weight: .medium) + label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") return label }() private lazy var placeCountLab: UILabel = { let label = UILabel() - label.font = FontManager.youSheBiaoTiHei(12) - label.textColor = .white + label.font = FontManager.youSheBiaoTiHei(14) + label.textColor = UIColor(hexStr: "#58EDFF") label.textAlignment = .center - label.backgroundColor = UIColor(hexStr: "#353B4F") - label.cornerRadius = 11 + label.backgroundColor = UIColor(hexStr: "#293445") + label.cornerRadius = 6 label.clipsToBounds = true return label }() @@ -464,7 +402,7 @@ final class GroupItineraryCell: UITableViewCell { private lazy var summaryTailLab: UILabel = { let label = UILabel() label.text = "个地方" - label.font = .systemFont(ofSize: 14, weight: .medium) + label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#353B4F") return label }() diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index c3cd9d5e..377861ec 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -210,11 +210,6 @@ final class GroupViewController: BaseViewController { self.itineraryGroupModel = model let current = model.groups.first(where: { $0.group_key == model.default_group_key }) self.rootView.itineraryPage.updateGroupName(current?.name ?? "") - self.rootView.itineraryPage.reloadMembers( - model.select_group_employee, - selectedId: AppContextManager.shared.userId, - groupKey: model.default_group_key - ) self.requestItinerarySchedules(groupKey: model.default_group_key) }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift b/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift index 53483a0b..5ba372dc 100644 --- a/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift +++ b/QuickLocation/Section/Group/InviteJoin/InviteJoinView.swift @@ -131,8 +131,6 @@ class InviteJoinView: UIView { lazy var navView: BaseNavigationView = { let nav = BaseNavigationView(title: "邀请成员") - nav.backBtn.setImage(UIImage(named: "Group/invite_back"), for: .normal) - nav.backBtn.layoutChain.width(36).height(36) return nav }() diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 6c14eb02..d954d74f 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -8,13 +8,12 @@ import UIKit import Network import CoreLocation -import RxSwift -import RxCocoa import SwiftyUserDefaults class GroupMemberView2: UIView { private var navBarHeightConstraint: NSLayoutConstraint? + private var currentMemberModel: GroupMemberModel? private static let batteryNormalColor = UIColor(hexStr: "#47DD00") private static let batteryLowColor = UIColor(hexStr: "#F85A5D") @@ -28,6 +27,7 @@ class GroupMemberView2: UIView { } func setupMemberInfo(_ model: GroupMemberModel, isOwner: Bool) { + currentMemberModel = model memberNameLab.text = model.nick_name ownView.isHidden = !isOwner statusDotView.backgroundColor = UIColor(hexStr: model.is_online ? "#67EA76" : "#D8D8D8") @@ -42,6 +42,7 @@ class GroupMemberView2: UIView { } else { setupUnknownDeviceInfo(battery: model.battery) } + refreshMemberReports(for: model) } private func updateDistance(for model: GroupMemberModel, isCurrentUser: Bool) { @@ -214,7 +215,27 @@ class GroupMemberView2: UIView { ] private func setupRx() { - + NotificationCenter.default.addObserver( + self, + selector: #selector(handleUnlockCountDidChange), + name: .unlockCountDidChange, + object: nil + ) + } + + @objc private func handleUnlockCountDidChange() { + guard let model = currentMemberModel else { return } + refreshMemberReports(for: model) + } + + private func refreshMemberReports(for model: GroupMemberModel) { + currentMemberModel = model + let isCurrentUser = model.user_id == AppContextManager.shared.userId + if isCurrentUser { + phoneReportView.configure(with: .currentUser()) + } else { + phoneReportView.configure(with: nil) + } } private func setupUI() { @@ -288,7 +309,7 @@ class GroupMemberView2: UIView { lazy var scrollView: UIScrollView = { let view = UIScrollView() view.backgroundColor = .clear - view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false view.bounces = false view.addSubview(scrollContentView) @@ -300,8 +321,14 @@ class GroupMemberView2: UIView { scrollContentView.addSubview(todayTrackView) todayTrackView.layoutChain - .topToBottomOfView(memberView, offset: 16) - .edges(excludingEdge: .top) + .topToBottomOfView(memberView, offset: 15) + .edgesHorzontal() + + scrollContentView.addSubview(phoneReportView) + phoneReportView.layoutChain + .topToBottomOfView(todayTrackView, offset: 15) + .edgesHorzontal() + .bottom(49 + kSafeBottomMargin) return view }() @@ -728,6 +755,15 @@ class GroupMemberView2: UIView { return label }() + // MARK: - 手机报告 / APP使用记录 + lazy var phoneReportView = MemberPhoneReportView() + /// 保留 UI 实现,当前不挂载到 scroll 内容 + lazy var appUsageView: MemberAppUsageView = { + let view = MemberAppUsageView() + view.isHidden = true + return view + }() + // MARK: - 今日轨迹 lazy var todayTrackView: UIView = { let view = UIView() @@ -942,6 +978,10 @@ class GroupMemberView2: UIView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + } /// 轻量网络状态监听,供面板读取当前路径 diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index a846c3af..7cc3438e 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -88,6 +88,7 @@ class HomeViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + UnlockCountManager.shared.refreshAuthorizationAndMonitoring() let manager = AuthorizeManager.manager(type: .locationAlways) rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized diff --git a/QuickLocation/Section/Home/MemberPhoneReportView.swift b/QuickLocation/Section/Home/MemberPhoneReportView.swift new file mode 100644 index 00000000..77a8f6a5 --- /dev/null +++ b/QuickLocation/Section/Home/MemberPhoneReportView.swift @@ -0,0 +1,316 @@ +// +// MemberPhoneReportView.swift +// QuickLocation +// + +import UIKit + +struct MemberPhoneReportPreview { + let screenTimeSeconds: Int? + let usageCount: Int + let unlockCount: Int + + static func currentUser(from manager: UnlockCountManager = .shared) -> MemberPhoneReportPreview { + let seconds = manager.todayScreenTimeSeconds + return MemberPhoneReportPreview( + screenTimeSeconds: seconds > 0 ? seconds : nil, + usageCount: manager.todayAppUsageCount, + unlockCount: manager.todayCount + ) + } +} + +/// 成员面板「手机报告」:屏幕使用时长 + 使用次数 + 今日解锁 +final class MemberPhoneReportView: UIView { + + private enum Layout { + static let designCardWidth: CGFloat = 345 + static let leftPanelWidthRatio: CGFloat = 164 / designCardWidth + static let leftPanelAspect: CGFloat = 147 / 164 + static let panelGap: CGFloat = 13 + static let cardInset: CGFloat = 12 + static let usageCardAspect: CGFloat = 69 / 146 + static let usageUnlockGap: CGFloat = 9 + } + + private let titleColor = UIColor(hexStr: "#293445") + private let usageTint = UIColor(hexStr: "#E8FBE8") + private let unlockTint = UIColor(hexStr: "#E8F4FF") + + private let sectionIcon = UIImageView() + private let sectionTitleLab = UILabel() + private let cardView = UIView() + private let leftPanel = UIView() + private let rightPanel = UIView() + private let screenTimeIcon = UIImageView() + private let screenTimeTitleLab = UILabel() + private let screenTimeBadge = ScreenTimeBadgeView() + private let usageCard = UIView() + private let usageIcon = UIImageView() + private let usageTitleLab = UILabel() + private let usageValueLab = UILabel() + private let unlockCard = UIView() + private let unlockIcon = UIImageView() + private let unlockTitleLab = UILabel() + private let unlockValueLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + configure(with: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with preview: MemberPhoneReportPreview?) { + if let preview { + screenTimeBadge.configure(seconds: preview.screenTimeSeconds) + usageValueLab.text = "\(preview.usageCount)" + unlockValueLab.text = "\(preview.unlockCount)" + } else { + screenTimeBadge.configure(seconds: nil) + usageValueLab.text = "**" + unlockValueLab.text = "**" + } + } + + private func setupUI() { + backgroundColor = .clear + + sectionIcon.image = UIImage(named: "Home/member_phone_report") + sectionIcon.contentMode = .scaleAspectFit + addSubview(sectionIcon) + sectionIcon.layoutChain + .top() + .left(15) + .width(18) + .heightToWidth(1) + + sectionTitleLab.text = "手机报告" + sectionTitleLab.font = .systemFont(ofSize: 16, weight: .bold) + sectionTitleLab.textColor = titleColor + addSubview(sectionTitleLab) + sectionTitleLab.layoutChain + .leftToRightOfView(sectionIcon, offset: 6) + .centerY(sectionIcon) + + cardView.backgroundColor = .white + cardView.cornerRadius = 16 + addSubview(cardView) + cardView.layoutChain + .topToBottomOfView(sectionIcon, offset: 12) + .edgesHorzontal(15) + .bottom() + + cardView.addSubview(leftPanel) + cardView.addSubview(rightPanel) + leftPanel.backgroundColor = UIColor(hexStr: "#FFFCEA") + leftPanel.cornerRadius = 14 + rightPanel.backgroundColor = .clear + leftPanel.layoutChain + .top(Layout.cardInset) + .left(Layout.cardInset) + .widthToView(cardView, multiplier: Layout.leftPanelWidthRatio) + .heightToWidth(Layout.leftPanelAspect) + + rightPanel.layoutChain + .top(Layout.cardInset) + .right(Layout.cardInset) + .leftToRightOfView(leftPanel, offset: Layout.panelGap) + .heightToView(leftPanel) + .bottom(Layout.cardInset) + + screenTimeIcon.image = UIImage(named: "Home/member_report_clock") + screenTimeIcon.contentMode = .scaleAspectFit + leftPanel.addSubview(screenTimeIcon) + screenTimeIcon.layoutChain + .top(7) + .left(7) + .width(20) + .height(20) + + screenTimeTitleLab.text = "屏幕使用时长" + screenTimeTitleLab.font = .systemFont(ofSize: 12, weight: .bold) + screenTimeTitleLab.textColor = UIColor(hexStr: "#293445") + leftPanel.addSubview(screenTimeTitleLab) + screenTimeTitleLab.layoutChain + .leftToRightOfView(screenTimeIcon, offset: 6) + .centerY(screenTimeIcon) + + leftPanel.addSubview(screenTimeBadge) + screenTimeBadge.layoutChain + .topToBottomOfView(screenTimeTitleLab, offset: 13) + .centerX(leftPanel, offset: 7) + + let screenTimeBadgeArrow = UIImageView(image: UIImage(named: "Home/member_screenTime_arrow")) + leftPanel.addSubview(screenTimeBadgeArrow) + screenTimeBadgeArrow.layoutChain + .topToBottomOfView(screenTimeBadge) + .centerX(screenTimeBadge) + + let barchartIcon = UIImageView(image: UIImage(named: "Home/member_screenTime")) + leftPanel.addSubview(barchartIcon) + barchartIcon.layoutChain + .centerX() + .topToBottomOfView(screenTimeBadge, offset: 12) + + usageCard.backgroundColor = UIColor(hexStr: "#F8FFEE") + usageCard.cornerRadius = 14 + rightPanel.addSubview(usageCard) + usageCard.layoutChain + .top() + .edgesHorzontal() + .heightToWidth(Layout.usageCardAspect) + + usageIcon.image = UIImage(named: "Home/member_usage_count") + usageIcon.contentMode = .scaleAspectFit + usageCard.addSubview(usageIcon) + usageIcon.layoutChain + .top(8) + .left(10) + .width(20) + .height(20) + + usageTitleLab.text = "使用次数" + usageTitleLab.font = .systemFont(ofSize: 12, weight: .bold) + usageTitleLab.textColor = UIColor(hexStr: "#293445") + usageCard.addSubview(usageTitleLab) + usageTitleLab.layoutChain + .leftToRightOfView(usageIcon, offset: 6) + .centerY(usageIcon) + .right(relation: .lessThanOrEqual) + + usageValueLab.font = .systemFont(ofSize: 24, weight: .bold) + usageValueLab.textColor = UIColor(hexStr: "#83DE00") + usageCard.addSubview(usageValueLab) + usageValueLab.layoutChain + .topToBottomOfView(usageTitleLab, offset: 4) + .centerX() + + let usageValueUnitLab = UILabel() + usageValueUnitLab.text = "次" + usageValueUnitLab.textColor = UIColor(hexStr: "#293445") + usageValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold) + usageCard.addSubview(usageValueUnitLab) + usageValueUnitLab.layoutChain + .leftToRightOfView(usageValueLab, offset: 3) + .bottomToView(usageValueLab, offset: -4) + + unlockCard.backgroundColor = UIColor(hexStr: "#F2FEFF") + unlockCard.cornerRadius = 14 + rightPanel.addSubview(unlockCard) + unlockCard.layoutChain + .topToBottomOfView(usageCard, offset: Layout.usageUnlockGap) + .edgesHorzontal() + .heightToWidth(Layout.usageCardAspect) + .bottom() + + unlockIcon.image = UIImage(named: "Home/member_daily_unlock") + unlockIcon.contentMode = .scaleAspectFit + unlockCard.addSubview(unlockIcon) + unlockIcon.layoutChain + .top(8) + .left(10) + .width(20) + .height(20) + + unlockTitleLab.text = "今日解锁" + unlockTitleLab.font = .systemFont(ofSize: 12, weight: .bold) + unlockTitleLab.textColor = UIColor(hexStr: "#293445") + unlockCard.addSubview(unlockTitleLab) + unlockTitleLab.layoutChain + .leftToRightOfView(unlockIcon, offset: 6) + .centerY(unlockIcon) + .right(relation: .lessThanOrEqual) + + unlockValueLab.font = .systemFont(ofSize: 24, weight: .bold) + unlockValueLab.textColor = UIColor(hexStr: "#08B6C4") + unlockCard.addSubview(unlockValueLab) + unlockValueLab.layoutChain + .topToBottomOfView(unlockTitleLab, offset: 4) + .centerX() + + let unlockValueUnitLab = UILabel() + unlockValueUnitLab.text = "次" + unlockValueUnitLab.textColor = UIColor(hexStr: "#293445") + unlockValueUnitLab.font = .systemFont(ofSize: 12, weight: .bold) + unlockCard.addSubview(unlockValueUnitLab) + unlockValueUnitLab.layoutChain + .leftToRightOfView(unlockValueLab, offset: 3) + .bottomToView(unlockValueLab, offset: -4) + } +} + +// MARK: - 屏幕时长 Badge + +private final class ScreenTimeBadgeView: UIView { + + private let contentStack = UIStackView() + private let numberColor = UIColor(hexStr: "#FF6B4A") + private let unitColor = UIColor(hexStr: "#293445") + private let numberFont = UIFont.systemFont(ofSize: 24, weight: .heavy) + private let unitFont = UIFont.systemFont(ofSize: 14, weight: .heavy) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + cornerRadius = 11 + contentStack.axis = .horizontal + contentStack.alignment = .lastBaseline + contentStack.spacing = 2 + addSubview(contentStack) + contentStack.layoutChain + .edges(UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(seconds: Int?) { + contentStack.arrangedSubviews.forEach { view in + contentStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + + guard let seconds, seconds > 0 else { + contentStack.addArrangedSubview(makeLabel(text: "**", font: numberFont, color: numberColor)) + return + } + + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + + if hours > 0 { + appendPart(number: "\(hours)", unit: "h") + if minutes > 0 { + contentStack.addArrangedSubview(makeSpacer(width: 4)) + appendPart(number: "\(minutes)", unit: "m") + } + } else { + appendPart(number: "\(minutes)", unit: "m") + } + } + + private func appendPart(number: String, unit: String) { + contentStack.addArrangedSubview(makeLabel(text: number, font: numberFont, color: numberColor)) + contentStack.addArrangedSubview(makeLabel(text: unit, font: unitFont, color: unitColor)) + } + + private func makeLabel(text: String, font: UIFont, color: UIColor) -> UILabel { + let label = UILabel() + label.text = text + label.font = font + label.textColor = color + return label + } + + private func makeSpacer(width: CGFloat) -> UIView { + let spacer = UIView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.widthAnchor.constraint(equalToConstant: width).isActive = true + return spacer + } +} diff --git a/QuickLocation/Section/Mine/Account/AccountView.swift b/QuickLocation/Section/Mine/Account/AccountView.swift index f87a487a..53ef00a4 100644 --- a/QuickLocation/Section/Mine/Account/AccountView.swift +++ b/QuickLocation/Section/Mine/Account/AccountView.swift @@ -14,6 +14,13 @@ class AccountView: UIView { var disposeBag = DisposeBag() private func setupRx() { + copyBtn.rx.tap + .subscribe(onNext: { _ in + UIPasteboard.general.string = AppContextManager.shared.userId + DLToast.showSuccess(text: "已复制") + }) + .disposed(by: disposeBag) + phoneView.rx.tapGesture.subscribe(onNext: { _ in AppRouter.push(Route.changePhone) }).disposed(by: disposeBag) @@ -116,13 +123,20 @@ class AccountView: UIView { .left(15) .centerY() + view.addSubview(copyBtn) + copyBtn.layoutChain + .right(15) + .centerY() + .width(14) + .height(14) + let contentLab = UILabel() contentLab.text = AppContextManager.shared.userId contentLab.font = .systemFont(ofSize: 14, weight: .medium) contentLab.textColor = UIColor(hexStr: "#1A1A1A") view.addSubview(contentLab) contentLab.layoutChain - .right(15) + .rightToLeftOfView(copyBtn, offset: -2) .centerY() let line = UIView() @@ -136,6 +150,12 @@ class AccountView: UIView { return view }() + lazy var copyBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Mine/copy"), for: .normal) + return btn + }() + /// 手机号 lazy var phoneView: UIView = { let view = UIView() diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift new file mode 100644 index 00000000..17829f07 --- /dev/null +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -0,0 +1,770 @@ +// +// MinePhotoWallView.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa + +/// 「我的」页顶部:绳线 + 成员照片墙 + 信息区 +final class MinePhotoWallView: UIView { + + var onCenteredMember: ((GroupMemberModel) -> Void)? + var onCopyID: ((String) -> Void)? + var onTapSelfAvatar: (() -> Void)? + + private var members: [GroupMemberModel] = [] + /// 与 members 对齐的夹子颜色下标(相邻不同色) + private var clipColorIndices: [Int] = [] + private var ownerUserId: String = "" + private var centeredIndex: Int = 0 + private var isDragging = false + private var lastLayoutWidth: CGFloat = 0 + + /// cell 包络按选中相框 70×90 + 夹子留白 + private let itemSize = CGSize(width: 72, height: 110) + private let itemSpacing: CGFloat = 18 + /// 左右边缘淡化带宽 + private let edgeFadeWidth: CGFloat = 56 + /// 绳两端距照片墙顶 + private let ropeEndY: CGFloat = 30 + /// 绳中间下垂量 + private let ropeSag: CGFloat = 28 + private var clipAttachInCell: CGFloat { MinePolaroidCell.clipAttachOffset } + + private let selectedSize = MinePolaroidCell.selectedFrameSize + private let unselectedSize = MinePolaroidCell.unselectedFrameSize + + /// 绳参数 t∈[-1,1] + private func ropeT(atX x: CGFloat) -> CGFloat { + let w = max(bounds.width, 1) + let t = (x - w * 0.5) / (w * 0.5) + return max(-1, min(1, t)) + } + + /// 绳在 x 处的 y:两端高、中间低 + private func ropeY(atX x: CGFloat) -> CGFloat { + let t = ropeT(atX: x) + return ropeEndY + ropeSag * (1 - t * t) + } + + /// 绳切线角(UIKit 正角顺时针),使照片跟随弧度倾斜 + private func ropeAngle(atX x: CGFloat) -> CGFloat { + let w = max(bounds.width, 1) + let t = ropeT(atX: x) + // y = end + sag*(1-t²), t=(x-w/2)/(w/2) → dy/dx = -4*sag*t/w + return atan(-4 * ropeSag * t / w) + } + + private var ropeCenterY: CGFloat { ropeEndY + ropeSag } + + // MARK: - Public + + func reload(members: [GroupMemberModel], ownerUserId: String, preferUserId: String) { + self.members = members + self.ownerUserId = ownerUserId + self.clipColorIndices = Self.makeClipColorIndices(for: members) + collectionView.reloadData() + + guard !members.isEmpty else { + updateInfo(nil) + return + } + + let idx: Int + if let i = members.firstIndex(where: { $0.user_id == preferUserId }) { + idx = i + } else if let i = members.firstIndex(where: { $0.user_id == AppContextManager.shared.userId }) { + idx = i + } else { + idx = 0 + } + centeredIndex = idx + collectionView.layoutIfNeeded() + scrollToIndex(idx, animated: false) + collectionView.layoutIfNeeded() + applyArcAndFocus() + // contentOffset / cell 布局下一拍再贴一次,避免首屏右侧离绳 + DispatchQueue.main.async { [weak self] in + self?.applyArcAndFocus() + } + updateInfo(members[idx]) + onCenteredMember?(members[idx]) + } + + /// 相邻夹子不同色;同成员尽量稳定 + private static func makeClipColorIndices(for members: [GroupMemberModel]) -> [Int] { + let colorCount = MinePolaroidCell.clipAssetNames.count + guard colorCount > 1 else { return Array(repeating: 0, count: members.count) } + var result: [Int] = [] + var prev = -1 + for (i, m) in members.enumerated() { + var options = Array(0..2) + if i == members.count - 1, members.count > 2, let first = result.first { + options = options.filter { $0 != first } + if options.isEmpty { options = Array(0.. 0.5 + if widthChanged, let layout = collectionView.collectionViewLayout as? MinePhotoWallFlowLayout { + lastLayoutWidth = bounds.width + let side = (bounds.width - itemSize.width) / 2 + let topInset = ropeCenterY - clipAttachInCell + layout.sectionInset = UIEdgeInsets(top: max(0, topInset), left: side, bottom: 0, right: side) + layout.invalidateLayout() + collectionView.layoutIfNeeded() + } + applyArcAndFocus() + } + + private func updateRopePath() { + let w = bounds.width + guard w > 0 else { return } + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: ropeEndY)) + path.addQuadCurve( + to: CGPoint(x: w, y: ropeEndY), + controlPoint: CGPoint(x: w * 0.5, y: ropeEndY + ropeSag * 2) + ) + ropeLayer.path = path.cgPath + ropeLayer.frame = bounds + + let shadowPath = UIBezierPath() + let dy: CGFloat = 1.5 + shadowPath.move(to: CGPoint(x: 0, y: ropeEndY + dy)) + shadowPath.addQuadCurve( + to: CGPoint(x: w, y: ropeEndY + dy), + controlPoint: CGPoint(x: w * 0.5, y: ropeEndY + ropeSag * 2 + dy) + ) + ropeShadowLayer.path = shadowPath.cgPath + ropeShadowLayer.frame = bounds + } + + private func scrollToIndex(_ index: Int, animated: Bool) { + guard index >= 0, index < members.count else { return } + let indexPath = IndexPath(item: index, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) + } + + private func nearestCenterIndex() -> Int { + let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2 + var best = 0 + var bestDist = CGFloat.greatestFiniteMagnitude + for i in 0.. 0.5 + if !moved && !self.isDragging { + self.playSwayOnce() + } + } + } + + /// 衣夹贴绳 + 切线倾斜 + 选中 70×90 / 未选中 60×70 + 左右边缘淡化 + private func applyArcAndFocus() { + guard bounds.width > 0 else { return } + let scrollCenterX = collectionView.contentOffset.x + collectionView.bounds.width / 2 + let span = itemSize.width + itemSpacing + for cell in collectionView.visibleCells { + guard let polaroid = cell as? MinePolaroidCell, + let indexPath = collectionView.indexPath(for: cell), + let attrs = collectionView.layoutAttributesForItem(at: indexPath) else { continue } + + cell.transform = .identity + + let dist = abs(attrs.center.x - scrollCenterX) + let t = min(dist / span, 1) + // 相框真实尺寸插值(不含夹子),避免非等比 scale + 旋转剪切变形 + let fw = selectedSize.width + (unselectedSize.width - selectedSize.width) * t + let fh = selectedSize.height + (unselectedSize.height - selectedSize.height) * t + + let frameInSelf = collectionView.convert(attrs.frame, to: self) + let attachX = frameInSelf.midX + let attachY = frameInSelf.minY + MinePolaroidCell.clipAttachOffset + let ty = ropeY(atX: attachX) - attachY + 2 + let angle = ropeAngle(atX: attachX) + + // 左右屏边淡化到 70% 透明度,仅相框淡化,夹子保持不透明 + let edgeT = min( + 1, + max(0, frameInSelf.midX / edgeFadeWidth), + max(0, (bounds.width - frameInSelf.midX) / edgeFadeWidth) + ) + let frameAlpha = 0.7 + 0.3 * edgeT + polaroid.setFrameAlpha(frameAlpha) + + polaroid.applyFocus(frameSize: CGSize(width: fw, height: fh), angle: angle) + polaroid.setSelectedLook(dist < itemSize.width * 0.45) + cell.transform = CGAffineTransform(translationX: 0, y: ty) + } + } + + private func updateInfo(_ model: GroupMemberModel?) { + guard let model = model else { + titleLab.text = " " + titleLab.isHidden = false + daysRow.isHidden = true + idLab.text = " " + return + } + let isSelf = model.user_id == AppContextManager.shared.userId + if isSelf { + titleLab.isHidden = false + daysRow.isHidden = true + titleLab.text = model.nick_name + } else { + titleLab.isHidden = true + daysRow.isHidden = false + daysBadge.text = model.joinDaysDisplay + } + idLab.text = "ID:\(model.user_id)" + } + + private func playSwayOnce() { + for cell in collectionView.visibleCells { + (cell as? MinePolaroidCell)?.playSwayOnce() + } + } +} + +// MARK: - Collection + +extension MinePhotoWallView: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + members.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: MinePolaroidCell = collectionView.dequeueReusableCell(for: indexPath) + let model = members[indexPath.item] + let colorIdx = indexPath.item < clipColorIndices.count ? clipColorIndices[indexPath.item] : 0 + cell.configure( + model: model, + isOwner: !ownerUserId.isEmpty && ownerUserId == model.user_id, + clipColorIndex: colorIdx + ) + return cell + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + // 新露出的 cell 立刻贴绳倾斜 + DispatchQueue.main.async { [weak self] in + self?.applyArcAndFocus() + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let model = members[indexPath.item] + if model.user_id == AppContextManager.shared.userId { + onTapSelfAvatar?() + } + centeredIndex = indexPath.item + let before = collectionView.contentOffset + scrollToIndex(indexPath.item, animated: true) + applyArcAndFocus() + updateInfo(model) + onCenteredMember?(model) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if abs(self.collectionView.contentOffset.x - before.x) < 0.5, !self.isDragging { + self.playSwayOnce() + } + } + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isDragging = true + for cell in collectionView.visibleCells { + (cell as? MinePolaroidCell)?.stopSway() + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + applyArcAndFocus() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + isDragging = false + settleToNearest() + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + isDragging = false + settleToNearest() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + applyArcAndFocus() + if !isDragging { + playSwayOnce() + } + } +} + +// MARK: - Flow layout(吸附居中) + +private final class MinePhotoWallFlowLayout: UICollectionViewFlowLayout { + override func targetContentOffset( + forProposedContentOffset proposedContentOffset: CGPoint, + withScrollingVelocity velocity: CGPoint + ) -> CGPoint { + guard let cv = collectionView else { return proposedContentOffset } + let rect = CGRect(origin: proposedContentOffset, size: cv.bounds.size) + guard let attrs = layoutAttributesForElements(in: rect), !attrs.isEmpty else { + return proposedContentOffset + } + let centerX = proposedContentOffset.x + cv.bounds.width / 2 + var best = attrs[0] + for a in attrs where abs(a.center.x - centerX) < abs(best.center.x - centerX) { + best = a + } + return CGPoint(x: best.center.x - cv.bounds.width / 2, y: proposedContentOffset.y) + } +} + +// MARK: - Polaroid cell + +final class MinePolaroidCell: UICollectionViewCell { + + static let selectedFrameSize = CGSize(width: 70, height: 90) + static let unselectedFrameSize = CGSize(width: 60, height: 70) + /// 夹子 16×24,咬合点偏上,整体略挂在绳下 + static let clipSize = CGSize(width: 16, height: 24) + static let clipAttachOffset: CGFloat = clipSize.height * 0.32 + private static let frameTopInset: CGFloat = 12 + private static let avatarInset: CGFloat = 5 + + static let clipAssetNames = [ + "Mine/clothespin", + "Mine/clothespin_blue", + "Mine/clothespin_green", + "Mine/clothespin_pink" + ] + + private static let swayKey = "minePolaroidSway" + private var isOwner = false + private var focusFrameSize = selectedFrameSize + private var focusAngle: CGFloat = 0 + + func configure(model: GroupMemberModel, isOwner: Bool, clipColorIndex: Int) { + self.isOwner = isOwner + avatarImg.image = model.userIcon + ownerTag.isHidden = !isOwner + let idx = ((clipColorIndex % Self.clipAssetNames.count) + Self.clipAssetNames.count) % Self.clipAssetNames.count + clipImg.image = UIImage(named: Self.clipAssetNames[idx]) + stopSway() + } + + /// 按真实相框尺寸布局,整块 polaroidHost 绕衣夹只做旋转(无非等比 scale) + func applyFocus(frameSize: CGSize, angle: CGFloat) { + focusFrameSize = frameSize + focusAngle = angle + layoutPolaroidHostIfNeeded() + polaroidHost.transform = CGAffineTransform(rotationAngle: angle) + } + + func setFrameAlpha(_ alpha: CGFloat) { + frameView.alpha = alpha + clipImg.alpha = 1 + } + + func setSelectedLook(_ selected: Bool) { + heartImg.isHidden = !selected + if selected { + ownerTag.isHidden = true + } else { + ownerTag.isHidden = !isOwner + } + } + + func playSwayOnce() { + stopSway() + let anim = CAKeyframeAnimation(keyPath: "transform.rotation.z") + anim.values = [0, 0.1, 0, -0.1, 0] + anim.keyTimes = [0, 0.25, 0.5, 0.75, 1] + anim.duration = 1.6 + anim.repeatCount = 1 + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + anim.isRemovedOnCompletion = true + anim.isAdditive = true + swayHost.layer.add(anim, forKey: Self.swayKey) + } + + func stopSway() { + swayHost.layer.removeAnimation(forKey: Self.swayKey) + } + + override func prepareForReuse() { + super.prepareForReuse() + stopSway() + heartImg.isHidden = true + ownerTag.isHidden = true + isOwner = false + focusFrameSize = Self.selectedFrameSize + focusAngle = 0 + polaroidHost.transform = .identity + transform = .identity + frameView.alpha = 1 + clipImg.alpha = 1 + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutPolaroidHostIfNeeded() + polaroidHost.transform = CGAffineTransform(rotationAngle: focusAngle) + } + + /// 用 frame 布局 host,锚点钉在衣夹;相框用当前 focusFrameSize + private func layoutPolaroidHostIfNeeded() { + let fw = focusFrameSize.width + let fh = focusFrameSize.height + let hostW = max(fw, Self.clipSize.width) + let hostH = Self.frameTopInset + fh + polaroidHost.bounds = CGRect(x: 0, y: 0, width: hostW, height: hostH) + + let anchor = CGPoint(x: 0.5, y: Self.clipAttachOffset / max(hostH, 1)) + polaroidHost.layer.anchorPoint = anchor + polaroidHost.layer.position = CGPoint( + x: contentView.bounds.midX, + y: Self.clipAttachOffset + ) + + swayHost.frame = polaroidHost.bounds + clipImg.frame = CGRect( + x: (hostW - Self.clipSize.width) / 2, + y: 0, + width: Self.clipSize.width, + height: Self.clipSize.height + ) + frameView.frame = CGRect(x: (hostW - fw) / 2, y: Self.frameTopInset, width: fw, height: fh) + frameView.cornerRadius = 18 * (fw / Self.selectedFrameSize.width) + + let avatarSide = max(0, fw - Self.avatarInset * 2) + avatarImg.frame = CGRect( + x: Self.avatarInset, + y: Self.avatarInset, + width: avatarSide, + height: avatarSide + ) + avatarImg.cornerRadius = max(4, avatarSide * 0.2) + + let captionMidY = Self.avatarInset + avatarSide + (fh - Self.avatarInset - avatarSide) / 2 + heartImg.bounds = CGRect(x: 0, y: 0, width: 12, height: 10) + heartImg.center = CGPoint(x: fw / 2, y: captionMidY) + ownerTag.sizeToFit() + ownerTag.center = CGPoint(x: fw / 2, y: captionMidY) + } + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.clipsToBounds = false + clipsToBounds = false + contentView.addSubview(polaroidHost) + polaroidHost.addSubview(swayHost) + swayHost.addSubview(frameView) + frameView.addSubview(avatarImg) + frameView.addSubview(heartImg) + frameView.addSubview(ownerTag) + swayHost.addSubview(clipImg) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var polaroidHost: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v + }() + + private lazy var swayHost: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v + }() + + private lazy var frameView: UIView = { + let v = UIView() + v.backgroundColor = .white + v.cornerRadius = 18 + v.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor + v.layer.shadowOffset = CGSize(width: 0, height: 2) + v.layer.shadowRadius = 4 + v.layer.shadowOpacity = 1 + return v + }() + + private lazy var avatarImg: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.cornerRadius = 12 + iv.backgroundColor = UIColor(hexStr: "#E8EEF2") + return iv + }() + + private lazy var clipImg: UIImageView = { + let iv = UIImageView(image: UIImage(named: "Mine/clothespin")) + iv.contentMode = .scaleAspectFit + return iv + }() + + private lazy var heartImg: UIImageView = { + let iv = UIImageView() + if #available(iOS 13.0, *) { + iv.image = UIImage(systemName: "heart.fill") + iv.tintColor = UIColor(hexStr: "#FF6B9D") + } + iv.contentMode = .scaleAspectFit + iv.isHidden = true + return iv + }() + + private lazy var ownerTag: UILabel = { + let label = PaddingLabel() + label.text = "圈主" + label.font = .systemFont(ofSize: 9, weight: .semibold) + label.textColor = .white + label.backgroundColor = UIColor(hexStr: "#16B3FF") + label.cornerRadius = 4 + label.clipsToBounds = true + label.textAlignment = .center + label.insets = UIEdgeInsets(top: 1, left: 5, bottom: 1, right: 5) + label.isHidden = true + return label + }() +} + +private final class PaddingLabel: UILabel { + var insets = UIEdgeInsets.zero + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + insets.left + insets.right, + height: size.height + insets.top + insets.bottom) + } +} + +extension GroupMemberModel { + var joinDaysDisplay: String { + if join_days > 0 { return "\(join_days)" } + if join_time > 0 { + let days = max(1, Int((Date().timeIntervalSince1970 - TimeInterval(join_time) / 1000) / 86400)) + return "\(days)" + } + return "--" + } +} diff --git a/QuickLocation/Section/Mine/MineView.swift b/QuickLocation/Section/Mine/MineView.swift index d91282c7..69110516 100644 --- a/QuickLocation/Section/Mine/MineView.swift +++ b/QuickLocation/Section/Mine/MineView.swift @@ -2,7 +2,7 @@ // MineView.swift // QuickLocation // -// Created based on Lanhu design: 我的·版本6 +// Created based on Lanhu design: 我的·版本6 / 照片墙改版 // import UIKit @@ -13,20 +13,11 @@ final class MineView: UIView { var disposeBag = DisposeBag() - // MARK: - Design Constants (iOS points, from Lanhu 版本6 @2x) - private let headerHeight: CGFloat = 160 - private let avatarSize: CGFloat = 64 - private let editBtnSize: CGFloat = 30 - private let copyIconSize: CGFloat = 14 - private let vipCardHeight: CGFloat = 90 - private let settingsRowHeight: CGFloat = 52 + private let quickItemTitles = ["权限检测", "数据安全", "状态设置", "隐私设置"] - // MARK: - Colors - private let titleColor = UIColor(hexStr: "#1A1A1A") - private let subtitleColor = UIColor(hexStr: "#767676") - private let vipBtnTextColor = UIColor(hexStr: "#00343B") + var onQuickTap: ((String) -> Void)? func setupData(_ model: UserConfigModel) { guard AppContextManager.shared.systemConfig?.isIntercept == false else { @@ -47,70 +38,33 @@ final class MineView: UIView { vipActivateButton.setTitle("点击续费", for: .normal) vipActivateButton.isHidden = model.vip == 3 } - - private func setupRx() { - avatarImageView.rx.tapGesture.subscribe { _ in - AppRouter.push(Route.myProfile) - }.disposed(by: disposeBag) - } - - // MARK: - UI Components - private lazy var headerBgImageView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Mine/header_bg") - iv.contentMode = .scaleAspectFill - iv.clipsToBounds = true - return iv + + // MARK: - Components + + private lazy var scrollView: UIScrollView = { + let sv = UIScrollView() + sv.backgroundColor = UIColor(hexStr: "#FAFAFA") + sv.showsVerticalScrollIndicator = false +// sv.alwaysBounceVertical = true + return sv }() - lazy var avatarImageView: UIImageView = { - let iv = UIImageView() - iv.isUserInteractionEnabled = true - iv.contentMode = .scaleAspectFill - iv.layer.cornerRadius = avatarSize / 2 - iv.layer.borderWidth = 2 - iv.layer.borderColor = UIColor.white.cgColor - iv.clipsToBounds = true - iv.layer.shadowColor = UIColor.black.withAlphaComponent(0.2).cgColor - iv.layer.shadowOffset = .zero - iv.layer.shadowRadius = 2 - iv.layer.shadowOpacity = 1 - iv.backgroundColor = UIColor(hexStr: "#E0E0E0") - iv.image = UIImage(named: "Mine/default_avatar") - return iv + private lazy var contentView: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v }() - lazy var nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 16, weight: .heavy) - label.textColor = titleColor - label.text = " " - return label + private lazy var headerBgView: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v }() - lazy var editButton: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Mine/edit"), for: .normal) - btn.backgroundColor = .clear - return btn + lazy var photoWall: MinePhotoWallView = { + MinePhotoWallView() }() - lazy var idLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = subtitleColor - label.text = " " - return label - }() - - lazy var copyButton: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Mine/copy"), for: .normal) - btn.backgroundColor = .clear - return btn - }() - - // VIP Card lazy var vipCardView: UIView = { let view = UIView() view.layer.cornerRadius = 16 @@ -124,7 +78,7 @@ final class MineView: UIView { iv.contentMode = .scaleToFill return iv }() - + lazy var maskBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Mine/mask_bg") @@ -132,15 +86,15 @@ final class MineView: UIView { iv.isHidden = true return iv }() - + lazy var vipTitleLab: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 18, weight: .bold) + label.font = FontManager.boboBold(18) label.textColor = .white label.text = " " return label }() - + lazy var vipContentLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .medium) @@ -154,27 +108,27 @@ final class MineView: UIView { btn.backgroundColor = UIColor(hexStr: "#C0FF54") btn.setTitle("立即开通", for: .normal) btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold) - btn.cornerRadius = 14 + btn.titleLabel?.font = FontManager.boboBold(14) + btn.cornerRadius = 9 btn.isHidden = true return btn }() - // Profile transition background (100pt section below VIP card) - private lazy var profileBgImageView: UIImageView = { - let iv = UIImageView() - iv.image = UIImage(named: "Mine/profile_bg") - iv.contentMode = .scaleToFill - return iv + private lazy var quickCardView: UIView = { + let v = UIView() + v.backgroundColor = .white + v.cornerRadius = 20 + return v }() - private lazy var bottomFillView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#F5FBFB") - return view + private lazy var quickStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.alignment = .center + return stack }() - // Settings Card private lazy var settingsCardView: UIView = { let view = UIView() view.backgroundColor = .white @@ -197,164 +151,192 @@ final class MineView: UIView { return tv }() - // MARK: - Callbacks - var onMenuTap: ((Int) -> Void)? - - // MARK: - Dynamic Height private var settingsCardHeightConstraint: NSLayoutConstraint? private var contentSizeObservation: NSKeyValueObservation? - // MARK: - Init + // 兼容旧绑定(已迁到 photoWall) + lazy var avatarImageView: UIImageView = UIImageView() + lazy var nameLabel: UILabel = UILabel() + lazy var idLabel: UILabel = UILabel() + lazy var editButton: UIButton = UIButton(type: .custom) + lazy var copyButton: UIButton = UIButton(type: .custom) + override init(frame: CGRect) { super.init(frame: frame) setupUI() setupLayout() - setupRx() + setupQuickActions() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - // MARK: - Setup private func setupUI() { - backgroundColor = .white + backgroundColor = UIColor(hexStr: "#FAFAFA") - addSubview(headerBgImageView) - addSubview(avatarImageView) - addSubview(nameLabel) - addSubview(editButton) - addSubview(idLabel) - addSubview(copyButton) + addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(headerBgView) - addSubview(vipCardView) + // 照片墙固定顶部,不随列表滚动 + addSubview(photoWall) + + contentView.addSubview(vipCardView) vipCardView.addSubview(vipCardBgImageView) vipCardView.addSubview(vipActivateButton) vipCardView.addSubview(vipTitleLab) vipCardView.addSubview(vipContentLab) vipCardView.addSubview(maskBgView) - addSubview(profileBgImageView) - addSubview(bottomFillView) - addSubview(settingsCardView) + contentView.addSubview(quickCardView) + quickCardView.addSubview(quickStack) + + contentView.addSubview(settingsCardView) settingsCardView.addSubview(settingsTableView) } private func setupLayout() { - // Header - headerBgImageView.layoutChain - .top().left().right() - .height(headerHeight) + photoWall.layoutChain + .top(40) + .edgesHorzontal() - // Avatar - avatarImageView.layoutChain - .top(kStatusBarHeight + 44) - .left(32) - .size(CGSize(width: avatarSize, height: avatarSize)) + scrollView.layoutChain + .topToBottomOfView(photoWall) + .edgesHorzontal() + .bottom() - // Name - nameLabel.layoutChain - .leftToRightOfView(avatarImageView, offset: 20) - .topToView(avatarImageView, offset: 8) + contentView.layoutChain + .edges() + .widthToView(scrollView) - // Edit button - editButton.layoutChain - .right(16) - .centerY(nameLabel) - .size(CGSize(width: editBtnSize, height: editBtnSize)) - - // ID - idLabel.layoutChain - .leftToRightOfView(avatarImageView, offset: 20) - .topToBottomOfView(nameLabel, offset: 4) - - // Copy button - copyButton.layoutChain - .leftToRightOfView(idLabel, offset: 4) - .centerY(idLabel) - .size(CGSize(width: copyIconSize, height: copyIconSize)) - - // VIP Card vipCardView.layoutChain - .topToBottomOfView(avatarImageView, offset: 28) + .top(12) .left(16).right(16) .height(vipCardHeight) - vipCardBgImageView.layoutChain - .edges() + vipCardBgImageView.layoutChain.edges() vipTitleLab.layoutChain .top(22) .left(77) - + vipContentLab.layoutChain .topToBottomOfView(vipTitleLab, offset: 6) .leftToView(vipTitleLab) - + vipActivateButton.layoutChain .right(19).centerY() .width(70).height(28) maskBgView.layoutChain.edges() - - // Profile transition background (100pt, overlaps VIP card bottom half) - profileBgImageView.layoutChain - .topToView(vipCardView, offset: 32) - .left().right() - .height(100) - // Fill below profile transition - bottomFillView.layoutChain - .topToBottomOfView(profileBgImageView) - .left().right().bottom() - - // Settings Card (dynamic height, scrolls when exceeds area above tabbar) - settingsCardView.layoutChain + quickCardView.layoutChain .topToBottomOfView(vipCardView, offset: 20) .left(16).right(16) + .height(90) - settingsCardHeightConstraint = settingsCardView.heightAnchor.constraint(equalToConstant: 312) + quickStack.layoutChain + .edges(all: 8) + + settingsCardView.layoutChain + .topToBottomOfView(quickCardView, offset: 16) + .left(16).right(16) + .bottom(20) + + settingsCardHeightConstraint = settingsCardView.heightAnchor.constraint(equalToConstant: 208) settingsCardHeightConstraint?.isActive = true - settingsTableView.layoutChain - .edges() + settingsTableView.layoutChain.edges() contentSizeObservation = settingsTableView.observe(\.contentSize, options: [.new, .initial]) { [weak self] _, _ in self?.refreshTableViewHeight() } } - // MARK: - Layout + private func setupQuickActions() { + let icons = [ + "Mine/quick_permission", + "Mine/quick_data_security", + "Mine/quick_status", + "Mine/quick_privacy" + ] + for (i, title) in quickItemTitles.enumerated() { + let item = makeQuickItem(icon: icons[i], title: title) + item.tag = i + item.rx.tapGesture + .subscribe(onNext: { [weak self] _ in + self?.onQuickTap?(title) + }) + .disposed(by: disposeBag) + quickStack.addArrangedSubview(item) + } + } + + private func makeQuickItem(icon: String, title: String) -> UIView { + let wrap = UIView() + wrap.isUserInteractionEnabled = true + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#F3F5F8") + view.cornerRadius = 16 + + let iv = UIImageView(image: UIImage(named: icon)) + iv.contentMode = .scaleAspectFill + view.addSubview(iv) + + let lab = UILabel() + lab.text = title + lab.font = .systemFont(ofSize: 12, weight: .medium) + lab.textColor = UIColor(hexStr: "#293445") + lab.textAlignment = .center + wrap.addSubview(view) + wrap.addSubview(lab) + view.layoutChain + .top(8) + .centerX() + .width(40) + .height(40) + iv.layoutChain + .centerY() + .centerX() + .width(24) + .height(24) + lab.layoutChain + .topToBottomOfView(view, offset: 6) + .edgesHorzontal(2) + .bottom(4) + return wrap + } + override func layoutSubviews() { super.layoutSubviews() + // 顶部淡色渐变氛围 + setGradientLayer( + frame: CGRect(x: 0, y: 0, width: bounds.width, height: min(280, bounds.height * 0.35)), + startPoint: CGPoint(x: 0.5, y: 0), + endPoint: CGPoint(x: 0.5, y: 1), + colors: [UIColor(hexStr: "#D8F4FF"), UIColor(hexStr: "#FAFAFA")], + locations: [0, 1] + ) refreshTableViewHeight() } func refreshTableViewHeight() { let contentHeight = settingsTableView.contentSize.height guard contentHeight > 0 else { return } - - let tabBarHeight: CGFloat = 56 + kSafeBottomMargin - let maxHeight = bounds.height - tabBarHeight - settingsCardView.frame.minY - - if contentHeight <= maxHeight { - settingsCardHeightConstraint?.constant = contentHeight - settingsTableView.isScrollEnabled = false - } else { - settingsCardHeightConstraint?.constant = maxHeight - settingsTableView.isScrollEnabled = true - } + settingsCardHeightConstraint?.constant = contentHeight + settingsTableView.isScrollEnabled = false } } // MARK: - MineMenuCell final class MineMenuCell: UITableViewCell { - + func configure(_ model: MineMenuItem) { iconImageView.image = UIImage(named: model.icon) titleLabel.text = model.title } - + private func setupSubviews() { contentView.addSubview(iconImageView) contentView.addSubview(titleLabel) @@ -377,7 +359,7 @@ final class MineMenuCell: UITableViewCell { .left(14).right().bottom() .height(0.5) } - + var showDivider: Bool = true { didSet { dividerLine.isHidden = !showDivider } } @@ -419,15 +401,4 @@ final class MineMenuCell: UITableViewCell { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } } diff --git a/QuickLocation/Section/Mine/MineViewController.swift b/QuickLocation/Section/Mine/MineViewController.swift index 5415e901..504e38a3 100644 --- a/QuickLocation/Section/Mine/MineViewController.swift +++ b/QuickLocation/Section/Mine/MineViewController.swift @@ -15,16 +15,15 @@ final class MineViewController: BaseViewController { override var isNavigationBarHidden: Bool { true } override var preferredStatusBarStyle: UIStatusBarStyle { .default } - // MARK: - Properties fileprivate var rootView: MineView! - + private var tableView: UITableView { rootView.settingsTableView } - - private let viewModel = MineViewModel() - // MARK: - Lifecycle + private let viewModel = MineViewModel() + private var groupKey: String = "" + override func loadView() { rootView = MineView(frame: UIScreen.main.bounds) view = rootView @@ -34,81 +33,76 @@ final class MineViewController: BaseViewController { super.viewDidLoad() bindViewModel() reactiveAction() - - rootView.avatarImageView.image = AppContextManager.shared.avaterIcon - rootView.nameLabel.text = AppContextManager.shared.name - rootView.idLabel.text = "ID:" + AppContextManager.shared.userId - viewModel.loadMenuData() } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) requestUserInfo() + requestGroupMembers() } - // MARK: - Bindings private func bindViewModel() { viewModel.output.sectionedItems .bind(to: tableView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) - + tableView.rx.modelSelected(MineMenuItem.self) .subscribe(viewModel.cellAction.inputs) .disposed(by: disposeBag) - - viewModel.userName - .bind(to: rootView.nameLabel.rx.text) - .disposed(by: disposeBag) - - viewModel.userID - .bind(to: rootView.idLabel.rx.text) - .disposed(by: disposeBag) } private func reactiveAction() { rootView.vipActivateButton.rx.tap - .subscribe(onNext: { [weak self] in + .subscribe(onNext: { _ in AppRouter.push(Route.vipRecharge) }) .disposed(by: disposeBag) - rootView.editButton.rx.tap - .subscribe(onNext: { [weak self] in - // TODO: Navigate to edit profile - }) - .disposed(by: disposeBag) + rootView.photoWall.onCopyID = { id in + UIPasteboard.general.string = id + DLToast.showSuccess(text: "已复制") + } - rootView.copyButton.rx.tap - .subscribe(onNext: { [weak self] in - guard let id = self?.rootView.idLabel.text else { return } - UIPasteboard.general.string = id.replacingOccurrences(of: "ID:", with: "") - DLToast.showSuccess(text: "已复制") - }) - .disposed(by: disposeBag) + rootView.photoWall.onTapSelfAvatar = { + AppRouter.push(Route.myProfile) + } + + rootView.onQuickTap = { [weak self] title in + self?.viewModel.handleMenu(MineMenuItem(icon: "", title: title)) + } } - - // MARK: - UITableViewDataSource - lazy private var dataSource: RxTableViewSectionedReloadDataSource = { - return RxTableViewSectionedReloadDataSource( + + private lazy var dataSource: RxTableViewSectionedReloadDataSource = { + RxTableViewSectionedReloadDataSource( configureCell: { (_, tableView, indexPath, model) in let cell: MineMenuCell = tableView.dequeueReusableCell(for: indexPath) cell.configure(model) return cell }) }() - - // MARK: - API + private func requestUserInfo() { UserService.userInfo().subscribe { response in guard let model = response.model else { return } if AppContextManager.shared.saveAccount(model) { - self.rootView.avatarImageView.image = model.userIcon - self.rootView.nameLabel.text = model.name - self.rootView.idLabel.text = "ID:\(model.uid ?? "")" self.rootView.setupData(model) } - }.disposed(by: disposeBag) } + + private func requestGroupMembers() { + GroupService.groupInfo().subscribe(onNext: { [weak self] response in + guard let self = self, let model = response.model else { return } + self.groupKey = model.default_group_key + let sorted = self.viewModel.sortedMembers(model.select_group_employee, groupKey: model.default_group_key) + // 圈主 id:group_key 含 userId + let ownerId = sorted.first(where: { model.default_group_key.contains($0.user_id) })?.user_id ?? "" + self.rootView.photoWall.reload( + members: sorted, + ownerUserId: ownerId, + preferUserId: AppContextManager.shared.userId + ) + }).disposed(by: disposeBag) + } } diff --git a/QuickLocation/Section/Mine/MineViewModel.swift b/QuickLocation/Section/Mine/MineViewModel.swift index 5e2e25b1..98c4ec44 100644 --- a/QuickLocation/Section/Mine/MineViewModel.swift +++ b/QuickLocation/Section/Mine/MineViewModel.swift @@ -14,49 +14,81 @@ class MineViewModel { struct Output { var sectionedItems: Observable<[MineListSectionModel]> } - + let output: Output - + private var disposeBag = DisposeBag() private let sectionedItems = PublishSubject<[MineListSectionModel]>() - + let userAvatar = PublishSubject() let userName = PublishSubject() let userID = PublishSubject() + /// 设置列表(设计稿) let menuItems: [MineMenuItem] = [ MineMenuItem(icon: "Mine/account_security", title: "账户与安全"), -// MineMenuItem(icon: "Mine/status_setting", title: "状态设置"), MineMenuItem(icon: "Mine/emergency_contact", title: "紧急联系人"), MineMenuItem(icon: "Mine/customer_service", title: "在线客服"), - MineMenuItem(icon: "Mine/privacy_agreement", title: "隐私与协议"), - MineMenuItem(icon: "Mine/permission_check", title: "权限检测") + MineMenuItem(icon: "Mine/menu_about", title: "关于我们") + ] + + /// 快捷入口 + let quickItems: [MineMenuItem] = [ + MineMenuItem(icon: "Mine/quick_permission", title: "权限检测"), + MineMenuItem(icon: "Mine/quick_data_security", title: "数据安全"), + MineMenuItem(icon: "Mine/quick_status", title: "状态设置"), + MineMenuItem(icon: "Mine/quick_privacy", title: "隐私设置") ] lazy var cellAction: Action = { this in return Action { model in - switch model.title { - case "账户与安全": - AppRouter.push(Route.account) - case "紧急联系人": - AppRouter.push(Route.emergencyContact) - case "隐私与协议": - AppRouter.push(Route.privacyPolicy) - case "在线客服": - this.requestWechatService() - case "权限检测": - AppRouter.push(Route.checkPermission) - default: - break - } + this.handleMenu(model) return .empty() } }(self) - + + func handleMenu(_ model: MineMenuItem) { + switch model.title { + case "账户与安全": + AppRouter.push(Route.account) + case "紧急联系人": + AppRouter.push(Route.emergencyContact) + case "隐私与协议", "隐私设置": + AppRouter.push(Route.privacyPolicy) + case "在线客服": + requestWechatService() + case "权限检测": + AppRouter.push(Route.checkPermission) + case "数据安全", "状态设置", "关于我们": + DLToast.show(text: "敬请期待") + default: + break + } + } + func loadMenuData() { sectionedItems.onNext(menuItems.mapSection()) } - + + /// 圈主 → 自己 → 在线(对齐首页) + func sortedMembers(_ list: [GroupMemberModel], groupKey: String) -> [GroupMemberModel] { + list.enumerated() + .sorted { lhs, rhs in + let lp = memberSortPriority(lhs.element, groupKey: groupKey) + let rp = memberSortPriority(rhs.element, groupKey: groupKey) + if lp != rp { return lp < rp } + return lhs.offset < rhs.offset + } + .map(\.element) + } + + private func memberSortPriority(_ member: GroupMemberModel, groupKey: String) -> Int { + if !groupKey.isEmpty, groupKey.contains(member.user_id) { return 0 } + if member.user_id == AppContextManager.shared.userId { return 1 } + if member.is_online { return 2 } + return 3 + } + func requestWechatService() { DLToast.showLoading() SystemService.wechatService().subscribe(onNext: { response in @@ -70,8 +102,7 @@ class MineViewModel { WXApi.send(req) }, onError: { _ in }).disposed(by: disposeBag) } - - // MARK: - Init + init() { output = Output( sectionedItems: sectionedItems.asObservable() diff --git a/QuickLocation/UIKit/BaseNavigationView.swift b/QuickLocation/UIKit/BaseNavigationView.swift index a404c18c..f2dace10 100644 --- a/QuickLocation/UIKit/BaseNavigationView.swift +++ b/QuickLocation/UIKit/BaseNavigationView.swift @@ -36,8 +36,8 @@ class BaseNavigationView: UIView { backBtn.layoutChain .centerY(titleLabel) .left(15) - .width(24) - .height(24) + .width(36) + .height(36) titleLabel.layoutChain .top(kStatusBarHeight + 12)