From 738d0b710fc42d6611d1291b978048f3246514f6 Mon Sep 17 00:00:00 2001 From: linshujie Date: Fri, 7 Aug 2026 10:58:34 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E6=89=8B=E6=9C=BA=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- QuickLocation.xcodeproj/project.pbxproj | 40 ++ .../phone_brightness.imageset/Contents.json | 8 + .../phone_brightness@2x.png | Bin 0 -> 1026 bytes .../phone_brightness@3x.png | Bin 0 -> 1779 bytes .../Home/phone_device.imageset/Contents.json | 8 + .../phone_device.imageset/phone_device@2x.png | Bin 0 -> 1710 bytes .../phone_device.imageset/phone_device@3x.png | Bin 0 -> 3074 bytes .../Home/phone_volume.imageset/Contents.json | 8 + .../phone_volume.imageset/phone_volume@2x.png | Bin 0 -> 921 bytes .../phone_volume.imageset/phone_volume@3x.png | Bin 0 -> 1598 bytes .../Home/phone_wifi.imageset/Contents.json | 8 + .../phone_wifi.imageset/phone_wifi@2x.png | Bin 0 -> 1201 bytes .../phone_wifi.imageset/phone_wifi@3x.png | Bin 0 -> 2163 bytes QuickLocation/Manager/App/RouterManager.swift | 14 + QuickLocation/QuickLocation.entitlements | 2 + .../Section/Home/HomeViewController.swift | 32 ++ .../Section/Home/MemberAppUsageView.swift | 320 +++++++++++ .../PhoneReportDetailVC.swift | 119 +++++ .../PhoneReportDetailView.swift | 497 ++++++++++++++++++ .../PhoneReportDetailViewModel.swift | 362 +++++++++++++ .../TodayTrackDetail/TodayTrackDetailVC.swift | 284 ++++++++++ .../TodayTrackDetailView.swift | 269 ++++++++++ .../TodayTrackDetailViewModel.swift | 123 +++++ 23 files changed, 2094 insertions(+) create mode 100644 QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_device.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@2x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_device.imageset/phone_device@3x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json create mode 100644 QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png create mode 100644 QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@3x.png create mode 100644 QuickLocation/Section/Home/MemberAppUsageView.swift create mode 100644 QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift create mode 100644 QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift create mode 100644 QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift create mode 100644 QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift create mode 100644 QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift create mode 100644 QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 50c0c10a..fd77b196 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -302,6 +302,12 @@ 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 */; }; + 55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D13023010000784730 /* TodayTrackDetailVC.swift */; }; + 55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D33023010000784732 /* TodayTrackDetailView.swift */; }; + 55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */; }; + 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D73023010000784736 /* PhoneReportDetailVC.swift */; }; + 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217D93023010000784738 /* PhoneReportDetailView.swift */; }; + 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */; }; 55B21D6E3023117900784774 /* 优设标题黑_猫啃网.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */; }; 55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; }; 55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; }; @@ -618,6 +624,12 @@ 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberPhoneReportView.swift; sourceTree = ""; }; 55B217C43022F0A000784724 /* MemberAppUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAppUsageView.swift; sourceTree = ""; }; 55B217D03022F0A000784726 /* UnlockCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockCountManager.swift; sourceTree = ""; }; + 55B217D13023010000784730 /* TodayTrackDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailVC.swift; sourceTree = ""; }; + 55B217D33023010000784732 /* TodayTrackDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailView.swift; sourceTree = ""; }; + 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTrackDetailViewModel.swift; sourceTree = ""; }; + 55B217D73023010000784736 /* PhoneReportDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailVC.swift; sourceTree = ""; }; + 55B217D93023010000784738 /* PhoneReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailView.swift; sourceTree = ""; }; + 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneReportDetailViewModel.swift; sourceTree = ""; }; 55B21D6D3023117900784774 /* 优设标题黑_猫啃网.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "优设标题黑_猫啃网.ttf"; sourceTree = ""; }; 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = ""; }; 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = ""; }; @@ -1032,6 +1044,8 @@ 55B2179230218CE100784774 /* GroupMemberView2.swift */, 55B217C23022F0A000784722 /* MemberPhoneReportView.swift */, 55B217C43022F0A000784724 /* MemberAppUsageView.swift */, + 55B217DD302301000078473C /* TodayTrackDetail */, + 55B217DE302301000078473D /* PhoneReportDetail */, 30A7A9102FCAEE3D00105780 /* GroupListPopView.swift */, 30D87CDE2FDFF1A100E958FD /* QuickMessageView.swift */, 30D87CDC2FDFF07500E958FD /* InteractionView.swift */, @@ -1703,6 +1717,26 @@ path = Unlock; sourceTree = ""; }; + 55B217DD302301000078473C /* TodayTrackDetail */ = { + isa = PBXGroup; + children = ( + 55B217D13023010000784730 /* TodayTrackDetailVC.swift */, + 55B217D33023010000784732 /* TodayTrackDetailView.swift */, + 55B217D53023010000784734 /* TodayTrackDetailViewModel.swift */, + ); + path = TodayTrackDetail; + sourceTree = ""; + }; + 55B217DE302301000078473D /* PhoneReportDetail */ = { + isa = PBXGroup; + children = ( + 55B217D73023010000784736 /* PhoneReportDetailVC.swift */, + 55B217D93023010000784738 /* PhoneReportDetailView.swift */, + 55B217DB302301000078473A /* PhoneReportDetailViewModel.swift */, + ); + path = PhoneReportDetail; + sourceTree = ""; + }; B07D45692FCECE07570D9B73 /* Pods */ = { isa = PBXGroup; children = ( @@ -1983,6 +2017,12 @@ 55B2179330218CE100784774 /* GroupMemberView2.swift in Sources */, 55B217C53022F0A000784725 /* MemberAppUsageView.swift in Sources */, 55B217C33022F0A000784723 /* MemberPhoneReportView.swift in Sources */, + 55B217D23023010000784731 /* TodayTrackDetailVC.swift in Sources */, + 55B217D43023010000784733 /* TodayTrackDetailView.swift in Sources */, + 55B217D63023010000784735 /* TodayTrackDetailViewModel.swift in Sources */, + 55B217D83023010000784737 /* PhoneReportDetailVC.swift in Sources */, + 55B217DA3023010000784739 /* PhoneReportDetailView.swift in Sources */, + 55B217DC302301000078473B /* PhoneReportDetailViewModel.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, 30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */, 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */, diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json new file mode 100644 index 00000000..1aa08092 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "phone_brightness@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "phone_brightness@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..46a7b9f2a36fbfba0df8be08c5f30bc7b06d1dfd GIT binary patch literal 1026 zcmV+d1pWJoP)2&&aB9XXR14q>W*4EaHtT>s^=Yy>Dg2~a+(qiK0nHqdBxl5(ebG=@V zNlgN);#3R(0*l4+krl@)1LyQ?1qQHy2@Ej+v)MesoiV0nwXrfL30N17>PfSZD>#ff zj(QUe2E$VpwlZmMp=gXinhnbaTyKwsVzKz3+}HdlX^0^(gCSso_MukE_c%k&%t!;7^3Gfq-4K_^h#MI-5Sr?yom5~gkd zf*_FJGfZx$T{PKGrf6m^Oq*Lfba-?urWmQt{5hof_AZY_==WYy)b$Jxk(ZYTlMn?8 ze{}*MglnnrZ0JP9hFR$cMDXJjc>sS zMPn=I{ztL_K&vP@L>g5x^lJ&gQkl|;DI0*- zq$C@FQ~`WPyBQBnWWI7)mAJ9L>+WK%aZ?q5o|nLRYp}7mvW9)I zk6K&Rz2Q{_0Ke@PGdb*obv z-W5nU3a+=Oivs=;LZCnnFDz4tZSk%^Ux@^z0myN-XAY0(G;*RO-?IA3csKkE&#`%Fsa96PjtdLO}1=!tD4|!ZevMOw=_;P|xfU|`R$r`he z?K8NL#FpqiHdV&__i;Kf zFmTKtrjIx(Wa4zsokKXl02VOeUd#X(2>7G%SuU4zas|I(#R!w)7KM!(D*M3Y;uY7M w4;a9z*!~3o0RR8?eUqF3000I_L_t&o09hogv4Qusg#Z8m07*qoM6N<$f;rCA@c;k- literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png b/QuickLocation/Assets.xcassets/Home/phone_brightness.imageset/phone_brightness@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..d76d27dc6fd5ec6d360ee032f072b4a06cd918d2 GIT binary patch literal 1779 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 PXa|5XzGPHk;1cmKH2Jh7 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json new file mode 100644 index 00000000..594bffbc --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "phone_volume@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "phone_volume@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..642c8da019bb068ef91dfa45e19d33f7e3f36a01 GIT binary patch literal 921 zcmV;K17`e*P)w<+F_x=DAF>#^40uxQp#2?_^jSCk30UrpI zhk|GuB`8JcYbc$ep`P!`n8`S=rlHL}z2}~D?)kpC=gyrOiA+C5QHJyR{1nTlTrOAQ zzSf8!2g_oiP1wq)&p{7a27EqWi2H^baI1+47BGP!2*|QL#e)f!X0?eUOp;I+W_7n&$T|*VPGY}F z!C>$n8v`s&Z@k~`NQZ}qH|s&>c-s7xrPRtQz5AG=x3ddE-`LzD`0B~UvA3`XdRg(* z%KH_XU09}XnIBXvDx_%#bZ}4=dgl8EeVAXOlCtM17lQcC=#;n?o^G{@s#0osl~h&3 zTuT9=#g#SMuT(7kW+Sk%0%p7A_{-M}am_4!MkC6www^JX9Cc3wl)W+)c*)I~db(oN z9qTZgMHz}EpWPgDoGSrrrg{z+9RLLK#gaa> z?HviA_Bi!yXS+Dkd}6wl!jS;#iz{)#v32j2_i1})*VYe*BY{T1ksMMkJ0HuA1R~)O zax^4vIUEU`ipmu4=_J43PxbyJNg^E8$l)~CkpL#fI?mA8=rARQE>d^rIYPn{twBMG zpJwFapg_P+t-&BgS74!1FZ4jQ`DMLcFROi6dXL)YY)OeerHy`eBwNo$u}PyyQk8 z&hthJ1OhLO#wO}jRaKu$p-^anYw@g!zygXfU$nQkuS*#5$LAyNXIPrmW{xobew@bR z@d|&K-ey)b^yh%P2=VKC|sH@3$l{ vfW>3c{r~^~|Nk?GMPvW~00v1!K~w_(oUEC#jdLSE00000NkvXXu0mjf;b^t2 literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png b/QuickLocation/Assets.xcassets/Home/phone_volume.imageset/phone_volume@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..15f55b592c5f19e8921d1795a9c8f027ae52b9b7 GIT binary patch literal 1598 zcmV-E2EqA>P)gA#vXIyt((id(Roa&wUd?CZGBF z`G#yZ8;r$b*W>Z{ugPR`ERjgWA>|rNoMSyYooM^==dWV--Mim zXpq-cIQD4-|C})~HpYni?XpdP4FdxM*Gr|+m$3Fj4pU*A{fvzHpqH79R3(X z!u(!;+W%R1}WRxB3x7i6cmCWQEi30&OKsn^!y37VXa(7#V7Xnt{-lF2k> zvRjnPDsePH5IJdvWQ znOTa=Es~DDrj2bF;IEs10p;`n4rp~PO3!8&sEFZXBc^OpER|?!c~uJgkV>_$HYT9f z6}{3Mo3yaBLM4l*j%J2MFPCX)^*>@Ch7D{g_eFrmHIH$dv0iGH&lhMqGWU|+i9(q` zGL@F&*(_L{%SEAJ9|ikP5;9t|X0*APr9?7iYOiKcCQvP?*V{n8_BQJ5Y$tESF-si# zS(TItq*5D}pftI&XvBX)XqIg^HZzuXE29zt9g{YHa2paKO~xvww!tYAAgm%*Pv%wG zlnA^kaCYK2AYjMcPMdt8NV$BTwsJY76NMdvNv&+6uclTaP{6Ls8{<>q!b10307^)Y)S-p)v%K~FbNj3;1zwbRHV&JwsPy}>X?9z6)2HR%9X=WB#XwS zl1_1KW-2L(V**L6|FKxY0*;y~8e69XGF7L&9TDIs6`5P0BC<)RfvUCFF%M=JmMCAS zKH+!!aztPm*`w-M-5u1dp20j=SXwnR8|rNn&@uL->j!g^GDPxRnM$W|&@n$0J#V&s zu}y$`lrY)&T1#;n!!`k&KdGguv6k4SZ;rvS6P@JbDOCd`kbC<&j?>`jVAZq3i@wB*ifZ@A>g(l6>+0o;Gb61pGZ9&_n*-6LQ$`uho9MRkn5E z%)Hx&YMlujSYE(y;zpi~6U*rM6g{1omcp+Y|9LV-kMY)SGoyZdG$5G}nx?H77?e~z z$-8if-`dJiJdq@RQ^%6TYevpWeVC|?UayjGqfU%nV27PHGOFsjyE^F{=0nHvHaV$z zV)Hn^aBh&;MhE*DVFKU6r|N+SUH29|6>4s3q_cw|x;lJO3a>Oxjov*g)81Z6cZ~TL z;q`j|K=(z+Q7hB=eEv`V4-LL=mWULib+XPs_hL)C=uQ9eh!2K7HDF`oDxV)Cau@?~e z9%Eu`j1l+SWeV!Z07*qoM6N<$f-61(&j0`b literal 0 HcmV?d00001 diff --git a/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json new file mode 100644 index 00000000..8bd9dff4 --- /dev/null +++ b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "phone_wifi@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "phone_wifi@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png b/QuickLocation/Assets.xcassets/Home/phone_wifi.imageset/phone_wifi@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4143cfb1a444a44d2ee8024a3397691ee3fcafbc GIT binary patch literal 1201 zcmV;i1Wx;jP)+Y{#zkWR$h2Ho}r_6`$tw`8ihlo@t=2uFlENcN*})G*T!O zzO&oy*wj;C8BQet2;6S>AFTM;Sa?rQ8!&(cOkhX=E|=>GPaZK@H69f)Nx`{rR9{<# zY~V2FW7M0Xs_Hi^v@uyexZb`}PESuiw;Z_y_yua9yEM0!@s9geee;h%COjO{w%@&AR|LGH|53Wuqjxz!ZS!_Kn!91T0(W~uU;vHV;8;e&40H@d`K#wyz^i8T`uD-sWK;U_5j*PC; zT6CRe=EAhMubJ|T808`%c8xTHa=E;z;%aH89&Z=<#)jy#kNx!N)WjJ*^wGrVpsYd1 z2nc~JF0awbT8!Y+baalX2momdM^+`xhF+_?MLQH1@ zgN}bK~w~gK0Ko2K}u}7NT|QpOGEv=)YRA@=?pEd(BIQ@G|xqd z-9=VjP;haX{&^mt(899lG&me$(|7M9f)6;;k_Ut$27jI^0sv5ZTPxjgHp#BRBbD}6rAVIEdrr1r~IQdJkU$ffii4XU%Ui+B~=Z;;ZVf^s{6tCFg4$BQhalZPzz{6 zGu$77eD-l}LP)ibZv%8;WCR2LGO8ND{hn?)M^rbe8GRwPzCox3G$HhdB%ghZo6s~j zpk2Nd(Die$D8kEt4SCf7P{kc=d z40E}&I>5ZM{if8xq;G^C@SUIJnt%*zpj@$Lz*nQ11L*SHk=ubn>*Hg=`DVD~>U`b6 zLpCoo^}qd-q^yIRB;Z$-RxP!s=cY>BM7*-6l;CG}#!|7s3^b;|}? zup!vnPZ;dAp9=T>Qir*Q`G=flo6Sac`v=tJxqS`Gk}9zAgCt%O)y}oLUG&N1I6d@_ z@r%b*yJ5Lz3`g*1xy-s5*UDcCr}1~|Aj^>Qx~~7KIGxS_pT#d_OBPHR^S6$Uj#V51 zX`1!}V?JZDYCJ1qo_(Bpz1|Fen10DoArqzFVDBLuU;qo4a4#7E0|9?Dew)o^2f69| z%!(nVx4NS6XHd~cgy%nUy-ffESUi{UKL7v#|NkGm$pI_eCf69t3^oJ>tn=e@&-u@PzW<#6b+ImIeAd_3 zTQZr<@c#b((^xF_MIw=yi^tT>KGZbrH3*r25D)08n}>UWo`OEGTCHCK&)c9UwKhJbz_Vx1tcQn(|G>ge!2AYi z0HlF_t;W4avH8D=iP(q{&*fHDfD?Yd|7o#U{4ZvGpzG9#(?@K?NUSC}PN;q$5coSx zg7;j z7ecZWUX_il9f`*eC3BpWe7+!Dd&v{?P=O1{KvtAkJRz&=TM}Gamgh4I3T$X)O*Xf8 zFn1_NM`@u_2eG(BcB8Vgwjt9{?8WSY%m+hw-UMGl45M@*3|!Q(0z9?-gSfny4a&mO z3cyC>0E*;tdCIGc$eTzWVeO8RL51RpByOax<)@hHR=_X}IXpU2Cpd@GJ4&TtIy8e* z^2!g$m{LjQiK|$NN@j60!O*g#QW+>x|Bck80PS^SD}rkeO9CfVg+V={>=tL6 zL%KWLWw5_Te1rYs8~T~h@9(`Oo$X%5Ltd%+7^RZzMGtU5LD|~gl{^%yVyXFft^&Dy zUS@+!$^m)x)lynon#J!Ml!tdGWO95&Muu<8&_KWR_uUeLR)jv485_B+c*x5&j@A~b zPCz`Y)r~D#SX>tFUAo&#-wfrLbfyBDrb$nCr;Ni!NBu)W2c$qQWF}n- zugPL)Rf@$E-{71{nx@d#`gj}!NrEZ&JXVEJasI`cWO?zfWHs_m~EYjKG6<+_{$uZ@a4o9oB zG&d_^pU%oK`}jBa)I8Ui>nV#eH{n0#SiRp3E0E3R%CdQwZdaQGMtm|c=9gCF1lCA& z+=Ey`c(S|EePN$YZ6Gk%;8VPfF~3hGHOeprX41!Ix9;zT73l72hh4v1#*-zGcCSbI z0xMD0NDQ4U3l>p<(lpp-SbaV-FWU&C2BJzwyGLl_ltHvKKgwpm5qgQFffemuuiT%S z5Sq$EBqA$LG?7fo_s?g9&d8Idb275y&YKG60$5(vE{@ z0E%L5SOH!+O8^fXjNmplw`FE-3GV|Zrllmm;!hg+fq8&sH}_-{p4^?F?5^|)2pQ4NjCuQSs_My1R~`Kw}wA&|a9A zuAiJa`y3k|8+MF(Gb;DsxFfzH;TY#GN|#cAn-VZjlM%%V~0b{qY%op;^%f2M05-&v&7~3h0+V5zpmz zN&$0}_62$i|EGBk3;zoG7&MRhU%c#q%<2~BUNDs}QIfxIx7&Z8OeX&cJl|F`^7$tK p00960$Zv$$00006Nkl applinks:smartdrive.zuom8.cn + com.apple.developer.networking.wifi-info + diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 7cc3438e..f8f99df4 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -18,6 +18,7 @@ import AMapNaviKit import CocoaMQTT import Lottie import MarqueeLabel +import ObjectMapper #endif class HomeViewController: BaseViewController { @@ -205,6 +206,16 @@ class HomeViewController: BaseViewController { rootView.groupMemberView.inviteBtn.rx.tap.subscribe(onNext: { _ in AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": self.viewModel.groupInfo]) }).disposed(by: disposeBag) + + // 今日轨迹二级页 + rootView.groupMemberView.todayTrackView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.openTodayTrackDetail() + }).disposed(by: disposeBag) + + // 手机报告二级页 + rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in + self?.openPhoneReportDetail() + }).disposed(by: disposeBag) // 地图回到自己 rootView.locationView.rx.tapGesture.subscribe { _ in @@ -564,6 +575,27 @@ class HomeViewController: BaseViewController { ) } + private func openTodayTrackDetail() { + let members = viewModel.memberList.map { $0.toJSON() } + let userInfo: [String: Any] = [ + "members": members, + "userId": selectedMemberId + ] + AppRouter.push(Route.todayTrackDetail, userInfo: userInfo) + } + + private func openPhoneReportDetail() { + let members = viewModel.memberList.map { $0.toJSON() } + var userInfo: [String: Any] = [ + "members": members, + "userId": selectedMemberId + ] + if let group = viewModel.groupModel { + userInfo["groupJson"] = group.toJSON() + } + AppRouter.push(Route.phoneReportDetail, userInfo: userInfo) + } + /// 选中成员:同步列表高亮 / 信息卡,并可选居中地图、收起面板 private func selectMember(userId: String, centerMap: Bool = true, dismissPanel: Bool = false) { let listUserId = (userId == "current") ? AppContextManager.shared.userId : userId diff --git a/QuickLocation/Section/Home/MemberAppUsageView.swift b/QuickLocation/Section/Home/MemberAppUsageView.swift new file mode 100644 index 00000000..3b8de914 --- /dev/null +++ b/QuickLocation/Section/Home/MemberAppUsageView.swift @@ -0,0 +1,320 @@ +// +// MemberAppUsageView.swift +// QuickLocation +// + +import UIKit + +struct MemberAppUsageItemPreview { + let rank: Int + let name: String + let tintHex: String + let durationText: String + let progress: CGFloat +} + +struct MemberAppUsagePreview { + let totalTimeText: String + let segmentRatios: [CGFloat] + let segmentColors: [UIColor] + let topApps: [MemberAppUsageItemPreview] + + static let mock = MemberAppUsagePreview( + totalTimeText: "24h 60m", + segmentRatios: [0.35, 0.28, 0.18, 0.19], + segmentColors: [ + UIColor(hexStr: "#FF8FB1"), + UIColor(hexStr: "#FFD36E"), + UIColor(hexStr: "#7BE495"), + UIColor(hexStr: "#6EC1FF") + ], + topApps: [ + .init(rank: 1, name: "微信", tintHex: "#07C160", durationText: "1h 45m", progress: 1.0), + .init(rank: 2, name: "抖音", tintHex: "#111111", durationText: "1h 25m", progress: 0.76), + .init(rank: 3, name: "小红书", tintHex: "#FF2442", durationText: "45m", progress: 0.43), + .init(rank: 4, name: "美团", tintHex: "#FFC300", durationText: "30m", progress: 0.29), + .init(rank: 5, name: "Safari", tintHex: "#007AFF", durationText: "18m", progress: 0.17) + ] + ) +} + +/// 成员面板「APP使用记录」:环形图 + TOP5 +final class MemberAppUsageView: UIView { + + private let titleColor = UIColor(hexStr: "#293445") + + private let sectionIcon = UIImageView() + private let sectionTitleLab = UILabel() + private let cardView = UIView() + private let donutView = AppUsageDonutView() + private let totalTimeLab = UILabel() + private let totalHintLab = UILabel() + private let topTitleLab = UILabel() + private let topStack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + configure(with: .mock) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with preview: MemberAppUsagePreview?) { + topStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + guard let preview else { + totalTimeLab.text = "--" + donutView.setSegments(ratios: [], colors: []) + return + } + + totalTimeLab.text = preview.totalTimeText + donutView.setSegments(ratios: preview.segmentRatios, colors: preview.segmentColors) + + for item in preview.topApps { + let row = AppUsageTopRowView() + row.configure(with: item) + topStack.addArrangedSubview(row) + } + } + + private func setupUI() { + backgroundColor = .clear + + sectionIcon.image = UIImage(named: "Home/member_app_usage") + sectionIcon.contentMode = .scaleAspectFit + addSubview(sectionIcon) + sectionIcon.layoutChain + .top() + .left(15) + .width(18) + .heightToWidth(1) + + sectionTitleLab.text = "APP使用记录" + 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) + .height(220) + .bottom() + + cardView.addSubview(donutView) + donutView.layoutChain + .left(12) + .centerY() + .width(110) + .height(110) + + totalTimeLab.font = FontManager.youSheBiaoTiHei(14) + totalTimeLab.textColor = titleColor + totalTimeLab.textAlignment = .center + cardView.addSubview(totalTimeLab) + totalTimeLab.layoutChain + .centerX(donutView) + .centerY(donutView, offset: -6) + + totalHintLab.text = "今日总计" + totalHintLab.font = .systemFont(ofSize: 9, weight: .medium) + totalHintLab.textColor = UIColor(hexStr: "#767676") + totalHintLab.textAlignment = .center + cardView.addSubview(totalHintLab) + totalHintLab.layoutChain + .centerX(donutView) + .topToBottomOfView(totalTimeLab, offset: 2) + + topTitleLab.text = "最爱使用TOP5" + topTitleLab.font = .systemFont(ofSize: 12, weight: .bold) + topTitleLab.textColor = titleColor + cardView.addSubview(topTitleLab) + topTitleLab.layoutChain + .top(14) + .leftToRightOfView(donutView, offset: 16) + .right(12) + + topStack.axis = .vertical + topStack.spacing = 10 + topStack.alignment = .fill + cardView.addSubview(topStack) + topStack.layoutChain + .topToBottomOfView(topTitleLab, offset: 10) + .leftToRightOfView(donutView, offset: 16) + .right(12) + .bottom(14) + } +} + +// MARK: - 环形图 + +private final class AppUsageDonutView: UIView { + + private var segmentLayers: [CAShapeLayer] = [] + private var ratios: [CGFloat] = [] + private var colors: [UIColor] = [] + private let lineWidth: CGFloat = 14 + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSegments(ratios: [CGFloat], colors: [UIColor]) { + self.ratios = ratios + self.colors = colors + redraw() + } + + private func redraw() { + segmentLayers.forEach { $0.removeFromSuperlayer() } + segmentLayers.removeAll() + guard !ratios.isEmpty, bounds.width > 0 else { return } + + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 + var startAngle = -CGFloat.pi / 2 + let total = max(ratios.reduce(0, +), 0.001) + + for (i, ratio) in ratios.enumerated() { + let sweep = (ratio / total) * CGFloat.pi * 2 + guard sweep > 0.001 else { continue } + let path = UIBezierPath(arcCenter: center, + radius: radius, + startAngle: startAngle, + endAngle: startAngle + sweep, + clockwise: true) + let layer = CAShapeLayer() + layer.path = path.cgPath + layer.strokeColor = colors[min(i, colors.count - 1)].cgColor + layer.fillColor = UIColor.clear.cgColor + layer.lineWidth = lineWidth + layer.lineCap = .butt + self.layer.addSublayer(layer) + segmentLayers.append(layer) + startAngle += sweep + } + } + + override func layoutSubviews() { + super.layoutSubviews() + redraw() + } +} + +// MARK: - TOP 行 + +private final class AppUsageTopRowView: UIView { + + private var progressRatio: CGFloat = 0 + private var progressWidthConstraint: NSLayoutConstraint? + private let rankLab = UILabel() + private let iconView = UIView() + private let iconLabel = UILabel() + private let nameLab = UILabel() + private let progressTrack = UIView() + private let progressFill = UIView() + private let durationLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with item: MemberAppUsageItemPreview) { + rankLab.text = "\(item.rank)" + nameLab.text = item.name + durationLab.text = item.durationText + iconView.backgroundColor = UIColor(hexStr: item.tintHex) + iconLabel.text = String(item.name.prefix(1)) + progressRatio = max(0.06, min(1, item.progress)) + setNeedsLayout() + } + + private func setupUI() { + rankLab.font = .systemFont(ofSize: 10, weight: .bold) + rankLab.textColor = UIColor(hexStr: "#353B4F") + rankLab.textAlignment = .center + addSubview(rankLab) + rankLab.layoutChain + .left() + .centerY() + .width(14) + + iconView.cornerRadius = 8 + addSubview(iconView) + iconView.layoutChain + .leftToRightOfView(rankLab, offset: 4) + .centerY() + .width(16) + .height(16) + + iconLabel.font = .systemFont(ofSize: 9, weight: .bold) + iconLabel.textColor = .white + iconLabel.textAlignment = .center + iconView.addSubview(iconLabel) + iconLabel.layoutChain.centerX().centerY() + + durationLab.font = .systemFont(ofSize: 10, weight: .medium) + durationLab.textColor = UIColor(hexStr: "#767676") + durationLab.setContentCompressionResistancePriority(.required, for: .horizontal) + addSubview(durationLab) + durationLab.layoutChain + .right() + .centerY() + + nameLab.font = .systemFont(ofSize: 11, weight: .medium) + nameLab.textColor = UIColor(hexStr: "#353B4F") + addSubview(nameLab) + nameLab.layoutChain + .leftToRightOfView(iconView, offset: 6) + .top() + .rightToLeftOfView(durationLab, offset: -6) + + progressTrack.backgroundColor = UIColor(hexStr: "#F0F0F0") + progressTrack.cornerRadius = 2 + addSubview(progressTrack) + progressTrack.layoutChain + .leftToView(nameLab) + .rightToView(durationLab) + .topToBottomOfView(nameLab, offset: 4) + .height(4) + .bottom() + + progressFill.backgroundColor = UIColor(hexStr: "#58EDFF") + progressFill.cornerRadius = 2 + progressTrack.addSubview(progressFill) + progressFill.layoutChain + .left() + .edgesVertical() + progressWidthConstraint = progressFill.widthAnchor.constraint(equalTo: progressTrack.widthAnchor, multiplier: 0) + progressWidthConstraint?.isActive = true + } + + override func layoutSubviews() { + super.layoutSubviews() + progressWidthConstraint?.isActive = false + progressWidthConstraint = progressFill.widthAnchor.constraint( + equalTo: progressTrack.widthAnchor, + multiplier: progressRatio + ) + progressWidthConstraint?.isActive = true + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift new file mode 100644 index 00000000..3932d235 --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailVC.swift @@ -0,0 +1,119 @@ +// +// PhoneReportDetailVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import ObjectMapper + +final class PhoneReportDetailVC: BaseViewController { + + private var rootView: PhoneReportDetailView! + private let viewModel: PhoneReportDetailViewModel + private var groupModel: GroupModel? + + init(members: [GroupMemberModel], selectedUserId: String, groupModel: GroupModel?) { + self.viewModel = PhoneReportDetailViewModel(members: members, selectedUserId: selectedUserId) + self.groupModel = groupModel + super.init(nibName: nil, bundle: nil) + } + + convenience init(userInfo: [String: Any]) { + let rawMembers = userInfo["members"] + let membersJson: [[String: Any]] + if let arr = rawMembers as? [[String: Any]] { + membersJson = arr + } else if let arr = rawMembers as? [Any] { + membersJson = arr.compactMap { $0 as? [String: Any] } + } else { + membersJson = [] + } + let members = membersJson.compactMap { GroupMemberModel(JSON: $0) } + let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? "" + let group = (userInfo["groupJson"] as? [String: Any]).flatMap { GroupModel(JSON: $0) } + self.init(members: members, selectedUserId: selected, groupModel: group) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + rootView = PhoneReportDetailView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.memberCV.dataSource = self + rootView.memberCV.delegate = self + bind() + rootView.memberCV.reloadData() + } + + private func bind() { + rootView.navView.onBackTapped = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + + rootView.switchGroupBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.switchGroup() + }) + .disposed(by: disposeBag) + + viewModel.selectedMemberId + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.rootView.memberCV.reloadData() + }) + .disposed(by: disposeBag) + + viewModel.snapshot + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] snap in + self?.rootView.apply(snap) + }) + .disposed(by: disposeBag) + + rootView.screenSection.onModeChanged = { [weak self] mode in + self?.viewModel.screenMode.accept(mode) + } + rootView.unlockSection.onModeChanged = { [weak self] mode in + self?.viewModel.unlockMode.accept(mode) + } + } + + private func switchGroup() { + guard let groupModel else { return } + GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in + guard let self, let key = groupKey else { return } + GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) + .subscribe(onNext: { [weak self] _ in + NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) + self?.navigationController?.popViewController(animated: true) + }) + .disposed(by: self.disposeBag) + } + } +} + +extension PhoneReportDetailVC: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + viewModel.members.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) + let model = viewModel.members[indexPath.item] + let selected = model.user_id == viewModel.selectedMemberId.value + let isMe = model.user_id == AppContextManager.shared.userId + cell.configure(model: model, isCurrentUser: isMe, isSelected: selected) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let model = viewModel.members[indexPath.item] + viewModel.selectedMemberId.accept(model.user_id) + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift new file mode 100644 index 00000000..96be3d7c --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift @@ -0,0 +1,497 @@ +// +// PhoneReportDetailView.swift +// QuickLocation +// + +import UIKit + +final class PhoneReportDetailView: UIView { + + lazy var navView: BaseNavigationView = { + let nav = BaseNavigationView(title: "手机报告") + return nav + }() + + lazy var navBgView: UIImageView = { + let iv = UIImageView() + iv.image = UIImage(named: "Common/navBar_bg_2") + iv.contentMode = .scaleAspectFill + return iv + }() + + let switchGroupBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.backgroundColor = .white.withAlphaComponent(0.8) + btn.setTitle(" 切换圈子 ", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) + btn.cornerRadius = 8 + btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 15, right: 18) + return btn + }() + + lazy var memberCV: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 61, height: 90) + layout.minimumLineSpacing = 12 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .white + cv.showsHorizontalScrollIndicator = false + cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + cv.register(GroupMemberListCell.self) + cv.applyPhoneReportCardStyle(cornerRadius: 20) + return cv + }() + + let scrollView = UIScrollView() + let contentView = UIView() + + // MARK: Status + let statusTitleLab = UILabel() + let statusCard = UIView() + let batteryRing = PhoneBatteryRingView() + let estimateLab = UILabel() + let brightnessCard = PhoneStatusMiniCard(iconName: "Home/phone_brightness", title: "亮度") + let volumeCard = PhoneStatusMiniCard(iconName: "Home/phone_volume", title: "音量") + let wifiCard = PhoneStatusMiniCard(iconName: "Home/phone_wifi", title: "WIFI") + let deviceCard = UIView() + let deviceNameLab = UILabel() + let storageLab = UILabel() + let storageBar = UIProgressView(progressViewStyle: .default) + let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device")) + + // MARK: Charts + let screenSection = PhoneReportChartSectionView( + title: "屏幕使用时间", + summaryPrefix: "今日使用时长", + barColor: UIColor(hexStr: "#5EC8FF"), + highlightColor: UIColor(hexStr: "#2F9BFF") + ) + let unlockSection = PhoneReportChartSectionView( + title: "手机使用次数", + summaryPrefix: "今日使用次数", + barColor: UIColor(hexStr: "#B7E86A"), + highlightColor: UIColor(hexStr: "#83DE00"), + valueSuffix: "次" + ) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#FAFAFA") + setupUI() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupUI() { + addSubview(navBgView) + addSubview(navView) + + navBgView.layoutChain + .edges(excludingEdge: .bottom) + .heightToWidth(160/375) + + navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) + navView.addRightButton(switchGroupBtn) + + addSubview(memberCV) + memberCV.layoutChain + .topToBottomOfView(navView, offset: 8) + .edgesHorzontal(15) + .height(100) + + addSubview(scrollView) + scrollView.showsVerticalScrollIndicator = false + scrollView.layoutChain + .topToBottomOfView(memberCV, offset: 12) + .edgesHorzontal() + .bottom() + scrollView.addSubview(contentView) + contentView.layoutChain.edges().widthToView(scrollView) + + statusTitleLab.text = "手机状态" + statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold) + statusTitleLab.textColor = UIColor(hexStr: "#293445") + contentView.addSubview(statusTitleLab) + statusTitleLab.layoutChain.top().left(15) + + statusCard.backgroundColor = .white + statusCard.applyPhoneReportCardStyle(cornerRadius: 20) + contentView.addSubview(statusCard) + statusCard.layoutChain + .topToBottomOfView(statusTitleLab, offset: 10) + .edgesHorzontal(15) + .height(175) + + let leftPanel = UIView() + leftPanel.backgroundColor = .clear + leftPanel.cornerRadius = 14 + statusCard.addSubview(leftPanel) + leftPanel.layoutChain + .edges(excludingEdge: .right) + .width(136) + + leftPanel.addSubview(batteryRing) + batteryRing.layoutChain.top(23).centerX().width(100).height(100) + + estimateLab.font = .systemFont(ofSize: 12, weight: .medium) + estimateLab.textColor = UIColor(hexStr: "#6B7280") + estimateLab.textAlignment = .center + estimateLab.backgroundColor = UIColor(hexStr: "#E8EEF5") + estimateLab.cornerRadius = 10 + leftPanel.addSubview(estimateLab) + estimateLab.layoutChain + .topToBottomOfView(batteryRing, offset: 12) + .centerX() + .height(22) + .width(120) + + let rightStack = UIStackView(arrangedSubviews: [brightnessCard, volumeCard, wifiCard]) + rightStack.axis = .horizontal + rightStack.distribution = .fillEqually + rightStack.spacing = 8 + statusCard.addSubview(rightStack) + rightStack.layoutChain + .top(12) + .leftToRightOfView(leftPanel, offset: 10) + .right(12) + .height(64) + + deviceCard.backgroundColor = UIColor(hexStr: "#F7FBFF") + deviceCard.cornerRadius = 12 + statusCard.addSubview(deviceCard) + deviceCard.layoutChain + .topToBottomOfView(rightStack, offset: 8) + .leftToView(rightStack) + .right(12) + .bottom(12) + + deviceNameLab.font = .systemFont(ofSize: 13, weight: .bold) + deviceNameLab.textColor = UIColor(hexStr: "#293445") + deviceCard.addSubview(deviceNameLab) + deviceNameLab.layoutChain.top(10).left(10).right(60) + + storageLab.font = .systemFont(ofSize: 11, weight: .medium) + storageLab.textColor = UIColor(hexStr: "#6B7280") + deviceCard.addSubview(storageLab) + storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10) + + storageBar.progressTintColor = UIColor(hexStr: "#5EC8FF") + storageBar.trackTintColor = UIColor(hexStr: "#E5E7EB") + deviceCard.addSubview(storageBar) + storageBar.layoutChain + .topToBottomOfView(storageLab, offset: 6) + .left(10) + .right(60) + .height(4) + .bottom(10) + + deviceImage.contentMode = .scaleAspectFit + deviceCard.addSubview(deviceImage) + deviceImage.layoutChain.right(8).centerY().width(36).height(54) + + contentView.addSubview(screenSection) + screenSection.layoutChain + .topToBottomOfView(statusCard, offset: 16) + .edgesHorzontal(15) + + contentView.addSubview(unlockSection) + unlockSection.layoutChain + .topToBottomOfView(screenSection, offset: 16) + .edgesHorzontal(15) + .bottom(24) + } + + func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) { + batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText) + estimateLab.text = "预计使用\(snap.estimateText)" + brightnessCard.setValue(snap.brightnessText) + volumeCard.setValue(snap.volumeText) + wifiCard.setValue(snap.wifiText) + deviceNameLab.text = snap.deviceText + storageLab.text = snap.storageText + storageBar.progress = Float(snap.storageRatio) + screenSection.setSummary("\(screenSection.summaryPrefix) \(snap.screenTodayText)") + unlockSection.setSummary("\(unlockSection.summaryPrefix) \(snap.unlockTodayText)次") + screenSection.setBars(snap.screenBars) + unlockSection.setBars(snap.unlockBars) + } +} + +private extension UIView { + func applyPhoneReportCardStyle(cornerRadius: CGFloat) { + layer.cornerRadius = cornerRadius + clipsToBounds = false + layer.masksToBounds = false + layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.1).cgColor + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.shadowOpacity = 1 + layer.shadowRadius = 10 + } +} + +// MARK: - Subviews + +final class PhoneBatteryRingView: UIView { + private let track = CAShapeLayer() + private let progress = CAShapeLayer() + private let valueLab = UILabel() + private let bolt = UIImageView(image: UIImage(systemName: "bolt.fill")) + + override init(frame: CGRect) { + super.init(frame: frame) + track.fillColor = UIColor.clear.cgColor + track.strokeColor = UIColor(hexStr: "#DEFFD8").cgColor + track.lineWidth = 11 + layer.addSublayer(track) + progress.fillColor = UIColor.clear.cgColor + progress.strokeColor = UIColor(hexStr: "#27DB4B").cgColor + progress.lineWidth = 11 + progress.lineCap = .round + progress.strokeEnd = 0 + layer.addSublayer(progress) + + bolt.tintColor = UIColor(hexStr: "#27DB4B") + bolt.contentMode = .scaleAspectFit + addSubview(bolt) + valueLab.font = .systemFont(ofSize: 16, weight: .heavy) + valueLab.textColor = UIColor(hexStr: "#293445") + valueLab.textAlignment = .center + addSubview(valueLab) + valueLab.layoutChain.centerX().centerY() + bolt.layoutChain.centerY(valueLab).rightToLeftOfView(valueLab) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let radius = min(bounds.width, bounds.height) / 2 - 6 + let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: true) + track.path = path.cgPath + progress.path = path.cgPath + } + + func configure(percent: Int?, text: String) { + valueLab.text = text + progress.strokeEnd = CGFloat(max(0, min(100, percent ?? 0))) / 100 + } +} + +final class PhoneStatusMiniCard: UIView { + private let icon = UIImageView() + private let titleLab = UILabel() + private let valueLab = UILabel() + + init(iconName: String, title: String) { + super.init(frame: .zero) + backgroundColor = UIColor(hexStr: "#F7FBFF") + cornerRadius = 12 + icon.image = UIImage(named: iconName) + icon.contentMode = .scaleAspectFit + addSubview(icon) + icon.layoutChain.top(7).left(5).width(16).height(16) + titleLab.text = title + titleLab.font = .systemFont(ofSize: 10, weight: .medium) + titleLab.textColor = UIColor(hexStr: "#AAAAAA") + titleLab.textAlignment = .center + addSubview(titleLab) + titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5) + valueLab.font = .systemFont(ofSize: 16, weight: .medium) + valueLab.textColor = UIColor(hexStr: "#293445") + valueLab.textAlignment = .center + addSubview(valueLab) + valueLab.layoutChain.topToBottomOfView(icon, offset: 19).edgesHorzontal(2).bottom(16) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func setValue(_ text: String) { + valueLab.text = text + } +} + +final class PhoneReportChartSectionView: UIView { + let summaryPrefix: String + private let titleLab = UILabel() + private let summaryLab = UILabel() + private let dayBtn = UIButton(type: .system) + private let weekBtn = UIButton(type: .system) + private let chart = PhoneReportBarChartView() + private let barColor: UIColor + private let highlightColor: UIColor + private let valueSuffix: String + + var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)? + + init(title: String, summaryPrefix: String, barColor: UIColor, highlightColor: UIColor, valueSuffix: String = "") { + self.summaryPrefix = summaryPrefix + self.barColor = barColor + self.highlightColor = highlightColor + self.valueSuffix = valueSuffix + super.init(frame: .zero) + titleLab.text = title + titleLab.font = .systemFont(ofSize: 16, weight: .bold) + titleLab.textColor = UIColor(hexStr: "#293445") + addSubview(titleLab) + titleLab.layoutChain.top().left() + + let card = UIView() + card.backgroundColor = .white + card.applyPhoneReportCardStyle(cornerRadius: 20) + addSubview(card) + card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180) + + summaryLab.font = .systemFont(ofSize: 13, weight: .medium) + summaryLab.textColor = UIColor(hexStr: "#293445") + card.addSubview(summaryLab) + summaryLab.layoutChain.top(14).left(14) + + configureToggle(dayBtn, title: "天", selected: true) + configureToggle(weekBtn, title: "周", selected: false) + let toggle = UIStackView(arrangedSubviews: [weekBtn, dayBtn]) + toggle.axis = .horizontal + toggle.spacing = 0 + toggle.backgroundColor = UIColor(hexStr: "#F3F4F6") + toggle.cornerRadius = 12 + card.addSubview(toggle) + toggle.layoutChain.centerY(summaryLab).right(14).height(28) + + dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside) + weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside) + + card.addSubview(chart) + chart.layoutChain.topToBottomOfView(summaryLab, offset: 16).edgesHorzontal(14).bottom(14) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func configureToggle(_ btn: UIButton, title: String, selected: Bool) { + btn.setTitle(title, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold) + btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + applyToggle(btn, selected: selected) + } + + private func applyToggle(_ btn: UIButton, selected: Bool) { + btn.backgroundColor = selected ? .white : .clear + btn.setTitleColor(selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF"), for: .normal) + btn.cornerRadius = 12 + } + + @objc private func tapDay() { + applyToggle(dayBtn, selected: true) + applyToggle(weekBtn, selected: false) + onModeChanged?(.day) + } + + @objc private func tapWeek() { + applyToggle(dayBtn, selected: false) + applyToggle(weekBtn, selected: true) + onModeChanged?(.week) + } + + func setSummary(_ text: String) { + summaryLab.text = text + } + + func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) { + chart.configure(bars: bars, color: barColor, highlight: highlightColor, suffix: valueSuffix) + } +} + +final class PhoneReportBarChartView: UIView { + private var bars: [PhoneReportDetailViewModel.ChartBar] = [] + private var color: UIColor = .systemBlue + private var highlight: UIColor = .systemBlue + private var suffix = "" + private var barLayers: [CALayer] = [] + private var labelViews: [UILabel] = [] + private var tipLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + tipLab.font = .systemFont(ofSize: 11, weight: .bold) + tipLab.textColor = .white + tipLab.backgroundColor = UIColor(hexStr: "#2F9BFF") + tipLab.textAlignment = .center + tipLab.cornerRadius = 8 + tipLab.clipsToBounds = true + tipLab.isHidden = true + addSubview(tipLab) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(bars: [PhoneReportDetailViewModel.ChartBar], color: UIColor, highlight: UIColor, suffix: String) { + self.bars = bars + self.color = color + self.highlight = highlight + self.suffix = suffix + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + barLayers.forEach { $0.removeFromSuperlayer() } + barLayers.removeAll() + labelViews.forEach { $0.removeFromSuperview() } + labelViews.removeAll() + tipLab.isHidden = true + guard !bars.isEmpty else { return } + + let maxVal = max(bars.map(\.value).max() ?? 1, 1) + let count = bars.count + let labelH: CGFloat = 16 + let chartH = bounds.height - labelH - 24 + let spacing: CGFloat = 8 + let barW = max(8, (bounds.width - spacing * CGFloat(count - 1)) / CGFloat(count)) + + for (i, bar) in bars.enumerated() { + let ratio = CGFloat(bar.value) / CGFloat(maxVal) + let h = max(4, chartH * max(0.05, ratio)) + let x = CGFloat(i) * (barW + spacing) + let y = bounds.height - labelH - h + let layer = CALayer() + layer.frame = CGRect(x: x, y: y, width: barW, height: h) + layer.backgroundColor = (bar.isToday ? highlight : color.withAlphaComponent(0.35)).cgColor + layer.cornerRadius = 4 + self.layer.addSublayer(layer) + barLayers.append(layer) + + let lab = UILabel() + lab.text = bar.label + lab.font = .systemFont(ofSize: 10, weight: .medium) + lab.textColor = UIColor(hexStr: "#9CA3AF") + lab.textAlignment = .center + lab.frame = CGRect(x: x - 6, y: bounds.height - labelH, width: barW + 12, height: labelH) + addSubview(lab) + labelViews.append(lab) + + if bar.isToday, bar.value > 0 { + let hours = bar.value / 3600 + let mins = (bar.value % 3600) / 60 + let text: String + if suffix == "次" { + text = "\(bar.value)次" + } else if hours > 0 { + text = mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" + } else { + text = "\(mins)m" + } + tipLab.text = " \(text) " + tipLab.sizeToFit() + tipLab.frame = CGRect( + x: max(0, x + barW / 2 - tipLab.bounds.width / 2 - 6), + y: max(0, y - 22), + width: tipLab.bounds.width + 12, + height: 18 + ) + tipLab.backgroundColor = highlight + tipLab.isHidden = false + } + } + } +} diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift new file mode 100644 index 00000000..7c92f1d2 --- /dev/null +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel.swift @@ -0,0 +1,362 @@ +// +// PhoneReportDetailViewModel.swift +// QuickLocation +// + +import Foundation +import RxSwift +import RxCocoa +import UIKit +import AVFoundation +import SystemConfiguration.CaptiveNetwork +import NetworkExtension +import CoreLocation +import SwiftyUserDefaults + +final class PhoneReportDetailViewModel { + + enum ChartMode: Equatable { + case day + case week + } + + struct ChartBar { + let label: String + let value: Int + let isToday: Bool + } + + struct StatusSnapshot { + let batteryText: String + let batteryPercent: Int? + let estimateText: String + let brightnessText: String + let volumeText: String + let wifiText: String + let deviceText: String + let storageText: String + let storageRatio: CGFloat + let screenTodayText: String + let unlockTodayText: String + let usageTodayText: String + let screenBars: [ChartBar] + let unlockBars: [ChartBar] + + func replacing(wifiText: String? = nil, volumeText: String? = nil) -> StatusSnapshot { + StatusSnapshot( + batteryText: batteryText, + batteryPercent: batteryPercent, + estimateText: estimateText, + brightnessText: brightnessText, + volumeText: volumeText ?? self.volumeText, + wifiText: wifiText ?? self.wifiText, + deviceText: deviceText, + storageText: storageText, + storageRatio: storageRatio, + screenTodayText: screenTodayText, + unlockTodayText: unlockTodayText, + usageTodayText: usageTodayText, + screenBars: screenBars, + unlockBars: unlockBars + ) + } + } + + let members: [GroupMemberModel] + let selectedMemberId: BehaviorRelay + let screenMode = BehaviorRelay(value: .day) + let unlockMode = BehaviorRelay(value: .day) + let snapshot: BehaviorRelay + + private let disposeBag = DisposeBag() + private let placeholder = "**" + private var volumeObservation: NSKeyValueObservation? + + init(members: [GroupMemberModel], selectedUserId: String) { + self.members = members + self.selectedMemberId = BehaviorRelay(value: selectedUserId) + self.snapshot = BehaviorRelay(value: Self.emptySnapshot()) + bindInputs() + refresh(userId: selectedUserId) + } + + private func bindInputs() { + selectedMemberId + .asObservable() + .skip(1) + .subscribe(onNext: { [weak self] userId in + self?.refresh(userId: userId) + }) + .disposed(by: disposeBag) + + screenMode + .asObservable() + .skip(1) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + self.refresh(userId: self.selectedMemberId.value) + }) + .disposed(by: disposeBag) + + unlockMode + .asObservable() + .skip(1) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + self.refresh(userId: self.selectedMemberId.value) + }) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(.unlockCountDidChange) + .subscribe(onNext: { [weak self] _ in + guard let self else { return } + self.refresh(userId: self.selectedMemberId.value) + }) + .disposed(by: disposeBag) + + observeSystemVolume() + } + + private func observeSystemVolume() { + let session = AVAudioSession.sharedInstance() + try? session.setActive(true) + volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, _ in + DispatchQueue.main.async { + guard let self else { return } + guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } + let volume = Self.volumeText() ?? self.placeholder + self.snapshot.accept(self.snapshot.value.replacing(volumeText: volume)) + } + } + } + + private func refresh(userId: String) { + let isSelf = userId == AppContextManager.shared.userId + let member = members.first { $0.user_id == userId } + + if isSelf { + snapshot.accept(makeSelfSnapshot()) + } else { + snapshot.accept(makeOtherSnapshot(member: member)) + } + } + + private func makeSelfSnapshot() -> StatusSnapshot { + let mgr = UnlockCountManager.shared + UIDevice.current.isBatteryMonitoringEnabled = true + let batteryLevel = UIDevice.current.batteryLevel + let batteryValid = batteryLevel >= 0 + let battery = Int(batteryLevel * 100) + let screenSec = mgr.todayScreenTimeSeconds + let unlock = mgr.todayCount + let usage = mgr.todayAppUsageCount + + let batteryText = batteryValid ? "\(battery)%" : placeholder + let batteryPercent: Int? = batteryValid ? battery : nil + let brightnessText = Self.brightnessText() ?? placeholder + let volumeText = Self.volumeText() ?? placeholder + let wifiText = Self.wifiSSID() ?? placeholder + let storageText = Self.storageText() ?? placeholder + let storageRatio = Self.storageRatio() ?? 0 + let screenTodayText = screenSec > 0 ? Self.formatDuration(screenSec) : placeholder + let screenBars = Self.screenBars(from: mgr, mode: screenMode.value) + let unlockBars = Self.unlockBars(unlock: unlock, mode: unlockMode.value) + + // 异步补拉 SSID(iOS 14+),成功后再刷新一次 + Self.fetchWifiSSIDAsync { [weak self] ssid in + guard let self, let ssid, !ssid.isEmpty else { return } + guard self.selectedMemberId.value == AppContextManager.shared.userId else { return } + let current = self.snapshot.value + guard current.wifiText != ssid else { return } + self.snapshot.accept(current.replacing(wifiText: ssid)) + } + + return StatusSnapshot( + batteryText: batteryText, + batteryPercent: batteryPercent, + estimateText: placeholder, + brightnessText: brightnessText, + volumeText: volumeText, + wifiText: wifiText, + deviceText: UIDevice.current.model, + storageText: storageText, + storageRatio: storageRatio, + screenTodayText: screenTodayText, + unlockTodayText: "\(unlock)", + usageTodayText: "\(usage)", + screenBars: screenBars, + unlockBars: unlockBars + ) + } + + private func makeOtherSnapshot(member: GroupMemberModel?) -> StatusSnapshot { + let battery = Int(member?.battery.int ?? 0) + let hasBattery = battery > 0 + return StatusSnapshot( + batteryText: hasBattery ? "\(battery)%" : placeholder, + batteryPercent: hasBattery ? battery : nil, + estimateText: placeholder, + brightnessText: placeholder, + volumeText: placeholder, + wifiText: placeholder, + deviceText: placeholder, + storageText: placeholder, + storageRatio: 0, + screenTodayText: placeholder, + unlockTodayText: placeholder, + usageTodayText: placeholder, + screenBars: [], + unlockBars: [] + ) + } + + private static func emptySnapshot() -> StatusSnapshot { + StatusSnapshot( + batteryText: "**", + batteryPercent: nil, + estimateText: "**", + brightnessText: "**", + volumeText: "**", + wifiText: "**", + deviceText: "**", + storageText: "**", + storageRatio: 0, + screenTodayText: "**", + unlockTodayText: "**", + usageTodayText: "**", + screenBars: [], + unlockBars: [] + ) + } + + private static func formatDuration(_ seconds: Int) -> String { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } + return "\(minutes)m" + } + + private static func brightnessText() -> String? { + let value = UIScreen.main.brightness + guard value >= 0 else { return nil } + return "\(Int(value * 100))%" + } + + private static func volumeText() -> String? { + let session = AVAudioSession.sharedInstance() + do { + try session.setActive(true) + } catch { + // 激活失败仍尝试读取当前值 + } + let volume = session.outputVolume + guard volume >= 0 else { return nil } + return "\(Int((volume * 100).rounded()))%" + } + + private static func hasLocationPermissionForWifi() -> Bool { + let status = CLLocationManager.authorizationStatus() + switch status { + case .authorizedAlways, .authorizedWhenInUse: + return true + default: + return false + } + } + + private static func wifiSSID() -> String? { + guard hasLocationPermissionForWifi() else { return nil } + guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil } + for name in interfaces { + if let info = CNCopyCurrentNetworkInfo(name as CFString) as? [String: Any], + let ssid = info[kCNNetworkInfoKeySSID as String] as? String, + !ssid.isEmpty { + return ssid + } + } + return nil + } + + /// iOS 14+ 异步获取当前 Wi‑Fi SSID(需定位权限 + Access Wi‑Fi Information) + private static func fetchWifiSSIDAsync(completion: @escaping (String?) -> Void) { + guard hasLocationPermissionForWifi() else { + completion(nil) + return + } + if #available(iOS 14.0, *) { + NEHotspotNetwork.fetchCurrent { network in + DispatchQueue.main.async { + let ssid = network?.ssid + completion((ssid?.isEmpty == false) ? ssid : nil) + } + } + } else { + completion(wifiSSID()) + } + } + + private static func storageText() -> String? { + guard + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), + let total = attrs[.systemSize] as? NSNumber, + let free = attrs[.systemFreeSize] as? NSNumber + else { return nil } + let used = total.doubleValue - free.doubleValue + let usedGB = used / 1_073_741_824 + let totalGB = total.doubleValue / 1_073_741_824 + return String(format: "%.1fGB/%.0fGB", usedGB, totalGB) + } + + private static func storageRatio() -> CGFloat? { + guard + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), + let total = attrs[.systemSize] as? NSNumber, + let free = attrs[.systemFreeSize] as? NSNumber, + total.doubleValue > 0 + else { return nil } + return CGFloat((total.doubleValue - free.doubleValue) / total.doubleValue) + } + + private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let byDay = Defaults[\.screenTimeByDay] + + return (0..<7).map { offset -> ChartBar in + let dayOffset = offset - 6 + let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today + let key = formatter.string(from: date) + let label = formatter.string(from: date).suffix(5).replacingOccurrences(of: "-", with: "/") + let value: Int + if dayOffset == 0 { + value = mgr.todayScreenTimeSeconds + } else { + value = byDay[key] ?? 0 + } + // week mode: keep same day bars for now (UI toggle still works) + _ = mode + return ChartBar(label: String(label), value: value, isToday: dayOffset == 0) + } + } + + private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let formatter = DateFormatter() + formatter.dateFormat = "M/d" + return (0..<7).map { offset -> ChartBar in + let dayOffset = offset - 6 + let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today + let isToday = dayOffset == 0 + _ = mode + return ChartBar( + label: formatter.string(from: date), + value: isToday ? unlock : 0, + isToday: isToday + ) + } + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift new file mode 100644 index 00000000..533e8428 --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift @@ -0,0 +1,284 @@ +// +// TodayTrackDetailVC.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa +import AMapNaviKit +import SwiftDate +import ObjectMapper + +final class TodayTrackDetailVC: BaseViewController { + + private var rootView: TodayTrackDetailView! + private let viewModel: TodayTrackDetailViewModel + private var routeOverlays: [MAPolyline] = [] + private var panStartHeight: CGFloat = 0 + private var stayList: [StayPoint] = [] + + init(members: [GroupMemberModel], selectedUserId: String) { + self.viewModel = TodayTrackDetailViewModel(members: members, selectedUserId: selectedUserId) + super.init(nibName: nil, bundle: nil) + } + + convenience init(userInfo: [String: Any]) { + let rawMembers = userInfo["members"] + let membersJson: [[String: Any]] + if let arr = rawMembers as? [[String: Any]] { + membersJson = arr + } else if let arr = rawMembers as? [Any] { + membersJson = arr.compactMap { $0 as? [String: Any] } + } else { + membersJson = [] + } + let members = membersJson.compactMap { GroupMemberModel(JSON: $0) } + let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? "" + self.init(members: members, selectedUserId: selected) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + rootView = TodayTrackDetailView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + rootView.mapView.delegate = self + rootView.memberCV.dataSource = self + rootView.memberCV.delegate = self + rootView.dateCV.dataSource = self + rootView.dateCV.delegate = self + rootView.timelineTV.dataSource = self + rootView.timelineTV.delegate = self + bindActions() + bindSheetPan() + rootView.memberCV.reloadData() + rootView.dateCV.reloadData() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + rootView.cleanupMap() + } + } + + private func bindActions() { + rootView.backBtn.rx.tap + .subscribe(onNext: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }) + .disposed(by: disposeBag) + + viewModel.trajectoryPoints + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] points in + self?.drawRoute(points) + }) + .disposed(by: disposeBag) + + viewModel.isEmpty + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] empty in + self?.rootView.emptyLab.isHidden = !empty + }) + .disposed(by: disposeBag) + + viewModel.stayPoints + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] stays in + self?.stayList = stays + self?.rootView.timelineTV.reloadData() + }) + .disposed(by: disposeBag) + + viewModel.selectedMemberId + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.rootView.memberCV.reloadData() + }) + .disposed(by: disposeBag) + + viewModel.selectedDateKey + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + self?.rootView.dateCV.reloadData() + }) + .disposed(by: disposeBag) + } + + private func bindSheetPan() { + let pan = UIPanGestureRecognizer() + rootView.sheetView.addGestureRecognizer(pan) + pan.rx.event.subscribe(onNext: { [weak self] gesture in + guard let self, let constraint = self.rootView.sheetHeightConstraint else { return } + let minH: CGFloat = 220 + kSafeBottomMargin + let maxH: CGFloat = UIScreen.main.bounds.height * 0.7 + switch gesture.state { + case .began: + self.panStartHeight = constraint.constant + case .changed: + let dy = gesture.translation(in: self.rootView).y + constraint.constant = min(maxH, max(minH, self.panStartHeight - dy)) + case .ended, .cancelled: + let mid = (minH + maxH) / 2 + let target = constraint.constant > mid ? maxH : minH + 100 + UIView.animate(withDuration: 0.25) { + constraint.constant = target + self.rootView.layoutIfNeeded() + } + default: + break + } + }).disposed(by: disposeBag) + } + + private func drawRoute(_ points: [TrackPoint]) { + rootView.mapView.removeOverlays(routeOverlays) + routeOverlays.removeAll() + if let anns = rootView.mapView.annotations { + rootView.mapView.removeAnnotations(anns) + } + + let coords = points + .map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lng) } + .filter { CLLocationCoordinate2DIsValid($0) } + guard coords.count >= 2 else { + if let first = coords.first { + rootView.mapView.setCenter(first, animated: true) + } + return + } + + var mutable = coords + if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) { + rootView.mapView.add(polyline) + routeOverlays.append(polyline) + let padding = UIEdgeInsets(top: 160, left: 40, bottom: 360, right: 40) + rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true) + } + if let start = coords.first { + let ann = MAPointAnnotation() + ann.coordinate = start + ann.title = "始" + rootView.mapView.addAnnotation(ann) + } + } + + private static func stayTitle(_ stay: StayPoint, isCurrent: Bool) -> String { + let addr = stay.address.isEmpty ? "**" : stay.address + if isCurrent { + return "当前 \(addr)" + } + let time = Date(timeIntervalSince1970: TimeInterval(stay.start_time)).toFormat("HH:mm") + return "\(time) \(addr)" + } + + private static func stayTags(_ stay: StayPoint, isCurrent: Bool) -> [String] { + let seconds = max(0, Int(stay.end_time - stay.start_time)) + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let duration: String + if hours > 0 { + duration = "停留 \(hours)小时\(minutes)分" + } else { + duration = "停留 \(minutes)分" + } + if isCurrent { + return [duration, "停留中..."] + } + return [duration] + } +} + +// MARK: - Collection / Table + +extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + if collectionView === rootView.memberCV { + return viewModel.members.count + } + return viewModel.dateItems.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + if collectionView === rootView.memberCV { + let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath) + let model = viewModel.members[indexPath.item] + let selected = model.user_id == viewModel.selectedMemberId.value + let isMe = model.user_id == AppContextManager.shared.userId + cell.configure(model: model, isCurrentUser: isMe, isSelected: selected) + return cell + } + let cell: TodayTrackDateCell = collectionView.dequeueReusableCell(for: indexPath) + let item = viewModel.dateItems[indexPath.item] + let selected = item.key == viewModel.selectedDateKey.value + cell.configure(title: item.title, selected: selected) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if collectionView === rootView.memberCV { + let model = viewModel.members[indexPath.item] + viewModel.selectedMemberId.accept(model.user_id) + return + } + let item = viewModel.dateItems[indexPath.item] + viewModel.selectedDateKey.accept(item.key) + } +} + +extension TodayTrackDetailVC: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + stayList.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TodayTrackStayCell = tableView.dequeueReusableCell(for: indexPath) + let stay = stayList[indexPath.row] + let isFirst = indexPath.row == 0 + let isLast = indexPath.row == stayList.count - 1 + cell.configure( + title: Self.stayTitle(stay, isCurrent: isFirst), + tags: Self.stayTags(stay, isCurrent: isFirst), + isFirst: isFirst, + isLast: isLast + ) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let stay = stayList[indexPath.row] + let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng) + guard CLLocationCoordinate2DIsValid(coord) else { return } + rootView.mapView.setCenter(coord, animated: true) + rootView.mapView.setZoomLevel(16, animated: true) + } +} + +extension TodayTrackDetailVC: MAMapViewDelegate { + func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { + guard let polyline = overlay as? MAPolyline else { return nil } + let renderer = MAPolylineRenderer(polyline: polyline) + renderer?.strokeColor = UIColor(hexStr: "#5EC8FF") + renderer?.lineWidth = 6 + return renderer + } + + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + guard !(annotation is MAUserLocation) else { return nil } + let id = "todayTrack.ann" + var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) + if view == nil { + view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) + } + view?.annotation = annotation + view?.image = UIImage(systemName: "mappin.circle.fill") + view?.centerOffset = CGPoint(x: 0, y: -12) + return view + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift new file mode 100644 index 00000000..bec2ea4b --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailView.swift @@ -0,0 +1,269 @@ +// +// TodayTrackDetailView.swift +// QuickLocation +// + +import UIKit +import AMapNaviKit + +final class TodayTrackDetailView: UIView { + + let mapView: MAMapView = { + let mv = MAMapView() + mv.zoomLevel = 14 + mv.showsUserLocation = false + mv.showsCompass = false + mv.showsScale = false + return mv + }() + + let backBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) + btn.tintColor = UIColor(hexStr: "#293445") + btn.backgroundColor = .white + btn.cornerRadius = 10 + return btn + }() + + let titleLab: UILabel = { + let lab = UILabel() + lab.text = "今日轨迹" + lab.font = .systemFont(ofSize: 17, weight: .semibold) + lab.textColor = UIColor(hexStr: "#293445") + lab.textAlignment = .center + return lab + }() + + lazy var memberCV: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 61, height: 90) + layout.minimumLineSpacing = 12 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .white + cv.showsHorizontalScrollIndicator = false + cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + cv.register(GroupMemberListCell.self) + cv.cornerRadius = 16 + return cv + }() + + let sheetView: UIView = { + let v = UIView() + v.backgroundColor = .white + v.layer.cornerRadius = 20 + v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + v.clipsToBounds = true + return v + }() + + let handleView: UIView = { + let v = UIView() + v.backgroundColor = UIColor(hexStr: "#E5E7EB") + v.cornerRadius = 2.5 + return v + }() + + lazy var dateCV: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.estimatedItemSize = CGSize(width: 56, height: 32) + layout.minimumLineSpacing = 8 + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = UIColor(hexStr: "#F3F4F6") + cv.showsHorizontalScrollIndicator = false + cv.contentInset = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + cv.cornerRadius = 18 + cv.register(TodayTrackDateCell.self) + return cv + }() + + lazy var timelineTV: UITableView = { + let tv = UITableView(frame: .zero, style: .plain) + tv.separatorStyle = .none + tv.backgroundColor = .clear + tv.showsVerticalScrollIndicator = false + tv.register(TodayTrackStayCell.self) + tv.rowHeight = UITableView.automaticDimension + tv.estimatedRowHeight = 72 + return tv + }() + + let emptyLab: UILabel = { + let lab = UILabel() + lab.text = "暂无轨迹" + lab.font = .systemFont(ofSize: 14, weight: .medium) + lab.textColor = UIColor(hexStr: "#9CA3AF") + lab.textAlignment = .center + lab.isHidden = true + return lab + }() + + private(set) var sheetHeightConstraint: NSLayoutConstraint? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F5F7FA") + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(mapView) + mapView.layoutChain.edges() + + addSubview(backBtn) + backBtn.layoutChain + .top(kStatusBarHeight + 6) + .left(15) + .width(36) + .height(36) + + addSubview(titleLab) + titleLab.layoutChain + .centerY(backBtn) + .centerX() + + addSubview(memberCV) + memberCV.layoutChain + .topToBottomOfView(backBtn, offset: 12) + .edgesHorzontal(15) + .height(100) + + addSubview(sheetView) + let sheetH = sheetView.heightAnchor.constraint(equalToConstant: 320 + kSafeBottomMargin) + sheetH.isActive = true + sheetHeightConstraint = sheetH + sheetView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sheetView.leadingAnchor.constraint(equalTo: leadingAnchor), + sheetView.trailingAnchor.constraint(equalTo: trailingAnchor), + sheetView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + sheetView.addSubview(handleView) + handleView.layoutChain + .top(8) + .centerX() + .width(36) + .height(5) + + sheetView.addSubview(dateCV) + dateCV.layoutChain + .topToBottomOfView(handleView, offset: 12) + .edgesHorzontal(15) + .height(36) + + sheetView.addSubview(timelineTV) + timelineTV.layoutChain + .topToBottomOfView(dateCV, offset: 12) + .edgesHorzontal(15) + .bottom(kSafeBottomMargin + 8) + + sheetView.addSubview(emptyLab) + emptyLab.layoutChain + .centerX() + .topToBottomOfView(dateCV, offset: 40) + } + + func cleanupMap() { + mapView.removeAnnotations(mapView.annotations) + mapView.removeOverlays(mapView.overlays) + mapView.delegate = nil + } +} + +// MARK: - Date cell + +final class TodayTrackDateCell: UICollectionViewCell { + private let titleLab = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .clear + contentView.cornerRadius = 14 + titleLab.font = .systemFont(ofSize: 13, weight: .semibold) + titleLab.textAlignment = .center + contentView.addSubview(titleLab) + titleLab.layoutChain.edges(UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(title: String, selected: Bool) { + titleLab.text = title + titleLab.textColor = selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF") + contentView.backgroundColor = selected ? .white : .clear + } +} + +// MARK: - Stay cell + +final class TodayTrackStayCell: UITableViewCell { + private let dot = UIView() + private let line = UIView() + private let titleLab = UILabel() + private let tagStack = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + dot.backgroundColor = UIColor(hexStr: "#16B3FF") + dot.cornerRadius = 5 + contentView.addSubview(dot) + dot.layoutChain.top(8).left().width(10).height(10) + + line.backgroundColor = UIColor(hexStr: "#E5E7EB") + contentView.addSubview(line) + line.layoutChain + .topToBottomOfView(dot, offset: 2) + .centerX(dot) + .width(2) + .bottom() + + titleLab.font = .systemFont(ofSize: 14, weight: .medium) + titleLab.textColor = UIColor(hexStr: "#293445") + titleLab.numberOfLines = 2 + contentView.addSubview(titleLab) + titleLab.layoutChain + .top(4) + .leftToRightOfView(dot, offset: 12) + .right() + + tagStack.axis = .horizontal + tagStack.spacing = 8 + contentView.addSubview(tagStack) + tagStack.layoutChain + .topToBottomOfView(titleLab, offset: 8) + .leftToView(titleLab) + .bottom(12) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(title: String, tags: [String], isFirst: Bool, isLast: Bool) { + titleLab.text = title + line.isHidden = isLast + dot.backgroundColor = isFirst ? UIColor(hexStr: "#16B3FF") : UIColor(hexStr: "#9CA3AF") + tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for tag in tags { + let lab = UILabel() + lab.text = tag + lab.font = .systemFont(ofSize: 12, weight: .medium) + lab.textColor = UIColor(hexStr: "#6B7280") + let wrap = UIView() + wrap.backgroundColor = UIColor(hexStr: "#F3F4F6") + wrap.cornerRadius = 8 + wrap.addSubview(lab) + lab.layoutChain.edges(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)) + tagStack.addArrangedSubview(wrap) + } + } +} diff --git a/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift new file mode 100644 index 00000000..55bf4302 --- /dev/null +++ b/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailViewModel.swift @@ -0,0 +1,123 @@ +// +// TodayTrackDetailViewModel.swift +// QuickLocation +// + +import Foundation +import RxSwift +import RxCocoa +import SwiftDate + +final class TodayTrackDetailViewModel { + + struct DateItem { + let date: Date + let title: String + let key: String + } + + let members: [GroupMemberModel] + let selectedMemberId: BehaviorRelay + let selectedDateKey: BehaviorRelay + let stayPoints = BehaviorRelay<[StayPoint]>(value: []) + let trajectoryPoints = BehaviorRelay<[TrackPoint]>(value: []) + let isEmpty = BehaviorRelay(value: true) + let loading = BehaviorRelay(value: false) + + private let disposeBag = DisposeBag() + let dateItems: [DateItem] + + init(members: [GroupMemberModel], selectedUserId: String, initialDate: Date = Date()) { + self.members = members + self.dateItems = Self.makeDateItems(around: initialDate) + self.selectedMemberId = BehaviorRelay(value: selectedUserId) + self.selectedDateKey = BehaviorRelay(value: Self.dayKey(initialDate)) + bindSelection() + reloadPlayback() + } + + private func bindSelection() { + selectedMemberId + .asObservable() + .skip(1) + .subscribe(onNext: { [weak self] _ in + self?.reloadPlayback() + }) + .disposed(by: disposeBag) + + selectedDateKey + .asObservable() + .skip(1) + .subscribe(onNext: { [weak self] _ in + self?.reloadPlayback() + }) + .disposed(by: disposeBag) + } + + private func reloadPlayback() { + let userId = selectedMemberId.value + let dateKey = selectedDateKey.value + loadPlayback(userId: userId, dateKey: dateKey) + } + + private func loadPlayback(userId: String, dateKey: String) { + if userId.isEmpty { + clearPlayback() + return + } + loading.accept(true) + DrivingService.playback(user_id: userId, date: dateKey) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] response in + self?.applyPlayback(response) + }, onError: { [weak self] _ in + self?.clearPlayback() + }) + .disposed(by: disposeBag) + } + + private func applyPlayback(_ response: ScheduleRecordListResponse) { + let trips = response.list + let stays = trips.flatMap { $0.stay_points }.sorted { lhs, rhs in + lhs.start_time > rhs.start_time + } + let path = trips.flatMap { $0.trajectory_path } + stayPoints.accept(stays) + trajectoryPoints.accept(path) + isEmpty.accept(stays.isEmpty && path.isEmpty) + loading.accept(false) + } + + private func clearPlayback() { + stayPoints.accept([]) + trajectoryPoints.accept([]) + isEmpty.accept(true) + loading.accept(false) + } + + private static func dayKey(_ date: Date) -> String { + date.toFormat("yyyy-MM-dd") + } + + private static func makeDateItems(around date: Date) -> [DateItem] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: date) + var items: [DateItem] = [] + for offset in -4...0 { + guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { + continue + } + let title: String + switch offset { + case 0: + title = "今天" + case -1: + title = "昨天" + default: + title = day.toFormat("MM-dd") + } + items.append(DateItem(date: day, title: title, key: dayKey(day))) + } + return items + } +}