- 个人中心(UI改版、照片墙效果)

- 手机报告功能(屏幕使用时间、使用次数、解锁次数)
This commit is contained in:
linshujie 2026-08-06 18:28:48 +08:00
parent 035137a776
commit eccad4b9d3
71 changed files with 1825 additions and 501 deletions

96
.gitignore vendored
View File

@ -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

View File

@ -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 */,

View File

@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 379 B

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View 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 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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")
} }

View File

@ -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 }
flushScreenSession()
Defaults[\.unlockCountDate] = today Defaults[\.unlockCountDate] = today
Defaults[\.unlockCountToday] = 0 Defaults[\.unlockCountToday] = 0
Defaults[\.appUsageToday] = 0
return true return true
} }
return false
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
}
}
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()
} }

View File

@ -189,6 +189,10 @@ struct GroupMemberModel: Mappable, Equatable {
} }
/// 0 1SOS 2 /// 0 1SOS 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"]
} }
} }

View File

@ -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
}() }()

View File

@ -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)
} }

View File

@ -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
}() }()

View File

@ -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)
}
} }
/// ///

View File

@ -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

View File

@ -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
}
}

View File

@ -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()

View File

@ -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 "--"
}
}

View File

@ -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 }
let tabBarHeight: CGFloat = 56 + kSafeBottomMargin
let maxHeight = bounds.height - tabBarHeight - settingsCardView.frame.minY
if contentHeight <= maxHeight {
settingsCardHeightConstraint?.constant = contentHeight settingsCardHeightConstraint?.constant = contentHeight
settingsTableView.isScrollEnabled = false 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
}
} }

View File

@ -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
})
.disposed(by: disposeBag)
rootView.copyButton.rx.tap
.subscribe(onNext: { [weak self] in
guard let id = self?.rootView.idLabel.text else { return }
UIPasteboard.general.string = id.replacingOccurrences(of: "ID:", with: "")
DLToast.showSuccess(text: "已复制") DLToast.showSuccess(text: "已复制")
})
.disposed(by: disposeBag)
} }
// MARK: - UITableViewDataSource rootView.photoWall.onTapSelfAvatar = {
lazy private var dataSource: RxTableViewSectionedReloadDataSource<MineListSectionModel> = { AppRouter.push(Route.myProfile)
return RxTableViewSectionedReloadDataSource<MineListSectionModel>( }
rootView.onQuickTap = { [weak self] title in
self?.viewModel.handleMenu(MineMenuItem(icon: "", title: title))
}
}
private lazy var dataSource: RxTableViewSectionedReloadDataSource<MineListSectionModel> = {
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)
// idgroup_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)
}
} }

View File

@ -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
this.handleMenu(model)
return .empty()
}
}(self)
func handleMenu(_ model: MineMenuItem) {
switch model.title { switch model.title {
case "账户与安全": case "账户与安全":
AppRouter.push(Route.account) AppRouter.push(Route.account)
case "紧急联系人": case "紧急联系人":
AppRouter.push(Route.emergencyContact) AppRouter.push(Route.emergencyContact)
case "隐私与协议": case "隐私与协议", "隐私设置":
AppRouter.push(Route.privacyPolicy) AppRouter.push(Route.privacyPolicy)
case "在线客服": case "在线客服":
this.requestWechatService() requestWechatService()
case "权限检测": case "权限检测":
AppRouter.push(Route.checkPermission) AppRouter.push(Route.checkPermission)
case "数据安全", "状态设置", "关于我们":
DLToast.show(text: "敬请期待")
default: default:
break break
} }
return .empty()
} }
}(self)
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()

View File

@ -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)