From fccd209ddbf275159afd476dd3ee57ea3b8658d1 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 8 Aug 2026 15:23:58 +0800 Subject: [PATCH 1/3] feat: earthquake map --- assets/map/icons/cross.png | Bin 0 -> 2127 bytes assets/map/icons/intensity-1-dark.png | Bin 0 -> 1030 bytes assets/map/icons/intensity-1.png | Bin 0 -> 1018 bytes assets/map/icons/intensity-2-dark.png | Bin 0 -> 1284 bytes assets/map/icons/intensity-2.png | Bin 0 -> 1223 bytes assets/map/icons/intensity-3-dark.png | Bin 0 -> 1460 bytes assets/map/icons/intensity-3.png | Bin 0 -> 1333 bytes assets/map/icons/intensity-4-dark.png | Bin 0 -> 935 bytes assets/map/icons/intensity-4.png | Bin 0 -> 1015 bytes assets/map/icons/intensity-5-dark.png | Bin 0 -> 1156 bytes assets/map/icons/intensity-5.png | Bin 0 -> 1186 bytes assets/map/icons/intensity-6-dark.png | Bin 0 -> 1258 bytes assets/map/icons/intensity-6.png | Bin 0 -> 1237 bytes assets/map/icons/intensity-7-dark.png | Bin 0 -> 1046 bytes assets/map/icons/intensity-7.png | Bin 0 -> 1115 bytes assets/map/icons/intensity-8-dark.png | Bin 0 -> 1317 bytes assets/map/icons/intensity-8.png | Bin 0 -> 1344 bytes assets/map/icons/intensity-9-dark.png | Bin 0 -> 1125 bytes assets/map/icons/intensity-9.png | Bin 0 -> 1087 bytes lib/app/router/app_router.dart | 10 + .../data/report_repository_impl.dart | 7 + .../earthquake/domain/earthquake_report.dart | 127 +++ .../domain/earthquake_report.freezed.dart | 850 ++++++++++++++ .../domain/earthquake_report.g.dart | 65 ++ .../earthquake/domain/report_repository.dart | 5 + .../pages/report_detail_page.dart | 1016 +++++++++++++++++ .../presentation/pages/report_list_page.dart | 43 +- lib/l10n/app_en.arb | 53 + lib/l10n/app_fil.arb | 12 + lib/l10n/app_id.arb | 12 + lib/l10n/app_ja.arb | 12 + lib/l10n/app_ko.arb | 12 + lib/l10n/app_th.arb | 12 + lib/l10n/app_vi.arb | 12 + lib/l10n/app_zh.arb | 12 + lib/l10n/app_zh_Hans.arb | 12 + lib/l10n/app_zh_Hant_HK.arb | 12 + lib/l10n/app_zh_TW.arb | 12 + lib/l10n/gen/app_localizations.dart | 72 ++ lib/l10n/gen/app_localizations_en.dart | 38 + lib/l10n/gen/app_localizations_fil.dart | 39 + lib/l10n/gen/app_localizations_id.dart | 38 + lib/l10n/gen/app_localizations_ja.dart | 38 + lib/l10n/gen/app_localizations_ko.dart | 38 + lib/l10n/gen/app_localizations_th.dart | 38 + lib/l10n/gen/app_localizations_vi.dart | 38 + lib/l10n/gen/app_localizations_zh.dart | 152 +++ lib/shared/navigation/app_routes.dart | 5 + lib/shared/seismic/report_colors.dart | 60 + lib/shared/widgets/intensity_badge.dart | 54 + pubspec.yaml | 19 + .../earthquake/earthquake_report_test.dart | 61 + 52 files changed, 2951 insertions(+), 35 deletions(-) create mode 100644 assets/map/icons/cross.png create mode 100644 assets/map/icons/intensity-1-dark.png create mode 100644 assets/map/icons/intensity-1.png create mode 100644 assets/map/icons/intensity-2-dark.png create mode 100644 assets/map/icons/intensity-2.png create mode 100644 assets/map/icons/intensity-3-dark.png create mode 100644 assets/map/icons/intensity-3.png create mode 100644 assets/map/icons/intensity-4-dark.png create mode 100644 assets/map/icons/intensity-4.png create mode 100644 assets/map/icons/intensity-5-dark.png create mode 100644 assets/map/icons/intensity-5.png create mode 100644 assets/map/icons/intensity-6-dark.png create mode 100644 assets/map/icons/intensity-6.png create mode 100644 assets/map/icons/intensity-7-dark.png create mode 100644 assets/map/icons/intensity-7.png create mode 100644 assets/map/icons/intensity-8-dark.png create mode 100644 assets/map/icons/intensity-8.png create mode 100644 assets/map/icons/intensity-9-dark.png create mode 100644 assets/map/icons/intensity-9.png create mode 100644 lib/features/earthquake/domain/earthquake_report.dart create mode 100644 lib/features/earthquake/domain/earthquake_report.freezed.dart create mode 100644 lib/features/earthquake/domain/earthquake_report.g.dart create mode 100644 lib/features/earthquake/presentation/pages/report_detail_page.dart create mode 100644 lib/shared/seismic/report_colors.dart create mode 100644 lib/shared/widgets/intensity_badge.dart create mode 100644 test/features/earthquake/earthquake_report_test.dart diff --git a/assets/map/icons/cross.png b/assets/map/icons/cross.png new file mode 100644 index 0000000000000000000000000000000000000000..4e177ddbb152752b820a27dd030e921210e71cc6 GIT binary patch literal 2127 zcmV-V2(b5wP)T& z@@++s59@f8U@asf$1HQ27m@MML5_>aP0K)yJ`5VHg+%1ThB@5~X2L`8SBAxe0pJ@3 z{!MTMzjY=I0N=>J3DN}qN@cU?CP)?h>Ks;ErjPDT5F7YE|15Ilib%g-H-Ts-NL~0# zOCpyq>)gua-UP9Mzpx-OJuULY6C$&-I`C{F#R_hM*ubBi71_E~=eDp=m5VzQ#1{UZ zJtC8nB2!Z$J9mm4IM7NH#0LI>13H(fDV^J%Jx)zfB|(k@UjfemQr4CmH{i$-_~#!O z837Uf{l!I?nSpcXU~Ei(Ulm^lIHi<&tE|DGN{xQAq=AmH--p*<*SU?S43&cAW$5+b z%o&|q$*s`Lz5`xRN-gF!%BA51zEO)z7znuY*BKenOpYAU*D01^yBEzIB_dA(w}3}VZ{z}gRvvpo zF!)*7^#p)_fu|B+j?nPITTh4r-(0tTPXNvQP=cODES3-WMvhI020wd!I}`RP5&0F^ znfvseJMh|T@b}-kYq^Aiu3BK?0m;G6sPdho{|@WT&fEgw}P@)NKt_vwBg4j+d3dA|W0+4qDK>EqjlEkeUL zT3m#^d*SR^7#%HYbxVoJr@*Tlo{AZ`oe9Nie-57|z~{LaF>eE7WBNifGy1~5W&d`R zulBDy8}ffKuuLP`qfW zD<*UY{t2bjdDu7{-JZ|VS=C9f&9SZ5y1!xaCS*>fA`Y{QxN5TVqsA&MS*n|-B1RpF6fK6(GNBCfA0BkZ7Ji~_u2EZmU zApm@6XaEek34!3l00zL2nh+2^3}^rhnF)d6g98I#NKA+T9~>C~9XBBod~j$0bku~1 z@By6u4W9<~QAvaRnYE^?9tM$8(cy#R9$;kM6K>vI8(0gaBoBU->)Eb5dc((Na@QY2+qtZzOp&+Hy8J_| zd+?1Sn&4_EqPy^oBAMV~Fs}RXjUt#Z=y3G#z&8qP!XOhe!w26epb1TGe4_v+*e*-iJn)S?n_#mrYxBW3@@PWcrHSo?-|nS}wM?jtyp{0H!5B%hHn8S< zQpeNT7NrKX51q8v-|}EW;g3yg;dgU`M&a90?yEWjzgt_Ba!uIq zicZ1r-X^^bCai1SN%&oeZN1i+u=-0lokZ^@d?;S>;#abwP>982Ye|?YYgmlhB&F5%XPOSAs6pG(7O3D{n_TfP)F}a_-Yzg>Tdi6ZR+Q zB4rz;6x_eBk1T61{1dDlTeeZLO*njb&4hgIPS6$N^7ZAHaQ!-L+2SF}htzDL>^td_ zLHh;4zO`iwT)U=~INOuur6)9ppI^i$6KosGSUKMi=hOs8@S%80GG-w+FS`-JkqHjrL;0k9 zj8cZ);2RBOf@An~f(=>B)5|8fg1kBcQfJxJ&>5002ovPDHLk FV1n~D32^`b literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-1-dark.png b/assets/map/icons/intensity-1-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b28a3d6a17b369569b04d6fbc2a2d36f822ec91e GIT binary patch literal 1030 zcmV+h1o``kP)iETqxQy&oDL-4K8si)#djn5hbB2{84>k5WrG zz-;itUcu*@;4ci;Q}9_K%|Oqs(sH1>5vZ&IPE-M9m5_q1BxxO3Sp<^t^k*?DsWbDM zBG&+3C-V>m&w-ylPr5+w%OE}RxwOQnq!wg$iPD^o$E3@e%XyT~_K-dGKuIYq0ngCt zCJJ#G*xoAa<$*pplb(%`n}ifNt!JSEmNT{W#~*#U7*;2h9Ug7o8UK0Nj95|IJ(63Gn&`k&zG%lD84!?!`61^(u`)c)VuKLjPX1Y_gP6Dg39F&ZLOe!UC=2SuKSvE^P$xni z>EbB{7mZC!T+iS4xHItj9<8L;zWeh@e)M?n+T{0r|L)%V{S0am$8pm9G0ry1mSr>9 z(xeA=kN#}*XoMy9}@Po!v_EbaRyvZLsbllCO zfXz0>!@?FTQepu+kMSKP=Yh$#hX4%^2a20R&F)@JR-ADKF7=WRQpC7m3<1Dt_fhx-lM7e*Om3I{BVYJprZmb zc{c9~9h(Hl^EujId#aW5pd&p1@8)<%*O5R4z<9T2@6yE05z>{w1i*O5WS1QpAe{+p z4p=D_wRau_Ccwoe=I-=G752~e#a(#)bwE9MmH_kwpLF~kw+>OC|d4BSl zyFsA~fiHvT1=-AiyZ-CA$JmkQfQO~B+xj}5DRe{t>@n&}?Dqo_0Vw7q0#M9J1fZCc z2tY9>5rAS&A^^plke*LgA;7L!iqI7tY(@!$(Mza6fDIDn0XTnF_)6Ho@w_4~ zVKe2$&6mZR=?6Eo3uZVEIHgv^CY0g3zYH#kwde)9g|qxGen^gjdHit&@KQ)D)enSG zgccGeY+SUIG6N7juFH}3S)k`)_+k!IOyoAnQf5H2q$wP6A(DJ``=fiU%RwhvIh(mm zqr*eZRxq1XNmD*vYS}tPD{T(gY^=%RR91007*qoM6N<$f=8~`-v9sr literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-2-dark.png b/assets/map/icons/intensity-2-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8caa74b215407e4ea54cfa96ab6fec0a6b494470 GIT binary patch literal 1284 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 literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-3-dark.png b/assets/map/icons/intensity-3-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a449b02ba3106735f5639a2b67fffcda10a5fd5d GIT binary patch literal 1460 zcmV;l1xxygP)JY&uuc_T+nq>g2GPqiX1Bva zo<@9>NYv(iAusBmW-o7ldoNr~wJ6(K4kthl-PY>I;PD3bQ>EW+Aa#NLcd#4i!lWV#hm=J63tZaIF!ZJ1=qrR@RLkxk8_& zOPti-@g3ReXyrAi19O)FCxz6~0LP5}i3TVYyoc&gR+E&x+3`ndfHwV|sy!Z*5)1{m zNQwru(*Q}a?>^v1sZkYh8#Oo60E)hK%R|nOeLa;8E~92eOM)9Hh2*ElM5kJ3R5lW+ zfCYG~JUFbhqqY87Jn={)ypQJ6;hMUz_i&>qquXE=wlcsze*m3(JJgBL@QHp=AiWIP*mglgn-R(`kSxmq z%;*|weed3>E{?R;x1>yt93r(6S_YOGz)S)5^v5rqQ{CSTdcmuGa%P1^4*?E&-QT62 zI;S#DzStrMq$_r0PKl)$fIXJY3$r3jM|61}GLT~cF_sUX2;tJv^XgER${18WU8ZN4 z$SRDaCP6Bv^)40ArnTB)2t6f})#9sD@=9XMMgebp@{l9P4JyrVgH}(6rG6Vs zM9cUreG{R#To;PVUil%GeH%35!OE`uRi13v^!o#7fTV*3DfZer789K!G(cG2dUt98 zrN+wKl2LP*1{l+~-WiuO@hB$TNp8Rx4e+he!)ptxC?)Pp7ADuhRN2%C)PzMEd2a0TunZdyKMClDJ^M~=V`c0yvXRt4DlV=A*3F5XKi5x zQ4zke!N1iGG$4$GBQseYnqo*)2i)prYF^tVG}zmBZ!U^VAH)2}Ei=VQ7`j7KMr^8M zb7m;m#0Muem=l5kiv9BWN=`>-6H##QAhJ^wrreGzt{@F%$efZqKL0;TDnd4~QVJ6Q O0000H_ptHcg{WM+;i@a6a^%k&6e=T z32s%~c5}11l~6Po0(9Qh@6xpU6Xa2>#&nQ+oL2nR6X=u2$Al z!`@4#$2PYLHA+lZK!!iA=x+~>57X)9`pEQlP+$?ykKZ#{LxvU5fD1WaaKh_LnXY@3 z6b!)|9e*4OsM4Qw4s}zKP{X$e)lfiAa?s@ca;!n_;=yhxz|!x+1(HVZNWa%@3kt|7 z6p|49bPSzZQbdcKWfTJG6~%Pes^&a>4{cvpM(O3n+*UdF0W1zZ^p)!i`0AU1b_$CW z3TFN0<~w_+5Fit)+j)j+%W9^C4FZ&bJfx|wl?uuC%ejG2!~!5Jijle z!4N@hHD4n(7Dogq@C&=Q2E0Fwad>H9}-Ip%KyR)7^A2^QQ1FyuA9&tE#{Q_g&eL=5mtb|gz; z5e1;fX7huQ5&AJU@rNXkQ2>wS9p_t}rD+Uh`IG_S{USZwL@F^74T2C(^)3WZWv!YF zfu#hQEtXuX3~dxhyT#93kgNzJzcM7fPa)C@z$&%$J_QjM7@L@1z(R~b&tqeQG$IN> zNqgW)jZ@Vf=2_o-Q(g)PjkUqB%VE*A!Re@okJNWMu$HhkU52(c=*9n;-DqvUS@in@ zpn#l%1(Hiq3x)1_E~8L@t=}zQQ$|U{(5cQ&(H{NozS6=6!G#7>Z65SM0q=5Np5=~? zIDnZa589!CSER)O2tU`|9Z>|;rojc8i`;$lkM$=Pcb-i|FhuY(EWJI3>`U9_apfK% z$;|Q}M4A8xCTv^;Ix^#YgR3&>9b1I}@eA%deKA(UCa)ti!0*r$TmrK*C{uAAr9)Gg zhQox5&j@Cd>ClwIJzDmXl67nj*xXLC@H6tBrGt}RZ@Vv6kVjyQA&{ZMOZY%!Cl60Q r%Ek*E^v0QC%TK-(1tgo$4TTND zvPAHNb%osso9D>R!)xCd-rXL6dCski4IPmotLZ- zVVhO9!Y^%IjXXO~dbaaA-_A?u1N$xwEn)RqcyO-%X!;3-VfIJF z`^(@{p~VPjs-=9)6$$zH*04(fZVe8OUZ8LoZfb)9cxq`8+P(@0ZW=uH0duu<{uPC! z_Uv}q7D~=NSVoTFP4ID?Cf1w4_Ce(2CDChft_g6$66JgrSQB744rXn(u_6FRWUo%) z1Xct%>Ddo`N#+8u$G^COGh`|N@45@^ewj=KaG}>%$xHy(LSK@J08yd8--J~%848<1 zza^&K5AVY&sYgKX(j5A2G0va;g;i1&AS(1-$vLJfz`;9h9&X`yJLOqkTv8K&?SAr8 ze~zhYS4c&GJcPa^m8Sq#V{|Y(+uR9ef|6e=21d(u8;_G;n@lAkqDwp^0oN+-5M6_{tf$3Wr_s~oLZ`)Dpb`Kjr}P3;%i}0Hl>pFe zEO0G%p9BV;(JjnGcu6=nNwgw0&W->UDWp7r`cib^gF6kB5^Vp?LQ=U{GY+w9cZKJKT%XkR3)a1os+D+)VhZWQipF(b*{|DDffFvt)?*IS*002ov JPDHLkV1fyJtmXg! literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-4.png b/assets/map/icons/intensity-4.png new file mode 100644 index 0000000000000000000000000000000000000000..7c79b08d298c91b4be8c1946cc61998f11eae31c GIT binary patch literal 1015 zcmVl_zKf;z_q*r2(>((d;y6y8e#&G;vI3b& zmWLkn$ZWDMS%<7;7)B4%fE+i;UXXorlEpTeiGPLuoF}uK1lLH|Gb?f=jo^OklAJ z%&)oEa%==5;jaMRIQa;f^X&Ms1)RKeuXSg+1}xRXT5Ymb|0^*l0C|XYq5C=Y|5S2+ zlLSY>opx9>SR}wRzUT|!h5o+KEve4?j22;qhk~C3YtjcK*?Go)f%0M7kM^85_Nzkk zVLjQ-`>+oumaL`9zH`ywiA@0ALfxd^4iG8GOB{ptf?;VP68c?4Lm z!$ZGD^ZecqC?!$>Muk2va?G1hPNV`%>|40)K4Y3hvLRyJMW~Z2KPf7n9$HvK>R5avyW3>}#Pef(?0im_a-b=}$LK{m zEWld?^8h=4eI2P`xgItIS~>6Bd{bz;u`#OfAUUuPOVYF8P4+!IF3(q935j*V2k6cC zf(e^1CM%g?4jaHx9oobBi-#}gFnJ=kAuE|-#uiQSP~el~r#tR>VqJy?zvU}5cbPAa z`-0gBE1L50O3P>mK2>FNj7?S{=ZEG!S>dFtx7TqMvc#A?*oRe_^Aq8?#nanPvfx5* lt{FDR`8V;-hm+Fs`9Hgbcha75Ii>&r002ovPDHLkV1i=7(iH#z literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-5-dark.png b/assets/map/icons/intensity-5-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..75b7730c6d8fad9e60154b74fad95cde49d83c03 GIT binary patch literal 1156 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 literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-7-dark.png b/assets/map/icons/intensity-7-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..867a20de4ee36ea97c098827ee424da30bfa442c GIT binary patch literal 1046 zcmV+x1nK*UP)Hg%@H8)kvGT?$%-}?ut)BYTjU1$r)cX03^_+;$lN0?igPyxUELP| z$UWyA3?h{wQ-DEFyi2BVL^OmjF7Z_pLePMLLOjM%{)k*5u~VKqLOPcP44RcZGk4Ah$6Ij)Ng{V{NxWJ|+1ez!aMB)?2 zg&o6KY(Q3MhnXn!T^4jKGkFwD&w$tV-w?sLK$m$cJ`|Awwc0|@MK8mlNNcwdKv#B!bHykEU(lTai~+3I{L>?JlLS2#Pv|BB;<#J!ude{t z*UkHHHUJL~V1o7r(EUD}$&XW}UoQDcZgJK6>x;J$E-$%p)K0f*Q(cvN3bmwSd?189zaalv_Yr5+aP_d@Nn%g$kMMTpAZ=jZ=dpTz6xbt;W} zhPeT{u%_Do5HuH1VSw4BxdA3sUXxtVGwc~4$`QJ}F@QnsZUL1E_xJTS-BQ^=a|g3R zh#1G};-*^WQr(}X>i(AJ2GD6GsH+A`kZ76=>8eATH&_&J#{eSL{as)>_0)hSp{)ab z-?Dgn20(Eum`s35@ja~z*fYR15-2zHxF}-RfReMB{;?Pxzq#p6xXJ)~kH@L#p|$;L zdHeCf$NGUP!il8KjsXth=luWy$becr?qGb_iJSLlqGOs#tC!eGn#q8g-VKZoOL6m- z40tbS5bOy%Mi)lguf zP7F@xb$MYF_-sN7$8DG>b3rQkf;TxKE6z2Li%&tZjgF~IAV+7wHz=hJiKcU*ja-jv z;iY&68l3dDxe^Rcc;jRVx=)0O(K8xtovng_WK)&H%nIQQG?+khjB@+@U+6eryoGG- QQvd(}07*qoM6N<$g0O+xHvj+t literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-7.png b/assets/map/icons/intensity-7.png new file mode 100644 index 0000000000000000000000000000000000000000..eca3d1e99588e4e30dc870dfe53f412ae9997a08 GIT binary patch literal 1115 zcmV-h1f=_kP)iK~#7F?OW|p z<1i4FZ|*;)f`|$T6(m%UTm=aoKm|z$p@Yytpn^aJgbHFRaHwE!-zYvpw6cvqBXe)& zjR)Ceyw9uEu08^l6GRm9Wy~Bf`%KLY)oEbIjF=ndGxI43f}N@YY`A7Fm@)ZePSu5k_T|mUV=57%k-}rUhz{aD0cgc7_GuM0D+#u86w@zn@ zISee=qedg5UXKX>atg$|3|=eJJ7XES~xJRPIch<8mFA(RA= z10EFB+7H7IiFbB;!$NZX?iim>1bryLQhr4HWw1+xICgshnZ9oW{m3Un(a3?hGM-ogByy`;C_uskILQwACC%s&m+E zq*fiEBUsIWSR(ij!PH;shvT=Z6Cr1ulL_~|!=Zp`Jpf;Jwc^2LsC+?n0%*+@_u5=NLN!6qQ{#bZJYY7f*7(Oqr7kb? zKab4y=0+7kc>&Phr&IU+Bn`z&A{qJkFl|7uXM{uBAuYyr z`TlO$BFe=8DDR5+S;n6T$_X$Ux$n*Ae$*6HCJ{>GRuL6^arvl`0?JP949AAq7 zNmqyqZX|7T8%}Kk6!iqeh9)OPY84>ibeg>^hWpsp*OcKY0@SxWj-&^3`-kc6+nX8d z+sX(fQ)?686d{=UE5gt?%ascBJUv`G)rJSQoAOI%#?KG}STV+v|$}kfYWX{#tUeJ}z(lVC+Is*Ol?eb19 z@L7cK9GKV(;65?~G`Wabh6vtxS9f)~CD$=J$~rU!MX~6KeSoLr6?m8N*c|9eFUz-d zejJ2AY%Rrg5XQu^Z($a^z{D0D`ra@cq;{LxRQ*|g?(IDl3zd|z^JCP h?8{yFQ%8@_{{`4&f#qDkhw}gc002ovPDHLkV1ftW06zc# literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-8-dark.png b/assets/map/icons/intensity-8-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..02f2e07b6c7f776f6a5ac94dc241ca710537091a GIT binary patch literal 1317 zcmV+=1={+FP)ZqxqQv=bcFJZlvy+`{x`We z<(9>PCNrB^jaijh#Vrd3i&>FbgIOysjY%_^{FuO0M$&>585UZTKxe<=`5%yF@{(K|zw8 z=fH<$c~yZ~1_8*+zEq&}pb&2~{2~G{FVg+kqER$z76Gut(lq40 z$c(($;v(2V8Uvv4$q~V!W|RiNXeL8Uw1y07jy$LW?4=UdVzn3!-E(Gp#%Uo_h5I|*YSS1S1i(Gp>TOzFPG`#>MDgn z-vK~N&K@2*pGl-I5lp)CpsxTR-;?Y-FirvFyEiXIAX3QHX0=KewHp0BKfe^Nn^RLx z;7}Gd#sNU$#B4wyI}6-EKLlg}&b&X10qPqZXPi1}9Q(jFck)BUZj zSP7?53dr&lK{s2E59kk@Y}CLDV^J?%Rz;{T~c^(qK`K?DK?apS;w2o{D! z6_^_PfrS%q5PzJUP*?;GfUaB1hB2MU>E;C(U0{PCa0-~r^1@oZ83dsRK&BBo-H0+k zfdjx7G*!r^$-1SoD4!jSo&_LuD&Va5i4OU#8|%XqwV(u^3IhyVnD6_6R)y)tc?jgY zxjDKDa*{Hm9)O$HsWM+P(B*&BzY7?z31Y18H+S*eTNRf`5j6RWm2Xnr@x)0P=fpk%y3&h=}DltONkXUj~WO;bn(z8z(f;vm>$pW4MyPjE bxqbd0Y2-V?vX9w=00000NkvXXu0mjfGnHB6 literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-8.png b/assets/map/icons/intensity-8.png new file mode 100644 index 0000000000000000000000000000000000000000..4480547ca369e6d72109b96a47a15cf19f937250 GIT binary patch literal 1344 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 rtRF;GG&djdWal00000NkvXXu0mjf_v{!V literal 0 HcmV?d00001 diff --git a/assets/map/icons/intensity-9.png b/assets/map/icons/intensity-9.png new file mode 100644 index 0000000000000000000000000000000000000000..3f1fd0d64af27e70eafac8c0ddb17ab11ae071c7 GIT binary patch literal 1087 zcmV-F1i<@=P)(1zGXo0={CY)kVEEY(4I4u= zRm{`q!iaM(!6IKv_$z=LC!a{*JRCnX&**65+_{fCv#aAYw>Bm#w%BI8QKD41*GnWFdM-Xje#exlO#`*8kMtspIrGBuAy2+XZYm#>_bRNbmmRA?6fwQnpx zl$J+5Gu18tR&j@N@<@z&T|&D6LCyaQ$9~kSQd$L=EItnG{Z*_>Xcb^8V8Vo&Kiz9m z)Myg`u@94hMOhVS6CkMev4*Fj_JI}wob>{Ze;KvyOJz&)S_BB{`GLwMdF29N%nyov zBt9xX$S4;8S)cBquNCD2=*;^?C>0E4VH$*OQG z=-lGG;bQP*i257|&~dN;dvDW*q#tTP0L%aMQQ)0Hy*j}>pZF$e&;H2nn_r)0! zbc_iEKFo-m?+W|o@bspgEO5{pXNHY1|1#7ic1n-W{{bxb$S1K?x3vHO002ovPDHLk FV1nqc@>>7^ literal 0 HcmV?d00001 diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 77d233cea..53d0fa9df 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -2,6 +2,7 @@ import 'package:dpip/app/shell/main_shell.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/features/data/presentation/pages/data_page.dart'; import 'package:dpip/features/earthquake/presentation/pages/earthquake_page.dart'; +import 'package:dpip/features/earthquake/presentation/pages/report_detail_page.dart'; import 'package:dpip/features/earthquake/presentation/pages/report_list_page.dart'; import 'package:dpip/features/events/presentation/pages/events_page.dart'; import 'package:dpip/features/home/presentation/pages/home_page.dart'; @@ -71,6 +72,15 @@ final GoRouter appRouter = GoRouter( path: AppRoutes.earthquakePath, name: AppRoutes.earthquake, builder: (_, _) => const ReportListPage(), + routes: [ + GoRoute( + path: AppRoutes.earthquakeReportPath, + name: AppRoutes.earthquakeReport, + builder: (_, state) => ReportDetailPage( + reportId: state.pathParameters['id']!, + ), + ), + ], ), GoRoute( path: AppRoutes.eewPath, diff --git a/lib/features/earthquake/data/report_repository_impl.dart b/lib/features/earthquake/data/report_repository_impl.dart index 69f896631..bbc66b02f 100644 --- a/lib/features/earthquake/data/report_repository_impl.dart +++ b/lib/features/earthquake/data/report_repository_impl.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; import 'package:dpip/features/earthquake/domain/report_repository.dart'; @@ -40,6 +41,12 @@ class ReportRepositoryImpl implements ReportRepository { return parseReportList(raw); }); + @override + Future> get(String id) => guardResult(() async { + final raw = await _api.getReport(id); + return EarthquakeReport.fromJson((raw as Map).cast()); + }); + /// Skips malformed rows so one bad record cannot blank the catalogue. static List parseReportList(List raw) { return [ diff --git a/lib/features/earthquake/domain/earthquake_report.dart b/lib/features/earthquake/domain/earthquake_report.dart new file mode 100644 index 000000000..64e6d964c --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.dart @@ -0,0 +1,127 @@ +/// Full earthquake report — `GET /api/v2/eq/report/{id}`, the per-area/town +/// intensity breakdown behind a catalogue row ([PartialEarthquakeReport]). +library; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'earthquake_report.freezed.dart'; +part 'earthquake_report.g.dart'; + +/// The full earthquake report — epicentre, magnitude, and the per-area/town +/// felt-intensity breakdown. Fetched by id from [ReportRepository.get] when a +/// catalogue row is opened. +@freezed +abstract class EarthquakeReport with _$EarthquakeReport { + const EarthquakeReport._(); + + const factory EarthquakeReport({ + required String id, + @JsonKey(name: 'lon') required double longitude, + @JsonKey(name: 'lat') required double latitude, + @JsonKey(name: 'loc') required String location, + required double depth, + @JsonKey(name: 'mag') required double magnitude, + required Map list, + + /// Origin time as Unix **milliseconds**. + required int time, + required int trem, + }) = _EarthquakeReport; + + factory EarthquakeReport.fromJson(Map json) => + _$EarthquakeReportFromJson(json); + + /// Leading CWA serial segment (e.g. `115032` or `115000`). + String get serial => id.split('-').first; + + /// `…000` serials are 小區域有感 — no numbered CWA report. + bool get isLocalFelt => serial.endsWith('000'); + + /// Numbered CWA report id, or null when [isLocalFelt]. + String? get number => isLocalFelt ? null : serial; + + bool get hasNumber => number != null; + + /// Origin time in UTC. + DateTime get originTimeUtc => + DateTime.fromMillisecondsSinceEpoch(time, isUtc: true); + + /// Short place string — prefer the parenthetical CWA locality when present. + String get shortLocation { + final open = location.indexOf('('); + final close = location.indexOf(')'); + if (open >= 0 && close > open) { + var inner = location.substring(open + 1, close); + if (inner.startsWith('位於')) inner = inner.substring(2); + return inner.trim(); + } + final fang = location.indexOf('方'); + if (fang >= 0) return location.substring(0, fang + 1).trim(); + return location.trim(); + } + + /// Highest observed intensity across every area/town in [list]. + int get maxIntensity { + var max = 0; + for (final area in list.values) { + for (final town in area.town.values) { + if (town.intensity > max) max = town.intensity; + } + } + return max; + } + + /// The official CWA report page for this event. + Uri get reportUrl { + final segments = id.split('-')..removeAt(0); + final magCode = (magnitude * 10).floor(); + final numberSuffix = hasNumber ? number!.substring(3) : ''; + return Uri.parse( + 'https://scweb.cwa.gov.tw/zh-tw/earthquake/details/' + '${segments.join()}$magCode$numberSuffix', + ); + } + + /// CWA's rendered report image (地震報告圖). The filename is derived from the + /// Taipei-local origin time, magnitude, and (when numbered) the report's + /// serial suffix — CWA doesn't expose this as a field, only as a static path. + Uri get reportImageUrl { + final t = originTimeUtc.add(const Duration(hours: 8)); // Asia/Taipei + final y = t.year.toString(); + final mo = t.month.toString().padLeft(2, '0'); + final d = t.day.toString().padLeft(2, '0'); + final h = t.hour.toString().padLeft(2, '0'); + final mi = t.minute.toString().padLeft(2, '0'); + final s = t.second.toString().padLeft(2, '0'); + final magCode = (magnitude * 10).floor(); + final numberSuffix = hasNumber ? number!.substring(3) : ''; + final name = '$y$mo$d$h$mi$s$magCode${numberSuffix}_H.png'; + final yearMonth = name.substring(0, 6); + return Uri.parse('https://scweb.cwa.gov.tw/webdata/OLDEQ/$yearMonth/$name'); + } +} + +/// One area's (縣市) maximum observed intensity and its station/town breakdown. +@freezed +abstract class AreaIntensity with _$AreaIntensity { + const factory AreaIntensity({ + @JsonKey(name: 'int') required int intensity, + required Map town, + }) = _AreaIntensity; + + factory AreaIntensity.fromJson(Map json) => + _$AreaIntensityFromJson(json); +} + +/// One station/town's observed intensity and coordinates. +@freezed +abstract class StationIntensity with _$StationIntensity { + const factory StationIntensity({ + @JsonKey(name: 'lon') required double longitude, + @JsonKey(name: 'lat') required double latitude, + @JsonKey(name: 'int') required int intensity, + }) = _StationIntensity; + + factory StationIntensity.fromJson(Map json) => + _$StationIntensityFromJson(json); +} diff --git a/lib/features/earthquake/domain/earthquake_report.freezed.dart b/lib/features/earthquake/domain/earthquake_report.freezed.dart new file mode 100644 index 000000000..af998b702 --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.freezed.dart @@ -0,0 +1,850 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'earthquake_report.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$EarthquakeReport { + + String get id;@JsonKey(name: 'lon') double get longitude;@JsonKey(name: 'lat') double get latitude;@JsonKey(name: 'loc') String get location; double get depth;@JsonKey(name: 'mag') double get magnitude; Map get list;/// Origin time as Unix **milliseconds**. + int get time; int get trem; +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EarthquakeReportCopyWith get copyWith => _$EarthquakeReportCopyWithImpl(this as EarthquakeReport, _$identity); + + /// Serializes this EarthquakeReport to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EarthquakeReport&&(identical(other.id, id) || other.id == id)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)&&const DeepCollectionEquality().equals(other.list, list)&&(identical(other.time, time) || other.time == time)&&(identical(other.trem, trem) || other.trem == trem)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,longitude,latitude,location,depth,magnitude,const DeepCollectionEquality().hash(list),time,trem); + +@override +String toString() { + return 'EarthquakeReport(id: $id, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude, list: $list, time: $time, trem: $trem)'; +} + + +} + +/// @nodoc +abstract mixin class $EarthquakeReportCopyWith<$Res> { + factory $EarthquakeReportCopyWith(EarthquakeReport value, $Res Function(EarthquakeReport) _then) = _$EarthquakeReportCopyWithImpl; +@useResult +$Res call({ + String id,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location, double depth,@JsonKey(name: 'mag') double magnitude, Map list, int time, int trem +}); + + + + +} +/// @nodoc +class _$EarthquakeReportCopyWithImpl<$Res> + implements $EarthquakeReportCopyWith<$Res> { + _$EarthquakeReportCopyWithImpl(this._self, this._then); + + final EarthquakeReport _self; + final $Res Function(EarthquakeReport) _then; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,Object? list = null,Object? time = null,Object? trem = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double,list: null == list ? _self.list : list // ignore: cast_nullable_to_non_nullable +as Map,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,trem: null == trem ? _self.trem : trem // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EarthquakeReport]. +extension EarthquakeReportPatterns on EarthquakeReport { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EarthquakeReport value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EarthquakeReport value) $default,){ +final _that = this; +switch (_that) { +case _EarthquakeReport(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EarthquakeReport value)? $default,){ +final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem) $default,) {final _that = this; +switch (_that) { +case _EarthquakeReport(): +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem)? $default,) {final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EarthquakeReport extends EarthquakeReport { + const _EarthquakeReport({required this.id, @JsonKey(name: 'lon') required this.longitude, @JsonKey(name: 'lat') required this.latitude, @JsonKey(name: 'loc') required this.location, required this.depth, @JsonKey(name: 'mag') required this.magnitude, required final Map list, required this.time, required this.trem}): _list = list,super._(); + factory _EarthquakeReport.fromJson(Map json) => _$EarthquakeReportFromJson(json); + +@override final String id; +@override@JsonKey(name: 'lon') final double longitude; +@override@JsonKey(name: 'lat') final double latitude; +@override@JsonKey(name: 'loc') final String location; +@override final double depth; +@override@JsonKey(name: 'mag') final double magnitude; + final Map _list; +@override Map get list { + if (_list is EqualUnmodifiableMapView) return _list; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_list); +} + +/// Origin time as Unix **milliseconds**. +@override final int time; +@override final int trem; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EarthquakeReportCopyWith<_EarthquakeReport> get copyWith => __$EarthquakeReportCopyWithImpl<_EarthquakeReport>(this, _$identity); + +@override +Map toJson() { + return _$EarthquakeReportToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EarthquakeReport&&(identical(other.id, id) || other.id == id)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)&&const DeepCollectionEquality().equals(other._list, _list)&&(identical(other.time, time) || other.time == time)&&(identical(other.trem, trem) || other.trem == trem)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,longitude,latitude,location,depth,magnitude,const DeepCollectionEquality().hash(_list),time,trem); + +@override +String toString() { + return 'EarthquakeReport(id: $id, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude, list: $list, time: $time, trem: $trem)'; +} + + +} + +/// @nodoc +abstract mixin class _$EarthquakeReportCopyWith<$Res> implements $EarthquakeReportCopyWith<$Res> { + factory _$EarthquakeReportCopyWith(_EarthquakeReport value, $Res Function(_EarthquakeReport) _then) = __$EarthquakeReportCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location, double depth,@JsonKey(name: 'mag') double magnitude, Map list, int time, int trem +}); + + + + +} +/// @nodoc +class __$EarthquakeReportCopyWithImpl<$Res> + implements _$EarthquakeReportCopyWith<$Res> { + __$EarthquakeReportCopyWithImpl(this._self, this._then); + + final _EarthquakeReport _self; + final $Res Function(_EarthquakeReport) _then; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,Object? list = null,Object? time = null,Object? trem = null,}) { + return _then(_EarthquakeReport( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double,list: null == list ? _self._list : list // ignore: cast_nullable_to_non_nullable +as Map,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,trem: null == trem ? _self.trem : trem // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$AreaIntensity { + +@JsonKey(name: 'int') int get intensity; Map get town; +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AreaIntensityCopyWith get copyWith => _$AreaIntensityCopyWithImpl(this as AreaIntensity, _$identity); + + /// Serializes this AreaIntensity to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AreaIntensity&&(identical(other.intensity, intensity) || other.intensity == intensity)&&const DeepCollectionEquality().equals(other.town, town)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,intensity,const DeepCollectionEquality().hash(town)); + +@override +String toString() { + return 'AreaIntensity(intensity: $intensity, town: $town)'; +} + + +} + +/// @nodoc +abstract mixin class $AreaIntensityCopyWith<$Res> { + factory $AreaIntensityCopyWith(AreaIntensity value, $Res Function(AreaIntensity) _then) = _$AreaIntensityCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'int') int intensity, Map town +}); + + + + +} +/// @nodoc +class _$AreaIntensityCopyWithImpl<$Res> + implements $AreaIntensityCopyWith<$Res> { + _$AreaIntensityCopyWithImpl(this._self, this._then); + + final AreaIntensity _self; + final $Res Function(AreaIntensity) _then; + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? intensity = null,Object? town = null,}) { + return _then(_self.copyWith( +intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int,town: null == town ? _self.town : town // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AreaIntensity]. +extension AreaIntensityPatterns on AreaIntensity { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AreaIntensity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AreaIntensity value) $default,){ +final _that = this; +switch (_that) { +case _AreaIntensity(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AreaIntensity value)? $default,){ +final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'int') int intensity, Map town)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that.intensity,_that.town);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'int') int intensity, Map town) $default,) {final _that = this; +switch (_that) { +case _AreaIntensity(): +return $default(_that.intensity,_that.town);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'int') int intensity, Map town)? $default,) {final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that.intensity,_that.town);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AreaIntensity implements AreaIntensity { + const _AreaIntensity({@JsonKey(name: 'int') required this.intensity, required final Map town}): _town = town; + factory _AreaIntensity.fromJson(Map json) => _$AreaIntensityFromJson(json); + +@override@JsonKey(name: 'int') final int intensity; + final Map _town; +@override Map get town { + if (_town is EqualUnmodifiableMapView) return _town; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_town); +} + + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AreaIntensityCopyWith<_AreaIntensity> get copyWith => __$AreaIntensityCopyWithImpl<_AreaIntensity>(this, _$identity); + +@override +Map toJson() { + return _$AreaIntensityToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AreaIntensity&&(identical(other.intensity, intensity) || other.intensity == intensity)&&const DeepCollectionEquality().equals(other._town, _town)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,intensity,const DeepCollectionEquality().hash(_town)); + +@override +String toString() { + return 'AreaIntensity(intensity: $intensity, town: $town)'; +} + + +} + +/// @nodoc +abstract mixin class _$AreaIntensityCopyWith<$Res> implements $AreaIntensityCopyWith<$Res> { + factory _$AreaIntensityCopyWith(_AreaIntensity value, $Res Function(_AreaIntensity) _then) = __$AreaIntensityCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'int') int intensity, Map town +}); + + + + +} +/// @nodoc +class __$AreaIntensityCopyWithImpl<$Res> + implements _$AreaIntensityCopyWith<$Res> { + __$AreaIntensityCopyWithImpl(this._self, this._then); + + final _AreaIntensity _self; + final $Res Function(_AreaIntensity) _then; + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? intensity = null,Object? town = null,}) { + return _then(_AreaIntensity( +intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int,town: null == town ? _self._town : town // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + + +} + + +/// @nodoc +mixin _$StationIntensity { + +@JsonKey(name: 'lon') double get longitude;@JsonKey(name: 'lat') double get latitude;@JsonKey(name: 'int') int get intensity; +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StationIntensityCopyWith get copyWith => _$StationIntensityCopyWithImpl(this as StationIntensity, _$identity); + + /// Serializes this StationIntensity to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StationIntensity&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.intensity, intensity) || other.intensity == intensity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,longitude,latitude,intensity); + +@override +String toString() { + return 'StationIntensity(longitude: $longitude, latitude: $latitude, intensity: $intensity)'; +} + + +} + +/// @nodoc +abstract mixin class $StationIntensityCopyWith<$Res> { + factory $StationIntensityCopyWith(StationIntensity value, $Res Function(StationIntensity) _then) = _$StationIntensityCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'int') int intensity +}); + + + + +} +/// @nodoc +class _$StationIntensityCopyWithImpl<$Res> + implements $StationIntensityCopyWith<$Res> { + _$StationIntensityCopyWithImpl(this._self, this._then); + + final StationIntensity _self; + final $Res Function(StationIntensity) _then; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? longitude = null,Object? latitude = null,Object? intensity = null,}) { + return _then(_self.copyWith( +longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [StationIntensity]. +extension StationIntensityPatterns on StationIntensity { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _StationIntensity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _StationIntensity value) $default,){ +final _that = this; +switch (_that) { +case _StationIntensity(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _StationIntensity value)? $default,){ +final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity) $default,) {final _that = this; +switch (_that) { +case _StationIntensity(): +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity)? $default,) {final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _StationIntensity implements StationIntensity { + const _StationIntensity({@JsonKey(name: 'lon') required this.longitude, @JsonKey(name: 'lat') required this.latitude, @JsonKey(name: 'int') required this.intensity}); + factory _StationIntensity.fromJson(Map json) => _$StationIntensityFromJson(json); + +@override@JsonKey(name: 'lon') final double longitude; +@override@JsonKey(name: 'lat') final double latitude; +@override@JsonKey(name: 'int') final int intensity; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StationIntensityCopyWith<_StationIntensity> get copyWith => __$StationIntensityCopyWithImpl<_StationIntensity>(this, _$identity); + +@override +Map toJson() { + return _$StationIntensityToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StationIntensity&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.intensity, intensity) || other.intensity == intensity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,longitude,latitude,intensity); + +@override +String toString() { + return 'StationIntensity(longitude: $longitude, latitude: $latitude, intensity: $intensity)'; +} + + +} + +/// @nodoc +abstract mixin class _$StationIntensityCopyWith<$Res> implements $StationIntensityCopyWith<$Res> { + factory _$StationIntensityCopyWith(_StationIntensity value, $Res Function(_StationIntensity) _then) = __$StationIntensityCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'int') int intensity +}); + + + + +} +/// @nodoc +class __$StationIntensityCopyWithImpl<$Res> + implements _$StationIntensityCopyWith<$Res> { + __$StationIntensityCopyWithImpl(this._self, this._then); + + final _StationIntensity _self; + final $Res Function(_StationIntensity) _then; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? longitude = null,Object? latitude = null,Object? intensity = null,}) { + return _then(_StationIntensity( +longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/features/earthquake/domain/earthquake_report.g.dart b/lib/features/earthquake/domain/earthquake_report.g.dart new file mode 100644 index 000000000..c7e62994d --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'earthquake_report.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_EarthquakeReport _$EarthquakeReportFromJson(Map json) => + _EarthquakeReport( + id: json['id'] as String, + longitude: (json['lon'] as num).toDouble(), + latitude: (json['lat'] as num).toDouble(), + location: json['loc'] as String, + depth: (json['depth'] as num).toDouble(), + magnitude: (json['mag'] as num).toDouble(), + list: (json['list'] as Map).map( + (k, e) => + MapEntry(k, AreaIntensity.fromJson(e as Map)), + ), + time: (json['time'] as num).toInt(), + trem: (json['trem'] as num).toInt(), + ); + +Map _$EarthquakeReportToJson(_EarthquakeReport instance) => + { + 'id': instance.id, + 'lon': instance.longitude, + 'lat': instance.latitude, + 'loc': instance.location, + 'depth': instance.depth, + 'mag': instance.magnitude, + 'list': instance.list.map((k, e) => MapEntry(k, e.toJson())), + 'time': instance.time, + 'trem': instance.trem, + }; + +_AreaIntensity _$AreaIntensityFromJson(Map json) => + _AreaIntensity( + intensity: (json['int'] as num).toInt(), + town: (json['town'] as Map).map( + (k, e) => + MapEntry(k, StationIntensity.fromJson(e as Map)), + ), + ); + +Map _$AreaIntensityToJson(_AreaIntensity instance) => + { + 'int': instance.intensity, + 'town': instance.town.map((k, e) => MapEntry(k, e.toJson())), + }; + +_StationIntensity _$StationIntensityFromJson(Map json) => + _StationIntensity( + longitude: (json['lon'] as num).toDouble(), + latitude: (json['lat'] as num).toDouble(), + intensity: (json['int'] as num).toInt(), + ); + +Map _$StationIntensityToJson(_StationIntensity instance) => + { + 'lon': instance.longitude, + 'lat': instance.latitude, + 'int': instance.intensity, + }; diff --git a/lib/features/earthquake/domain/report_repository.dart b/lib/features/earthquake/domain/report_repository.dart index 9d532db34..2a17ece9f 100644 --- a/lib/features/earthquake/domain/report_repository.dart +++ b/lib/features/earthquake/domain/report_repository.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; @@ -13,4 +14,8 @@ abstract interface class ReportRepository { int page = 1, ReportListQuery query = ReportListQuery.empty, }); + + /// The full report — including the per-area/town intensity breakdown — by + /// [id], for the detail page behind a catalogue row. + Future> get(String id); } diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart new file mode 100644 index 000000000..5c771cc20 --- /dev/null +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -0,0 +1,1016 @@ +/// Full report detail — the app's own map (epicentre + per-town intensity +/// dots) with a swipe-up sheet for 詳細資訊 + 各地震度, opened from a +/// [ReportListPage] catalogue row. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dpip/app/theme/app_motion.dart'; +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; +import 'package:dpip/features/earthquake/domain/intensity.dart'; +import 'package:dpip/features/earthquake/domain/report_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/base_map.dart'; +import 'package:dpip/shared/map/camera_fit.dart'; +import 'package:dpip/shared/seismic/intensity_colors.dart'; +import 'package:dpip/shared/seismic/report_colors.dart'; +import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/intensity_badge.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Fetches and shows the full report for [reportId] — epicentre, magnitude, +/// depth, and the per-area/town felt-intensity breakdown. +/// +/// No persistent app bar: the map fills the whole page behind a floating back +/// button (peek state); a pinned "地震報告" header — with its own back button — +/// only appears once the sheet is dragged up to its expanded content (see +/// [_ExpandedHeader]) and stays fixed while that content scrolls, matching +/// the legacy app, where that title bar was part of the sheet's full state, +/// not the page chrome. Exactly one back button is ever on screen: +/// [_sheetExpanded] toggles which one, so expanding the sheet hides the +/// floating button instead of stacking both. +class ReportDetailPage extends StatefulWidget { + const ReportDetailPage({super.key, required this.reportId}); + + /// The report id (e.g. `115053-2026-0731-005836`), from the route's `:id`. + final String reportId; + + @override + State createState() => _ReportDetailPageState(); +} + +class _ReportDetailPageState extends State { + final ValueNotifier _sheetExpanded = ValueNotifier(false); + + @override + void dispose() { + _sheetExpanded.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final repository = context.read(); + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: AsyncView( + future: () => repository.get(widget.reportId), + builder: (context, report) => _ReportMapDetail( + report: report, + sheetExpanded: _sheetExpanded, + ), + ), + ), + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: ValueListenableBuilder( + valueListenable: _sheetExpanded, + builder: (context, expanded, child) => + expanded ? const SizedBox.shrink() : child!, + child: FrostedSurface( + borderRadius: AppRadius.large, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// The report's epicentre + station bounds, in the map library's coordinate +/// type — computed here (not on the domain model) so the domain layer stays +/// free of a `maplibre_gl` dependency. +LatLngBounds _reportBounds(EarthquakeReport report) { + var minLat = report.latitude, maxLat = report.latitude; + var minLon = report.longitude, maxLon = report.longitude; + for (final area in report.list.values) { + for (final town in area.town.values) { + minLat = math.min(minLat, town.latitude); + maxLat = math.max(maxLat, town.latitude); + minLon = math.min(minLon, town.longitude); + maxLon = math.max(maxLon, town.longitude); + } + } + return LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ); +} + +/// Highest intensity first; ties broken by name so the order is stable. +int _byIntensityDesc( + MapEntry a, + MapEntry b, +) { + final byIntensity = b.value.intensity.compareTo(a.value.intensity); + return byIntensity != 0 ? byIntensity : a.key.compareTo(b.key); +} + +/// Same ordering as [_byIntensityDesc], for a single area's town entries. +int _byTownIntensityDesc( + MapEntry a, + MapEntry b, +) { + final byIntensity = b.value.intensity.compareTo(a.value.intensity); + return byIntensity != 0 ? byIntensity : a.key.compareTo(b.key); +} + +/// The map surface: epicentre + per-town intensity dots, framed to the +/// report's extent, with [_ReportSheet] docked at the bottom. +class _ReportMapDetail extends StatefulWidget { + const _ReportMapDetail({required this.report, required this.sheetExpanded}); + + final EarthquakeReport report; + + /// Set by [_ReportSheet] as it's dragged — drives the page's floating back + /// button's visibility (see [ReportDetailPage]). + final ValueNotifier sheetExpanded; + + @override + State<_ReportMapDetail> createState() => _ReportMapDetailState(); +} + +class _ReportMapDetailState extends State<_ReportMapDetail> { + static const String _sourceId = 'report-detail-src'; + static const String _symbolLayerId = 'report-detail-symbol'; + + /// Icon size by zoom — far past the legacy app's `kSymbolIconSize` (5 → + /// 0.2, 15 → 0.6): those source PNGs render at native (device-pixel) size, + /// so on a modern 3x display that old scale is only ~20 logical px — this + /// page needs the marker legible at a glance, so it's sized up accordingly. + static const List _iconSizeExpression = [ + 'interpolate', + ['linear'], + ['zoom'], + 5, + 0.75, + 15, + 1.7, + ]; + + /// `intensity-1`…`intensity-9` (+ `-dark`) and `cross` — the legacy app's + /// own map-marker artwork, ported verbatim (same PNGs) so the report map + /// looks exactly as it did before, not a new icon style. + static final List _iconNames = [ + 'cross', + for (var i = 1; i <= 9; i++) 'intensity-$i', + for (var i = 1; i <= 9; i++) 'intensity-$i-dark', + ]; + + MapLibreMapController? _controller; + bool _iconsLoaded = false; + + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + } + + Future _onStyleLoaded() async { + final controller = _controller; + if (controller == null) return; + final dark = Theme.of(context).brightness == Brightness.dark; + try { + if (!_iconsLoaded) { + await _loadIcons(controller); + _iconsLoaded = true; + } + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: _geoJson(dark: dark)), + ); + await controller.addSymbolLayer( + _sourceId, + _symbolLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'icon'], + iconSize: _iconSizeExpression, + iconAllowOverlap: true, + iconIgnorePlacement: true, + symbolZOrder: 'source', + ), + ); + } catch (e, st) { + Log.handle(e, st, 'report detail map render failed'); + } + _frame(); + } + + /// Registers every marker icon once — cheap enough (19 small PNGs) to load + /// eagerly rather than lazily per feature. + Future _loadIcons(MapLibreMapController controller) async { + for (final name in _iconNames) { + final data = await rootBundle.load('assets/map/icons/$name.png'); + await controller.addImage(name, data.buffer.asUint8List()); + } + } + + /// Fits the camera once, leaving room for the sheet at its resting height — + /// not re-run on drag (the sheet's resting fraction is a fixed chrome + /// allowance, matching [MapScaffold]'s per-layer `bottomChromeFraction`). + void _frame() { + final controller = _controller; + if (controller == null || !mounted) return; + final bounds = safeFitBounds(_reportBounds(widget.report)); + if (bounds == null) return; + final size = MediaQuery.sizeOf(context); + final fit = boundsFitCamera( + bounds, + viewport: size, + topInset: MediaQuery.paddingOf(context).top, + bottomInset: size.height * _ReportSheet.rest, + ); + if (fit == null) return; + controller.moveCamera(CameraUpdate.newLatLngZoom(fit.target, fit.zoom)); + } + + /// Every town marker (sorted low→high intensity, so a hot reading draws on + /// top of a calm one) plus the epicentre cross last — same draw order as + /// the legacy app's `EarthquakeReport.toGeoJson()`. + Map _geoJson({required bool dark}) { + final report = widget.report; + final towns = [ + for (final area in report.list.values) ...area.town.values, + ]..sort((a, b) => a.intensity.compareTo(b.intensity)); + + final features = >[ + for (final town in towns) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [town.longitude, town.latitude], + }, + 'properties': { + 'icon': 'intensity-${town.intensity}${dark ? '' : '-dark'}', + }, + }, + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [report.longitude, report.latitude], + }, + 'properties': {'icon': 'cross'}, + }, + ]; + return {'type': 'FeatureCollection', 'features': features}; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: BaseMap( + showUserLocation: false, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + ), + ), + Positioned.fill( + child: _ReportSheet( + report: widget.report, + expandedNotifier: widget.sheetExpanded, + ), + ), + ], + ); + } +} + +/// The draggable bottom sheet — mirrors the legacy `MorphingSheet`'s two +/// distinct layouts (not one continuously-revealed list): [peek] shows a +/// compact summary card, dragged past the midpoint swaps to the full +/// 詳細資訊 + 各地震度 + report-image content under a pinned "地震報告" header +/// that stays fixed while that content scrolls. +class _ReportSheet extends StatefulWidget { + const _ReportSheet({required this.report, required this.expandedNotifier}); + + final EarthquakeReport report; + + /// Mirrors this sheet's collapsed/expanded state up to the page, so it can + /// hide its floating back button while the sheet's own header has one. + final ValueNotifier expandedNotifier; + + /// Collapsed height — sized to the compact summary card. + static const double peek = 0.32; + + /// Alias kept for the map's bottom-inset framing (see [_ReportMapDetailState._frame]). + static const double rest = peek; + + static const double _expanded = 1.0; + static const double _switchThreshold = (peek + _expanded) / 2; + + @override + State<_ReportSheet> createState() => _ReportSheetState(); +} + +class _ReportSheetState extends State<_ReportSheet> { + final ValueNotifier _extent = ValueNotifier(_ReportSheet.peek); + + /// Programmatically collapses the sheet back to its [peek] state — used by + /// the expanded header's back button, which folds the sheet down to the map + /// rather than popping the page. + final DraggableScrollableController _dragController = + DraggableScrollableController(); + + @override + void dispose() { + _extent.dispose(); + _dragController.dispose(); + super.dispose(); + } + + void _collapse() { + _dragController.animateTo( + _ReportSheet.peek, + duration: AppMotion.medium, + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final report = widget.report; + + return Align( + alignment: Alignment.bottomCenter, + child: NotificationListener( + onNotification: (notification) { + _extent.value = notification.extent; + // Runs from the scroll-notification dispatch, not mid-build, so + // it's safe to update the page-level notifier the floating back + // button listens to. + widget.expandedNotifier.value = + notification.extent >= _ReportSheet._switchThreshold; + return false; + }, + child: DraggableScrollableSheet( + controller: _dragController, + initialChildSize: _ReportSheet.peek, + minChildSize: _ReportSheet.peek, + maxChildSize: _ReportSheet._expanded, + snap: true, + builder: (context, scrollController) { + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: AppRadius.topSheet, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, -2), + ), + ], + ), + child: ClipRRect( + borderRadius: AppRadius.topSheet, + child: ValueListenableBuilder( + valueListenable: _extent, + builder: (context, extent, _) { + final expanded = extent >= _ReportSheet._switchThreshold; + // Flush with the screen top at full extent — the status + // bar/notch inset has to come back as padding on the + // expanded header, or it renders underneath it. + final atTop = extent >= _ReportSheet._expanded - 0.02; + return Column( + children: [ + // Pinned above the scroll body, not a scrolling item: + // the "地震報告" bar (back button + title) stays while + // the 詳細資訊 content scrolls underneath it. Keep the + // ListView's horizontal inset on the header too, so it + // sits where it did when it was the list's first child. + if (expanded) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + child: _ExpandedHeader( + atTop: atTop, + onBack: _collapse, + ), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + expanded ? 0 : AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + + MediaQuery.paddingOf(context).bottom, + ), + children: expanded + ? _expandedContent(context, report) + : _peekContent(context, report), + ), + ), + ], + ); + }, + ), + ), + ); + }, + ), + ), + ); + } + + List _peekContent(BuildContext context, EarthquakeReport report) => [ + _ReportPeekSummary(report: report), + ]; + + List _expandedContent(BuildContext context, EarthquakeReport report) { + final l10n = AppLocalizations.of(context); + // Highest-felt area first, left-to-right/top-to-bottom reading order. + final areas = report.list.entries.toList()..sort(_byIntensityDesc); + return [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ReportHeader(report: report), + const SizedBox(height: AppSpacing.lg), + SectionHeader(l10n.reportDetailInfo), + _ReportInfoCard(report: report), + if (areas.isNotEmpty) ...[ + SectionHeader(l10n.reportDetailAreaIntensity), + _AreaIntensityCard(report: report, areas: areas), + ], + SectionHeader(l10n.reportDetailImage), + _ReportImageCard(report: report), + ], + ), + ), + ]; + } +} + +/// The expanded sheet's pinned header — appears only once dragged up (matches +/// the legacy `MorphingSheet.fullBuilder`'s `SliverAppBar`) and then stays +/// fixed above the scroll body while the 詳細資訊 content scrolls underneath: +/// a back button at the left and the "地震報告" title, flush at the top when +/// the sheet is fully extended. The page's floating back button hides itself +/// whenever this is showing (see [ReportDetailPage]), so there's never more +/// than one on screen. +/// +/// Its back button **collapses** the sheet back to the map ([onBack]), not +/// pops the page — the floating button is what leaves (and only it is visible +/// in the collapsed state), so each state has exactly one sensible action. +class _ExpandedHeader extends StatelessWidget { + const _ExpandedHeader({required this.atTop, required this.onBack}); + + /// Whether the sheet is flush with the screen top — adds the status-bar + /// inset back as padding so the row doesn't render underneath it. + final bool atTop; + + /// Collapses the sheet to its peek state (showing the epicentre-intensity + /// map), replacing the page-pop the floating back button does. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final topInset = atTop ? MediaQuery.paddingOf(context).top : 0.0; + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.sm, + topInset + AppSpacing.xs, + AppSpacing.sm, + AppSpacing.xs, + ), + child: Row( + children: [ + IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack), + Expanded( + child: Text( + l10n.reportDetailTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + // Balances the leading back button so the title stays centred. + const SizedBox(width: 48), + ], + ), + ); + } +} + +/// The collapsed sheet's compact summary — eyebrow/location/time to the left, +/// a large intensity badge to the right, then a magnitude/depth stat row. +/// Mirrors the legacy `MorphingSheet.partialBuilder` layout exactly. +class _ReportPeekSummary extends StatelessWidget { + const _ReportPeekSummary({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final presentation = Intensity.displayForReport( + report.maxIntensity, + report.originTimeUtc, + ); + final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final time = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? l10n.reportDetailNumbered(report.number!) + : l10n.reportDetailLocalFelt, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.outline, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + report.shortLocation, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + time, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + IntensityBadge( + label: presentation.label, + color: IntensityColors.discrete(presentation.colorLevel), + size: 56, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _PeekStat( + label: l10n.reportDetailMagnitude, + value: l10n.reportListMagnitude( + report.magnitude.toStringAsFixed(1), + ), + ), + ), + Expanded( + child: _PeekStat( + label: l10n.reportDetailDepth, + value: l10n.reportFilterDepthKm( + report.depth.toStringAsFixed(1), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _PeekStat extends StatelessWidget { + const _PeekStat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +/// Intensity badge + numbered/local-felt eyebrow + location — the expanded +/// sheet's header, with a filled chip opening the official CWA report page. +class _ReportHeader extends StatelessWidget { + const _ReportHeader({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final presentation = Intensity.displayForReport( + report.maxIntensity, + report.originTimeUtc, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IntensityBadge( + label: presentation.label, + color: IntensityColors.discrete(presentation.colorLevel), + size: 56, + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? l10n.reportDetailNumbered(report.number!) + : l10n.reportDetailLocalFelt, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.outline, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + report.shortLocation, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ActionChip( + avatar: Icon(Icons.open_in_new, size: 16, color: colors.onPrimary), + label: Text(l10n.reportDetailOpenReport), + backgroundColor: colors.primary, + labelStyle: TextStyle(color: colors.onPrimary), + side: BorderSide(color: colors.primary), + onPressed: () => _openReportPage(context), + ), + ], + ); + } + + Future _openReportPage(BuildContext context) async { + try { + await launchUrl(report.reportUrl, mode: LaunchMode.externalApplication); + } catch (e, st) { + Log.handle(e, st, 'open report page failed: ${report.reportUrl}'); + } + } +} + +/// 發震時間 / 震央座標 / 地震規模 / 震源深度 as a bordered info card. +class _ReportInfoCard extends StatelessWidget { + const _ReportInfoCard({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final originTime = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + final coordinates = + '${report.latitude.toStringAsFixed(2)}°N・' + '${report.longitude.toStringAsFixed(2)}°E'; + + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _InfoRow(label: l10n.reportDetailOriginTime, value: originTime), + const Divider(height: 1), + _InfoRow(label: l10n.reportDetailEpicenter, value: coordinates), + const Divider(height: 1), + _InfoRow( + label: l10n.reportDetailMagnitude, + value: l10n.reportListMagnitude( + report.magnitude.toStringAsFixed(1), + ), + dotColor: MagnitudeColors.of(report.magnitude), + ), + const Divider(height: 1), + _InfoRow( + label: l10n.reportDetailDepth, + value: l10n.reportFilterDepthKm(report.depth.toStringAsFixed(1)), + dotColor: DepthColors.of(report.depth), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value, this.dotColor}); + + final String label; + final String value; + final Color? dotColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const Spacer(), + if (dotColor != null) ...[ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: AppSpacing.sm), + ], + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// 各地震度 — one row per area (縣市), each with a wrap of its town chips. +class _AreaIntensityCard extends StatelessWidget { + const _AreaIntensityCard({required this.report, required this.areas}); + + final EarthquakeReport report; + final List> areas; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: Column( + // Otherwise each row centers as a whole block at its own shrink-wrapped + // width (see _AreaRow's inner Column), so a county with fewer/narrower + // chips visibly drifts toward the middle instead of sitting flush left. + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < areas.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _AreaRow(areaName: areas[i].key, area: areas[i].value), + ], + ], + ), + ); + } +} + +class _AreaRow extends StatelessWidget { + const _AreaRow({required this.areaName, required this.area}); + + final String areaName; + final AreaIntensity area; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + areaName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + // Highest-felt town first, left-to-right/top-to-bottom. + for (final MapEntry(key: townName, value: town) + in area.town.entries.toList()..sort(_byTownIntensityDesc)) + _TownChip(townName: townName, intensity: town.intensity), + ], + ), + ], + ), + ); + } +} + +class _TownChip extends StatelessWidget { + const _TownChip({required this.townName, required this.intensity}); + + final String townName; + final int intensity; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Raw wire intensity, not [Intensity.displayForReport] — the per-town + // reading has always been on the fine 0–9 scale (only the top-level + // report max needed the pre-2020 legacy-scale remap). + final label = Intensity.label(intensity); + final color = IntensityColors.discrete(intensity); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + border: Border.all(color: color), + borderRadius: AppRadius.small, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 20, + height: 20, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: AppRadius.small, + ), + child: Center( + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: + ThemeData.estimateBrightnessForColor(color) == + Brightness.dark + ? Colors.white + : Colors.black87, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + Text(townName, style: theme.textTheme.labelLarge), + ], + ), + ); + } +} + +/// 地震報告圖 — CWA's rendered report image; falls back to a plain message when +/// it hasn't been generated yet or fails to load. +class _ReportImageCard extends StatefulWidget { + const _ReportImageCard({required this.report}); + + final EarthquakeReport report; + + @override + State<_ReportImageCard> createState() => _ReportImageCardState(); +} + +class _ReportImageCardState extends State<_ReportImageCard> { + bool _failed = false; + + /// Placeholder height while loading / on failure — a guess, not the real + /// aspect ratio (that varies per event), so it's only a skeleton size; the + /// image itself sizes to its own natural proportions once loaded. + static const double _placeholderHeight = 200; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + + if (_failed) { + return ClipRRect( + borderRadius: AppRadius.medium, + child: Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: Text( + l10n.reportDetailImageUnavailable, + style: TextStyle(color: colors.onSurfaceVariant), + ), + ), + ); + } + + return ClipRRect( + borderRadius: AppRadius.medium, + child: Image.network( + widget.report.reportImageUrl.toString(), + // No forced aspect ratio / BoxFit.cover — the report image's real + // proportions vary per event, so this sizes to the image's own + // natural aspect ratio at full width instead of cropping or padding. + width: double.infinity, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + }, + errorBuilder: (context, error, stackTrace) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _failed = true); + }); + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 4c26f884c..d52bd4952 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -10,11 +10,14 @@ import 'package:dpip/features/earthquake/domain/report_repository.dart'; import 'package:dpip/features/earthquake/presentation/report_list_controller.dart'; import 'package:dpip/features/earthquake/presentation/widgets/report_filter_sheet.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -334,12 +337,15 @@ class _ReportTile extends StatelessWidget { final mag = report.magnitude.toStringAsFixed(1); return InkWell( - onTap: null, // Detail route lands next. + onTap: () => context.pushNamed( + AppRoutes.earthquakeReport, + pathParameters: {'id': report.id}, + ), child: Padding( padding: const EdgeInsets.all(AppSpacing.md), child: Row( children: [ - _IntensityBadge(label: intensity.label, color: intensityColor), + IntensityBadge(label: intensity.label, color: intensityColor), const SizedBox(width: AppSpacing.md), Expanded( child: Column( @@ -382,36 +388,3 @@ class _ReportTile extends StatelessWidget { ); } } - -class _IntensityBadge extends StatelessWidget { - const _IntensityBadge({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - final onBadge = - ThemeData.estimateBrightnessForColor(color) == Brightness.dark - ? Colors.white - : Colors.black87; - return DecoratedBox( - decoration: BoxDecoration(color: color, borderRadius: AppRadius.small), - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Text( - label, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: onBadge, - fontWeight: FontWeight.w900, - height: 1, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ), - ); - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e62b4835f..2d5d90f0e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -328,6 +328,59 @@ "@reportListSearch": { "description": "Fetches the report list with the current draft filters" }, + "reportDetailTitle": "Earthquake Report", + "@reportDetailTitle": { + "description": "Header title over the report detail map's back button" + }, + "reportDetailNumbered": "No. {number} Significant Earthquake", + "@reportDetailNumbered": { + "description": "Eyebrow label on the detail header for a numbered CWA report", + "placeholders": { + "number": { + "type": "String" + } + } + }, + "reportDetailLocalFelt": "Local Felt Earthquake", + "@reportDetailLocalFelt": { + "description": "Eyebrow label on the detail header for a …000 (unnumbered) report" + }, + "reportDetailInfo": "Details", + "@reportDetailInfo": { + "description": "Section header over origin time / epicenter / magnitude / depth" + }, + "reportDetailOriginTime": "Origin time", + "@reportDetailOriginTime": { + "description": "Row label for the report's origin date/time" + }, + "reportDetailEpicenter": "Epicenter", + "@reportDetailEpicenter": { + "description": "Row label for the epicenter's latitude/longitude" + }, + "reportDetailMagnitude": "Magnitude", + "@reportDetailMagnitude": { + "description": "Row label for the report's magnitude" + }, + "reportDetailDepth": "Depth", + "@reportDetailDepth": { + "description": "Row label for the report's hypocentral depth" + }, + "reportDetailAreaIntensity": "Intensity by area", + "@reportDetailAreaIntensity": { + "description": "Section header over the per-area/town felt-intensity breakdown" + }, + "reportDetailImage": "Report image", + "@reportDetailImage": { + "description": "Section header over the CWA-rendered report image" + }, + "reportDetailImageUnavailable": "Report image not available", + "@reportDetailImageUnavailable": { + "description": "Shown in place of the report image when it fails to load" + }, + "reportDetailOpenReport": "Report page", + "@reportDetailOpenReport": { + "description": "Button that opens the official CWA report page in a browser" + }, "navMore": "More", "@navMore": { "description": "Bottom-nav label and page title for the More tab" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index ac152c635..a59e1566f 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -69,6 +69,18 @@ "reportFilterApply": "I-apply", "reportFilterReset": "I-reset", "reportListSearch": "Maghanap", + "reportDetailTitle": "Ulat ng Lindol", + "reportDetailNumbered": "Blg. {number} Makabuluhang Naramdamang Lindol", + "reportDetailLocalFelt": "Lokal na Naramdamang Lindol", + "reportDetailInfo": "Mga Detalye", + "reportDetailOriginTime": "Oras ng pangyayari", + "reportDetailEpicenter": "Coordinates ng Epicenter", + "reportDetailMagnitude": "Magnitude", + "reportDetailDepth": "Lalim ng Hypocenter", + "reportDetailAreaIntensity": "Intensity ayon sa lugar", + "reportDetailImage": "Larawan ng Ulat", + "reportDetailImageUnavailable": "Wala pang available na larawan ng ulat", + "reportDetailOpenReport": "Pahina ng Ulat", "navMore": "Higit Pa", "appLogs": "Mga log ng app", "changelogTitle": "Changelog", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 76e761d48..5d50fb11f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -69,6 +69,18 @@ "reportFilterApply": "Terapkan", "reportFilterReset": "Reset", "reportListSearch": "Cari", + "reportDetailTitle": "Laporan Gempa", + "reportDetailNumbered": "Gempa Dirasakan Signifikan No. {number}", + "reportDetailLocalFelt": "Gempa Dirasakan Lokal", + "reportDetailInfo": "Detail", + "reportDetailOriginTime": "Waktu kejadian", + "reportDetailEpicenter": "Koordinat episentrum", + "reportDetailMagnitude": "Magnitudo", + "reportDetailDepth": "Kedalaman hiposenter", + "reportDetailAreaIntensity": "Intensitas per wilayah", + "reportDetailImage": "Gambar laporan", + "reportDetailImageUnavailable": "Gambar laporan belum tersedia", + "reportDetailOpenReport": "Halaman laporan", "navMore": "Lainnya", "appLogs": "Log aplikasi", "changelogTitle": "Catatan pembaruan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 636d97f85..3351341ca 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -69,6 +69,18 @@ "reportFilterApply": "適用", "reportFilterReset": "リセット", "reportListSearch": "検索", + "reportDetailTitle": "地震レポート", + "reportDetailNumbered": "No.{number} 顕著有感地震", + "reportDetailLocalFelt": "局地的な有感地震", + "reportDetailInfo": "詳細情報", + "reportDetailOriginTime": "発震時刻", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源の深さ", + "reportDetailAreaIntensity": "地域別震度", + "reportDetailImage": "地震レポート画像", + "reportDetailImageUnavailable": "レポート画像はまだありません", + "reportDetailOpenReport": "レポートページ", "navMore": "その他", "appLogs": "アプリログ", "changelogTitle": "更新履歴", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 15b7a8043..fd50c70a7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -69,6 +69,18 @@ "reportFilterApply": "적용", "reportFilterReset": "초기화", "reportListSearch": "조회", + "reportDetailTitle": "지진 보고서", + "reportDetailNumbered": "번호 {number} 유의미 유감지진", + "reportDetailLocalFelt": "국지적 유감지진", + "reportDetailInfo": "상세 정보", + "reportDetailOriginTime": "발생 시각", + "reportDetailEpicenter": "진앙 좌표", + "reportDetailMagnitude": "지진 규모", + "reportDetailDepth": "진원 깊이", + "reportDetailAreaIntensity": "지역별 진도", + "reportDetailImage": "지진 보고서 이미지", + "reportDetailImageUnavailable": "보고서 이미지가 아직 없습니다", + "reportDetailOpenReport": "보고서 페이지", "navMore": "더보기", "appLogs": "앱 로그", "changelogTitle": "변경 로그", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index c380cf107..cbd25010c 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -69,6 +69,18 @@ "reportFilterApply": "ใช้", "reportFilterReset": "รีเซ็ต", "reportListSearch": "ค้นหา", + "reportDetailTitle": "รายงานแผ่นดินไหว", + "reportDetailNumbered": "แผ่นดินไหวรู้สึกได้อย่างมีนัยสำคัญ หมายเลข {number}", + "reportDetailLocalFelt": "แผ่นดินไหวรู้สึกได้เฉพาะพื้นที่", + "reportDetailInfo": "รายละเอียด", + "reportDetailOriginTime": "เวลาเกิดเหตุ", + "reportDetailEpicenter": "พิกัดศูนย์กลาง", + "reportDetailMagnitude": "ขนาดแผ่นดินไหว", + "reportDetailDepth": "ความลึกจุดศูนย์กลาง", + "reportDetailAreaIntensity": "ความเข้มแยกตามพื้นที่", + "reportDetailImage": "ภาพรายงานแผ่นดินไหว", + "reportDetailImageUnavailable": "ยังไม่มีภาพรายงาน", + "reportDetailOpenReport": "หน้ารายงาน", "navMore": "เพิ่มเติม", "appLogs": "บันทึกแอป", "changelogTitle": "บันทึกการอัปเดต", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ef4987c95..928b9bdec 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -69,6 +69,18 @@ "reportFilterApply": "Áp dụng", "reportFilterReset": "Đặt lại", "reportListSearch": "Tìm", + "reportDetailTitle": "Báo cáo động đất", + "reportDetailNumbered": "Động đất có cảm nhận đáng kể số {number}", + "reportDetailLocalFelt": "Động đất cảm nhận cục bộ", + "reportDetailInfo": "Chi tiết", + "reportDetailOriginTime": "Thời gian xảy ra", + "reportDetailEpicenter": "Tọa độ tâm chấn", + "reportDetailMagnitude": "Độ lớn", + "reportDetailDepth": "Độ sâu chấn tiêu", + "reportDetailAreaIntensity": "Cường độ theo khu vực", + "reportDetailImage": "Hình ảnh báo cáo", + "reportDetailImageUnavailable": "Hình ảnh báo cáo chưa có sẵn", + "reportDetailOpenReport": "Trang báo cáo", "navMore": "Thêm", "appLogs": "Nhật ký ứng dụng", "changelogTitle": "Nhật ký cập nhật", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 89989330e..43a945c5d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 4e8e8e7b8..a6735050d 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -69,6 +69,18 @@ "reportFilterApply": "应用", "reportFilterReset": "重置", "reportListSearch": "查询", + "reportDetailTitle": "地震报告", + "reportDetailNumbered": "编号 {number} 显著有感地震", + "reportDetailLocalFelt": "小区域有感地震", + "reportDetailInfo": "详细信息", + "reportDetailOriginTime": "发震时间", + "reportDetailEpicenter": "震中坐标", + "reportDetailMagnitude": "地震规模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震报告图", + "reportDetailImageUnavailable": "报告图尚未提供", + "reportDetailOpenReport": "报告页面", "navMore": "更多", "appLogs": "应用日志", "changelogTitle": "更新日志", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index fd522fd86..f7b8ff9a9 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 9539e8056..b86ab9ce7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4858bf433..83bfa55df 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -531,6 +531,78 @@ abstract class AppLocalizations { /// **'Search'** String get reportListSearch; + /// Header title over the report detail map's back button + /// + /// In en, this message translates to: + /// **'Earthquake Report'** + String get reportDetailTitle; + + /// Eyebrow label on the detail header for a numbered CWA report + /// + /// In en, this message translates to: + /// **'No. {number} Significant Earthquake'** + String reportDetailNumbered(String number); + + /// Eyebrow label on the detail header for a …000 (unnumbered) report + /// + /// In en, this message translates to: + /// **'Local Felt Earthquake'** + String get reportDetailLocalFelt; + + /// Section header over origin time / epicenter / magnitude / depth + /// + /// In en, this message translates to: + /// **'Details'** + String get reportDetailInfo; + + /// Row label for the report's origin date/time + /// + /// In en, this message translates to: + /// **'Origin time'** + String get reportDetailOriginTime; + + /// Row label for the epicenter's latitude/longitude + /// + /// In en, this message translates to: + /// **'Epicenter'** + String get reportDetailEpicenter; + + /// Row label for the report's magnitude + /// + /// In en, this message translates to: + /// **'Magnitude'** + String get reportDetailMagnitude; + + /// Row label for the report's hypocentral depth + /// + /// In en, this message translates to: + /// **'Depth'** + String get reportDetailDepth; + + /// Section header over the per-area/town felt-intensity breakdown + /// + /// In en, this message translates to: + /// **'Intensity by area'** + String get reportDetailAreaIntensity; + + /// Section header over the CWA-rendered report image + /// + /// In en, this message translates to: + /// **'Report image'** + String get reportDetailImage; + + /// Shown in place of the report image when it fails to load + /// + /// In en, this message translates to: + /// **'Report image not available'** + String get reportDetailImageUnavailable; + + /// Button that opens the official CWA report page in a browser + /// + /// In en, this message translates to: + /// **'Report page'** + String get reportDetailOpenReport; + /// Bottom-nav label and page title for the More tab /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 88b0faf9b..f04ceac98 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -241,6 +241,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reportListSearch => 'Search'; + @override + String get reportDetailTitle => 'Earthquake Report'; + + @override + String reportDetailNumbered(String number) { + return 'No. $number Significant Earthquake'; + } + + @override + String get reportDetailLocalFelt => 'Local Felt Earthquake'; + + @override + String get reportDetailInfo => 'Details'; + + @override + String get reportDetailOriginTime => 'Origin time'; + + @override + String get reportDetailEpicenter => 'Epicenter'; + + @override + String get reportDetailMagnitude => 'Magnitude'; + + @override + String get reportDetailDepth => 'Depth'; + + @override + String get reportDetailAreaIntensity => 'Intensity by area'; + + @override + String get reportDetailImage => 'Report image'; + + @override + String get reportDetailImageUnavailable => 'Report image not available'; + + @override + String get reportDetailOpenReport => 'Report page'; + @override String get navMore => 'More'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 13c714c72..005088857 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -242,6 +242,45 @@ class AppLocalizationsFil extends AppLocalizations { @override String get reportListSearch => 'Maghanap'; + @override + String get reportDetailTitle => 'Ulat ng Lindol'; + + @override + String reportDetailNumbered(String number) { + return 'Blg. $number Makabuluhang Naramdamang Lindol'; + } + + @override + String get reportDetailLocalFelt => 'Lokal na Naramdamang Lindol'; + + @override + String get reportDetailInfo => 'Mga Detalye'; + + @override + String get reportDetailOriginTime => 'Oras ng pangyayari'; + + @override + String get reportDetailEpicenter => 'Coordinates ng Epicenter'; + + @override + String get reportDetailMagnitude => 'Magnitude'; + + @override + String get reportDetailDepth => 'Lalim ng Hypocenter'; + + @override + String get reportDetailAreaIntensity => 'Intensity ayon sa lugar'; + + @override + String get reportDetailImage => 'Larawan ng Ulat'; + + @override + String get reportDetailImageUnavailable => + 'Wala pang available na larawan ng ulat'; + + @override + String get reportDetailOpenReport => 'Pahina ng Ulat'; + @override String get navMore => 'Higit Pa'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index e48954000..68a73e0c3 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -241,6 +241,44 @@ class AppLocalizationsId extends AppLocalizations { @override String get reportListSearch => 'Cari'; + @override + String get reportDetailTitle => 'Laporan Gempa'; + + @override + String reportDetailNumbered(String number) { + return 'Gempa Dirasakan Signifikan No. $number'; + } + + @override + String get reportDetailLocalFelt => 'Gempa Dirasakan Lokal'; + + @override + String get reportDetailInfo => 'Detail'; + + @override + String get reportDetailOriginTime => 'Waktu kejadian'; + + @override + String get reportDetailEpicenter => 'Koordinat episentrum'; + + @override + String get reportDetailMagnitude => 'Magnitudo'; + + @override + String get reportDetailDepth => 'Kedalaman hiposenter'; + + @override + String get reportDetailAreaIntensity => 'Intensitas per wilayah'; + + @override + String get reportDetailImage => 'Gambar laporan'; + + @override + String get reportDetailImageUnavailable => 'Gambar laporan belum tersedia'; + + @override + String get reportDetailOpenReport => 'Halaman laporan'; + @override String get navMore => 'Lainnya'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index aa1a36b57..623438a26 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -240,6 +240,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get reportListSearch => '検索'; + @override + String get reportDetailTitle => '地震レポート'; + + @override + String reportDetailNumbered(String number) { + return 'No.$number 顕著有感地震'; + } + + @override + String get reportDetailLocalFelt => '局地的な有感地震'; + + @override + String get reportDetailInfo => '詳細情報'; + + @override + String get reportDetailOriginTime => '発震時刻'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源の深さ'; + + @override + String get reportDetailAreaIntensity => '地域別震度'; + + @override + String get reportDetailImage => '地震レポート画像'; + + @override + String get reportDetailImageUnavailable => 'レポート画像はまだありません'; + + @override + String get reportDetailOpenReport => 'レポートページ'; + @override String get navMore => 'その他'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 424c0b382..8b95e928d 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -240,6 +240,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get reportListSearch => '조회'; + @override + String get reportDetailTitle => '지진 보고서'; + + @override + String reportDetailNumbered(String number) { + return '번호 $number 유의미 유감지진'; + } + + @override + String get reportDetailLocalFelt => '국지적 유감지진'; + + @override + String get reportDetailInfo => '상세 정보'; + + @override + String get reportDetailOriginTime => '발생 시각'; + + @override + String get reportDetailEpicenter => '진앙 좌표'; + + @override + String get reportDetailMagnitude => '지진 규모'; + + @override + String get reportDetailDepth => '진원 깊이'; + + @override + String get reportDetailAreaIntensity => '지역별 진도'; + + @override + String get reportDetailImage => '지진 보고서 이미지'; + + @override + String get reportDetailImageUnavailable => '보고서 이미지가 아직 없습니다'; + + @override + String get reportDetailOpenReport => '보고서 페이지'; + @override String get navMore => '더보기'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index a77423eab..9992e1b78 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -240,6 +240,44 @@ class AppLocalizationsTh extends AppLocalizations { @override String get reportListSearch => 'ค้นหา'; + @override + String get reportDetailTitle => 'รายงานแผ่นดินไหว'; + + @override + String reportDetailNumbered(String number) { + return 'แผ่นดินไหวรู้สึกได้อย่างมีนัยสำคัญ หมายเลข $number'; + } + + @override + String get reportDetailLocalFelt => 'แผ่นดินไหวรู้สึกได้เฉพาะพื้นที่'; + + @override + String get reportDetailInfo => 'รายละเอียด'; + + @override + String get reportDetailOriginTime => 'เวลาเกิดเหตุ'; + + @override + String get reportDetailEpicenter => 'พิกัดศูนย์กลาง'; + + @override + String get reportDetailMagnitude => 'ขนาดแผ่นดินไหว'; + + @override + String get reportDetailDepth => 'ความลึกจุดศูนย์กลาง'; + + @override + String get reportDetailAreaIntensity => 'ความเข้มแยกตามพื้นที่'; + + @override + String get reportDetailImage => 'ภาพรายงานแผ่นดินไหว'; + + @override + String get reportDetailImageUnavailable => 'ยังไม่มีภาพรายงาน'; + + @override + String get reportDetailOpenReport => 'หน้ารายงาน'; + @override String get navMore => 'เพิ่มเติม'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 645b63138..bf78def1d 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -240,6 +240,44 @@ class AppLocalizationsVi extends AppLocalizations { @override String get reportListSearch => 'Tìm'; + @override + String get reportDetailTitle => 'Báo cáo động đất'; + + @override + String reportDetailNumbered(String number) { + return 'Động đất có cảm nhận đáng kể số $number'; + } + + @override + String get reportDetailLocalFelt => 'Động đất cảm nhận cục bộ'; + + @override + String get reportDetailInfo => 'Chi tiết'; + + @override + String get reportDetailOriginTime => 'Thời gian xảy ra'; + + @override + String get reportDetailEpicenter => 'Tọa độ tâm chấn'; + + @override + String get reportDetailMagnitude => 'Độ lớn'; + + @override + String get reportDetailDepth => 'Độ sâu chấn tiêu'; + + @override + String get reportDetailAreaIntensity => 'Cường độ theo khu vực'; + + @override + String get reportDetailImage => 'Hình ảnh báo cáo'; + + @override + String get reportDetailImageUnavailable => 'Hình ảnh báo cáo chưa có sẵn'; + + @override + String get reportDetailOpenReport => 'Trang báo cáo'; + @override String get navMore => 'Thêm'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 912e22f47..e9ddb9226 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -239,6 +239,44 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; @@ -1526,6 +1564,44 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get reportListSearch => '查询'; + @override + String get reportDetailTitle => '地震报告'; + + @override + String reportDetailNumbered(String number) { + return '编号 $number 显著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小区域有感地震'; + + @override + String get reportDetailInfo => '详细信息'; + + @override + String get reportDetailOriginTime => '发震时间'; + + @override + String get reportDetailEpicenter => '震中坐标'; + + @override + String get reportDetailMagnitude => '地震规模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震报告图'; + + @override + String get reportDetailImageUnavailable => '报告图尚未提供'; + + @override + String get reportDetailOpenReport => '报告页面'; + @override String get navMore => '更多'; @@ -2813,6 +2889,44 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; @@ -4100,6 +4214,44 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index fc6a76704..522085545 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -26,6 +26,11 @@ abstract final class AppRoutes { static const String earthquake = 'earthquake'; static const String earthquakePath = 'earthquake'; + /// A single report's detail (震度/規模 breakdown) — nested under + /// [earthquakePath] with an `:id` path parameter (the report id). + static const String earthquakeReport = 'earthquakeReport'; + static const String earthquakeReportPath = ':id'; + /// Live EEW monitor — nested under [dataPath]. static const String eew = 'eew'; static const String eewPath = 'eew'; diff --git a/lib/shared/seismic/report_colors.dart b/lib/shared/seismic/report_colors.dart new file mode 100644 index 000000000..42a827a6c --- /dev/null +++ b/lib/shared/seismic/report_colors.dart @@ -0,0 +1,60 @@ +/// Colour ramps for an earthquake report's magnitude and hypocentral depth — +/// small indicator dots next to the numeric value on the report detail page. +/// Ported verbatim (same hex values) from the legacy palette. +library; + +import 'package:flutter/material.dart'; + +/// Continuous colour ramp for magnitude, interpolated between named stops. +abstract final class MagnitudeColors { + static const List<(double magnitude, Color color)> _stops = [ + (2.5, Color(0xFF00C8C8)), + (3.5, Color(0xFF00C800)), + (4.5, Color(0xFFFFC800)), + (6.0, Color(0xFFFF0000)), + (7.0, Color(0xFF9600FF)), + ]; + + /// The colour for [magnitude], linearly interpolated between stops and + /// clamped to the end colours outside the ramp's range. + static Color of(double magnitude) { + if (magnitude <= _stops.first.$1) return _stops.first.$2; + if (magnitude >= _stops.last.$1) return _stops.last.$2; + for (var i = 0; i < _stops.length - 1; i++) { + final (lo, loColor) = _stops[i]; + final (hi, hiColor) = _stops[i + 1]; + if (magnitude >= lo && magnitude < hi) { + return Color.lerp(loColor, hiColor, (magnitude - lo) / (hi - lo))!; + } + } + return _stops.first.$2; + } +} + +/// Continuous colour ramp for hypocentral depth (km), interpolated between +/// named stops — shallow (red) to deep (blue). +abstract final class DepthColors { + static const List<(double depthKm, Color color)> _stops = [ + (5, Color(0xFFFF0000)), + (15, Color(0xFFFF6400)), + (30, Color(0xFFFFC800)), + (50, Color(0xFF00C800)), + (100, Color(0xFF00C8C8)), + (150, Color(0xFF0000C8)), + ]; + + /// The colour for [depthKm], linearly interpolated between stops and + /// clamped to the end colours outside the ramp's range. + static Color of(double depthKm) { + if (depthKm <= _stops.first.$1) return _stops.first.$2; + if (depthKm >= _stops.last.$1) return _stops.last.$2; + for (var i = 0; i < _stops.length - 1; i++) { + final (lo, loColor) = _stops[i]; + final (hi, hiColor) = _stops[i + 1]; + if (depthKm >= lo && depthKm < hi) { + return Color.lerp(loColor, hiColor, (depthKm - lo) / (hi - lo))!; + } + } + return _stops.first.$2; + } +} diff --git a/lib/shared/widgets/intensity_badge.dart b/lib/shared/widgets/intensity_badge.dart new file mode 100644 index 000000000..f2028f819 --- /dev/null +++ b/lib/shared/widgets/intensity_badge.dart @@ -0,0 +1,54 @@ +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:flutter/material.dart'; + +/// A filled square badge showing a felt-intensity label (e.g. `5⁻`) over its +/// palette [color] — the report list row and report detail header share this +/// so the two never drift in style. +class IntensityBadge extends StatelessWidget { + const IntensityBadge({ + super.key, + required this.label, + required this.color, + this.size = 48, + }); + + /// Text drawn on the badge (see [Intensity.displayForReport]). + final String label; + + /// Palette colour for the badge fill (see `IntensityColors.discrete`). + final Color color; + + /// Edge length of the square badge. + final double size; + + @override + Widget build(BuildContext context) { + final onBadge = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black87; + final titleLarge = Theme.of(context).textTheme.titleLarge; + // Scale proportionally from the reference 48px badge size so larger/ + // smaller badges (e.g. the detail header) keep the same visual weight. + final fontSize = (titleLarge?.fontSize ?? 22) * (size / 48); + return DecoratedBox( + decoration: BoxDecoration(color: color, borderRadius: AppRadius.small), + child: SizedBox( + width: size, + height: size, + child: Center( + child: Text( + label, + style: titleLarge?.copyWith( + color: onBadge, + fontWeight: FontWeight.w900, + height: 1, + fontSize: fontSize, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 061643e39..e4d8beb15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,3 +68,22 @@ flutter: - assets/location.json.gz - assets/map/town_boundaries.bin.gz - assets/travel_time.json.gz + - assets/map/icons/cross.png + - assets/map/icons/intensity-1.png + - assets/map/icons/intensity-1-dark.png + - assets/map/icons/intensity-2.png + - assets/map/icons/intensity-2-dark.png + - assets/map/icons/intensity-3.png + - assets/map/icons/intensity-3-dark.png + - assets/map/icons/intensity-4.png + - assets/map/icons/intensity-4-dark.png + - assets/map/icons/intensity-5.png + - assets/map/icons/intensity-5-dark.png + - assets/map/icons/intensity-6.png + - assets/map/icons/intensity-6-dark.png + - assets/map/icons/intensity-7.png + - assets/map/icons/intensity-7-dark.png + - assets/map/icons/intensity-8.png + - assets/map/icons/intensity-8-dark.png + - assets/map/icons/intensity-9.png + - assets/map/icons/intensity-9-dark.png diff --git a/test/features/earthquake/earthquake_report_test.dart b/test/features/earthquake/earthquake_report_test.dart new file mode 100644 index 000000000..3d2471b46 --- /dev/null +++ b/test/features/earthquake/earthquake_report_test.dart @@ -0,0 +1,61 @@ +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EarthquakeReport', () { + final json = { + 'id': '115053-2026-0731-005836', + 'lat': 23.1, + 'lon': 121.36, + 'depth': 20.2, + 'loc': '臺東縣政府北北東方 44.1 公里 (位於臺東縣成功鎮)', + 'mag': 4.7, + 'time': 1785430716000, + 'trem': 0, + 'list': { + '臺東縣': { + 'int': 4, + 'town': { + '成功鎮': {'lon': 121.36, 'lat': 23.1, 'int': 4}, + '長濱鄉': {'lon': 121.4, 'lat': 23.3, 'int': 3}, + }, + }, + }, + }; + + test('fromJson round-trip', () { + final report = EarthquakeReport.fromJson(json); + expect(report.id, '115053-2026-0731-005836'); + expect(report.magnitude, 4.7); + expect(report.isLocalFelt, isFalse); + expect(report.hasNumber, isTrue); + expect(report.number, '115053'); + expect(report.shortLocation, '臺東縣成功鎮'); + expect(report.maxIntensity, 4); + expect(report.list['臺東縣']?.town['成功鎮']?.intensity, 4); + expect(EarthquakeReport.fromJson(report.toJson()), report); + }); + + test('…000 serial is local-felt, not a numbered report', () { + final report = EarthquakeReport.fromJson({ + ...json, + 'id': '115000-2026-0731-005836', + }); + expect(report.isLocalFelt, isTrue); + expect(report.hasNumber, isFalse); + expect(report.number, isNull); + }); + + test('reportUrl and reportImageUrl are derived from id/time/magnitude', () { + final report = EarthquakeReport.fromJson(json); + expect( + report.reportUrl.toString(), + 'https://scweb.cwa.gov.tw/zh-tw/earthquake/details/2026073100583647053', + ); + expect( + report.reportImageUrl.toString(), + 'https://scweb.cwa.gov.tw/webdata/OLDEQ/202607/2026073100583647053_H.png', + ); + }); + }); +} From 348eae589b57b8057be1cc29ade2356503c6df9b Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 8 Aug 2026 15:36:38 +0800 Subject: [PATCH 2/3] fix: sort --- .../pages/report_detail_page.dart | 218 ++++++++++-------- 1 file changed, 118 insertions(+), 100 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index 5c771cc20..a64b39cae 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -122,22 +122,50 @@ LatLngBounds _reportBounds(EarthquakeReport report) { ); } -/// Highest intensity first; ties broken by name so the order is stable. -int _byIntensityDesc( - MapEntry a, - MapEntry b, -) { - final byIntensity = b.value.intensity.compareTo(a.value.intensity); - return byIntensity != 0 ? byIntensity : a.key.compareTo(b.key); +/// One 震度 level's counties (縣市) and, per county, the felt area/town names — +/// grouping by intensity first (highest first), not by county, mirrors the +/// reference report layout: the level is stated once per group instead of +/// once per chip. +class _IntensityGroup { + const _IntensityGroup({required this.intensity, required this.counties}); + + final int intensity; + final List<_CountyAreas> counties; } -/// Same ordering as [_byIntensityDesc], for a single area's town entries. -int _byTownIntensityDesc( - MapEntry a, - MapEntry b, -) { - final byIntensity = b.value.intensity.compareTo(a.value.intensity); - return byIntensity != 0 ? byIntensity : a.key.compareTo(b.key); +/// One county's felt area/town names at its parent [_IntensityGroup]'s level. +class _CountyAreas { + const _CountyAreas({required this.countyName, required this.areaNames}); + + final String countyName; + final List areaNames; +} + +/// Regroups the report's county→town map by intensity level (highest first), +/// then by county, preserving each county's/town's original (API) order +/// within that. +List<_IntensityGroup> _buildIntensityGroups(EarthquakeReport report) { + final byIntensity = >>{}; + for (final MapEntry(key: countyName, value: area) in report.list.entries) { + for (final MapEntry(key: townName, value: town) in area.town.entries) { + byIntensity + .putIfAbsent(town.intensity, () => {}) + .putIfAbsent(countyName, () => []) + .add(townName); + } + } + final levels = byIntensity.keys.toList()..sort((a, b) => b.compareTo(a)); + return [ + for (final intensity in levels) + _IntensityGroup( + intensity: intensity, + counties: [ + for (final MapEntry(key: countyName, value: areaNames) + in byIntensity[intensity]!.entries) + _CountyAreas(countyName: countyName, areaNames: areaNames), + ], + ), + ]; } /// The map surface: epicentre + per-town intensity dots, framed to the @@ -449,8 +477,7 @@ class _ReportSheetState extends State<_ReportSheet> { List _expandedContent(BuildContext context, EarthquakeReport report) { final l10n = AppLocalizations.of(context); - // Highest-felt area first, left-to-right/top-to-bottom reading order. - final areas = report.list.entries.toList()..sort(_byIntensityDesc); + final groups = _buildIntensityGroups(report); return [ Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), @@ -461,9 +488,9 @@ class _ReportSheetState extends State<_ReportSheet> { const SizedBox(height: AppSpacing.lg), SectionHeader(l10n.reportDetailInfo), _ReportInfoCard(report: report), - if (areas.isNotEmpty) ...[ + if (groups.isNotEmpty) ...[ SectionHeader(l10n.reportDetailAreaIntensity), - _AreaIntensityCard(report: report, areas: areas), + _AreaIntensityCard(groups: groups), ], SectionHeader(l10n.reportDetailImage), _ReportImageCard(report: report), @@ -818,12 +845,14 @@ class _InfoRow extends StatelessWidget { } } -/// 各地震度 — one row per area (縣市), each with a wrap of its town chips. +/// 各地震度 — grouped by felt intensity (highest first), not by county: each +/// [_IntensityGroup] states its level once in a left-hand column via +/// [IntensityBadge], with every county (bold) and its felt areas listed to +/// the right, left-to-right/top-to-bottom. class _AreaIntensityCard extends StatelessWidget { - const _AreaIntensityCard({required this.report, required this.areas}); + const _AreaIntensityCard({required this.groups}); - final EarthquakeReport report; - final List> areas; + final List<_IntensityGroup> groups; @override Widget build(BuildContext context) { @@ -833,112 +862,101 @@ class _AreaIntensityCard extends StatelessWidget { borderRadius: AppRadius.medium, clipBehavior: Clip.antiAlias, child: Column( - // Otherwise each row centers as a whole block at its own shrink-wrapped - // width (see _AreaRow's inner Column), so a county with fewer/narrower - // chips visibly drifts toward the middle instead of sitting flush left. crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < areas.length; i++) ...[ - if (i > 0) const Divider(height: 1), - _AreaRow(areaName: areas[i].key, area: areas[i].value), - ], + for (var i = 0; i < groups.length; i++) + _IntensityGroupRow(group: groups[i]), ], ), ); } } -class _AreaRow extends StatelessWidget { - const _AreaRow({required this.areaName, required this.area}); +/// One 震度 level's band — a background wash of its palette [IntensityColors] +/// colour (strongest at the top, fading as intensity drops down the card) +/// stands in for the vertical divider line a plain list would need, reading +/// as a single severity gradient at a glance (the broadcast-graphic +/// convention this section's reference design followed). +class _IntensityGroupRow extends StatelessWidget { + const _IntensityGroupRow({required this.group}); - final String areaName; - final AreaIntensity area; + final _IntensityGroup group; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - areaName, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + final colors = Theme.of(context).colorScheme; + // Raw wire intensity, not [Intensity.displayForReport] — per-town/group + // readings have always been on the fine 0–9 scale (only the top-level + // report max needed the pre-2020 legacy-scale remap). + final label = Intensity.label(group.intensity); + final color = IntensityColors.discrete(group.intensity); + + return DecoratedBox( + decoration: BoxDecoration(color: color.withValues(alpha: 0.14)), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: IntensityBadge(label: label, color: color, size: 40), ), - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - // Highest-felt town first, left-to-right/top-to-bottom. - for (final MapEntry(key: townName, value: town) - in area.town.entries.toList()..sort(_byTownIntensityDesc)) - _TownChip(townName: townName, intensity: town.intensity), - ], - ), - ], + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < group.counties.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: AppSpacing.md, + endIndent: AppSpacing.md, + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + _CountyAreasRow(county: group.counties[i]), + ], + ], + ), + ), + ], + ), ), ); } } -class _TownChip extends StatelessWidget { - const _TownChip({required this.townName, required this.intensity}); +class _CountyAreasRow extends StatelessWidget { + const _CountyAreasRow({required this.county}); - final String townName; - final int intensity; + final _CountyAreas county; @override Widget build(BuildContext context) { final theme = Theme.of(context); - // Raw wire intensity, not [Intensity.displayForReport] — the per-town - // reading has always been on the fine 0–9 scale (only the top-level - // report max needed the pre-2020 legacy-scale remap). - final label = Intensity.label(intensity); - final color = IntensityColors.discrete(intensity); - - return Container( + return Padding( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.16), - border: Border.all(color: color), - borderRadius: AppRadius.small, + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, ), - child: Row( - mainAxisSize: MainAxisSize.min, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, children: [ - SizedBox( - width: 20, - height: 20, - child: DecoratedBox( - decoration: BoxDecoration( - color: color, - borderRadius: AppRadius.small, - ), - child: Center( - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: - ThemeData.estimateBrightnessForColor(color) == - Brightness.dark - ? Colors.white - : Colors.black87, - fontWeight: FontWeight.w900, - height: 1, - ), - ), - ), + Text( + county.countyName, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + // l10n-ignore: CJK enumeration comma (頓號) joining names, not display text itself. + county.areaNames.join('、'), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: AppSpacing.xs), - Text(townName, style: theme.textTheme.labelLarge), ], ), ); From 5a6b6519540da20ae87a1deea9fd1076960265a2 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 8 Aug 2026 17:14:32 +0800 Subject: [PATCH 3/3] fix: icon size --- .../pages/report_detail_page.dart | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index a64b39cae..6dff3f801 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -893,34 +893,34 @@ class _IntensityGroupRow extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration(color: color.withValues(alpha: 0.14)), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: IntensityBadge(label: label, color: color, size: 40), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < group.counties.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - indent: AppSpacing.md, - endIndent: AppSpacing.md, - color: colors.outlineVariant.withValues(alpha: 0.4), - ), - _CountyAreasRow(county: group.counties[i]), - ], + child: Row( + // Default cross-axis alignment (center), not .stretch — the badge is + // a fixed-size square ([IntensityBadge]); stretching would force it + // to the county column's height whenever a group spans multiple rows. + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: IntensityBadge(label: label, color: color, size: 40), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < group.counties.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: AppSpacing.md, + endIndent: AppSpacing.md, + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + _CountyAreasRow(county: group.counties[i]), ], - ), + ], ), - ], - ), + ), + ], ), ); }