|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
305A76212FCA8C7000227D26 /* Single+ObjectMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Single+ObjectMapper.swift"; sourceTree = "<group>"; };
|
||||
305A76232FCA8C7000227D26 /* GroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupView.swift; sourceTree = "<group>"; };
|
||||
55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = "<group>"; };
|
||||
305A76242FCA8C7000227D26 /* GroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewController.swift; sourceTree = "<group>"; };
|
||||
305A76252FCA8C7000227D26 /* GroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViewModel.swift; sourceTree = "<group>"; };
|
||||
305A76272FCA8C7000227D26 /* GroupMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -610,6 +613,11 @@
|
|||
55B2179230218CE100784774 /* GroupMemberView2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberView2.swift; sourceTree = "<group>"; };
|
||||
55B217943022DD6B00784774 /* 荆南波波黑-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "荆南波波黑-Bold.ttf"; sourceTree = "<group>"; };
|
||||
55B217A03022F0A000784701 /* FontManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontManager.swift; sourceTree = "<group>"; };
|
||||
55B217B03022F0A000784710 /* GroupItineraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupItineraryView.swift; sourceTree = "<group>"; };
|
||||
55B217C03022F0A000784720 /* MinePhotoWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinePhotoWallView.swift; sourceTree = "<group>"; };
|
||||
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = "<group>"; };
|
||||
55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = "<group>"; };
|
||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = "<group>"; };
|
||||
55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = "<group>"; };
|
||||
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
|
||||
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -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 = "<group>";
|
||||
};
|
||||
55B217D23022F0A000784728 /* Unlock */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55B217D03022F0A000784726 /* UnlockCountManager.swift */,
|
||||
);
|
||||
path = Unlock;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 551 B After Width: | Height: | Size: 5.7 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 643 B |
BIN
QuickLocation/Assets.xcassets/Home/member_app_usage.imageset/member_phone_report@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
8
QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/Contents.json
vendored
Normal file
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 801 B |
BIN
QuickLocation/Assets.xcassets/Home/member_daily_unlock.imageset/member_daily_unlock@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
8
QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/Contents.json
vendored
Normal file
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 730 B |
BIN
QuickLocation/Assets.xcassets/Home/member_phone_report.imageset/member_app_usage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
8
QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/Contents.json
vendored
Normal file
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 626 B |
BIN
QuickLocation/Assets.xcassets/Home/member_report_clock.imageset/member_report_clock@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
QuickLocation/Assets.xcassets/Home/member_screenTime.imageset/组 48025@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
22
QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
QuickLocation/Assets.xcassets/Home/member_screenTime_arrow.imageset/组 48027@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 525 B |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
QuickLocation/Assets.xcassets/Home/member_usage_count.imageset/member_usage_count@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 379 B |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_blue.imageset/clothespin_blue@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_green.imageset/clothespin_green@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/clothespin_pink.imageset/clothespin_pink@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
8
QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/Contents.json
vendored
Normal file
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/quick_data_security.imageset/icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -23,6 +23,24 @@ extension DefaultsKeys {
|
|||
var isShowGuide: DefaultsKey<Bool> { .init("isShowGuide", defaultValue: true) }
|
||||
/// 气泡持续时间
|
||||
var bubbleKeepTimes: DefaultsKey<Date?> { .init("bubbleKeepTimes") }
|
||||
/// 解锁统计:今日次数
|
||||
var unlockCountToday: DefaultsKey<Int> { .init("unlockCountToday", defaultValue: 0) }
|
||||
/// 解锁统计:累计次数
|
||||
var unlockCountTotal: DefaultsKey<Int> { .init("unlockCountTotal", defaultValue: 0) }
|
||||
/// 解锁统计:日期 yyyy-MM-dd
|
||||
var unlockCountDate: DefaultsKey<String> { .init("unlockCountDate", defaultValue: "") }
|
||||
/// 上次锁屏态(杀进程补计)
|
||||
var unlockWasLocked: DefaultsKey<Bool> { .init("unlockWasLocked", defaultValue: false) }
|
||||
var unlockLastCountedAt: DefaultsKey<Date?> { .init("unlockLastCountedAt") }
|
||||
var unlockLastHeartbeatAt: DefaultsKey<Date?> { .init("unlockLastHeartbeatAt") }
|
||||
var unlockLastLatitude: DefaultsKey<Double?> { .init("unlockLastLatitude") }
|
||||
var unlockLastLongitude: DefaultsKey<Double?> { .init("unlockLastLongitude") }
|
||||
/// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd
|
||||
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
||||
/// 手机报告:进行中的解锁 session 起点(持久化,杀进程可续)
|
||||
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
||||
/// 手机报告:本 App 今日进入前台次数
|
||||
var appUsageToday: DefaultsKey<Int> { .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")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 轻量网络状态监听,供面板读取当前路径
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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..<colorCount).filter { $0 != prev }
|
||||
// 首尾成环时避免与第一个相同(仅当人数>2)
|
||||
if i == members.count - 1, members.count > 2, let first = result.first {
|
||||
options = options.filter { $0 != first }
|
||||
if options.isEmpty { options = Array(0..<colorCount).filter { $0 != prev } }
|
||||
}
|
||||
let hash = abs(m.user_id.hashValue)
|
||||
let pick = options[hash % options.count]
|
||||
result.append(pick)
|
||||
prev = pick
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var ropeShadowLayer: CAShapeLayer = {
|
||||
let layer = CAShapeLayer()
|
||||
layer.strokeColor = UIColor(hexStr: "#C8D0D8").withAlphaComponent(0.55).cgColor
|
||||
layer.fillColor = UIColor.clear.cgColor
|
||||
layer.lineWidth = 1.5
|
||||
layer.lineCap = .round
|
||||
return layer
|
||||
}()
|
||||
|
||||
private lazy var ropeLayer: CAShapeLayer = {
|
||||
let layer = CAShapeLayer()
|
||||
layer.strokeColor = UIColor.white.cgColor
|
||||
layer.fillColor = UIColor.clear.cgColor
|
||||
layer.lineWidth = 1
|
||||
layer.lineCap = .round
|
||||
return layer
|
||||
}()
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = MinePhotoWallFlowLayout()
|
||||
layout.scrollDirection = .horizontal
|
||||
layout.itemSize = itemSize
|
||||
layout.minimumLineSpacing = itemSpacing
|
||||
let side = (UIScreen.main.bounds.width - itemSize.width) / 2
|
||||
let topInset = ropeCenterY - MinePolaroidCell.clipAttachOffset
|
||||
layout.sectionInset = UIEdgeInsets(top: max(0, topInset), left: side, bottom: 0, right: side)
|
||||
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
cv.backgroundColor = .clear
|
||||
cv.showsHorizontalScrollIndicator = false
|
||||
cv.decelerationRate = .fast
|
||||
cv.clipsToBounds = false
|
||||
cv.dataSource = self
|
||||
cv.delegate = self
|
||||
cv.register(MinePolaroidCell.self)
|
||||
return cv
|
||||
}()
|
||||
|
||||
private lazy var titleLab: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||||
label.textColor = UIColor(hexStr: "#353B4F")
|
||||
label.textAlignment = .center
|
||||
label.text = " "
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var daysRow: UIView = {
|
||||
let v = UIView()
|
||||
v.isHidden = true
|
||||
v.addSubview(daysPrefixLab)
|
||||
v.addSubview(daysBadge)
|
||||
v.addSubview(daysSuffixLab)
|
||||
daysPrefixLab.layoutChain
|
||||
.left()
|
||||
.centerY()
|
||||
daysBadge.layoutChain
|
||||
.leftToRightOfView(daysPrefixLab, offset: 4)
|
||||
.centerY()
|
||||
daysSuffixLab.layoutChain
|
||||
.leftToRightOfView(daysBadge, offset: 4)
|
||||
.right()
|
||||
.centerY()
|
||||
.top()
|
||||
.bottom()
|
||||
return v
|
||||
}()
|
||||
|
||||
private lazy var daysPrefixLab: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "已经在圈子"
|
||||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||||
label.textColor = UIColor(hexStr: "#293445")
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var daysBadge: UILabel = {
|
||||
let label = PaddingLabel()
|
||||
label.font = .systemFont(ofSize: 12, weight: .bold)
|
||||
label.textColor = UIColor(hexStr: "#16B3FF")
|
||||
label.backgroundColor = UIColor(hexStr: "#E3F6FF")
|
||||
label.cornerRadius = 4
|
||||
label.clipsToBounds = true
|
||||
label.textAlignment = .center
|
||||
label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var daysSuffixLab: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "天"
|
||||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||||
label.textColor = UIColor(hexStr: "#293445")
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var idRow: UIView = {
|
||||
let v = UIView()
|
||||
v.addSubview(idLab)
|
||||
v.addSubview(copyBtn)
|
||||
idLab.layoutChain
|
||||
.left()
|
||||
.top()
|
||||
.bottom()
|
||||
.centerY()
|
||||
copyBtn.layoutChain
|
||||
.leftToRightOfView(idLab, offset: 4)
|
||||
.right()
|
||||
.centerY()
|
||||
.width(14)
|
||||
.height(14)
|
||||
return v
|
||||
}()
|
||||
|
||||
private lazy var idLab: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
label.textColor = UIColor(hexStr: "#767676")
|
||||
label.text = " "
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var copyBtn: UIButton = {
|
||||
let btn = UIButton(type: .custom)
|
||||
btn.setImage(UIImage(named: "Mine/copy"), for: .normal)
|
||||
return btn
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
layer.addSublayer(ropeShadowLayer)
|
||||
layer.addSublayer(ropeLayer)
|
||||
addSubview(collectionView)
|
||||
addSubview(titleLab)
|
||||
addSubview(daysRow)
|
||||
addSubview(idRow)
|
||||
|
||||
collectionView.layoutChain
|
||||
.top()
|
||||
.edgesHorzontal()
|
||||
.height(170)
|
||||
|
||||
titleLab.layoutChain
|
||||
.topToBottomOfView(collectionView, offset: 0)
|
||||
.centerX()
|
||||
.height(24)
|
||||
|
||||
daysRow.layoutChain
|
||||
.centerY(titleLab)
|
||||
.centerX()
|
||||
.height(25)
|
||||
|
||||
idRow.layoutChain
|
||||
.topToBottomOfView(titleLab, offset: 6)
|
||||
.centerX()
|
||||
.height(16)
|
||||
.bottom(4)
|
||||
|
||||
copyBtn.rx.tap
|
||||
.subscribe(onNext: { [weak self] in
|
||||
guard let self = self, self.centeredIndex < self.members.count else { return }
|
||||
self.onCopyID?(self.members[self.centeredIndex].user_id)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private let disposeBag = DisposeBag()
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateRopePath()
|
||||
let widthChanged = abs(bounds.width - lastLayoutWidth) > 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..<members.count {
|
||||
let attrs = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0))
|
||||
guard let mid = attrs?.center.x else { continue }
|
||||
let d = abs(mid - centerX)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private func settleToNearest() {
|
||||
guard !members.isEmpty else { return }
|
||||
let idx = nearestCenterIndex()
|
||||
centeredIndex = idx
|
||||
let before = collectionView.contentOffset
|
||||
scrollToIndex(idx, animated: true)
|
||||
applyArcAndFocus()
|
||||
let model = members[idx]
|
||||
updateInfo(model)
|
||||
onCenteredMember?(model)
|
||||
// offset 未变时不会回调 didEndScrollingAnimation,直接播摆动
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let moved = abs(self.collectionView.contentOffset.x - before.x) > 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 "--"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MineListSectionModel> = {
|
||||
return RxTableViewSectionedReloadDataSource<MineListSectionModel>(
|
||||
|
||||
private lazy var dataSource: RxTableViewSectionedReloadDataSource<MineListSectionModel> = {
|
||||
RxTableViewSectionedReloadDataSource<MineListSectionModel>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>()
|
||||
let userName = PublishSubject<String>()
|
||||
let userID = PublishSubject<String>()
|
||||
|
||||
/// 设置列表(设计稿)
|
||||
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<MineMenuItem, Void> = { 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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||