|
|
@ -45,96 +45,6 @@ npm-debug.log
|
||||||
*.png.orig
|
*.png.orig
|
||||||
*.jpg.orig
|
*.jpg.orig
|
||||||
screenshot/
|
screenshot/
|
||||||
.opencode/skills/README.md
|
.opencode/
|
||||||
.opencode/skills/ask-matt/SKILL.md
|
.cursor/
|
||||||
.opencode/skills/ask-matt/agents/openai.yaml
|
openspec/
|
||||||
.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
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@
|
||||||
305A76DE2FCA8C7000227D26 /* ObservableType+ObjectMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76202FCA8C7000227D26 /* ObservableType+ObjectMapper.swift */; };
|
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 */; };
|
305A76DF2FCA8C7000227D26 /* Single+ObjectMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76212FCA8C7000227D26 /* Single+ObjectMapper.swift */; };
|
||||||
305A76E02FCA8C7000227D26 /* GroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76232FCA8C7000227D26 /* GroupView.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 */; };
|
305A76E12FCA8C7000227D26 /* GroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76242FCA8C7000227D26 /* GroupViewController.swift */; };
|
||||||
305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; };
|
305A76E22FCA8C7000227D26 /* GroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76252FCA8C7000227D26 /* GroupViewModel.swift */; };
|
||||||
305A76E32FCA8C7000227D26 /* GroupMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A76272FCA8C7000227D26 /* GroupMemberView.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 */; };
|
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B2179230218CE100784774 /* GroupMemberView2.swift */; };
|
||||||
55B217953022DD6B00784774 /* 荆南波波黑-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B217943022DD6B00784774 /* 荆南波波黑-Bold.ttf */; };
|
55B217953022DD6B00784774 /* 荆南波波黑-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B217943022DD6B00784774 /* 荆南波波黑-Bold.ttf */; };
|
||||||
55B217A13022F0A000784702 /* FontManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217A03022F0A000784701 /* FontManager.swift */; };
|
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 */; };
|
55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; };
|
||||||
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
||||||
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = "<group>"; };
|
||||||
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; 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>"; };
|
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -928,6 +936,7 @@
|
||||||
305A75262FCA8C7000227D26 /* Money */,
|
305A75262FCA8C7000227D26 /* Money */,
|
||||||
305A75282FCA8C7000227D26 /* Notification */,
|
305A75282FCA8C7000227D26 /* Notification */,
|
||||||
305A752A2FCA8C7000227D26 /* Theme */,
|
305A752A2FCA8C7000227D26 /* Theme */,
|
||||||
|
55B217D23022F0A000784728 /* Unlock */,
|
||||||
305A752C2FCA8C7000227D26 /* URL */,
|
305A752C2FCA8C7000227D26 /* URL */,
|
||||||
30D87CD52FDF9F1900E958FD /* MQTT */,
|
30D87CD52FDF9F1900E958FD /* MQTT */,
|
||||||
30C6666A2FFB7C3000E62B25 /* IAPManager */,
|
30C6666A2FFB7C3000E62B25 /* IAPManager */,
|
||||||
|
|
@ -1021,6 +1030,8 @@
|
||||||
305A76282FCA8C7000227D26 /* HomeView.swift */,
|
305A76282FCA8C7000227D26 /* HomeView.swift */,
|
||||||
55B2179030217D6600784774 /* HomeView2.swift */,
|
55B2179030217D6600784774 /* HomeView2.swift */,
|
||||||
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
||||||
|
55B217C23022F0A000784722 /* MemberPhoneReportView.swift */,
|
||||||
|
55B217C43022F0A000784724 /* MemberAppUsageView.swift */,
|
||||||
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */,
|
||||||
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */,
|
||||||
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
30D87CDC2FDFF07500E958FD /* InteractionView.swift */,
|
||||||
|
|
@ -1071,6 +1082,7 @@
|
||||||
305A76372FCA8C7000227D26 /* MineViewController.swift */,
|
305A76372FCA8C7000227D26 /* MineViewController.swift */,
|
||||||
305A76382FCA8C7000227D26 /* MineViewModel.swift */,
|
305A76382FCA8C7000227D26 /* MineViewModel.swift */,
|
||||||
305A76362FCA8C7000227D26 /* MineView.swift */,
|
305A76362FCA8C7000227D26 /* MineView.swift */,
|
||||||
|
55B217C03022F0A000784720 /* MinePhotoWallView.swift */,
|
||||||
30EFF3CB2FDA667A00EB35D4 /* MyProfile */,
|
30EFF3CB2FDA667A00EB35D4 /* MyProfile */,
|
||||||
30EFF3BC2FD9585200EB35D4 /* Account */,
|
30EFF3BC2FD9585200EB35D4 /* Account */,
|
||||||
30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */,
|
30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */,
|
||||||
|
|
@ -1683,6 +1695,14 @@
|
||||||
path = Font;
|
path = Font;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
55B217D23022F0A000784728 /* Unlock */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
55B217D03022F0A000784726 /* UnlockCountManager.swift */,
|
||||||
|
);
|
||||||
|
path = Unlock;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B07D45692FCECE07570D9B73 /* Pods */ = {
|
B07D45692FCECE07570D9B73 /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -1961,6 +1981,8 @@
|
||||||
30BF300C2FED09BA00D9CB52 /* ScheduleDetailView.swift in Sources */,
|
30BF300C2FED09BA00D9CB52 /* ScheduleDetailView.swift in Sources */,
|
||||||
305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */,
|
305A76C22FCA8C7000227D26 /* BaseViewModel.swift in Sources */,
|
||||||
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */,
|
||||||
|
55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */,
|
||||||
|
55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */,
|
||||||
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
||||||
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
|
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
|
||||||
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,
|
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,
|
||||||
|
|
@ -1992,6 +2014,7 @@
|
||||||
305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */,
|
305A76D02FCA8C7000227D26 /* MoneyFormatter.swift in Sources */,
|
||||||
305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */,
|
305A76D12FCA8C7000227D26 /* TimeSpecificNotificationManager.swift in Sources */,
|
||||||
305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */,
|
305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */,
|
||||||
|
55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */,
|
||||||
305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */,
|
305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */,
|
||||||
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
|
305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */,
|
||||||
305A798C2FCAB99300227D26 /* HomeViewModel.swift in Sources */,
|
305A798C2FCAB99300227D26 /* HomeViewModel.swift in Sources */,
|
||||||
|
|
@ -2055,6 +2078,7 @@
|
||||||
305A76ED2FCA8C7000227D26 /* MemberAnnotationView.swift in Sources */,
|
305A76ED2FCA8C7000227D26 /* MemberAnnotationView.swift in Sources */,
|
||||||
30BAB8532FCD337C00C33B5C /* GroupService.swift in Sources */,
|
30BAB8532FCD337C00C33B5C /* GroupService.swift in Sources */,
|
||||||
305A76EE2FCA8C7000227D26 /* MineView.swift in Sources */,
|
305A76EE2FCA8C7000227D26 /* MineView.swift in Sources */,
|
||||||
|
55B217C13022F0A000784721 /* MinePhotoWallView.swift in Sources */,
|
||||||
305A76EF2FCA8C7000227D26 /* MineViewController.swift in Sources */,
|
305A76EF2FCA8C7000227D26 /* MineViewController.swift in Sources */,
|
||||||
30A87A5E2FEE71A50095E7C6 /* CreateBubbleView.swift in Sources */,
|
30A87A5E2FEE71A50095E7C6 /* CreateBubbleView.swift in Sources */,
|
||||||
30D74ABF2FEA67F30050EB2C /* CreateScheduleView.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 {
|
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
|
|
||||||
|
UnlockCountManager.shared.start(launchOptions: launchOptions)
|
||||||
FontManager.registerCustomFonts()
|
FontManager.registerCustomFonts()
|
||||||
setupLocation()
|
setupLocation()
|
||||||
ApiManager.shared.setup()
|
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 isShowGuide: DefaultsKey<Bool> { .init("isShowGuide", defaultValue: true) }
|
||||||
/// 气泡持续时间
|
/// 气泡持续时间
|
||||||
var bubbleKeepTimes: DefaultsKey<Date?> { .init("bubbleKeepTimes") }
|
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 resumePopupQueue = Notification.Name("resumePopupQueue")
|
||||||
/// 终止弹窗队列(登录成功后)
|
/// 终止弹窗队列(登录成功后)
|
||||||
static let invalidatePopupQueue = Notification.Name("invalidatePopupQueue")
|
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 lastGeofenceCenter: CLLocationCoordinate2D?
|
||||||
private var lastKnownLocation: CLLocation?
|
private var lastKnownLocation: CLLocation?
|
||||||
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
/// 当前解锁 session 起点(锁屏结算屏幕时长)
|
||||||
|
private var screenSessionStart: Date?
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -74,6 +76,23 @@ final class UnlockCountManager: NSObject {
|
||||||
return Defaults[\.unlockCountTotal]
|
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 {
|
var hasAlwaysLocationAuthorization: Bool {
|
||||||
authorizationStatus() == .authorizedAlways
|
authorizationStatus() == .authorizedAlways
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +114,7 @@ final class UnlockCountManager: NSObject {
|
||||||
}
|
}
|
||||||
didStart = true
|
didStart = true
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
|
restoreScreenSessionIfNeeded()
|
||||||
|
|
||||||
let available = UIApplication.shared.isProtectedDataAvailable
|
let available = UIApplication.shared.isProtectedDataAvailable
|
||||||
isSessionLocked = !available
|
isSessionLocked = !available
|
||||||
|
|
@ -140,6 +160,9 @@ final class UnlockCountManager: NSObject {
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
handleNonActiveLaunchIfNeeded(launchOptions)
|
handleNonActiveLaunchIfNeeded(launchOptions)
|
||||||
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
||||||
|
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked {
|
||||||
|
startScreenSession()
|
||||||
|
}
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,13 +185,120 @@ final class UnlockCountManager: NSObject {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func rollDayIfNeeded() -> Bool {
|
private func rollDayIfNeeded() -> Bool {
|
||||||
|
migrateScreenTimeStorageIfNeeded()
|
||||||
let today = todayString()
|
let today = todayString()
|
||||||
if Defaults[\.unlockCountDate] != today {
|
guard Defaults[\.unlockCountDate] != today else { return false }
|
||||||
Defaults[\.unlockCountDate] = today
|
|
||||||
Defaults[\.unlockCountToday] = 0
|
flushScreenSession()
|
||||||
return true
|
|
||||||
|
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
|
// MARK: - Counting
|
||||||
|
|
@ -183,12 +313,14 @@ final class UnlockCountManager: NSObject {
|
||||||
isSessionLocked = false
|
isSessionLocked = false
|
||||||
Defaults[\.unlockLastHeartbeatAt] = now
|
Defaults[\.unlockLastHeartbeatAt] = now
|
||||||
stopProtectedDataPolling()
|
stopProtectedDataPolling()
|
||||||
|
startScreenSession()
|
||||||
notifyChange()
|
notifyChange()
|
||||||
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
print("🔓 UnlockCount +\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||||||
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
debugLog("recordUnlock reason=\(reason) today=\(Defaults[\.unlockCountToday]) total=\(Defaults[\.unlockCountTotal])")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markLocked() {
|
private func markLocked() {
|
||||||
|
flushScreenSession()
|
||||||
isSessionLocked = true
|
isSessionLocked = true
|
||||||
hasSeenLockInSession = true
|
hasSeenLockInSession = true
|
||||||
Defaults[\.unlockWasLocked] = true
|
Defaults[\.unlockWasLocked] = true
|
||||||
|
|
@ -344,6 +476,7 @@ final class UnlockCountManager: NSObject {
|
||||||
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||||||
endBootBackgroundTask()
|
endBootBackgroundTask()
|
||||||
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
||||||
|
recordAppUsage()
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
}
|
}
|
||||||
/// 状态:0、无状态 1、SOS 2、气泡等
|
/// 状态:0、无状态 1、SOS 2、气泡等
|
||||||
var status: Int = 0
|
var status: Int = 0
|
||||||
|
/// 入圈天数(若服务端下发)
|
||||||
|
var join_days: Int = 0
|
||||||
|
/// 入圈时间戳毫秒(若服务端下发)
|
||||||
|
var join_time: Int64 = 0
|
||||||
/// 是否在线
|
/// 是否在线
|
||||||
var is_online: Bool = false
|
var is_online: Bool = false
|
||||||
/// 设备电量
|
/// 设备电量
|
||||||
|
|
@ -235,6 +239,8 @@ struct GroupMemberModel: Mappable, Equatable {
|
||||||
is_online <- map["is_online"]
|
is_online <- map["is_online"]
|
||||||
last_active_time <- map["last_active_time"]
|
last_active_time <- map["last_active_time"]
|
||||||
status <- map["status"]
|
status <- map["status"]
|
||||||
|
join_days <- (map["join_days"], kStrTransformInt)
|
||||||
|
join_time <- map["join_time"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,82 +21,22 @@ final class GroupItineraryView: UIView {
|
||||||
groupNameLab.text = name.isEmpty ? " " : name
|
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]) {
|
func reloadSchedules(_ list: [ScheduleModel]) {
|
||||||
scheduleSections = Self.groupByDay(list)
|
scheduleSections = Self.groupByDay(list)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
emptyLab.isHidden = !list.isEmpty
|
emptyLab.isHidden = !list.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private var memberList: [GroupMemberModel] = []
|
private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = []
|
||||||
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 func setupUI() {
|
private func setupUI() {
|
||||||
addSubview(headerRow)
|
addSubview(headerRow)
|
||||||
headerRow.addSubview(groupIcon)
|
headerRow.addSubview(groupIcon)
|
||||||
headerRow.addSubview(groupNameLab)
|
headerRow.addSubview(groupNameLab)
|
||||||
headerRow.addSubview(changeGroupBtn)
|
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(tableView)
|
||||||
addSubview(emptyLab)
|
addSubview(emptyLab)
|
||||||
|
addSubview(createBtn)
|
||||||
|
|
||||||
headerRow.layoutChain
|
headerRow.layoutChain
|
||||||
.top(8)
|
.top(8)
|
||||||
|
|
@ -119,53 +59,65 @@ final class GroupItineraryView: UIView {
|
||||||
.centerY()
|
.centerY()
|
||||||
.rightToLeftOfView(changeGroupBtn, offset: -8, relation: .lessThanOrEqual)
|
.rightToLeftOfView(changeGroupBtn, offset: -8, relation: .lessThanOrEqual)
|
||||||
|
|
||||||
memberCV.layoutChain
|
|
||||||
.edges(all: 5, excludingEdge: .bottom)
|
|
||||||
.bottom()
|
|
||||||
|
|
||||||
tableView.layoutChain
|
tableView.layoutChain
|
||||||
.topToBottomOfView(memberCV, offset: 8)
|
.topToBottomOfView(headerRow, offset: 12)
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
.bottom()
|
.bottom()
|
||||||
|
|
||||||
emptyLab.layoutChain
|
emptyLab.layoutChain
|
||||||
.centerX()
|
.centerX()
|
||||||
.centerY(tableView)
|
.centerY(tableView)
|
||||||
|
|
||||||
|
createBtn.layoutChain
|
||||||
|
.bottom(kSafeBottomMargin + 102)
|
||||||
|
.right(10)
|
||||||
|
.width(60)
|
||||||
|
.heightToWidth(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupRx() {
|
private func setupRx() {
|
||||||
|
createBtn.rx.tap.subscribe(onNext: { _ in
|
||||||
|
AppRouter.push(Route.createSchedule)
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
changeGroupBtn.rx.tap
|
changeGroupBtn.rx.tap
|
||||||
.subscribe(onNext: { [weak self] in self?.onSwitchGroup?() })
|
.subscribe(onNext: { [weak self] in self?.onSwitchGroup?() })
|
||||||
.disposed(by: disposeBag)
|
.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 }
|
let sorted = list.sorted { $0.timestamp > $1.timestamp }
|
||||||
var map: [String: [ScheduleModel]] = [:]
|
var map: [String: [ScheduleModel]] = [:]
|
||||||
var order: [String] = []
|
var order: [String] = []
|
||||||
|
var dayDateMap: [String: (dayText: String, dateText: String)] = [:]
|
||||||
let fmt = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
fmt.locale = Locale(identifier: "zh_CN")
|
fmt.locale = Locale(identifier: "zh_CN")
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
for item in sorted {
|
for item in sorted {
|
||||||
let date = Date(timeIntervalSince1970: TimeInterval(item.timestamp) / 1000)
|
let date = Date(timeIntervalSince1970: TimeInterval(item.timestamp) / 1000)
|
||||||
fmt.dateFormat = "M月d日"
|
fmt.dateFormat = "M月d日"
|
||||||
let day = fmt.string(from: date)
|
let dateText = fmt.string(from: date)
|
||||||
let title: String
|
let dayText: String
|
||||||
if calendar.isDateInToday(date) {
|
if calendar.isDateInToday(date) {
|
||||||
title = "今天 \(day)"
|
dayText = "今天"
|
||||||
} else if calendar.isDateInYesterday(date) {
|
} else if calendar.isDateInYesterday(date) {
|
||||||
title = "昨天 \(day)"
|
dayText = "昨天"
|
||||||
} else {
|
} else {
|
||||||
fmt.dateFormat = "EEEE M月d日"
|
fmt.dateFormat = "EEEE"
|
||||||
title = fmt.string(from: date)
|
dayText = fmt.string(from: date)
|
||||||
}
|
}
|
||||||
if map[title] == nil {
|
let key = "\(dayText) \(dateText)"
|
||||||
order.append(title)
|
if map[key] == nil {
|
||||||
map[title] = []
|
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 = {
|
lazy var headerRow: UIView = {
|
||||||
|
|
@ -198,24 +150,9 @@ final class GroupItineraryView: UIView {
|
||||||
return btn
|
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 = {
|
lazy var tableView: UITableView = {
|
||||||
let tv = UITableView(frame: .zero, style: .grouped)
|
let tv = UITableView(frame: .zero, style: .grouped)
|
||||||
tv.backgroundColor = .clear
|
tv.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||||||
tv.separatorStyle = .none
|
tv.separatorStyle = .none
|
||||||
tv.showsVerticalScrollIndicator = false
|
tv.showsVerticalScrollIndicator = false
|
||||||
tv.register(GroupItineraryCell.self)
|
tv.register(GroupItineraryCell.self)
|
||||||
|
|
@ -240,9 +177,15 @@ final class GroupItineraryView: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
lazy var createBtn: UIButton = {
|
||||||
|
let btn = UIButton()
|
||||||
|
btn.setImage(UIImage(named: "Schedule/create"), for: .normal)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
backgroundColor = .clear
|
||||||
setupUI()
|
setupUI()
|
||||||
setupRx()
|
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
|
// MARK: - UITableView
|
||||||
extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate {
|
extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate {
|
||||||
func numberOfSections(in tableView: UITableView) -> Int {
|
func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
|
@ -289,10 +208,27 @@ extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
let header = UIView()
|
let header = UIView()
|
||||||
header.backgroundColor = .clear
|
header.backgroundColor = .clear
|
||||||
|
let sectionData = scheduleSections[section]
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = scheduleSections[section].title
|
let color = UIColor(hexStr: "#353B4F")
|
||||||
label.font = .systemFont(ofSize: 13, weight: .semibold)
|
let attr = NSMutableAttributedString()
|
||||||
label.textColor = UIColor(hexStr: "#353B4F")
|
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)
|
header.addSubview(label)
|
||||||
label.layoutChain.left(16).centerY()
|
label.layoutChain.left(16).centerY()
|
||||||
return header
|
return header
|
||||||
|
|
@ -340,9 +276,9 @@ final class GroupItineraryCell: UITableViewCell {
|
||||||
let date = Date(timeIntervalSince1970: TimeInterval(model.timestamp) / 1000)
|
let date = Date(timeIntervalSince1970: TimeInterval(model.timestamp) / 1000)
|
||||||
let ongoing = Calendar.current.isDateInToday(date) || date > Date()
|
let ongoing = Calendar.current.isDateInToday(date) || date > Date()
|
||||||
statusLab.text = ongoing ? "进行中" : "已完成"
|
statusLab.text = ongoing ? "进行中" : "已完成"
|
||||||
statusLab.textColor = ongoing ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#999999")
|
statusLab.textColor = ongoing ? UIColor(hexStr: "#00B0C6") : UIColor(hexStr: "#999999")
|
||||||
statusLab.backgroundColor = ongoing
|
statusLab.backgroundColor = ongoing
|
||||||
? UIColor(hexStr: "#E3F6FF")
|
? UIColor(hexStr: "#DCFCFF")
|
||||||
: UIColor(hexStr: "#F0F0F0")
|
: UIColor(hexStr: "#F0F0F0")
|
||||||
|
|
||||||
let tag = model.groups.first?.group_name ?? ""
|
let tag = model.groups.first?.group_name ?? ""
|
||||||
|
|
@ -420,43 +356,45 @@ final class GroupItineraryCell: UITableViewCell {
|
||||||
private lazy var avatarImg: UIImageView = {
|
private lazy var avatarImg: UIImageView = {
|
||||||
let iv = UIImageView()
|
let iv = UIImageView()
|
||||||
iv.contentMode = .scaleAspectFill
|
iv.contentMode = .scaleAspectFill
|
||||||
iv.cornerRadius = 18
|
iv.cornerRadius = 10
|
||||||
iv.clipsToBounds = true
|
iv.clipsToBounds = true
|
||||||
return iv
|
return iv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var nameLab: UILabel = {
|
private lazy var nameLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 14, weight: .semibold)
|
label.font = .systemFont(ofSize: 14, weight: .bold)
|
||||||
label.textColor = UIColor(hexStr: "#353B4F")
|
label.textColor = UIColor(hexStr: "#353B4F")
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var statusLab: PaddingLabel = {
|
private lazy var statusLab: PaddingLabel = {
|
||||||
let label = PaddingLabel()
|
let label = PaddingLabel()
|
||||||
label.font = .systemFont(ofSize: 11, weight: .medium)
|
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.cornerRadius = 6
|
label.cornerRadius = 4
|
||||||
label.clipsToBounds = true
|
label.clipsToBounds = true
|
||||||
label.insets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8)
|
label.insets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8)
|
||||||
|
label.borderWidth = 1
|
||||||
|
label.borderColor = UIColor(hexStr: "#84E2ED")
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var summaryLab: UILabel = {
|
private lazy var summaryLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "今天停留了"
|
label.text = "今天停留了"
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
label.textColor = UIColor(hexStr: "#353B4F")
|
label.textColor = UIColor(hexStr: "#353B4F")
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var placeCountLab: UILabel = {
|
private lazy var placeCountLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = FontManager.youSheBiaoTiHei(12)
|
label.font = FontManager.youSheBiaoTiHei(14)
|
||||||
label.textColor = .white
|
label.textColor = UIColor(hexStr: "#58EDFF")
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.backgroundColor = UIColor(hexStr: "#353B4F")
|
label.backgroundColor = UIColor(hexStr: "#293445")
|
||||||
label.cornerRadius = 11
|
label.cornerRadius = 6
|
||||||
label.clipsToBounds = true
|
label.clipsToBounds = true
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
@ -464,7 +402,7 @@ final class GroupItineraryCell: UITableViewCell {
|
||||||
private lazy var summaryTailLab: UILabel = {
|
private lazy var summaryTailLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "个地方"
|
label.text = "个地方"
|
||||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
label.textColor = UIColor(hexStr: "#353B4F")
|
label.textColor = UIColor(hexStr: "#353B4F")
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -210,11 +210,6 @@ final class GroupViewController: BaseViewController {
|
||||||
self.itineraryGroupModel = model
|
self.itineraryGroupModel = model
|
||||||
let current = model.groups.first(where: { $0.group_key == model.default_group_key })
|
let current = model.groups.first(where: { $0.group_key == model.default_group_key })
|
||||||
self.rootView.itineraryPage.updateGroupName(current?.name ?? "")
|
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)
|
self.requestItinerarySchedules(groupKey: model.default_group_key)
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,6 @@ class InviteJoinView: UIView {
|
||||||
|
|
||||||
lazy var navView: BaseNavigationView = {
|
lazy var navView: BaseNavigationView = {
|
||||||
let nav = BaseNavigationView(title: "邀请成员")
|
let nav = BaseNavigationView(title: "邀请成员")
|
||||||
nav.backBtn.setImage(UIImage(named: "Group/invite_back"), for: .normal)
|
|
||||||
nav.backBtn.layoutChain.width(36).height(36)
|
|
||||||
return nav
|
return nav
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Network
|
import Network
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import RxSwift
|
|
||||||
import RxCocoa
|
|
||||||
import SwiftyUserDefaults
|
import SwiftyUserDefaults
|
||||||
|
|
||||||
class GroupMemberView2: UIView {
|
class GroupMemberView2: UIView {
|
||||||
|
|
||||||
private var navBarHeightConstraint: NSLayoutConstraint?
|
private var navBarHeightConstraint: NSLayoutConstraint?
|
||||||
|
private var currentMemberModel: GroupMemberModel?
|
||||||
|
|
||||||
private static let batteryNormalColor = UIColor(hexStr: "#47DD00")
|
private static let batteryNormalColor = UIColor(hexStr: "#47DD00")
|
||||||
private static let batteryLowColor = UIColor(hexStr: "#F85A5D")
|
private static let batteryLowColor = UIColor(hexStr: "#F85A5D")
|
||||||
|
|
@ -28,6 +27,7 @@ class GroupMemberView2: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupMemberInfo(_ model: GroupMemberModel, isOwner: Bool) {
|
func setupMemberInfo(_ model: GroupMemberModel, isOwner: Bool) {
|
||||||
|
currentMemberModel = model
|
||||||
memberNameLab.text = model.nick_name
|
memberNameLab.text = model.nick_name
|
||||||
ownView.isHidden = !isOwner
|
ownView.isHidden = !isOwner
|
||||||
statusDotView.backgroundColor = UIColor(hexStr: model.is_online ? "#67EA76" : "#D8D8D8")
|
statusDotView.backgroundColor = UIColor(hexStr: model.is_online ? "#67EA76" : "#D8D8D8")
|
||||||
|
|
@ -42,6 +42,7 @@ class GroupMemberView2: UIView {
|
||||||
} else {
|
} else {
|
||||||
setupUnknownDeviceInfo(battery: model.battery)
|
setupUnknownDeviceInfo(battery: model.battery)
|
||||||
}
|
}
|
||||||
|
refreshMemberReports(for: model)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateDistance(for model: GroupMemberModel, isCurrentUser: Bool) {
|
private func updateDistance(for model: GroupMemberModel, isCurrentUser: Bool) {
|
||||||
|
|
@ -214,7 +215,27 @@ class GroupMemberView2: UIView {
|
||||||
]
|
]
|
||||||
|
|
||||||
private func setupRx() {
|
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() {
|
private func setupUI() {
|
||||||
|
|
@ -288,7 +309,7 @@ class GroupMemberView2: UIView {
|
||||||
lazy var scrollView: UIScrollView = {
|
lazy var scrollView: UIScrollView = {
|
||||||
let view = UIScrollView()
|
let view = UIScrollView()
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
view.showsHorizontalScrollIndicator = false
|
view.showsVerticalScrollIndicator = false
|
||||||
view.bounces = false
|
view.bounces = false
|
||||||
|
|
||||||
view.addSubview(scrollContentView)
|
view.addSubview(scrollContentView)
|
||||||
|
|
@ -300,8 +321,14 @@ class GroupMemberView2: UIView {
|
||||||
|
|
||||||
scrollContentView.addSubview(todayTrackView)
|
scrollContentView.addSubview(todayTrackView)
|
||||||
todayTrackView.layoutChain
|
todayTrackView.layoutChain
|
||||||
.topToBottomOfView(memberView, offset: 16)
|
.topToBottomOfView(memberView, offset: 15)
|
||||||
.edges(excludingEdge: .top)
|
.edgesHorzontal()
|
||||||
|
|
||||||
|
scrollContentView.addSubview(phoneReportView)
|
||||||
|
phoneReportView.layoutChain
|
||||||
|
.topToBottomOfView(todayTrackView, offset: 15)
|
||||||
|
.edgesHorzontal()
|
||||||
|
.bottom(49 + kSafeBottomMargin)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
@ -728,6 +755,15 @@ class GroupMemberView2: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - 手机报告 / APP使用记录
|
||||||
|
lazy var phoneReportView = MemberPhoneReportView()
|
||||||
|
/// 保留 UI 实现,当前不挂载到 scroll 内容
|
||||||
|
lazy var appUsageView: MemberAppUsageView = {
|
||||||
|
let view = MemberAppUsageView()
|
||||||
|
view.isHidden = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - 今日轨迹
|
// MARK: - 今日轨迹
|
||||||
lazy var todayTrackView: UIView = {
|
lazy var todayTrackView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
|
|
@ -942,6 +978,10 @@ class GroupMemberView2: UIView {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 轻量网络状态监听,供面板读取当前路径
|
/// 轻量网络状态监听,供面板读取当前路径
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ class HomeViewController: BaseViewController {
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
UnlockCountManager.shared.refreshAuthorizationAndMonitoring()
|
||||||
|
|
||||||
let manager = AuthorizeManager.manager(type: .locationAlways)
|
let manager = AuthorizeManager.manager(type: .locationAlways)
|
||||||
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
|
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()
|
var disposeBag = DisposeBag()
|
||||||
|
|
||||||
private func setupRx() {
|
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
|
phoneView.rx.tapGesture.subscribe(onNext: { _ in
|
||||||
AppRouter.push(Route.changePhone)
|
AppRouter.push(Route.changePhone)
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
@ -116,13 +123,20 @@ class AccountView: UIView {
|
||||||
.left(15)
|
.left(15)
|
||||||
.centerY()
|
.centerY()
|
||||||
|
|
||||||
|
view.addSubview(copyBtn)
|
||||||
|
copyBtn.layoutChain
|
||||||
|
.right(15)
|
||||||
|
.centerY()
|
||||||
|
.width(14)
|
||||||
|
.height(14)
|
||||||
|
|
||||||
let contentLab = UILabel()
|
let contentLab = UILabel()
|
||||||
contentLab.text = AppContextManager.shared.userId
|
contentLab.text = AppContextManager.shared.userId
|
||||||
contentLab.font = .systemFont(ofSize: 14, weight: .medium)
|
contentLab.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
contentLab.textColor = UIColor(hexStr: "#1A1A1A")
|
contentLab.textColor = UIColor(hexStr: "#1A1A1A")
|
||||||
view.addSubview(contentLab)
|
view.addSubview(contentLab)
|
||||||
contentLab.layoutChain
|
contentLab.layoutChain
|
||||||
.right(15)
|
.rightToLeftOfView(copyBtn, offset: -2)
|
||||||
.centerY()
|
.centerY()
|
||||||
|
|
||||||
let line = UIView()
|
let line = UIView()
|
||||||
|
|
@ -136,6 +150,12 @@ class AccountView: UIView {
|
||||||
return view
|
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 = {
|
lazy var phoneView: UIView = {
|
||||||
let view = 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
|
// MineView.swift
|
||||||
// QuickLocation
|
// QuickLocation
|
||||||
//
|
//
|
||||||
// Created based on Lanhu design: 我的·版本6
|
// Created based on Lanhu design: 我的·版本6 / 照片墙改版
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
@ -13,20 +13,11 @@ final class MineView: UIView {
|
||||||
|
|
||||||
var disposeBag = DisposeBag()
|
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 vipCardHeight: CGFloat = 90
|
||||||
|
|
||||||
private let settingsRowHeight: CGFloat = 52
|
private let settingsRowHeight: CGFloat = 52
|
||||||
|
private let quickItemTitles = ["权限检测", "数据安全", "状态设置", "隐私设置"]
|
||||||
|
|
||||||
// MARK: - Colors
|
var onQuickTap: ((String) -> Void)?
|
||||||
private let titleColor = UIColor(hexStr: "#1A1A1A")
|
|
||||||
private let subtitleColor = UIColor(hexStr: "#767676")
|
|
||||||
private let vipBtnTextColor = UIColor(hexStr: "#00343B")
|
|
||||||
|
|
||||||
func setupData(_ model: UserConfigModel) {
|
func setupData(_ model: UserConfigModel) {
|
||||||
guard AppContextManager.shared.systemConfig?.isIntercept == false else {
|
guard AppContextManager.shared.systemConfig?.isIntercept == false else {
|
||||||
|
|
@ -48,69 +39,32 @@ final class MineView: UIView {
|
||||||
vipActivateButton.isHidden = model.vip == 3
|
vipActivateButton.isHidden = model.vip == 3
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupRx() {
|
// MARK: - Components
|
||||||
avatarImageView.rx.tapGesture.subscribe { _ in
|
|
||||||
AppRouter.push(Route.myProfile)
|
|
||||||
}.disposed(by: disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UI Components
|
private lazy var scrollView: UIScrollView = {
|
||||||
private lazy var headerBgImageView: UIImageView = {
|
let sv = UIScrollView()
|
||||||
let iv = UIImageView()
|
sv.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||||||
iv.image = UIImage(named: "Mine/header_bg")
|
sv.showsVerticalScrollIndicator = false
|
||||||
iv.contentMode = .scaleAspectFill
|
// sv.alwaysBounceVertical = true
|
||||||
iv.clipsToBounds = true
|
return sv
|
||||||
return iv
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var avatarImageView: UIImageView = {
|
private lazy var contentView: UIView = {
|
||||||
let iv = UIImageView()
|
let v = UIView()
|
||||||
iv.isUserInteractionEnabled = true
|
v.backgroundColor = .clear
|
||||||
iv.contentMode = .scaleAspectFill
|
return v
|
||||||
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
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var nameLabel: UILabel = {
|
private lazy var headerBgView: UIView = {
|
||||||
let label = UILabel()
|
let v = UIView()
|
||||||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
v.backgroundColor = .clear
|
||||||
label.textColor = titleColor
|
return v
|
||||||
label.text = " "
|
|
||||||
return label
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var editButton: UIButton = {
|
lazy var photoWall: MinePhotoWallView = {
|
||||||
let btn = UIButton(type: .custom)
|
MinePhotoWallView()
|
||||||
btn.setImage(UIImage(named: "Mine/edit"), for: .normal)
|
|
||||||
btn.backgroundColor = .clear
|
|
||||||
return btn
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
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 = {
|
lazy var vipCardView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.layer.cornerRadius = 16
|
view.layer.cornerRadius = 16
|
||||||
|
|
@ -135,7 +89,7 @@ final class MineView: UIView {
|
||||||
|
|
||||||
lazy var vipTitleLab: UILabel = {
|
lazy var vipTitleLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 18, weight: .bold)
|
label.font = FontManager.boboBold(18)
|
||||||
label.textColor = .white
|
label.textColor = .white
|
||||||
label.text = " "
|
label.text = " "
|
||||||
return label
|
return label
|
||||||
|
|
@ -154,27 +108,27 @@ final class MineView: UIView {
|
||||||
btn.backgroundColor = UIColor(hexStr: "#C0FF54")
|
btn.backgroundColor = UIColor(hexStr: "#C0FF54")
|
||||||
btn.setTitle("立即开通", for: .normal)
|
btn.setTitle("立即开通", for: .normal)
|
||||||
btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal)
|
btn.setTitleColor(UIColor(hexStr: "#00343B"), for: .normal)
|
||||||
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
|
btn.titleLabel?.font = FontManager.boboBold(14)
|
||||||
btn.cornerRadius = 14
|
btn.cornerRadius = 9
|
||||||
btn.isHidden = true
|
btn.isHidden = true
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Profile transition background (100pt section below VIP card)
|
private lazy var quickCardView: UIView = {
|
||||||
private lazy var profileBgImageView: UIImageView = {
|
let v = UIView()
|
||||||
let iv = UIImageView()
|
v.backgroundColor = .white
|
||||||
iv.image = UIImage(named: "Mine/profile_bg")
|
v.cornerRadius = 20
|
||||||
iv.contentMode = .scaleToFill
|
return v
|
||||||
return iv
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var bottomFillView: UIView = {
|
private lazy var quickStack: UIStackView = {
|
||||||
let view = UIView()
|
let stack = UIStackView()
|
||||||
view.backgroundColor = UIColor(hexStr: "#F5FBFB")
|
stack.axis = .horizontal
|
||||||
return view
|
stack.distribution = .fillEqually
|
||||||
|
stack.alignment = .center
|
||||||
|
return stack
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Settings Card
|
|
||||||
private lazy var settingsCardView: UIView = {
|
private lazy var settingsCardView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = .white
|
view.backgroundColor = .white
|
||||||
|
|
@ -197,91 +151,71 @@ final class MineView: UIView {
|
||||||
return tv
|
return tv
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Callbacks
|
|
||||||
var onMenuTap: ((Int) -> Void)?
|
|
||||||
|
|
||||||
// MARK: - Dynamic Height
|
|
||||||
private var settingsCardHeightConstraint: NSLayoutConstraint?
|
private var settingsCardHeightConstraint: NSLayoutConstraint?
|
||||||
private var contentSizeObservation: NSKeyValueObservation?
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
setupUI()
|
setupUI()
|
||||||
setupLayout()
|
setupLayout()
|
||||||
setupRx()
|
setupQuickActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
backgroundColor = .white
|
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||||||
|
|
||||||
addSubview(headerBgImageView)
|
addSubview(scrollView)
|
||||||
addSubview(avatarImageView)
|
scrollView.addSubview(contentView)
|
||||||
addSubview(nameLabel)
|
contentView.addSubview(headerBgView)
|
||||||
addSubview(editButton)
|
|
||||||
addSubview(idLabel)
|
|
||||||
addSubview(copyButton)
|
|
||||||
|
|
||||||
addSubview(vipCardView)
|
// 照片墙固定顶部,不随列表滚动
|
||||||
|
addSubview(photoWall)
|
||||||
|
|
||||||
|
contentView.addSubview(vipCardView)
|
||||||
vipCardView.addSubview(vipCardBgImageView)
|
vipCardView.addSubview(vipCardBgImageView)
|
||||||
vipCardView.addSubview(vipActivateButton)
|
vipCardView.addSubview(vipActivateButton)
|
||||||
vipCardView.addSubview(vipTitleLab)
|
vipCardView.addSubview(vipTitleLab)
|
||||||
vipCardView.addSubview(vipContentLab)
|
vipCardView.addSubview(vipContentLab)
|
||||||
vipCardView.addSubview(maskBgView)
|
vipCardView.addSubview(maskBgView)
|
||||||
|
|
||||||
addSubview(profileBgImageView)
|
contentView.addSubview(quickCardView)
|
||||||
addSubview(bottomFillView)
|
quickCardView.addSubview(quickStack)
|
||||||
addSubview(settingsCardView)
|
|
||||||
|
contentView.addSubview(settingsCardView)
|
||||||
settingsCardView.addSubview(settingsTableView)
|
settingsCardView.addSubview(settingsTableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupLayout() {
|
private func setupLayout() {
|
||||||
// Header
|
photoWall.layoutChain
|
||||||
headerBgImageView.layoutChain
|
.top(40)
|
||||||
.top().left().right()
|
.edgesHorzontal()
|
||||||
.height(headerHeight)
|
|
||||||
|
|
||||||
// Avatar
|
scrollView.layoutChain
|
||||||
avatarImageView.layoutChain
|
.topToBottomOfView(photoWall)
|
||||||
.top(kStatusBarHeight + 44)
|
.edgesHorzontal()
|
||||||
.left(32)
|
.bottom()
|
||||||
.size(CGSize(width: avatarSize, height: avatarSize))
|
|
||||||
|
|
||||||
// Name
|
contentView.layoutChain
|
||||||
nameLabel.layoutChain
|
.edges()
|
||||||
.leftToRightOfView(avatarImageView, offset: 20)
|
.widthToView(scrollView)
|
||||||
.topToView(avatarImageView, offset: 8)
|
|
||||||
|
|
||||||
// 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
|
vipCardView.layoutChain
|
||||||
.topToBottomOfView(avatarImageView, offset: 28)
|
.top(12)
|
||||||
.left(16).right(16)
|
.left(16).right(16)
|
||||||
.height(vipCardHeight)
|
.height(vipCardHeight)
|
||||||
|
|
||||||
vipCardBgImageView.layoutChain
|
vipCardBgImageView.layoutChain.edges()
|
||||||
.edges()
|
|
||||||
|
|
||||||
vipTitleLab.layoutChain
|
vipTitleLab.layoutChain
|
||||||
.top(22)
|
.top(22)
|
||||||
|
|
@ -297,53 +231,101 @@ final class MineView: UIView {
|
||||||
|
|
||||||
maskBgView.layoutChain.edges()
|
maskBgView.layoutChain.edges()
|
||||||
|
|
||||||
// Profile transition background (100pt, overlaps VIP card bottom half)
|
quickCardView.layoutChain
|
||||||
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
|
|
||||||
.topToBottomOfView(vipCardView, offset: 20)
|
.topToBottomOfView(vipCardView, offset: 20)
|
||||||
.left(16).right(16)
|
.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
|
settingsCardHeightConstraint?.isActive = true
|
||||||
|
|
||||||
settingsTableView.layoutChain
|
settingsTableView.layoutChain.edges()
|
||||||
.edges()
|
|
||||||
|
|
||||||
contentSizeObservation = settingsTableView.observe(\.contentSize, options: [.new, .initial]) { [weak self] _, _ in
|
contentSizeObservation = settingsTableView.observe(\.contentSize, options: [.new, .initial]) { [weak self] _, _ in
|
||||||
self?.refreshTableViewHeight()
|
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() {
|
override func layoutSubviews() {
|
||||||
super.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()
|
refreshTableViewHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshTableViewHeight() {
|
func refreshTableViewHeight() {
|
||||||
let contentHeight = settingsTableView.contentSize.height
|
let contentHeight = settingsTableView.contentSize.height
|
||||||
guard contentHeight > 0 else { return }
|
guard contentHeight > 0 else { return }
|
||||||
|
settingsCardHeightConstraint?.constant = contentHeight
|
||||||
let tabBarHeight: CGFloat = 56 + kSafeBottomMargin
|
settingsTableView.isScrollEnabled = false
|
||||||
let maxHeight = bounds.height - tabBarHeight - settingsCardView.frame.minY
|
|
||||||
|
|
||||||
if contentHeight <= maxHeight {
|
|
||||||
settingsCardHeightConstraint?.constant = contentHeight
|
|
||||||
settingsTableView.isScrollEnabled = false
|
|
||||||
} else {
|
|
||||||
settingsCardHeightConstraint?.constant = maxHeight
|
|
||||||
settingsTableView.isScrollEnabled = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,15 +401,4 @@ final class MineMenuCell: UITableViewCell {
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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,7 +15,6 @@ final class MineViewController: BaseViewController {
|
||||||
override var isNavigationBarHidden: Bool { true }
|
override var isNavigationBarHidden: Bool { true }
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle { .default }
|
override var preferredStatusBarStyle: UIStatusBarStyle { .default }
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
fileprivate var rootView: MineView!
|
fileprivate var rootView: MineView!
|
||||||
|
|
||||||
private var tableView: UITableView {
|
private var tableView: UITableView {
|
||||||
|
|
@ -23,8 +22,8 @@ final class MineViewController: BaseViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private let viewModel = MineViewModel()
|
private let viewModel = MineViewModel()
|
||||||
|
private var groupKey: String = ""
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
rootView = MineView(frame: UIScreen.main.bounds)
|
rootView = MineView(frame: UIScreen.main.bounds)
|
||||||
view = rootView
|
view = rootView
|
||||||
|
|
@ -34,20 +33,15 @@ final class MineViewController: BaseViewController {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
bindViewModel()
|
bindViewModel()
|
||||||
reactiveAction()
|
reactiveAction()
|
||||||
|
|
||||||
rootView.avatarImageView.image = AppContextManager.shared.avaterIcon
|
|
||||||
rootView.nameLabel.text = AppContextManager.shared.name
|
|
||||||
rootView.idLabel.text = "ID:" + AppContextManager.shared.userId
|
|
||||||
|
|
||||||
viewModel.loadMenuData()
|
viewModel.loadMenuData()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
requestUserInfo()
|
requestUserInfo()
|
||||||
|
requestGroupMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bindings
|
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
viewModel.output.sectionedItems
|
viewModel.output.sectionedItems
|
||||||
.bind(to: tableView.rx.items(dataSource: dataSource))
|
.bind(to: tableView.rx.items(dataSource: dataSource))
|
||||||
|
|
@ -56,41 +50,31 @@ final class MineViewController: BaseViewController {
|
||||||
tableView.rx.modelSelected(MineMenuItem.self)
|
tableView.rx.modelSelected(MineMenuItem.self)
|
||||||
.subscribe(viewModel.cellAction.inputs)
|
.subscribe(viewModel.cellAction.inputs)
|
||||||
.disposed(by: disposeBag)
|
.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() {
|
private func reactiveAction() {
|
||||||
rootView.vipActivateButton.rx.tap
|
rootView.vipActivateButton.rx.tap
|
||||||
.subscribe(onNext: { [weak self] in
|
.subscribe(onNext: { _ in
|
||||||
AppRouter.push(Route.vipRecharge)
|
AppRouter.push(Route.vipRecharge)
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
rootView.editButton.rx.tap
|
rootView.photoWall.onCopyID = { id in
|
||||||
.subscribe(onNext: { [weak self] in
|
UIPasteboard.general.string = id
|
||||||
// TODO: Navigate to edit profile
|
DLToast.showSuccess(text: "已复制")
|
||||||
})
|
}
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
rootView.copyButton.rx.tap
|
rootView.photoWall.onTapSelfAvatar = {
|
||||||
.subscribe(onNext: { [weak self] in
|
AppRouter.push(Route.myProfile)
|
||||||
guard let id = self?.rootView.idLabel.text else { return }
|
}
|
||||||
UIPasteboard.general.string = id.replacingOccurrences(of: "ID:", with: "")
|
|
||||||
DLToast.showSuccess(text: "已复制")
|
rootView.onQuickTap = { [weak self] title in
|
||||||
})
|
self?.viewModel.handleMenu(MineMenuItem(icon: "", title: title))
|
||||||
.disposed(by: disposeBag)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDataSource
|
private lazy var dataSource: RxTableViewSectionedReloadDataSource<MineListSectionModel> = {
|
||||||
lazy private var dataSource: RxTableViewSectionedReloadDataSource<MineListSectionModel> = {
|
RxTableViewSectionedReloadDataSource<MineListSectionModel>(
|
||||||
return RxTableViewSectionedReloadDataSource<MineListSectionModel>(
|
|
||||||
configureCell: { (_, tableView, indexPath, model) in
|
configureCell: { (_, tableView, indexPath, model) in
|
||||||
let cell: MineMenuCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: MineMenuCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
cell.configure(model)
|
cell.configure(model)
|
||||||
|
|
@ -98,17 +82,27 @@ final class MineViewController: BaseViewController {
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - API
|
|
||||||
private func requestUserInfo() {
|
private func requestUserInfo() {
|
||||||
UserService.userInfo().subscribe { response in
|
UserService.userInfo().subscribe { response in
|
||||||
guard let model = response.model else { return }
|
guard let model = response.model else { return }
|
||||||
if AppContextManager.shared.saveAccount(model) {
|
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)
|
self.rootView.setupData(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
}.disposed(by: disposeBag)
|
}.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,39 +24,71 @@ class MineViewModel {
|
||||||
let userName = PublishSubject<String>()
|
let userName = PublishSubject<String>()
|
||||||
let userID = PublishSubject<String>()
|
let userID = PublishSubject<String>()
|
||||||
|
|
||||||
|
/// 设置列表(设计稿)
|
||||||
let menuItems: [MineMenuItem] = [
|
let menuItems: [MineMenuItem] = [
|
||||||
MineMenuItem(icon: "Mine/account_security", title: "账户与安全"),
|
MineMenuItem(icon: "Mine/account_security", title: "账户与安全"),
|
||||||
// MineMenuItem(icon: "Mine/status_setting", title: "状态设置"),
|
|
||||||
MineMenuItem(icon: "Mine/emergency_contact", title: "紧急联系人"),
|
MineMenuItem(icon: "Mine/emergency_contact", title: "紧急联系人"),
|
||||||
MineMenuItem(icon: "Mine/customer_service", title: "在线客服"),
|
MineMenuItem(icon: "Mine/customer_service", title: "在线客服"),
|
||||||
MineMenuItem(icon: "Mine/privacy_agreement", title: "隐私与协议"),
|
MineMenuItem(icon: "Mine/menu_about", title: "关于我们")
|
||||||
MineMenuItem(icon: "Mine/permission_check", 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
|
lazy var cellAction: Action<MineMenuItem, Void> = { this in
|
||||||
return Action { model in
|
return Action { model in
|
||||||
switch model.title {
|
this.handleMenu(model)
|
||||||
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
|
|
||||||
}
|
|
||||||
return .empty()
|
return .empty()
|
||||||
}
|
}
|
||||||
}(self)
|
}(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() {
|
func loadMenuData() {
|
||||||
sectionedItems.onNext(menuItems.mapSection())
|
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() {
|
func requestWechatService() {
|
||||||
DLToast.showLoading()
|
DLToast.showLoading()
|
||||||
SystemService.wechatService().subscribe(onNext: { response in
|
SystemService.wechatService().subscribe(onNext: { response in
|
||||||
|
|
@ -71,7 +103,6 @@ class MineViewModel {
|
||||||
}, onError: { _ in }).disposed(by: disposeBag)
|
}, onError: { _ in }).disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Init
|
|
||||||
init() {
|
init() {
|
||||||
output = Output(
|
output = Output(
|
||||||
sectionedItems: sectionedItems.asObservable()
|
sectionedItems: sectionedItems.asObservable()
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ class BaseNavigationView: UIView {
|
||||||
backBtn.layoutChain
|
backBtn.layoutChain
|
||||||
.centerY(titleLabel)
|
.centerY(titleLabel)
|
||||||
.left(15)
|
.left(15)
|
||||||
.width(24)
|
.width(36)
|
||||||
.height(24)
|
.height(36)
|
||||||
|
|
||||||
titleLabel.layoutChain
|
titleLabel.layoutChain
|
||||||
.top(kStatusBarHeight + 12)
|
.top(kStatusBarHeight + 12)
|
||||||
|
|
|
||||||