From 1f1a0364ca58616260114439e8cb63cafe901e20 Mon Sep 17 00:00:00 2001 From: taipaise Date: Sun, 9 Nov 2025 19:58:49 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20asset=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Images.xcassets/Report/Contents.json | 6 +++++ .../Report/camera_icon.imageset/Contents.json | 23 ++++++++++++++++++ .../camera_icon.imageset/camera_icon@1x.png | Bin 0 -> 485 bytes .../camera_icon.imageset/camera_icon@2x.png | Bin 0 -> 815 bytes .../camera_icon.imageset/camera_icon@3x.png | Bin 0 -> 1147 bytes .../location_icon.imageset/Contents.json | 23 ++++++++++++++++++ .../location_icon@1x.png | Bin 0 -> 418 bytes .../location_icon@2x.png | Bin 0 -> 883 bytes .../location_icon@3x.png | Bin 0 -> 1273 bytes .../round_delete_icon.imageset/Contents.json | 23 ++++++++++++++++++ .../round_delete_icon@1x.png | Bin 0 -> 526 bytes .../round_delete_icon@2x.png | Bin 0 -> 815 bytes .../round_delete_icon@3x.png | Bin 0 -> 1226 bytes .../navigation=home, status=fill.png | Bin 0 -> 360 bytes .../navigation=home, status=fill@2x.png | Bin 0 -> 529 bytes .../navigation=home, status=fill@3x.png | Bin 0 -> 708 bytes .../navigation=_mypage, status=fill.png | Bin 0 -> 436 bytes .../navigation=_mypage, status=fill@2x.png | Bin 0 -> 731 bytes .../navigation=_mypage, status=fill@3x.png | Bin 0 -> 1067 bytes .../navigation=recommend, status=fill.png | Bin 0 -> 618 bytes .../navigation=recommend, status=fill@2x.png | Bin 0 -> 1163 bytes .../navigation=recommend, status=fill@3x.png | Bin 0 -> 1532 bytes .../navigation=report, status=empty.png | Bin 0 -> 333 bytes .../navigation=report, status=empty@2x.png | Bin 0 -> 499 bytes .../navigation=report, status=empty@3x.png | Bin 0 -> 752 bytes .../navigation=report, status=fill.png | Bin 0 -> 269 bytes .../navigation=report, status=fill@2x.png | Bin 0 -> 448 bytes .../navigation=report, status=fill@3x.png | Bin 0 -> 671 bytes 28 files changed, 75 insertions(+) create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/Contents.json create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@1x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@3x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png create mode 100644 Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@3x.png diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json new file mode 100644 index 00000000..7dac3331 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "camera_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "camera_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "camera_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..b650f4a35ad4970ea01b32e0c59aa4c95d894f8c GIT binary patch literal 485 zcmVc;gr+itI$b zA@QWKXJ>Yv=C?$D9lX!!ToQSb5s`%D_1qB0)}Cjr2|?Bumbcm<;Q>g~I-j8ifNY9i z#uD^E&|OQ=gDn@L2_4Gt(Y=lg(*x9qT0xdx`2NZcKQCcssy37kWAxt6n#dCzp(wWlnT_hGV+ZL(aEPi$!uRe6(V;LPe`k z+x8t0WIN}2XlCCccTjVkstWWBwwd~x;u;Cd5~8vVsfwWau!W_sEuk;U$6{B(#!!b2 zAZT(}d$D|uUzW@~b#c>sL}CyBrO>72%o`uZ8@C*_==dc<~>GzGijV b{}cQG24kT5T-@L@00000NkvXXu0mjfaTv)M literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dee5cd53b22f955a6a6b0d44ca7e68f430936889 GIT binary patch literal 815 zcmV+~1JL}5P)nu~K~#7F?U%uE z(@+qG|J7tjZlnWD1xX3w!c39_en|tNf`kf!E5K9`=MKmmFf&|G2e<=71(;h7M$4`& z%kr}6t}j00S+>Df1Ujm5ETcQ-^wr!t6~{x7erUZ!)95G z(Skj+Bmjq{_lnQm*>z2L{9!mclHX8wGQn~TEvU@k%mDj0%;;8}KFl{|l5cfvh_5#i zt%}PG@U3*yZ6QY5bIv^~71N!D;+S`={>4a=IA zf+JZs_rGJcqi*u;VWHz6kZJL>oRo@hlF>C2-rygghVtj^ib>R+@)j@UZ-VVH(T1bS tP_>TG`2qZX=ntsmZyMSiJ9hl<`~ecKF9ivKsT%+Q002ovPDHLkV1koPYasvt literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..ad07be9824087f60d30988211323f893c27ee992 GIT binary patch literal 1147 zcmV->1cdvEP) zpgS4K-ec?;{bm4L_c6D+{klDXj*gCwj*g{)5Ex^=4|3cMn)Ho;EKN5j2uLu2&FRIy zwRVp`_R)@vh(_tw<`4l;7uXonx3uX_1H_Yw;O4iUO|xv%IF9y(7;m9Kp9nm zPt3)Rj<63TC+2()ioAj%M0VfvIAdEr=69RT8XLi~DPWgB#!u&*K0Acg8li+SXRLzs ztMWJ_OKgXmS+hAVVoO%$c;e{FIVMm*O{H^0t}0u5+EiFU`m#10xpU_+lxVcW{N@ux zc8eF2@Q}JN(}x>^uF9V_w4H~;7%q|1X%j7&DR8M`U-)B!6@Wk^SPW-rRjt6KupDBZ zI*}D%rr)TyVS;$v7^}y)(kJX&4sV4@*+;*7;KfcP{rr%=8)1H`0(()-rK{Y> zaSKUUW|Ht3I5FdITb!MJ4rw~-$HRLb#qp%+7;!W*M#dr#K?E*}0COiTaUry6TPbNo z7?qCJcUd)KPs1CD+lJ^kVwYjO@gfHLX=PgU941GmK=$GQK3FOITJ z@A57mE`5n7_uFh%)Tdl$RK(HS4Y-k@pQE<}Wz+?Z$Zl1ik+s8FJ0)$L3Vw3Nff6XA zU&MoJyJ@5NdVT3UBKsQ31n|;MfgnQzN)2e#cM*cc;YY+RLQGuwu6`<07nsjju4-ca z+0t!M;;mrmFO<>WVT+&6BTg@8?XtA_O1y-83}phS^vwvaD#mgn;Y%5d6m+d7D~l#j zMm_Nm=W}AtjyQ3s?3i4g*gs}`G0MwKE8=rEd-ZK!C6tqw;rsM$24#YzmJx0bEqanN z#?>|!aEIvObKMk~%z+?l3qYAj__C)alvT%6-9rn(!cvhk(gts48^<$gb2T#$K??xo z!c%rkJ8ZwA;z>(_Frf!@Iq%(7G%VKMnz~^ALONN^Nk^^?af3?`ef>7a2Gm}rv0$wVaLa_;BBtA$pL}h#VW~WjXIMQlxiYP5tQGh^ zj`HARtPan{@y7oFFuj~A?wn#Q6&z96$%@UsL>%wv=;-L^=;-LE;~)Bo!B;NdA&US2 N002ovPDHLkV1kZr5TF16 literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json new file mode 100644 index 00000000..940e4c33 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "location_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "location_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "location_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..92ea656b6bb8ddb7729a1edcbc649d23dbe23dfd GIT binary patch literal 418 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 literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cc1373ee75171b0cede780900dfa0af3d04f72e2 GIT binary patch literal 1226 zcmV;*1U37KP)D?~~_M@QGJ+lCuKA@TkF-8s?wg#?5`p)h{&XV87SDQM`L!y<1( zvAW^z-R@_cE9=la;|Y3T>-fe;t#0&Oc9C30QTQUB`k?vv&|RPU{cUi3e6(tIlc|7$ zzw#W5|YVJLT$<5L}IaAK0G{xzP|e)17i#)e!Fq~4Ui&`Bp3`j3*Wr-hvGxc>RlT~&Rh z!HqFqC(JrK@6q5D28TqW(HaqExMVz`VGO8?-~wjuWW+_|bx1%QT*69(+r}e4%al?c z4$TDLKpV{~TuB+tdYckvU91yOK;Q))9nb%*j4$D`2n}6`rNAMV_4BdVR876;w8IL_ zT{JHZ2H*hELCS2La1zvbdQ{nMofB!G^_dg)(%lo=Tja-oS`tS-2MD%lijq0okmEOLvff76*O5Y8R(wZM3tqYu!$c z+$1#Yd3q}=YG$L1<;Pmnl^8AV4jXH0me(n=Rn?!t1}(Klip!qk`1HRU>+9|hpokQ% z$D@0(2($p?_h@|G{R-0^8Wge83L=U@8#lcU78G`CV5#|g2ITYkDKvih(z`%_;?g_K zm%`8-a0j1{iEOiaKmZEta?YkrqM%N)+SbBV)>KaCP6fU^s4E zjPLC1e3fbmHYqF$Lj*SjV|E8VjI9-HyXy1#7E3!W(%%6ta3O{bqEY(4++eyFuJ{>x o!da}N{CgYEQ)u)TyGB>?AMoZPKO2l9m;e9(07*qoM6N<$f@EYiga7~l literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png new file mode 100644 index 0000000000000000000000000000000000000000..9052e98d0d76e6e5dd3ad16789e63132af36a539 GIT binary patch literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#zn(6RAr*{oC)jr#3J^H9zp~KzM#{s3i&{#% zWfWWWqIa3!c)RVgb_LT%wz9Z`=ag?`EjRaAyrgG=!oQO{I6`w2a~i7)utBp zf>ZY&m|xY(`msoGo*IL-=Hq#KTjuL@dSuVIx@`7do3^>NQw(4Gn(Ho9)&HMm{82+I zcf~}GkL&l>{%Sv4v`}QvH7Q#G{zaD@>Ua$Phpp-VTDW^woSBfHi>yeC#NOBYSsbsO zoq9s1>30ybP0l+XkK DAWDv( literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c1dd8a08dd061662d1d837b4cb507c350a96dee1 GIT binary patch literal 529 zcmV+s0`C2ZP)Kt~O??C3pgw||tD{?egSMNXLw$oTO28#Ui^bBM zm*OHwFW0+zV87pVflI#oCAmMx2M`275C$-;iJ;QDkWW8eTnG#%?@xDF1J(t~VN{-d zjJrZ0`ca6XUu5285IXo9k+{blw#Z);8kz zLWV`|o$4l(mVi1YP0^3p+%cLjCQZ?g*<5=-GbT;Zidp3^v|VdPX^I{kz&YCE__@Dy z4iv~UO$Uz1Q#+491V`klZS5@N3KDP)3Aly?Ttfn`ApzHrfNM5FAb~rSwUq+0DLaHK z7?k}YrNCTvl;P$pBtG+$eJ7>BRJJ%$?j6YC1O7%N$y)KgtZBOyPY?t_{6D?`7BY+? T`a38y00000NkvXXu0mjf(J0yF literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..287b59362c88343a486affd7e9bbc383b3c93e9d GIT binary patch literal 708 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ79h;%I?XTvD9BhG z(|ezU)W|U_o8aA<<@rPgU8)c&Rk9`>(?ufKfl7Um+hBu z`wP{p=J{4Z%8j3r=BdBDd%f&pt>eFEnPo94j~8#|-n)0fiADZ~bwO7jZ(}+r$B^!SeT8Mhl2G&1!R z#vk0y=*Yw>_jq4Z9`l~%5o-+JX454p=Elcrf95l1-F4LKlYMN~ zC%f_Qf~x_#WzS#pa&|J8{@=K3qTl4^_Djco|8(56P`B-Q{N(qm>s>8J8bgjRo7xjK zX@bQB#aD$2Kj+@R^?u23rbY>lBMu5|Oojp-jSf64NeTkY_=K`pj8wl))AIEA7w2i>tQ?srROec|8P9P~<;v-AGh=RZyE-*I6boVc-_11 zN`hl;)s;6}m%W?yx@L+^%H*4;?soRjjwJuU2p1gzI^yLIJWKz3pm;t a_I{Ag@wg$p>iQRDkbtMFpUXO@geCx$jX2E! literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png new file mode 100644 index 0000000000000000000000000000000000000000..71aa5cf3d76092d93802238e18f996c5d2e1feb5 GIT binary patch literal 436 zcmV;l0ZaagP)NH!Ff)-60TR9enNTDa4>d#cVH}32 zL!7t`QkVXuD6-zi-|y)!1DrTEXp%JX=1VCdR^Ln=sVZV1aV5Ur)`JDy={#1yTTL;u z-6G&A@DyYlyU{VNY-W3Ru@r! zNprD@B|-#7s40jQ@a!e+6{y`qs`A;?K|63HBof){gI1uv`SwSA9MHUh5C}Hed(xbD zgY{a5G_R>Dc%=S}whE}7hcFzqg;CTh@LwRx_6d~l?_fCGM!R_+H7+VbH~_U`es(@t e^EBab!jxacC2mp=ppM%B0000OXmjMfO3M66Qnr-+@RqEg%h+(q%H_I@B*qzY`jP) zjpKRDv=u@nPV6_+G*bO-5{>Qn{qttt{4YR7MMXu$Lx$j(CtK}^6}@3n5tzc8;e-ey z1$=%)^-sz9ml%$4Ccp%)6deK_F1wdNL*J_nTnKR@ptae4&xCiS9sCyr}O(fJi`Vb9bjmxhw+-CqyK`BNf6mOxqA35}=q}t$k~( z%IG>E6i^c%Jb4gt3;`kmewxPmBY{)+8w7|1B+2N?UBV$g`5z!aya%*^0UV)!>qKhm z#8zTDK8qX87fTy<;Bw3o!ALQAT!fXB~wyTp6|r?Z|%@oufq z9x#oI4&Icx6qAF-tRWGxMSvZO`&dEEHh6E>F$AVr4Tw4=tjZzRKGZ z3zv}Yx{h(L!5j2)x~L!KUCkAsE6)C1R+~~VUYq}&=3UCq0C5*<1T*^c#&ZSeBX(&8 zSS4CO6zDy9dwkIzaBP?DnPC>*9?KQr+Ah1GzA^hRru+;j>S2wtU@XSGQkpA3o2pR_ z{1$5ZP~-uhgo(&;O!M}?CY+N=;P$PoI|assHmtY%pd(D#_M_zmK6X1 N002ovPDHLkV1f=AJ-q+` literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..460990bc8a2de3c2e834b94c1f23f8b5b4050cbf GIT binary patch literal 1067 zcmV+`1l0S9P)8Q!V`ci-GMuIz|s}4GXWz$fwNFj zbTdRKA8qG&FAa$c(%zPP@3fQq+qBKhWTyW)ANQVf0Effja5x+ehr{7;*oKfG{lk+* zW|{?foWTK@f}3OF!48=aGO%}#GFwsUONa!pE>IyT7Ou*ti{k5&{Hc1K6if*C?(EP0 z1}tJ#AZamiOzV)=(l!)kJ%8GkMR~A9eqeGnjjE#nm5uLz7FefDa4g2A`~h z=p{5FOn;^)KZ45H(H;z9T%c=lKnpNK(X>cLV^D5z@~!w-hG9Gp)2F3_T^PWaK)u6H z15d;XSV08tTxB8(44Moe*w~i|>nR`akHG-OYSPuypHa4dRn%mPNF06L zS1m3ktwy8~nc4$%289~OOFDXHcM_-`YaYHAX7~o3Nimxr4e|qndlymZ0&UQ?i^(+Z z$F)rcCs{#Mq0>L!6xmA)YeK`T26O~Bau@fwVgsR-cojLSs*#a(j9Jf z8yr;5Gn=hk(Nd7EXDjo=2!;u={i6ku-9D?FtQ>=^?yW4~gXjPL{@S$apzLBID9cfn zREQ&~f!0;xk&=sDuab#BAYJsvRlm2Ht0`F!6Bdy|n0cvLCsx(PibyF}adn~5aYcc; zZOx;OsC+(abX?Vf66sRdfd8BBzYQgUOte!v1)|2CSS5jCU>m80zc)InB+$Lf>V_iqa%t=V}kZB|9s zpyc0wyiFXR2%!RW;nqVoG(8V5%Et$vl+MQ;kOP}?CNb%;yJiS$i9Cvp&2l@y6D@7?3G=MgsE(Ac2$;Iy&GB;12>{K&U7QB^~?$B^}TqK@mDSQt+l=3j#rL zGR!y+oX9o~qCp(V_N?~ooPC^G1N?Si5|T5LaXs){Qm9KeX9eg2kpllXsiJC@h*m`- z(4`UZz%gIw}ew-x;-}S z^dyIw8dR}Tx;qTx8SPD4lD8ZnBmfflTSJ5^QJj{5Jvb|e=Kx|Mi8}BlzQ>iPK3cwr*MPibKt& z7zpFE9WwiW-w!H~g#_Sw*r?AE52842HdXx-mq@i$4l|}bCRRL&8px}a>YcXHtw87=)6r4_f1s!GB*sW9>s|y`X9))NS&nE%AE=P ziNuH`68H+8BCn5Ulg&N@egI{jaShcxNLByHl>Zby04L+u|Jy>#IRF3v07*qoM6N<$ Eg6EwDoB#j- literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..232efe6329bf08fda4c00ad311287724aa5abe2d GIT binary patch literal 1163 zcmV;61a$j}P)JR z(?}G@-}A4gaiaA~v>>`d-v1!0rK=vpjoWr_du#a%5>AK{<%BrV`~{>pZorKj;err9 zL^yK7NKhqQFd}Kw*v{}KrIqd2jmLJRAmwvt;~D>$@64Mw?>&G83l{v+Amo5yRt?AL zo8Wns;L9aTDU{l_)v{qmq!z#jeRSB{mH_@`460|{iwG?k;hrkXExYr=f*cVRzz04& z=+)?FonHz)3}^wc=E0ZOw;?Bl1n4X46^8JT24O-b(9$INN*=3`Sb)Cr&lbbcF2v0` zG7Fj1I=N)P40^fA04g!Bv{91k{m#oh$Uq_iKL_88=;E8Rp%qKJaT~R8VZ%h`ZX#d} z*yPHQi0v#QrV8K#kVfuX2+ceqYpBcRounRyzeo0*Izz05x@=VOO8@h#Hl$#xfa2=< zhp^4lKDd6^eZ31Q(9d74lh?Wz8iu76oxg0WE$nXPvpS2bmv7O(O@CXK!E>Dc{tK;i z@f-cT>T5PUM>?Xu76=l%0>`G~J@wB%H=BOU$ICcUj(7~u|gCJ6T58Luzl3Q%#d5YThc%~bHPtvIlf^=&p{ zJeM_MUeubeA z2jkLmsTr;#{WQ({6VD`EiQLNT?x0s;5K4qRxvlEDHLduSMMN$F@*u4Up*=85oZfyr z&7PQ>6*wVgC2TsWe~-Fvf&E`% z$TwC;LC=p5_UbS~QW28)o)AG5hK9u=*l7NsDK?2`ncTubGHF0a%F*R#X*F+1lYbYHN6>y^$22^ zWOu-WF7JM@1{6ChQy1w<0;zvO$j)ytNF2maAgvCwO_%1DY8FDJDpF#B`r>qV2H6}$ z`p%!axo$IKY$(nwuJgAw^BHWDitYEKu;Ju-m+>GWh2s1-EKb!lj)bet!@aLsQP=`U zw43|&!t~uE{&B@{O1aw+_xZf3w9vG>x(2O^LD;A zVC?g>!vgjBC~(y@-gi>nV!gzA;R905FuZZf%L+1}eZ1e`Wqz9t-9ycAxlmkq)jrvG zX0WYika70#Zgq0Fw+vpT!0S=;FdHGhm7<_DFrcQ5;Ao!m5X11 z15|UC=fCqM=Y0m%-J*h+__THW$LMSGt=2({i*qkn2_@2`;k2q>TBz}PN5d5H)RO9) zw}0eot;C!hiJ+3JyG-(rPZ=7_R&X(s(&@B!C{(Zh z&S8$p$4OhH)Zf*R5^H>wj}wW76e5UiMB1`x%1TqD%-;4<#@zq!ZJ&TEkGC$}#gXKs z+%RwZq$N^j0%buOA`QKZT_@Vjzqb6+{5_Yu+gJ&ta4pi<38b(Qi34g>{ns%cHNK_i zlfz$Y9J&lj;5`Kk#=16dAyO=3-@ITFiG3_9;l|Piy@|3(uba>B^RMb1lCN2~FCZr; iCnqN-CnqQ8s{9M&2yx5T|6%|D0000y(VDRWId!Zx*q_w;|r{bp+XGu>>m&H)GBt_sek z&lcgInB{n1xGgfex^d0Tr-=%SwlB9xkII;mF_ocNKjNs%nnj#(60g2^1+NhCNI95$ z)Uq!#&B(gZeDU6*AIB@YUw*&PGk5Qt-A2cs$0e+PC%W^(&+|=WnK{U{Ts}ad!mp-O4VQgKz>zoG}GNquHMFH acNorJw2g3(59tT`mci52&t;ucLK6USY=t)f literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f67b30e44ba2f49d0fd138d906a4f21323687a42 GIT binary patch literal 499 zcmVF8{YKXCY(GX(d4fF=~2I39u4fF;kN~*p{qCCqHdW2q}jI$;hQad1& zu++}?0?RBgv$ODLX8}b~6qlH?4$&;RAVcSTIzqdohRihZz1yNrg#kp*$rTZWaELJ` z3{Jdj(JmRHmt>zZ`alQZK^&j@hw(aFPrNI`B{~9UVItWEl7Z*_87S8-7=@;Jn7xhI z>DQkN21im~IRk`YzS)-ZYu~lvOCC9ZEkOft&DIAnKn=bG4%vi%RYNpHnP5p&&Oj8g zz*%)}(0Pk}rWH?AWcR*-An;Nit;(q`#L&V%GdT8ARHW87z(N!9Aw&nz->7R;2T+fB zpZa6pKbT`fTO>%cs|{-!c#YQ`+VIs@&@c7jRpTSMS`5%B-3lP>MiE|Uu+*GJ-ctg;g_D> zGt)y9*YW$_-x7ajYuPeojfYQcLwhe+dvD&gbmD}Zr+MO%yXq4ivfq68k#gk7-PN}( zA8voQaCz;$TkKESLYDuUaqUi4vgwK1=FG`FXXcj7Q(Aud_a)EEsqdtP6c@BJFlq`+ zV1g1p;$5yXwU%$WQ*DLJ=DIZ}MjELuo;&@vGnd8-!7u7E6CJ-4*lhRT|Ni{(`&U*! zR%&8K`;{Fy6D1Z+flv zZ`Qj^FTp8W#j<_XHfyB%e$Co=z$>zQXXb~$e(JqJ=ccR{`~7?G=lJKn{34uLhOFzF zML0Yf8*mVb+%IPRd>7Fiw*BGt85eYKFirZ?AZKd!Ys<4uDyQRB50tpEU9*16|LI+r z`pK$=?~iky?e%|}m!25xa7{U5zLwhLHEWq)#HJp;Q+h2v+B>Ar*{oC)#o~8^|1u_g?Pz_R{1pLb1EQ zGPF8vcZwAWDG-v~GwH!H@xm^yb%uORTvD7Lh3f^F^#4Ek_^Dy;PRlK=pWm+Ae6~Tt zO|D(-e1ntBA)a-m4>@+m+-k~UwtFf*_4WE)B^q7NF1(Uk=BKbbWy2)t{Jwb)u9SYL z-JcL`|K(#v-2?W(_dNwoDrX%g^;mEJ$G0uh!C+G(d!f%8oj#^4`@nCHKbqVCx|YGy L)z4*}Q$iB}!~kXQ literal 0 HcmV?d00001 diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2e1d83baa9610be149ab4eecebaa60f40771e709 GIT binary patch literal 448 zcmV;x0YCnUP){S0_hDanQvd;G&=+ia3Z4DpXfHIrwjgn}a%7bm>$P zDt1$87l-q@wG{F$X`9!Q_gO;1gXDX6$s-3SrId;?aw&=rC)0+oi4YOdW$Ej2i>vGA z3SuM4BO7xM;Y;<#N9nIrxc4gR0*jU*hL*SrhX7^*V)%PI1ZZy>Kw8HK=s^Jc+bh^u zTfpEzKbqzA;u)m$qVpFE#2dhbB?Ef;92 zy?p=t+9i~!8+{kPmr&Sr3AH=z13|ohd}3~X-5v{Ql*WbOBU_b`oS&^{nd14z>V(lmgyCLH+?N+EsZZ3ag+10000QMZ+E?xpEV z^fm9yGu{&Y`GbsKe$lP_>|ViAJJj#biTTtL)6fA1m(m_Ayb!6g=K8exH)i|aou82_ z%KTY<@>=`VKGU@WvfA6L)>)Es@ z+Iq^B#tYN#xUaFfwvI1le&>s_^J4S2zAg`VJ%5UH;QG7j7xo=;U}6(8n9$J3!lUv* z%2cZI>mu>mrPdlUmOUn+V$(lVeo()+?|0XPrgsabpRa!Fuld!Y&w82lx5t-qTSQDQ zoPF(T5*`0i&xuP)W4?V$TJ#3}trMEqetmf=J}WolhrN>(!&0AjPiD{kXKrT6`d#wQ z&duos8x}KX7Fj<(eEhvch5d~Wbzfh_*o$15uaVYYs@f5eTNm?n#!ows18|pJJey=+ z$DerMT(t4#Vh`sF4$+%$^JzK-lAN=|8bW zal5T17-}d7*=1jUuaLY?F-$J|`>tE+<$JP^#QtvRkeE?aTK9%&${xQO#>O7Sc0T1XtOury=*64{pJ;=vOQOiv7+u6{1-oD!M<8QvdS literal 0 HcmV?d00001 From a0caab109593efb4fb1d4346bf95f1ecfe10ac36 Mon Sep 17 00:00:00 2001 From: taipaise Date: Sun, 9 Nov 2025 20:04:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A0=9C=EB=B3=B4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Entity/Enum/ReportType.swift | 12 + .../Entity/RoutineCompletionEntity.swift | 28 + .../Common/Component/RequiredTitleLabel.swift | 61 ++ .../Component/SelectableItemTableView.swift | 8 +- .../Common/DesignSystem/BitnagilIcon.swift | 5 + .../PresentationDependencyAssembler.swift | 4 + .../Component/RoutineDeleteAlertView.swift | 123 ++++ .../Home/ViewModel/HomeViewModel.swift | 2 +- .../Sources/Report/Model/PhotoItem.swift | 18 + .../Sources/Report/Model/ReportType+.swift | 36 ++ .../Report/Model/SelectPhotoType.swift | 35 ++ .../Sources/Report/View/LocationButton.swift | 41 ++ .../Report/View/ReportCameraButton.swift | 74 +++ .../View/ReportPhotoCollectionViewCell.swift | 89 +++ .../Sources/Report/View/ReportTextView.swift | 167 ++++++ .../Report/View/ReportViewController.swift | 539 ++++++++++++++++++ .../Report/ViewModel/ReportViewModel.swift | 103 ++++ SupportingFiles/Info.plist | 4 + 18 files changed, 1347 insertions(+), 2 deletions(-) create mode 100644 Projects/Domain/Sources/Entity/Enum/ReportType.swift create mode 100644 Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift create mode 100644 Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift create mode 100644 Projects/Presentation/Sources/Home/View/Component/RoutineDeleteAlertView.swift create mode 100644 Projects/Presentation/Sources/Report/Model/PhotoItem.swift create mode 100644 Projects/Presentation/Sources/Report/Model/ReportType+.swift create mode 100644 Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift create mode 100644 Projects/Presentation/Sources/Report/View/LocationButton.swift create mode 100644 Projects/Presentation/Sources/Report/View/ReportCameraButton.swift create mode 100644 Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift create mode 100644 Projects/Presentation/Sources/Report/View/ReportTextView.swift create mode 100644 Projects/Presentation/Sources/Report/View/ReportViewController.swift create mode 100644 Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift diff --git a/Projects/Domain/Sources/Entity/Enum/ReportType.swift b/Projects/Domain/Sources/Entity/Enum/ReportType.swift new file mode 100644 index 00000000..492ad77a --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/ReportType.swift @@ -0,0 +1,12 @@ +// +// ReportType.swift +// Domain +// +// Created by 이동현 on 11/9/25. +// + +public enum ReportType: String, CaseIterable { + case lamp + case road + case etc +} diff --git a/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift b/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift new file mode 100644 index 00000000..e038fec7 --- /dev/null +++ b/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift @@ -0,0 +1,28 @@ +// +// RoutineCompletionEntity.swift +// Domain +// +// Created by 최정인 on 8/6/25. +// + +public struct RoutineCompletionEntity { + public let performedDate: String + public let routineId: String + public let completeYn: Bool + public let historySeq: Int + public let routineType: String + + public init( + performedDate: String, + routineId: String, + completeYn: Bool, + historySeq: Int, + routineType: String + ) { + self.performedDate = performedDate + self.routineId = routineId + self.completeYn = completeYn + self.historySeq = historySeq + self.routineType = routineType + } +} diff --git a/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift b/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift new file mode 100644 index 00000000..148edd6f --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift @@ -0,0 +1,61 @@ +// +// RequiredTitleLabel.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class RequiredTitleLabel: UIView { + private enum Layout { + static let asteriskLeadingSpacing: CGFloat = 3 + } + + private let titleLabel = UILabel() + private let asteriskLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + configureAttribute(title: title) + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute(title: String) { + titleLabel.font = BitnagilFont.init( + style: .body2, + weight: .semiBold + ).font + titleLabel.textColor = BitnagilColor.gray10 + titleLabel.text = title + + asteriskLabel.text = "*" + asteriskLabel.font = BitnagilFont.init( + style: .body1, + weight: .semiBold + ).font + asteriskLabel.textColor = BitnagilColor.error + } + + private func configureLayout() { + addSubview(titleLabel) + addSubview(asteriskLabel) + + titleLabel.snp.makeConstraints { make in + make.verticalEdges.leading.equalToSuperview() + } + + asteriskLabel.snp.makeConstraints { make in + make.leading + .equalTo(titleLabel.snp.trailing) + .offset(Layout.asteriskLeadingSpacing) + + make.top.equalTo(titleLabel) + } + } +} diff --git a/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift b/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift index bb2d6f12..8eb5da26 100644 --- a/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift +++ b/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift @@ -19,17 +19,20 @@ final class SelectableItemTableView } - private(set) var output: Output + let output: Output private var routines: [String: [Routine]] = [:] private var routinesCompleted: [String: Bool] = [:] private let nicknameSubject = CurrentValueSubject("") diff --git a/Projects/Presentation/Sources/Report/Model/PhotoItem.swift b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift new file mode 100644 index 00000000..140d4879 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift @@ -0,0 +1,18 @@ +// +// PhotoItem.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Foundation + +public struct PhotoItem: Hashable { + public let id: UUID + public let data: Data + + public init(id: UUID = .init(), data: Data) { + self.id = id + self.data = data + } +} diff --git a/Projects/Presentation/Sources/Report/Model/ReportType+.swift b/Projects/Presentation/Sources/Report/Model/ReportType+.swift new file mode 100644 index 00000000..829e8248 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/ReportType+.swift @@ -0,0 +1,36 @@ +// +// ReportType+.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Domain + +extension ReportType: SelectableItem { + var id: Int { + switch self { + case .lamp: + return 0 + case .road: + return 1 + case .etc: + return 2 + } + } + + var displayName: String? { + return nil + } + + var description: String { + switch self { + case .lamp: + return "가로등" + case .road: + return "도로" + case .etc: + return "기타" + } + } +} diff --git a/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift new file mode 100644 index 00000000..62b3766f --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift @@ -0,0 +1,35 @@ +// +// SelectPhotoType.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +enum SelectPhotoType: String, CaseIterable { + case camera + case library +} + +extension SelectPhotoType: SelectableItem { + var id: Int { + switch self { + case .camera: + return 0 + case .library: + return 1 + } + } + + var displayName: String? { + return nil + } + + var description: String { + switch self { + case .camera: + return "직접 촬영하기" + case .library: + return "사진 라이브러리에서 선택" + } + } +} diff --git a/Projects/Presentation/Sources/Report/View/LocationButton.swift b/Projects/Presentation/Sources/Report/View/LocationButton.swift new file mode 100644 index 00000000..b45865b4 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/LocationButton.swift @@ -0,0 +1,41 @@ +// +// LocationButton.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class LocationButton: UIButton { + private enum Layout { + static let locationImageSize: CGFloat = 24 + } + + private let locationImageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = BitnagilColor.orange500 + locationImageView.image = BitnagilIcon.locationIcon + } + + private func configureLayout() { + addSubview(locationImageView) + + locationImageView.snp.makeConstraints { make in + make.size.equalTo(Layout.locationImageSize) + make.center.equalToSuperview() + } + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift new file mode 100644 index 00000000..85e23ba6 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift @@ -0,0 +1,74 @@ +// +// ReportCameraButton.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class ReportCameraButton: UIButton { + private enum Layout { + static let imageSize: CGFloat = 20 + static let imageTopSpacing: CGFloat = 15 + } + + private let cameraImageView = UIImageView() + private let imageCountLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = BitnagilColor.gray99 + cameraImageView.image = BitnagilIcon.cameraIcon + configure(imageCount: 0) + } + + private func configureLayout() { + addSubview(cameraImageView) + addSubview(imageCountLabel) + + cameraImageView.snp.makeConstraints { make in + make.size.equalTo(Layout.imageSize) + make.centerX.equalToSuperview() + make.top.equalToSuperview().offset(Layout.imageTopSpacing) + } + + imageCountLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalTo(cameraImageView.snp.bottom) + } + } + + func configure(imageCount: Int) { + let text = "\(imageCount)/3" + + let nsString = text as NSString + let firstRange = nsString.rangeOfComposedCharacterSequence(at: .zero) + + let font = BitnagilFont.init( + style: .button1, + weight: .regular).font + + let attribute = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: BitnagilColor.gray70 ?? .black, + .font: font]) + attribute + .addAttributes( + [.foregroundColor: BitnagilColor.gray10 ?? .black], + range: firstRange) + imageCountLabel.attributedText = attribute + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift b/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift new file mode 100644 index 00000000..2cfbb05e --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift @@ -0,0 +1,89 @@ +// +// ReportPhotoCollectionViewCell.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +protocol ReportPhotoCollectionViewCellDelegate: AnyObject { + func reportPhotoCollectionViewCellWillDeleteCell(_ cell: ReportPhotoCollectionViewCell, uuid: UUID) +} + +final class ReportPhotoCollectionViewCell: UICollectionViewCell { + private enum Layout { + static let imageCornerRadius: CGFloat = 8 + static let imageSize: CGFloat = 64 + static let deleteButtonSize: CGFloat = 16 + } + + private let imageView = UIImageView() + private let deleteButton = UIButton() + private let deleteButtonImageView = UIImageView() + private var uuid: UUID? + + weak var delegate: ReportPhotoCollectionViewCellDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + } + + private func configureAttribute() { + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = Layout.imageCornerRadius + imageView.layer.masksToBounds = true + + deleteButton.addAction( + UIAction { [weak self] _ in + guard + let self, + let uuid + else { return } + + self.delegate?.reportPhotoCollectionViewCellWillDeleteCell(self, uuid: uuid) + }, + for: .touchUpInside) + deleteButton.backgroundColor = .clear + deleteButtonImageView.image = BitnagilIcon.roundDeleteIcon + } + + private func configureLayout() { + addSubview(imageView) + addSubview(deleteButtonImageView) + addSubview(deleteButton) + + imageView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.bottom.equalToSuperview() + make.size.equalTo(Layout.imageSize) + } + + deleteButton.snp.makeConstraints { make in + make.size.equalTo(Layout.deleteButtonSize) + make.centerY.equalTo(imageView.snp.top) + make.centerX.equalTo(imageView.snp.trailing) + } + + deleteButtonImageView.snp.makeConstraints { make in + make.edges.equalTo(deleteButton) + } + } + + func configure(item: PhotoItem) { + imageView.image = UIImage(data: item.data) + uuid = item.id + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportTextView.swift b/Projects/Presentation/Sources/Report/View/ReportTextView.swift new file mode 100644 index 00000000..f0b65fab --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportTextView.swift @@ -0,0 +1,167 @@ +// +// ReportTextView.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +protocol ReportTextViewDelegate: AnyObject { + func reportTextViewDidChanged(_ reportTextView: ReportTextView, text: String?) + func reportTextViewDidTapped(_ reportTextView: ReportTextView) +} + +final class ReportTextView: UIView { + enum ReportTextViewType { + case combo + case editable + case nonEditable + } + + private enum Layout { + static let textViewHorizontalSpacing: CGFloat = 20 + static let textViewVerticalSpacing: CGFloat = 14 + static let placeholderTopSpacing: CGFloat = 15 + static let chevronImageLeadingSpacing: CGFloat = 17 + static let chevronImageTrailingSpacing: CGFloat = 19 + static let chevronImageWidth: CGFloat = 10 + static let chevronImageHeight: CGFloat = 6 + } + + private let placeholderLabel = UILabel() + private let textView = UITextView() + private let chevronImage = UIImageView() + private let button = UIButton() + weak var delegate: ReportTextViewDelegate? + + init(type: ReportTextViewType, placeholder: String?) { + super.init(frame: .zero) + configureAttribute(type: type, placeholder: placeholder) + configureLayout(type: type) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute(type: ReportTextViewType, placeholder: String?) { + backgroundColor = BitnagilColor.gray99 + + placeholderLabel.textColor = BitnagilColor.gray80 + placeholderLabel.font = BitnagilFont.init( + style: .body2, + weight: .medium + ).font + + chevronImage.image = BitnagilIcon + .chevronIcon(direction: .down)? + .withRenderingMode(.alwaysTemplate) + chevronImage.tintColor = BitnagilColor.gray10 + + button.backgroundColor = .clear + button.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.reportTextViewDidTapped(self) + }, + for: .touchUpInside) + + textView.delegate = self + textView.font = BitnagilFont.init( + style: .body2, + weight: .medium + ).font + textView.textColor = BitnagilColor.gray10 + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 1, left: 0, bottom: 0, right: 0) + textView.textContainer.lineFragmentPadding = 0 + + if type != .editable { + textView.isEditable = false + } + + if let placeholder { + placeholderLabel.text = placeholder + } + } + + private func configureLayout(type: ReportTextViewType) { + addSubview(placeholderLabel) + addSubview(textView) + + if type == .combo { + addSubview(chevronImage) + addSubview(button) + + chevronImage.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.chevronImageTrailingSpacing) + make.width.equalTo(Layout.chevronImageWidth) + make.height.equalTo(Layout.chevronImageHeight) + } + + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + textView.snp.makeConstraints { make in + make.verticalEdges + .equalToSuperview() + .inset(Layout.textViewVerticalSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.textViewHorizontalSpacing) + + make.trailing + .equalTo(chevronImage.snp.leading) + .inset(Layout.chevronImageLeadingSpacing) + } + } else { + textView.snp.makeConstraints { make in + make.verticalEdges + .equalToSuperview() + .inset(Layout.textViewVerticalSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.textViewHorizontalSpacing) + } + } + + placeholderLabel.snp.makeConstraints { make in + make.horizontalEdges + .equalTo(textView) + + make.top + .equalToSuperview() + .offset(Layout.placeholderTopSpacing) + } + } + + func configure(text: String) { + textView.text = text + updatePlaceholderVisibility() + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !(textView.text?.isEmpty ?? true) + } +} + +extension ReportTextView: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + updatePlaceholderVisibility() + } + + func textViewDidChange(_ textView: UITextView) { + updatePlaceholderVisibility() + } + + func textViewDidEndEditing(_ textView: UITextView) { + updatePlaceholderVisibility() + delegate?.reportTextViewDidChanged(self, text: textView.text) + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportViewController.swift b/Projects/Presentation/Sources/Report/View/ReportViewController.swift new file mode 100644 index 00000000..20c357c3 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportViewController.swift @@ -0,0 +1,539 @@ +// +// ReportViewController.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import Combine +import Domain +import PhotosUI +import SnapKit +import UIKit + +final class ReportViewController: BaseViewController { + private enum CollectionViewSection { case main } + + private enum Layout { + static let horizontalInset: CGFloat = 20 + static let navigationBarBottomSpacing: CGFloat = 80 + static let titleLabelTopSpacing: CGFloat = 28 + static let titleLabelBottomSpacing: CGFloat = 8 + static let titleLabelHeight: CGFloat = 24 + static let cameraButtonTopSpacing: CGFloat = 8 + static let cameraButtonSize: CGFloat = 64 + static let cameraButtonCornerRadius: CGFloat = 8 + static let reportPhotoCollectionViewTopSpacing: CGFloat = 1 + static let reportCollectionLeadingSpacing: CGFloat = 14 + static let reportCollectionViewCellSize: CGFloat = 72 + static let reportCollectionViewCellSpacing: CGFloat = 7 + static let reportCollectionViewHeight: CGFloat = 72 + static let inputViewCornerRadius: CGFloat = 12 + static let textViewHeight: CGFloat = 52 + static let reportContentTextViewHeight: CGFloat = 88 + static let locationButtonSize: CGFloat = 52 + static let locationButtonLeadingSpacing: CGFloat = 12 + static let locationButtonCornerRadius: CGFloat = 12 + static let registerButtonCornerRadius: CGFloat = 12 + static let registerButtonTopSpacing: CGFloat = 47 + static let registerButtonBottomSpacing: CGFloat = 14 + static let registerButtonHeight: CGFloat = 54 + static let categoryBottomSheetHeight: CGFloat = 226 + static let cameraBottomSheetHeight: CGFloat = 174 + } + + private typealias Section = ReportViewController.CollectionViewSection + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private let scrollView = UIScrollView() + private let scrollContentView = UIView() + private let collectionViewTitleLabel = RequiredTitleLabel(title: "제보사진 등록") + private let categoryTitleLabel = RequiredTitleLabel(title: "제보 카테고리") + private let contentTitleLabel = RequiredTitleLabel(title: "제보 내용") + private let locationTitleLabel = RequiredTitleLabel(title: "내 위치 등록") + private let cameraButton = ReportCameraButton(frame: .zero) + private let photoCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) + private let categoryTextView = ReportTextView(type: .combo, placeholder: "카테고리 선택") + private let reportTitleTextView = ReportTextView(type: .editable, placeholder: "제보 제목을 작성해주세요.") + private let reportContentTextView = ReportTextView(type: .editable, placeholder: "어떤 위험인지 간단히 설명해주세요.(100자 내외)") + private let locationTextView = ReportTextView(type: .nonEditable, placeholder: "현재 위치 검색") + private let locationButton = LocationButton() + private let registerButton = PrimaryButton(buttonState: .disabled, buttonTitle: "제출하기") + private let photoSelectionView = SelectableItemTableView(items: SelectPhotoType.allCases.sorted(by: { $0.id < $1.id }), markIsSelected: false) + private let categorySelectionView = SelectableItemTableView(items: ReportType.allCases.sorted(by: { $0.id < $1.id})) + private var cancellables: Set = [] + private var dataSource: DataSource? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기")) + } + + override func configureAttribute() { + configureCollectionViewDataSource() + configurePhotoCollectionLayout() + + view.backgroundColor = .white + + scrollView.showsVerticalScrollIndicator = false + photoCollectionView.delegate = self + + [ + categoryTextView, + locationTextView, + reportTitleTextView, + reportContentTextView].forEach { textView in + textView.layer.cornerRadius = Layout.inputViewCornerRadius + textView.layer.masksToBounds = true + textView.delegate = self + } + + cameraButton.layer.cornerRadius = Layout.locationButtonCornerRadius + cameraButton.layer.masksToBounds = true + cameraButton.addAction( + UIAction { [weak self] _ in + self?.showCameraBottomSheet() + }, + for: .touchUpInside) + + locationButton.layer.cornerRadius = Layout.locationButtonCornerRadius + locationButton.layer.masksToBounds = true + + registerButton.layer.cornerRadius = Layout.registerButtonCornerRadius + registerButton.layer.masksToBounds = true + registerButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .configureLocation) + }, + for: .touchUpInside) + + photoSelectionView.delegate = self + categorySelectionView.delegate = self + applySnapshot(items: [], animating: false) + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.addSubview(scrollView) + scrollView.addSubview(scrollContentView) + scrollContentView.addSubview(collectionViewTitleLabel) + scrollContentView.addSubview(categoryTitleLabel) + scrollContentView.addSubview(contentTitleLabel) + scrollContentView.addSubview(locationTitleLabel) + scrollContentView.addSubview(cameraButton) + scrollContentView.addSubview(photoCollectionView) + scrollContentView.addSubview(categoryTextView) + scrollContentView.addSubview(reportTitleTextView) + scrollContentView.addSubview(reportContentTextView) + scrollContentView.addSubview(locationTextView) + scrollContentView.addSubview(locationButton) + scrollContentView.addSubview(registerButton) + + scrollView.snp.makeConstraints { make in + make.edges.equalTo(safeArea) + } + + scrollContentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.equalTo(scrollView.frameLayoutGuide).priority(.low) + } + + collectionViewTitleLabel.snp.makeConstraints { make in + make.top + .equalToSuperview() + .offset(Layout.navigationBarBottomSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + cameraButton.snp.makeConstraints { make in + make.top + .equalTo(collectionViewTitleLabel.snp.bottom) + .offset(Layout.cameraButtonTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.size.equalTo(Layout.cameraButtonSize) + } + + photoCollectionView.snp.makeConstraints { make in + make.top + .equalTo(collectionViewTitleLabel.snp.bottom) + .offset(Layout.reportPhotoCollectionViewTopSpacing) + + make.leading + .equalTo(cameraButton.snp.trailing) + .offset(Layout.reportCollectionLeadingSpacing) + + make.trailing + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.reportCollectionViewHeight) + } + + categoryTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(cameraButton.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + categoryTextView.snp.makeConstraints { make in + make.top + .equalTo(categoryTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.textViewHeight) + } + + contentTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(categoryTextView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + reportTitleTextView.snp.makeConstraints { make in + make.top + .equalTo(contentTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.textViewHeight) + } + + reportContentTextView.snp.makeConstraints { make in + make.top + .equalTo(reportTitleTextView.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.reportContentTextViewHeight) + .priority(.medium) + } + + locationTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(reportContentTextView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + locationButton.snp.makeConstraints { make in + make.top + .equalTo(locationTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.trailing + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.size + .equalTo(Layout.locationButtonSize) + } + + locationTextView.snp.makeConstraints { make in + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.trailing + .equalTo(locationButton.snp.leading) + .offset(-Layout.locationButtonLeadingSpacing) + + make.top + .equalTo(locationButton) + + make.height + .equalTo(Layout.textViewHeight) + } + + registerButton.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.top + .greaterThanOrEqualTo(locationTextView.snp.bottom) + .offset(Layout.registerButtonTopSpacing) + + make.bottom + .equalToSuperview() + .inset(Layout.registerButtonBottomSpacing) + + make.height + .equalTo(Layout.registerButtonHeight) + } + } + + override func bind() { + viewModel.output.categoryPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] category in + self?.categoryTextView.configure(text: category ?? "") + } + .store(in: &cancellables) + + viewModel.output.titlePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + self?.reportTitleTextView.configure(text: title ?? "") + } + .store(in: &cancellables) + + viewModel.output.contentPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] content in + self?.reportContentTextView.configure(text: content ?? "") + } + .store(in: &cancellables) + + viewModel.output.locationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] address in + self?.locationTextView.configure(text: address ?? "") + } + .store(in: &cancellables) + + viewModel.output.exceptionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] message in + let alertController = UIAlertController(title: "알림", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + self?.present(alertController, animated: true) + } + .store(in: &cancellables) + + viewModel.output.selectedPhotoPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] items in + self?.applySnapshot(items: items, animating: true) + self?.cameraButton.configure(imageCount: items.count) + } + .store(in: &cancellables) + } + + private func showCameraBottomSheet() { + presentCustomBottomSheet(contentViewController: photoSelectionView, maxHeight: Layout.cameraBottomSheetHeight) + } + + private func showCategoryBottomSheet() { + presentCustomBottomSheet(contentViewController: categorySelectionView, maxHeight: Layout.categoryBottomSheetHeight ) + } + + private func configureCollectionViewDataSource() { + let registration = UICollectionView.CellRegistration { cell, _, item in + cell.configure(item: item) + cell.delegate = self + } + + dataSource = DataSource(collectionView: photoCollectionView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) + } + photoCollectionView.dataSource = dataSource + } + + private func configurePhotoCollectionLayout() { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(Layout.reportCollectionViewCellSize), + heightDimension: .absolute(Layout.reportCollectionViewCellSize) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .estimated(Layout.reportCollectionViewCellSize), + heightDimension: .estimated(Layout.reportCollectionViewCellSize) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(Layout.reportCollectionViewCellSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero + section.interGroupSpacing = .zero + section.orthogonalScrollingBehavior = .continuous + + let layout = UICollectionViewCompositionalLayout(section: section) + photoCollectionView.setCollectionViewLayout(layout, animated: false) + } + + private func applySnapshot(items: [PhotoItem], animating: Bool) { + var snapshot = Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + dataSource?.apply(snapshot, animatingDifferences: true) + } + + private func presentCamera() { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + let alertController = UIAlertController(title: "카메라 사용 불가", message: "이 기기에서 카메라를 사용할 수 없습니다.", preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + present(alertController, animated: true) + return + } + + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.allowsEditing = false + picker.delegate = self + present(picker, animated: true) + } + + private func presentPhotoPicker() { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .images + config.selectionLimit = 3 + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + private func dismissIfNeededThen(_ action: @escaping () -> Void) { + if let presentedViewController { + print(presentedViewController) + } + + if Thread.isMainThread { + if let presented = presentedViewController { + presented.dismiss(animated: true, completion: action) + } else { + action() + } + } else { + DispatchQueue.main.async { [weak self] in + self?.dismissIfNeededThen(action) + } + } + } +} + +extension ReportViewController: ReportTextViewDelegate { + func reportTextViewDidChanged(_ reportTextView: ReportTextView, text: String?) { + switch reportTextView { + case reportTitleTextView: + viewModel.action(input: .inputTitle(title: text)) + case reportContentTextView: + viewModel.action(input: .inputContent(content: text)) + default: return + } + } + + func reportTextViewDidTapped(_ reportTextView: ReportTextView) { + showCategoryBottomSheet() + } +} + +extension ReportViewController: ReportPhotoCollectionViewCellDelegate { + func reportPhotoCollectionViewCellWillDeleteCell(_ cell: ReportPhotoCollectionViewCell, uuid: UUID) { + viewModel.action(input: .removePhoto(id: uuid)) + } +} + +extension ReportViewController: UICollectionViewDelegate { + +} + +extension ReportViewController: SelectableItemTableViewDelegate { + func selectableItemTableView(_ sender: SelectableItemTableView, didSelectItem: T?) where T : SelectableItem, T : CaseIterable, T : Equatable { + if + T.self == ReportType.self, + let item = didSelectItem as? ReportType + { + viewModel.action(input: .selectCategory(type: item)) + + } else if + T.self == SelectPhotoType.self, + let item = didSelectItem as? SelectPhotoType + { + switch item { + case .camera: + dismissIfNeededThen { [weak self] in + self?.presentCamera() + } + case .library: + dismissIfNeededThen { [weak self] in + self?.presentPhotoPicker() + } + } + } + } +} + +extension ReportViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + defer { picker.dismiss(animated: true) } + + guard + let image = info[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 0.5) + else { return } + + viewModel.action(input: .selectPhoto(photoData: data)) + } +} + +extension ReportViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + guard + let itemProvider = results.first?.itemProvider, + itemProvider.canLoadObject(ofClass: UIImage.self) + else { return } + + itemProvider.loadObject(ofClass: UIImage.self) { image, _ in + guard + let selectedImage = image as? UIImage, + let imageData = selectedImage.jpegData(compressionQuality: 0.5) + else { return } + + DispatchQueue.main.async { + self.viewModel.action(input: .selectPhoto(photoData: imageData)) + } + } + } +} diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift new file mode 100644 index 00000000..e44ff627 --- /dev/null +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift @@ -0,0 +1,103 @@ +// +// ReportViewModel.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Combine +import Domain +import Foundation + +final class ReportViewModel: ViewModel { + enum Input { + case selectCategory(type: ReportType) + case inputTitle(title: String?) + case inputContent(content: String?) + case configureLocation + case selectPhoto(photoData: Data) + case removePhoto(id: UUID) + } + + struct Output { + let categoryPublisher: AnyPublisher + let titlePublisher: AnyPublisher + let contentPublisher: AnyPublisher + let locationPublisher: AnyPublisher + let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never> + let exceptionPublisher: AnyPublisher + } + + private(set) var output: Output + private let categorySubject = CurrentValueSubject(nil) + private let titleSubject = CurrentValueSubject(nil) + private let contentSubject = CurrentValueSubject(nil) + private let locationSubject = CurrentValueSubject(nil) + private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([]) + private let exceptionSubject = PassthroughSubject() + private var latitude: Double? = nil + private var longitude: Double? = nil + + init() { + self.output = Output( + categoryPublisher: categorySubject.map { $0?.description }.eraseToAnyPublisher(), + titlePublisher: titleSubject.eraseToAnyPublisher(), + contentPublisher: contentSubject.eraseToAnyPublisher(), + locationPublisher: locationSubject.eraseToAnyPublisher(), + selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(), + exceptionPublisher: exceptionSubject.eraseToAnyPublisher()) + } + + func action(input: Input) { + switch input { + case .selectCategory(let type): + configureCategory(type: type) + case .inputTitle(let title): + configureTitle(title: title) + case .inputContent(let content): + configureContent(content: content) + case .configureLocation: + configureLocation() + case .selectPhoto(let photoData): + selectPhoto(photoData: photoData) + case .removePhoto(let id): + removePhoto(id: id) + } + } + + private func configureCategory(type: ReportType?) { + categorySubject.send(type) + } + + private func configureTitle(title: String?) { + titleSubject.send(title) + } + + private func configureContent(content: String?) { + contentSubject.send(content) + } + + private func configureLocation() { + // 카카오 sdk로 현 위치 설정 + } + + private func selectPhoto(photoData: Data?) { + guard let photoData else { return } + var currentSelectedPhoto = selectedPhotoSubject.value + + guard currentSelectedPhoto.count < 3 else { + exceptionSubject.send("사진은 최대 3장까지 선택할 수 있습니다.") + return + } + + let item = PhotoItem(data: photoData) + currentSelectedPhoto.append(item) + + selectedPhotoSubject.send(currentSelectedPhoto) + } + + private func removePhoto(id: UUID) { + let currentSelectedPhoto = selectedPhotoSubject.value.filter { $0.id != id } + selectedPhotoSubject.send(currentSelectedPhoto) + } +} diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 6b2e0bfe..1a1e8eb7 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -2,6 +2,10 @@ + NSPhotoLibraryUsageDescription + 앨범에서 사진을 선택합니다. 허용하시겠습니까? + NSCameraUsageDescription + 카메라를 사용해 사진을 촬영합니다. 허용하시겠습니까? BaseURL $(BASE_URL) CFBundleExecutable From 6f3d5c727c1acc7979c3d95f9a4c5f3726d47189 Mon Sep 17 00:00:00 2001 From: taipaise Date: Sun, 9 Nov 2025 21:55:57 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Report/View/ReportCameraButton.swift | 6 +++--- .../Sources/Report/View/ReportViewController.swift | 8 +++++--- .../Sources/Report/ViewModel/ReportViewModel.swift | 9 ++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift index 85e23ba6..8396a75a 100644 --- a/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift +++ b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift @@ -31,7 +31,7 @@ final class ReportCameraButton: UIButton { private func configureAttribute() { backgroundColor = BitnagilColor.gray99 cameraImageView.image = BitnagilIcon.cameraIcon - configure(imageCount: 0) + configure(imageCount: 0, maxCount: 3) } private func configureLayout() { @@ -50,8 +50,8 @@ final class ReportCameraButton: UIButton { } } - func configure(imageCount: Int) { - let text = "\(imageCount)/3" + func configure(imageCount: Int, maxCount: Int) { + let text = "\(imageCount)/\(maxCount)" let nsString = text as NSString let firstRange = nsString.rangeOfComposedCharacterSequence(at: .zero) diff --git a/Projects/Presentation/Sources/Report/View/ReportViewController.swift b/Projects/Presentation/Sources/Report/View/ReportViewController.swift index 20c357c3..cd90392d 100644 --- a/Projects/Presentation/Sources/Report/View/ReportViewController.swift +++ b/Projects/Presentation/Sources/Report/View/ReportViewController.swift @@ -349,8 +349,10 @@ final class ReportViewController: BaseViewController { viewModel.output.selectedPhotoPublisher .receive(on: DispatchQueue.main) .sink { [weak self] items in - self?.applySnapshot(items: items, animating: true) - self?.cameraButton.configure(imageCount: items.count) + guard let self else { return } + self.applySnapshot(items: items, animating: true) + self.cameraButton + .configure(imageCount: items.count, maxCount: viewModel.output.maxPhotoCount) } .store(in: &cancellables) } @@ -431,7 +433,7 @@ final class ReportViewController: BaseViewController { private func dismissIfNeededThen(_ action: @escaping () -> Void) { if let presentedViewController { - print(presentedViewController) + } if Thread.isMainThread { diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift index e44ff627..1432a9c4 100644 --- a/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift @@ -26,6 +26,7 @@ final class ReportViewModel: ViewModel { let locationPublisher: AnyPublisher let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never> let exceptionPublisher: AnyPublisher + let maxPhotoCount: Int } private(set) var output: Output @@ -35,6 +36,7 @@ final class ReportViewModel: ViewModel { private let locationSubject = CurrentValueSubject(nil) private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([]) private let exceptionSubject = PassthroughSubject() + private let maxPhotoCount = 3 private var latitude: Double? = nil private var longitude: Double? = nil @@ -45,7 +47,8 @@ final class ReportViewModel: ViewModel { contentPublisher: contentSubject.eraseToAnyPublisher(), locationPublisher: locationSubject.eraseToAnyPublisher(), selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(), - exceptionPublisher: exceptionSubject.eraseToAnyPublisher()) + exceptionPublisher: exceptionSubject.eraseToAnyPublisher(), + maxPhotoCount: maxPhotoCount) } func action(input: Input) { @@ -85,8 +88,8 @@ final class ReportViewModel: ViewModel { guard let photoData else { return } var currentSelectedPhoto = selectedPhotoSubject.value - guard currentSelectedPhoto.count < 3 else { - exceptionSubject.send("사진은 최대 3장까지 선택할 수 있습니다.") + guard currentSelectedPhoto.count < maxPhotoCount else { + exceptionSubject.send("사진은 최대 \(maxPhotoCount)장까지 선택할 수 있습니다.") return }