diff --git a/.crowdin/strings.pot b/.crowdin/strings.pot index 0c93b9b6f..aafc7d610 100644 --- a/.crowdin/strings.pot +++ b/.crowdin/strings.pot @@ -1,338 +1,338 @@ -#: ./lib/core/service.dart:291 +#: ./lib/core/service.dart:285 msgid "正在更新位置" msgstr "" -#: ./lib/core/service.dart:292 +#: ./lib/core/service.dart:286 msgid "取得 GPS 位置中..." msgstr "" -#: ./lib/app/settings/notify/page.dart:51 +#: ./lib/app/settings/notify/page.dart:56 msgid "接收全部" msgstr "" -#: ./lib/app/settings/notify/page.dart:50 +#: ./lib/app/settings/notify/page.dart:65 msgid "關閉" msgstr "" -#: ./lib/app/settings/notify/_widgets/eew_notify_section.dart:51 +#: ./lib/app/settings/notify/_widgets/eew_notify_section.dart:70 msgid "接收類別" msgstr "" -#: ./lib/app/settings/notify/page.dart:35 +#: ./lib/app/settings/notify/page.dart:55 msgid "所在地震度1以上" msgstr "" -#: ./lib/app/settings/notify/page.dart:46 +#: ./lib/app/settings/notify/page.dart:61 msgid "海嘯消息、海嘯警報" msgstr "" -#: ./lib/app/settings/notify/page.dart:45 +#: ./lib/app/settings/notify/page.dart:60 msgid "只接收海嘯警報" msgstr "" -#: ./lib/app/settings/notify/page.dart:41 +#: ./lib/app/settings/notify/page.dart:66 msgid "接收所在地" msgstr "" -#: ./lib/app/settings/notify/page.dart:27 +#: ./lib/app/settings/notify/page.dart:54 msgid "所在地震度4以上" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:28 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:33 msgid "音效測試" msgstr "" -#: ./lib/route/announcement/announcement.dart:81 +#: ./lib/route/announcement/announcement.dart:80 msgid "公告" msgstr "" -#: ./lib/app/settings/notify/(5.basic)/announcement/page.dart:32 +#: ./lib/app/settings/notify/(5.basic)/announcement/page.dart:37 msgid "發送公告時" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:46 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:51 msgid "音效測試為在裝置上執行的本地通知,僅用於確認裝置在接收通知時是否能正常播放音效。此測試不會向伺服器發送任何請求" msgstr "" -#: ./lib/app/settings/notify/page.dart:82 +#: ./lib/app/settings/notify/page.dart:104 msgid "伺服器排隊中,請稍候…" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:166 +#: ./lib/app/welcome/4-permissions/page.dart:111 msgid "通知" msgstr "" -#: ./lib/app/settings/page.dart:182 +#: ./lib/app/settings/page.dart:189 msgid "推播通知設定與通知音效測試" msgstr "" -#: ./lib/app/home/_widgets/location_not_set_card.dart:33 +#: ./lib/app/home_old/_widgets/location_not_set_card.dart:41 msgid "尚未設定所在地" msgstr "" -#: ./lib/app/settings/notify/page.dart:201 +#: ./lib/app/settings/notify/page.dart:208 msgid "請先設定所在地來使用通知功能" msgstr "" -#: ./lib/app/settings/notify/page.dart:211 +#: ./lib/app/settings/notify/page.dart:217 msgid "設定所在地" msgstr "" -#: ./lib/app/settings/experimental/page.dart:209 +#: ./lib/app/settings/experimental/page.dart:215 msgid "地震速報" msgstr "" -#: ./lib/app/settings/notify/page.dart:232 +#: ./lib/app/settings/notify/page.dart:238 msgid "緊急地震速報" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:113 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:125 msgid "地震" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1579 +#: ./lib/app/map/_lib/managers/monitor.dart:1584 msgid "強震監視器" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1302 +#: ./lib/app/map/_lib/managers/report.dart:1294 msgid "地震報告" msgstr "" -#: ./lib/app/settings/notify/page.dart:290 +#: ./lib/app/settings/notify/page.dart:297 msgid "震度速報" msgstr "" -#: ./lib/app/settings/notify/page.dart:304 +#: ./lib/app/settings/notify/page.dart:310 msgid "天氣" msgstr "" -#: ./lib/app/home/_widgets/thunderstorm_card.dart:63 +#: ./lib/app/home_old/_widgets/thunderstorm_card.dart:72 msgid "雷雨即時訊息" msgstr "" -#: ./lib/app/settings/notify/page.dart:334 +#: ./lib/app/settings/notify/page.dart:339 msgid "天氣警特報" msgstr "" -#: ./lib/app/settings/notify/page.dart:354 +#: ./lib/app/settings/notify/page.dart:358 msgid "防災資訊" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:129 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:141 msgid "海嘯" msgstr "" -#: ./lib/app/settings/notify/page.dart:378 +#: ./lib/app/settings/notify/page.dart:383 msgid "海嘯資訊" msgstr "" -#: ./lib/app/settings/notify/page.dart:390 +#: ./lib/app/settings/notify/page.dart:394 msgid "其他" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:31 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:36 msgid "重大" msgstr "" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:31 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:36 msgid "海嘯警報發布時" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:37 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:42 msgid "一般" msgstr "" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:37 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:42 msgid "海嘯消息發布時" msgstr "" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:41 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:46 msgid "太平洋海嘯消息(無聲通知)" msgstr "" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:42 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:47 msgid "太平洋海嘯消息發布時" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:35 msgid "強震監視器(一般)" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:36 msgid "偵測到晃動" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:35 msgid "震度速報(一般)" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:36 msgid "所在地(鄉鎮)實測震度 3 以上" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:36 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:41 msgid "震度速報(無聲通知)" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:37 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:42 msgid "所在地(鄉鎮)實測震度 1 以上" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:35 msgid "地震報告(一般)" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:36 msgid "所在地(縣市)實測震度 3 以上" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:36 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:41 msgid "地震報告(無聲通知)" msgstr "" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:37 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:42 msgid "所在地(縣市)實測震度 1 以上" msgstr "" -#: ./lib/app/settings/notify/_lib/utils.dart:15 +#: ./lib/app/settings/notify/_lib/utils.dart:29 msgid "已更新通知設定" msgstr "" -#: ./lib/app/settings/notify/_lib/utils.dart:22 +#: ./lib/app/settings/notify/_lib/utils.dart:40 msgid "更新通知設定失敗" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:30 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:35 msgid "緊急地震速報(重大)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:31 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:36 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 4 以上" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:36 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:41 msgid "緊急地震速報(一般)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:37 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:42 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 2 以上" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:41 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:46 msgid "緊急地震速報(無聲)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:42 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:47 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 1 以上" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:46 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:51 msgid "地震速報(重大)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:47 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:52 msgid "所在地(鄉鎮)預估震度 4 以上" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:51 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:56 msgid "地震速報(一般)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:52 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:57 msgid "所在地(鄉鎮)預估震度 2 以上" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:56 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:61 msgid "地震速報(無聲)" msgstr "" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:57 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:62 msgid "所在地(鄉鎮)預估震度 1 以上" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:36 msgid "所在地(鄉鎮)發布防災警訊時" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:42 msgid "所在地(鄉鎮)發布防災資訊時" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:37 msgid "" "所在地(鄉鎮)發布紅色燈號之\n" "天氣警特報" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:43 msgid "" "所在地(鄉鎮)發布上述除外燈號之\n" "天氣警特報" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:37 msgid "所在地(鄉鎮)發布山區暴雨時" msgstr "" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:43 msgid "所在地(鄉鎮)發布雷雨即時訊息時" msgstr "" -#: ./lib/app/settings/experimental/page.dart:197 -msgid "啟動時進入強震監視器" +#: ./lib/app/settings/experimental/page.dart:48 +msgid "地震速報不限制非 CWA 來源" msgstr "" -#: ./lib/app/settings/experimental/page.dart:57 -msgid "地震速報不限制非 CWA 來源" +#: ./lib/app/settings/experimental/page.dart:203 +msgid "啟動時進入強震監視器" msgstr "" -#: ./lib/app/settings/page.dart:428 +#: ./lib/app/settings/page.dart:430 msgid "實驗性功能" msgstr "" -#: ./lib/app/settings/page.dart:429 +#: ./lib/app/settings/page.dart:431 msgid "搶先體驗開發中的新功能" msgstr "" -#: ./lib/app/settings/experimental/page.dart:154 +#: ./lib/app/settings/experimental/page.dart:160 msgid "注意" msgstr "" -#: ./lib/app/settings/experimental/page.dart:162 +#: ./lib/app/settings/experimental/page.dart:168 msgid "這些功能仍在開發中,可能會不穩定或在未來的版本中變更。" msgstr "" -#: ./lib/app/settings/experimental/page.dart:188 +#: ./lib/app/settings/experimental/page.dart:194 msgid "啟動行為" msgstr "" -#: ./lib/app/settings/experimental/page.dart:198 +#: ./lib/app/settings/experimental/page.dart:204 msgid "開啟 App 時直接進入強震監視器地圖" msgstr "" -#: ./lib/app/settings/experimental/page.dart:218 +#: ./lib/app/settings/experimental/page.dart:224 msgid "不限制非 CWA 來源" msgstr "" -#: ./lib/app/settings/experimental/page.dart:219 +#: ./lib/app/settings/experimental/page.dart:225 msgid "顯示所有來源的地震速報資料" msgstr "" -#: ./lib/app/settings/experimental/page.dart:280 +#: ./lib/app/settings/experimental/page.dart:281 msgid "啟用實驗性功能" msgstr "" -#: ./lib/app/settings/experimental/page.dart:286 +#: ./lib/app/settings/experimental/page.dart:287 msgid "你即將啟用:" msgstr "" -#: ./lib/app/settings/experimental/page.dart:317 +#: ./lib/app/settings/experimental/page.dart:318 msgid "此功能為實驗性質,可能會造成應用程式不穩定或行為異常。如遇問題,請至設定中關閉此功能。" msgstr "" @@ -340,15 +340,15 @@ msgstr "" msgid "取消" msgstr "" -#: ./lib/app/settings/layout/page.dart:272 +#: ./lib/app/settings/layout/page.dart:163 msgid "啟用" msgstr "" -#: ./lib/app/settings/page.dart:151 +#: ./lib/app/settings/page.dart:158 msgid "單位" msgstr "" -#: ./lib/app/settings/page.dart:152 +#: ./lib/app/settings/page.dart:159 msgid "調整 DPIP 顯示數值時使用的單位" msgstr "" @@ -360,131 +360,131 @@ msgstr "" msgid "切換溫度顯示單位為華氏度 (℉)" msgstr "" -#: ./lib/app/settings/page.dart:141 +#: ./lib/app/settings/page.dart:148 msgid "語言" msgstr "" -#: ./lib/app/settings/page.dart:142 +#: ./lib/app/settings/page.dart:149 msgid "調整 DPIP 的顯示語言" msgstr "" -#: ./lib/app/settings/locale/page.dart:41 +#: ./lib/app/settings/locale/page.dart:49 msgid "顯示語言" msgstr "" -#: ./lib/app/settings/locale/page.dart:42 +#: ./lib/app/settings/locale/page.dart:50 msgid "系統語言" msgstr "" -#: ./lib/app/settings/locale/page.dart:52 +#: ./lib/app/settings/locale/page.dart:60 msgid "協助翻譯" msgstr "" -#: ./lib/app/settings/locale/page.dart:53 +#: ./lib/app/settings/locale/page.dart:61 msgid "點擊這裡來幫助我們改進 DPIP 的翻譯" msgstr "" -#: ./lib/app/settings/locale/select/page.dart:116 +#: ./lib/app/settings/locale/select/page.dart:122 msgid "已翻譯 {translated}・已校對 {approved}" msgstr "" -#: ./lib/app/settings/locale/select/page.dart:129 +#: ./lib/app/settings/locale/select/page.dart:135 msgid "來源語言" msgstr "" -#: ./lib/app/settings/locale/select/page.dart:163 +#: ./lib/app/settings/locale/select/page.dart:172 msgid "選擇語言" msgstr "" -#: ./lib/app/settings/donate/page.dart:52 +#: ./lib/app/settings/donate/page.dart:59 msgid "無法連線至商店,請稍後再試" msgstr "" -#: ./lib/app/settings/donate/page.dart:59 +#: ./lib/app/settings/donate/page.dart:65 msgid "找不到商品,請稍候再試" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:521 -msgid "重新載入" -msgstr "" - -#: ./lib/app/settings/donate/page.dart:171 -msgid "正在載入商店物品中" -msgstr "" - -#: ./lib/app/settings/donate/page.dart:225 +#: ./lib/app/settings/donate/page.dart:136 msgid "支持 DPIP" msgstr "" -#: ./lib/app/settings/donate/page.dart:233 +#: ./lib/app/settings/donate/page.dart:144 msgid "DPIP 作為一款致力於提供即時地震資訊的 App,目前並無廣告或其他盈利模式。您的支持將幫助我們維持伺服器運行與持續開發。" msgstr "" -#: ./lib/app/settings/donate/page.dart:260 +#: ./lib/app/settings/donate/page.dart:170 msgid "訂閱制" msgstr "" -#: ./lib/app/settings/donate/page.dart:276 +#: ./lib/app/settings/donate/page.dart:189 msgid "推薦" msgstr "" -#: ./lib/app/settings/donate/page.dart:443 +#: ./lib/app/settings/donate/page.dart:353 msgid "{price}/月" msgstr "" -#: ./lib/app/settings/donate/page.dart:475 +#: ./lib/app/settings/donate/page.dart:385 msgid "單次支援" msgstr "" -#: ./lib/app/settings/donate/page.dart:615 +#: ./lib/app/settings/donate/page.dart:520 msgid "恢復購買" msgstr "" -#: ./lib/app/settings/donate/page.dart:628 +#: ./lib/app/settings/donate/page.dart:530 msgid "無法連線至 {store},請稍後再試。" msgstr "" -#: ./lib/app/settings/donate/page.dart:639 +#: ./lib/app/settings/donate/page.dart:541 msgid "正在恢復您購買的訂閱" msgstr "" -#: ./lib/app/settings/donate/page.dart:644 +#: ./lib/app/settings/donate/page.dart:546 msgid "使用條款" msgstr "" -#: ./lib/app/settings/donate/page.dart:648 +#: ./lib/app/settings/donate/page.dart:550 msgid "隱私權政策" msgstr "" -#: ./lib/app/settings/proxy/page.dart:51 +#: ./lib/app/map/_lib/managers/report.dart:546 +msgid "重新載入" +msgstr "" + +#: ./lib/app/settings/donate/page.dart:636 +msgid "正在載入商店物品中" +msgstr "" + +#: ./lib/app/settings/proxy/page.dart:38 msgid "設定已儲存" msgstr "" -#: ./lib/app/settings/page.dart:200 +#: ./lib/app/settings/page.dart:207 msgid "HTTP 代理" msgstr "" -#: ./lib/app/settings/page.dart:201 +#: ./lib/app/settings/page.dart:208 msgid "調整 HTTP 代理伺服器設定" msgstr "" -#: ./lib/app/settings/proxy/page.dart:75 +#: ./lib/app/settings/proxy/page.dart:74 msgid "啟用代理" msgstr "" -#: ./lib/app/settings/proxy/page.dart:76 +#: ./lib/app/settings/proxy/page.dart:75 msgid "透過代理伺服器發送所有網路請求" msgstr "" -#: ./lib/app/settings/proxy/page.dart:88 +#: ./lib/app/settings/proxy/page.dart:87 msgid "代理主機" msgstr "" -#: ./lib/app/settings/proxy/page.dart:102 +#: ./lib/app/settings/proxy/page.dart:101 msgid "代理端口" msgstr "" -#: ./lib/app/settings/proxy/page.dart:117 +#: ./lib/app/settings/proxy/page.dart:116 msgid "設定儲存後,需要重新啟動應用程式才能生效" msgstr "" @@ -492,123 +492,123 @@ msgstr "" msgid "設定" msgstr "" -#: ./lib/app/settings/page.dart:71 +#: ./lib/app/settings/page.dart:79 msgid "自訂你的 DPIP 使用體驗" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:174 +#: ./lib/app/welcome/4-permissions/page.dart:119 msgid "位置" msgstr "" -#: ./lib/app/settings/location/page.dart:417 +#: ./lib/app/settings/location/page.dart:421 msgid "所在地" msgstr "" -#: ./lib/app/settings/location/page.dart:241 +#: ./lib/app/settings/location/page.dart:245 msgid "設定你的所在地來接收當地的即時資訊" msgstr "" -#: ./lib/app/settings/page.dart:113 +#: ./lib/app/settings/page.dart:120 msgid "介面" msgstr "" -#: ./lib/app/settings/layout/page.dart:47 +#: ./lib/app/settings/layout/page.dart:183 msgid "版面" msgstr "" -#: ./lib/app/settings/layout/page.dart:48 +#: ./lib/app/settings/layout/page.dart:184 msgid "調整首頁的版面樣式" msgstr "" -#: ./lib/app/settings/theme/page.dart:25 +#: ./lib/app/settings/theme/page.dart:32 msgid "主題" msgstr "" -#: ./lib/app/settings/theme/page.dart:26 +#: ./lib/app/settings/theme/page.dart:33 msgid "調整 DPIP 整體的外觀與顏色" msgstr "" -#: ./lib/app/settings/map/page.dart:49 +#: ./lib/app/home/layout.dart:93 msgid "地圖" msgstr "" -#: ./lib/app/settings/map/page.dart:50 +#: ./lib/app/settings/map/page.dart:59 msgid "調整地圖的顯示樣式" msgstr "" -#: ./lib/app/settings/page.dart:191 +#: ./lib/app/settings/page.dart:198 msgid "網路" msgstr "" -#: ./lib/app/settings/page.dart:210 +#: ./lib/app/settings/page.dart:217 msgid "資訊" msgstr "" -#: ./lib/app/changelog/page.dart:54 +#: ./lib/app/home_old/page.dart:163 msgid "更新日誌" msgstr "" -#: ./lib/app/settings/page.dart:230 +#: ./lib/app/settings/page.dart:237 msgid "瀏覽 DPIP 的歷次更新紀錄" msgstr "" -#: ./lib/app/settings/page.dart:240 +#: ./lib/app/settings/page.dart:247 msgid "第三方套件授權" msgstr "" -#: ./lib/app/settings/page.dart:241 +#: ./lib/app/settings/page.dart:248 msgid "DPIP 的實現歸功於開放原始碼" msgstr "" -#: ./lib/app/settings/page.dart:264 +#: ./lib/app/settings/page.dart:271 msgid "贊助我們" msgstr "" -#: ./lib/app/settings/page.dart:267 +#: ./lib/app/settings/page.dart:274 msgid "幫助我們維護伺服器的穩定和長久發展" msgstr "" -#: ./lib/app/settings/page.dart:343 +#: ./lib/app/settings/page.dart:349 msgid "下載" msgstr "" -#: ./lib/app/settings/page.dart:383 +#: ./lib/app/settings/page.dart:385 msgid "除錯" msgstr "" -#: ./lib/app/settings/page.dart:391 +#: ./lib/app/settings/page.dart:393 msgid "應用程式版本" msgstr "" -#: ./lib/app/settings/page.dart:400 +#: ./lib/app/settings/page.dart:402 msgid "裝置資訊" msgstr "" -#: ./lib/app/settings/page.dart:409 +#: ./lib/app/settings/page.dart:411 msgid "複製通知 Token" msgstr "" -#: ./lib/app/debug/logs/page.dart:16 +#: ./lib/app/debug/logs/page.dart:22 msgid "App 日誌" msgstr "" -#: ./lib/app/settings/page.dart:463 +#: ./lib/app/settings/page.dart:465 msgid "任何資訊應以中央氣象署發布之內容為準" msgstr "" -#: ./lib/app/settings/location/page.dart:74 +#: ./lib/app/settings/location/page.dart:85 msgid "無法取得通知權限" msgstr "" -#: ./lib/app/settings/location/page.dart:76 +#: ./lib/app/settings/location/page.dart:87 msgid "無法取得位置權限" msgstr "" -#: ./lib/app/settings/location/page.dart:77 +#: ./lib/app/settings/location/page.dart:88 msgid "無法取得自啟動權限" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:180 +#: ./lib/app/welcome/4-permissions/page.dart:125 msgid "省電策略" msgstr "" @@ -616,782 +616,810 @@ msgstr "" msgid "無法取得權限" msgstr "" -#: ./lib/app/settings/location/page.dart:84 +#: ./lib/app/settings/location/page.dart:94 msgid "自動定位功能需要您允許 DPIP 使用通知權限才能正常運作。請您到應用程式設定中找到並允許「通知」權限後再試一次。" msgstr "" -#: ./lib/app/settings/location/page.dart:86 +#: ./lib/app/settings/location/page.dart:95 msgid "自動定位功能需要您允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到並允許「位置」權限後再試一次。" msgstr "" -#: ./lib/app/settings/location/page.dart:89 +#: ./lib/app/settings/location/page.dart:98 msgid "自動定位功能需要您永遠允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「永遠」後再試一次。" msgstr "" -#: ./lib/app/settings/location/page.dart:91 +#: ./lib/app/settings/location/page.dart:99 msgid "自動定位功能需要您一律允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「一律允許」後再試一次。" msgstr "" -#: ./lib/app/settings/location/page.dart:93 +#: ./lib/app/settings/location/page.dart:100 msgid "為了獲得更好的自動定位體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景自動設定所在地資訊。" msgstr "" -#: ./lib/app/settings/location/page.dart:95 +#: ./lib/app/settings/location/page.dart:101 msgid "為了獲得更好的自動定位體驗,您需要給予「無限制」以便讓 DPIP 在背景自動設定所在地資訊。" msgstr "" -#: ./lib/app/settings/location/page.dart:96 +#: ./lib/app/settings/location/page.dart:102 msgid "自動定位功能需要您允許 DPIP 使用權限才能正常運作。請您到應用程式設定中找到並允許「權限」後再試一次。" msgstr "" -#: ./lib/app/settings/location/page.dart:174 +#: ./lib/app/settings/location/page.dart:180 msgid "自動啟動" msgstr "" -#: ./lib/app/settings/location/page.dart:175 +#: ./lib/app/settings/location/page.dart:181 msgid "為了獲得更好的 DPIP 體驗,請依照步驟啟用自動啟動功能,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" msgstr "" -#: ./lib/app/settings/location/page.dart:199 +#: ./lib/app/settings/location/page.dart:203 msgid "為了獲得更好的 DPIP 體驗,請依照步驟關閉省電策略,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" msgstr "" -#: ./lib/app/settings/location/page.dart:233 +#: ./lib/app/settings/location/page.dart:237 msgid "一律允許" msgstr "" -#: ./lib/app/settings/location/page.dart:233 +#: ./lib/app/settings/location/page.dart:237 msgid "永遠" msgstr "" -#: ./lib/app/settings/location/page.dart:253 +#: ./lib/app/settings/location/page.dart:257 msgid "自動更新" msgstr "" -#: ./lib/app/settings/location/page.dart:254 +#: ./lib/app/settings/location/page.dart:258 msgid "定期更新目前的所在地" msgstr "" -#: ./lib/app/settings/location/page.dart:263 +#: ./lib/app/settings/location/page.dart:267 msgid "" "自動定位功能將使用您的裝置上的 GPS,即使 DPIP " "關閉或未在使用時,也會根據您的地理位置,自動更新您的所在地,提供即時的天氣和地震資訊,讓您隨時掌握當地最新狀況。" msgstr "" -#: ./lib/app/settings/location/page.dart:334 +#: ./lib/app/settings/location/page.dart:338 msgid "通知功能已被拒絕,請移至設定允許權限。" msgstr "" -#: ./lib/app/settings/location/page.dart:395 +#: ./lib/app/settings/location/page.dart:399 msgid "省電策略已被拒絕,請移至設定允許權限。" msgstr "" -#: ./lib/app/settings/location/select/[city]/page.dart:98 +#: ./lib/app/settings/location/select/[city]/page.dart:109 msgid "設定所在地時發生錯誤,請稍候再試一次。" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:233 +#: ./lib/app/home_old/_widgets/location_button.dart:204 msgid "新增地點" msgstr "" -#: ./lib/app/settings/location/select/page.dart:33 +#: ./lib/app/home/_widgets/location_chip.dart:148 msgid "縣市" msgstr "" -#: ./lib/app/settings/location/select/page.dart:44 +#: ./lib/app/settings/location/select/page.dart:49 msgid "目前所在地" msgstr "" -#: ./lib/app/settings/layout/page.dart:56 +#: ./lib/app/settings/layout/page.dart:90 +msgid "停用" +msgstr "" + +#: ./lib/app/settings/layout/page.dart:192 msgid "拖曳調整順序" msgstr "" -#: ./lib/app/settings/layout/page.dart:100 +#: ./lib/app/settings/layout/page.dart:236 msgid "已停用" msgstr "" -#: ./lib/app/settings/layout/page.dart:135 +#: ./lib/app/settings/layout/page.dart:271 msgid "所有區塊皆已啟用" msgstr "" -#: ./lib/app/settings/layout/page.dart:202 -msgid "停用" -msgstr "" - -#: ./lib/app/settings/map/page.dart:61 +#: ./lib/app/settings/map/page.dart:70 msgid "初始圖層" msgstr "" -#: ./lib/app/settings/map/page.dart:62 +#: ./lib/app/settings/map/page.dart:71 msgid "調整地圖的底圖以及初始顯示的圖層" msgstr "" -#: ./lib/app/settings/map/page.dart:74 +#: ./lib/app/settings/map/page.dart:83 msgid "自動縮放" msgstr "" -#: ./lib/app/settings/map/page.dart:75 +#: ./lib/app/settings/map/page.dart:84 msgid "接收到檢知時自動縮放地圖" msgstr "" -#: ./lib/app/settings/map/page.dart:101 +#: ./lib/app/settings/map/page.dart:110 msgid "動畫幀率" msgstr "" -#: ./lib/app/settings/map/page.dart:102 +#: ./lib/app/settings/map/page.dart:111 msgid "調整強震監視器震波模擬動畫的流暢度" msgstr "" -#: ./lib/app/settings/map/page.dart:136 +#: ./lib/app/settings/map/page.dart:144 msgid "過高的動畫幀率可能會造成卡頓或裝置發熱" msgstr "" -#: ./lib/app/settings/theme/page.dart:46 +#: ./lib/app/settings/theme/page.dart:53 msgid "主題模式" msgstr "" -#: ./lib/app/settings/theme/mode/page.dart:63 +#: ./lib/app/settings/theme/mode/page.dart:69 msgid "淺色" msgstr "" -#: ./lib/app/settings/theme/mode/page.dart:64 +#: ./lib/app/settings/theme/mode/page.dart:70 msgid "深色" msgstr "" -#: ./lib/app/settings/theme/mode/page.dart:59 +#: ./lib/app/settings/theme/mode/page.dart:67 msgid "跟隨系統主題" msgstr "" -#: ./lib/app/settings/theme/color/page.dart:22 +#: ./lib/app/settings/theme/color/page.dart:30 msgid "主題色彩" msgstr "" -#: ./lib/app/settings/theme/color/page.dart:43 +#: ./lib/app/settings/theme/color/page.dart:51 msgid "使用系統配色" msgstr "" -#: ./lib/app/settings/theme/color/page.dart:62 +#: ./lib/app/settings/theme/color/page.dart:70 msgid "自訂" msgstr "" -#: ./lib/app/settings/theme/color/page.dart:72 +#: ./lib/app/settings/theme/color/page.dart:79 msgid "自訂色彩" msgstr "" -#: ./lib/app/home/_widgets/thunderstorm_card.dart:84 -msgid "您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。" +#: ./lib/app/map/_lib/managers/radar.dart:614 +msgid "雷達回波" msgstr "" -#: ./lib/app/home/_widgets/forecast_card.dart:59 -msgid "天氣預報(24h)" +#: ./lib/app/home/_widgets/weather_parameters.dart:52 +msgid "相對溼度" msgstr "" -#: ./lib/app/home/_widgets/location_out_of_service.dart:28 -msgid "服務區域外,僅在臺灣各地可用" +#: ./lib/app/home/_widgets/weather_parameters.dart:57 +msgid "空氣品質" msgstr "" -#: ./lib/app/map/_lib/managers/radar.dart:587 -msgid "雷達回波" +#: ./lib/app/map/_lib/managers/wind.dart:343 +msgid "風向/風速" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:497 -msgid "無資料" +#: ./lib/app/home/_widgets/weather_parameters.dart:68 +msgid "降水量" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:372 -msgid "{wind}級 {Desc}" +#: ./lib/app/home/_widgets/location_chip.dart:84 +msgid "清除暫時位置" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:389 -msgid "陣風 {speed} m/s" +#: ./lib/app/home/_widgets/location_chip.dart:159 +msgid "目前選擇地區" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:471 -msgid "無法測量" +#: ./lib/app/home_old/_widgets/location_button.dart:170 +msgid "快速切換" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:492 -msgid "指北針不可靠" +#: ./lib/app/home/layout.dart:88 +msgid "首頁" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:494 -msgid "指北針準確度下降" +#: ./lib/app/home/layout.dart:98 +msgid "小工具" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:495 -msgid "指北針正常" +#: ./lib/app/changelog/page.dart:86 +msgid "發生錯誤" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:502 -msgid "方向精確度" +#: ./lib/route/image_viewer/image_viewer.dart:85 +msgid "再試一次" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:521 -msgid "正常範圍:±0-15°" +#: ./lib/app/changelog/page.dart:217 +msgid "目前版本" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:538 -msgid "附近有強磁場干擾,指北針方向可能完全不準確。請遠離磁鐵、電子裝置或金屬物品。" +#: ./lib/app/welcome/4-permissions/page.dart:383 +msgid "下一步" msgstr "" -#: ./lib/app/home/_widgets/wind_card.dart:539 -msgid "附近可能有磁場干擾,指北針方向可能有偏差。" +#: ./lib/app/welcome/1-about/page.dart:73 +msgid "防災資訊平台" msgstr "" -#: ./lib/route/image_viewer/image_viewer.dart:145 -msgid "確定" +#: ./lib/app/welcome/2-exptech/page.dart:98 +msgid "我們是誰?" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:29 -msgid "尚未設定" +#: ./lib/app/welcome/2-exptech/page.dart:105 +msgid "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:138 -msgid "切換區域" +#: ./lib/app/welcome/2-exptech/page.dart:111 +msgid "我們的初衷" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:144 -msgid "位置設定" +#: ./lib/app/welcome/2-exptech/page.dart:118 +msgid "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:195 -msgid "快速切換" +#: ./lib/app/welcome/3-notice/page.dart:50 +msgid "注意事項" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:240 -msgid "選擇縣市" +#: ./lib/app/welcome/3-notice/page.dart:70 +msgid "任何資訊應以中央氣象署發布之內容為準。" msgstr "" -#: ./lib/app/home/_widgets/location_button.dart:250 -msgid "目前選擇" +#: ./lib/app/welcome/3-notice/page.dart:87 +msgid "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" msgstr "" -#: ./lib/app/home/page.dart:888 -msgid "濕度" +#: ./lib/app/welcome/3-notice/page.dart:101 +msgid "強烈搖晃有機率比通知早抵達使用者所在地。" msgstr "" -#: ./lib/app/home/page.dart:916 -msgid "風速" +#: ./lib/app/welcome/3-notice/page.dart:115 +msgid "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" msgstr "" -#: ./lib/app/home/_widgets/weather_header.dart:181 -msgid "風向" +#: ./lib/app/welcome/3-notice/page.dart:129 +msgid "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" msgstr "" -#: ./lib/app/home/_widgets/weather_header.dart:190 -msgid "風級" +#: ./lib/app/welcome/1-about/page.dart:51 +msgid "歡迎使用 DPIP" msgstr "" -#: ./lib/app/home/page.dart:895 -msgid "氣壓" +#: ./lib/app/welcome/1-about/page.dart:96 +msgid "" +"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " +"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" msgstr "" -#: ./lib/app/home/page.dart:902 -msgid "降雨" +#: ./lib/app/welcome/4-permissions/page.dart:112 +msgid "在重大災害發生時以通知來傳遞即時防災資訊" msgstr "" -#: ./lib/app/home/page.dart:909 -msgid "能見度" +#: ./lib/app/welcome/4-permissions/page.dart:120 +msgid "使用定位來自動更新所在地設定,提供當地的即時防災資訊" msgstr "" -#: ./lib/app/home/page.dart:923 -msgid "陣風" +#: ./lib/app/welcome/4-permissions/page.dart:126 +msgid "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" msgstr "" -#: ./lib/app/home/_widgets/weather_header.dart:233 -msgid "陣風級" +#: ./lib/route/image_viewer/image_viewer.dart:254 +msgid "儲存" msgstr "" -#: ./lib/app/home/_widgets/weather_header.dart:241 -msgid "日照" +#: ./lib/app/welcome/4-permissions/page.dart:133 +msgid "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" msgstr "" -#: ./lib/app/home/_widgets/hero_weather.dart:142 -msgid "體感 {feelsLike}°" +#: ./lib/app/welcome/4-permissions/page.dart:293 +msgid "權限請求" msgstr "" -#: ./lib/app/home/_widgets/hero_weather.dart:185 -msgid "無天氣資料" +#: ./lib/app/welcome/4-permissions/page.dart:294 +msgid "需要使用者手動到設定開啟相關權限。" msgstr "" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:14 -msgid "全國 · 生效中" +#: ./lib/route/image_viewer/image_viewer.dart:145 +msgid "確定" msgstr "" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:16 -msgid "全國 · 歷史" +#: ./lib/app/welcome/4-permissions/page.dart:317 +msgid "需要背景位置權限" msgstr "" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:18 -msgid "所在地 · 生效中" +#: ./lib/app/welcome/4-permissions/page.dart:319 +msgid "" +"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" +"\n" +"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" msgstr "" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:20 -msgid "所在地 · 歷史" +#: ./lib/app/welcome/4-permissions/page.dart:325 +msgid "稍後" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1258 -msgid "EEW" +#: ./lib/app/welcome/4-permissions/page.dart:329 +msgid "前往設定" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1397 -msgid "第 {serial} 報" +#: ./lib/app/welcome/4-permissions/page.dart:406 +msgid "權限" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1415 -msgid "" -"{time} 左右,{location}附近發生有感地震,預估規模 " -"M{magnitude}、所在地最大震度{intensity}。" +#: ./lib/app/welcome/4-permissions/page.dart:419 +msgid "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1423 -msgid "" -"{time} 左右,{location}附近發生有感地震,預估規模 " -"M{magnitude}、深度{depth}公里。" +#: ./lib/app/home_old/_widgets/thunderstorm_card.dart:93 +msgid "您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1469 -msgid "所在地預估" +#: ./lib/app/home_old/_widgets/forecast_card.dart:68 +msgid "天氣預報(24h)" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1505 -msgid "震波" +#: ./lib/app/home_old/_widgets/location_out_of_service.dart:34 +msgid "服務區域外,僅在臺灣各地可用" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1527 -msgid " 秒" +#: ./lib/app/map/_lib/managers/monitor.dart:518 +msgid "無資料" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1544 -msgid "抵達" +#: ./lib/app/home_old/_widgets/wind_card.dart:329 +msgid "{wind}級 {Desc}" msgstr "" -#: ./lib/app/home/page.dart:195 -msgid "已更新至 {version}" +#: ./lib/app/home_old/_widgets/wind_card.dart:346 +msgid "陣風 {speed} m/s" msgstr "" -#: ./lib/utils/weather_icon.dart:284 -msgid "取得天氣異常" +#: ./lib/app/home_old/_widgets/wind_card.dart:408 +msgid "無法測量" msgstr "" -#: ./lib/app/home/page.dart:366 -msgid "取得歷史資訊異常" +#: ./lib/app/home_old/_widgets/wind_card.dart:429 +msgid "指北針不可靠" msgstr "" -#: ./lib/app/home/page.dart:777 -msgid "上午" +#: ./lib/app/home_old/_widgets/wind_card.dart:431 +msgid "指北針準確度下降" msgstr "" -#: ./lib/app/home/page.dart:777 -msgid "下午" +#: ./lib/app/home_old/_widgets/wind_card.dart:432 +msgid "指北針正常" msgstr "" -#: ./lib/app/changelog/page.dart:76 -msgid "發生錯誤" +#: ./lib/app/home_old/_widgets/wind_card.dart:439 +msgid "方向精確度" msgstr "" -#: ./lib/route/image_viewer/image_viewer.dart:85 -msgid "再試一次" +#: ./lib/app/home_old/_widgets/wind_card.dart:458 +msgid "正常範圍:±0-15°" msgstr "" -#: ./lib/app/changelog/page.dart:203 -msgid "目前版本" +#: ./lib/app/home_old/_widgets/wind_card.dart:475 +msgid "附近有強磁場干擾,指北針方向可能完全不準確。請遠離磁鐵、電子裝置或金屬物品。" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:403 -msgid "下一步" +#: ./lib/app/home_old/_widgets/wind_card.dart:476 +msgid "附近可能有磁場干擾,指北針方向可能有偏差。" msgstr "" -#: ./lib/app/welcome/1-about/page.dart:68 -msgid "防災資訊平台" +#: ./lib/app/home_old/_widgets/location_button.dart:38 +msgid "尚未設定" msgstr "" -#: ./lib/app/welcome/2-exptech/page.dart:93 -msgid "我們是誰?" +#: ./lib/app/home_old/_widgets/location_button.dart:211 +msgid "選擇縣市" msgstr "" -#: ./lib/app/welcome/2-exptech/page.dart:100 -msgid "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" +#: ./lib/app/home_old/_widgets/location_button.dart:221 +msgid "目前選擇" msgstr "" -#: ./lib/app/welcome/2-exptech/page.dart:106 -msgid "我們的初衷" +#: ./lib/app/home_old/_widgets/location_button.dart:279 +msgid "切換區域" msgstr "" -#: ./lib/app/welcome/2-exptech/page.dart:113 -msgid "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" +#: ./lib/app/home_old/_widgets/location_button.dart:285 +msgid "位置設定" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:45 -msgid "注意事項" +#: ./lib/app/home_old/page.dart:630 +msgid "濕度" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:65 -msgid "任何資訊應以中央氣象署發布之內容為準。" +#: ./lib/app/home_old/page.dart:658 +msgid "風速" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:82 -msgid "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" +#: ./lib/app/home_old/_widgets/weather_header.dart:199 +msgid "風向" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:97 -msgid "強烈搖晃有機率比通知早抵達使用者所在地。" +#: ./lib/app/home_old/_widgets/weather_header.dart:206 +msgid "風級" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:111 -msgid "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" +#: ./lib/app/home_old/page.dart:637 +msgid "氣壓" msgstr "" -#: ./lib/app/welcome/3-notice/page.dart:125 -msgid "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" +#: ./lib/app/home_old/page.dart:644 +msgid "降雨" msgstr "" -#: ./lib/app/welcome/1-about/page.dart:46 -msgid "歡迎使用 DPIP" +#: ./lib/app/home_old/page.dart:651 +msgid "能見度" msgstr "" -#: ./lib/app/welcome/1-about/page.dart:91 -msgid "" -"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " -"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" +#: ./lib/app/home_old/page.dart:665 +msgid "陣風" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:167 -msgid "在重大災害發生時以通知來傳遞即時防災資訊" +#: ./lib/app/home_old/_widgets/weather_header.dart:243 +msgid "陣風級" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:175 -msgid "使用定位來自動更新所在地設定,提供當地的即時防災資訊" +#: ./lib/app/home_old/_widgets/weather_header.dart:251 +msgid "日照" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:181 -msgid "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" +#: ./lib/app/home_old/_widgets/hero_weather.dart:151 +msgid "體感 {feelsLike}°" msgstr "" -#: ./lib/route/image_viewer/image_viewer.dart:255 -msgid "儲存" +#: ./lib/app/home_old/_widgets/hero_weather.dart:194 +msgid "無天氣資料" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:188 -msgid "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:32 +msgid "全國 · 生效中" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:352 -msgid "權限請求" +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:34 +msgid "全國 · 歷史" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:353 -msgid "需要使用者手動到設定開啟相關權限。" +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:36 +msgid "所在地 · 生效中" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:376 -msgid "需要背景位置權限" +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:38 +msgid "所在地 · 歷史" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:378 +#: ./lib/app/map/_lib/managers/monitor.dart:1274 +msgid "EEW" +msgstr "" + +#: ./lib/app/map/_lib/managers/monitor.dart:1407 +msgid "第 {serial} 報" +msgstr "" + +#: ./lib/app/map/_lib/managers/monitor.dart:1425 msgid "" -"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" -"\n" -"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" +"{time} 左右,{location}附近發生有感地震,預估規模 " +"M{magnitude}、所在地最大震度{intensity}。" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:384 -msgid "稍後" +#: ./lib/app/map/_lib/managers/monitor.dart:1433 +msgid "" +"{time} 左右,{location}附近發生有感地震,預估規模 " +"M{magnitude}、深度{depth}公里。" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:388 -msgid "前往設定" +#: ./lib/app/map/_lib/managers/monitor.dart:1479 +msgid "所在地預估" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:426 -msgid "權限" +#: ./lib/app/map/_lib/managers/monitor.dart:1515 +msgid "震波" msgstr "" -#: ./lib/app/welcome/4-permissions/page.dart:439 -msgid "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" +#: ./lib/app/map/_lib/managers/monitor.dart:1535 +msgid " 秒" +msgstr "" + +#: ./lib/app/map/_lib/managers/monitor.dart:1551 +msgid "抵達" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:84 +#: ./lib/app/home_old/page.dart:158 +msgid "已更新至 {version}" +msgstr "" + +#: ./lib/utils/weather_icon.dart:282 +msgid "取得天氣異常" +msgstr "" + +#: ./lib/app/home_old/page.dart:331 +msgid "取得歷史資訊異常" +msgstr "" + +#: ./lib/app/home_old/page.dart:571 +msgid "上午" +msgstr "" + +#: ./lib/app/home_old/page.dart:571 +msgid "下午" +msgstr "" + +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:96 msgid "地圖圖層" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:85 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:97 msgid "選擇要顯示的地圖圖層" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:91 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:103 msgid "底圖" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:97 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:109 msgid "簡單" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:119 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:131 msgid "監視器" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:124 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:136 msgid "報告" msgstr "" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:135 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:147 msgid "氣象" msgstr "" -#: ./lib/app/map/_lib/managers/temperature.dart:453 +#: ./lib/app/map/_lib/managers/temperature.dart:480 msgid "氣溫" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:577 +#: ./lib/app/map/_lib/managers/precipitation.dart:587 msgid "降水" msgstr "" -#: ./lib/app/map/_lib/managers/wind.dart:320 -msgid "風向/風速" -msgstr "" - -#: ./lib/app/map/_lib/managers/lightning.dart:294 +#: ./lib/app/map/_lib/managers/lightning.dart:302 msgid "閃電" msgstr "" -#: ./lib/app/map/_widgets/map_legend.dart:202 +#: ./lib/app/map/_widgets/map_legend.dart:250 msgid "單位:{unit}" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:485 +#: ./lib/app/map/_lib/managers/tsunami.dart:494 msgid "近期無海嘯資訊" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:486 +#: ./lib/app/map/_lib/managers/tsunami.dart:494 msgid "海嘯警報" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:496 +#: ./lib/app/map/_lib/managers/tsunami.dart:504 msgid "{id}號 第{serial}報" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:551 +#: ./lib/app/map/_lib/managers/tsunami.dart:558 msgid "發布" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:553 +#: ./lib/app/map/_lib/managers/tsunami.dart:560 msgid "更新" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:554 +#: ./lib/app/map/_lib/managers/tsunami.dart:561 msgid "解除" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:607 +#: ./lib/app/map/_lib/managers/tsunami.dart:612 msgid "預估海嘯到達時間及波高" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:626 +#: ./lib/app/map/_lib/managers/tsunami.dart:630 msgid "各地觀測到的海嘯" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:641 +#: ./lib/app/map/_lib/managers/tsunami.dart:645 msgid "地震資訊" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:654 +#: ./lib/app/map/_lib/managers/tsunami.dart:657 msgid "發生時間" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1072 +#: ./lib/app/map/_lib/managers/report.dart:1069 msgid "位於" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:736 +#: ./lib/app/map/_lib/managers/tsunami.dart:730 msgid "規模" msgstr "" -#: ./lib/app/map/_lib/managers/tsunami.dart:765 +#: ./lib/app/map/_lib/managers/tsunami.dart:753 msgid "深度" msgstr "" -#: ./lib/app/map/_lib/managers/radar.dart:744 +#: ./lib/app/map/_lib/managers/radar.dart:750 msgid "長按設定播放起點" msgstr "" -#: ./lib/app/map/_lib/managers/radar.dart:760 +#: ./lib/app/map/_lib/managers/radar.dart:766 msgid "目前時間" msgstr "" -#: ./lib/app/map/_lib/managers/radar.dart:765 +#: ./lib/app/map/_lib/managers/radar.dart:771 msgid "播放起點" msgstr "" -#: ./lib/app/map/_lib/managers/radar.dart:1099 +#: ./lib/app/map/_lib/managers/radar.dart:1100 msgid "播放進度" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:393 +#: ./lib/app/map/_lib/managers/lightning.dart:399 msgid "5 分鐘內對地閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:401 +#: ./lib/app/map/_lib/managers/lightning.dart:407 msgid "10 分鐘內對地閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:409 +#: ./lib/app/map/_lib/managers/lightning.dart:415 msgid "30 分鐘內對地閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:417 +#: ./lib/app/map/_lib/managers/lightning.dart:423 msgid "60 分鐘內對地閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:425 +#: ./lib/app/map/_lib/managers/lightning.dart:431 msgid "5 分鐘內雲間閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:433 +#: ./lib/app/map/_lib/managers/lightning.dart:439 msgid "10 分鐘內雲間閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:441 +#: ./lib/app/map/_lib/managers/lightning.dart:447 msgid "30 分鐘內雲間閃電" msgstr "" -#: ./lib/app/map/_lib/managers/lightning.dart:449 +#: ./lib/app/map/_lib/managers/lightning.dart:455 msgid "60 分鐘內雲間閃電" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:396 +#: ./lib/app/map/_lib/managers/precipitation.dart:420 msgid "今日" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:397 +#: ./lib/app/map/_lib/managers/precipitation.dart:421 msgid "10 分鐘" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:398 +#: ./lib/app/map/_lib/managers/precipitation.dart:422 msgid "1 小時" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:399 +#: ./lib/app/map/_lib/managers/precipitation.dart:423 msgid "3 小時" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:400 +#: ./lib/app/map/_lib/managers/precipitation.dart:424 msgid "6 小時" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:401 +#: ./lib/app/map/_lib/managers/precipitation.dart:425 msgid "12 小時" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:402 +#: ./lib/app/map/_lib/managers/precipitation.dart:426 msgid "24 小時" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:403 +#: ./lib/app/map/_lib/managers/precipitation.dart:427 msgid "2 天" msgstr "" -#: ./lib/app/map/_lib/managers/precipitation.dart:404 +#: ./lib/app/map/_lib/managers/precipitation.dart:428 msgid "3 天" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:474 +#: ./lib/app/map/_lib/managers/monitor.dart:495 msgid "海外測站" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:494 +#: ./lib/app/map/_lib/managers/monitor.dart:515 msgid "即時震度:" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:517 +#: ./lib/app/map/_lib/managers/monitor.dart:538 msgid "地動加速度:" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:541 +#: ./lib/app/map/_lib/managers/monitor.dart:562 msgid "地動速度:" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1331 +#: ./lib/app/map/_lib/managers/monitor.dart:1346 msgid "規模 M{magnitude},所在地預估{intensity}" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1349 +#: ./lib/app/map/_lib/managers/monitor.dart:1362 msgid "{countdown}秒後抵達" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1352 +#: ./lib/app/map/_lib/managers/monitor.dart:1365 msgid "已抵達" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1364 +#: ./lib/app/map/_lib/managers/monitor.dart:1376 msgid "規模 M{magnitude},深度{depth}公里" msgstr "" -#: ./lib/app/map/_lib/managers/monitor.dart:1591 +#: ./lib/app/map/_lib/managers/monitor.dart:1594 msgid "目前沒有生效中的地震速報" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:516 +#: ./lib/app/map/_lib/managers/report.dart:541 msgid "CWA 正在製圖中" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:639 +#: ./lib/app/map/_lib/managers/report.dart:660 msgid "近期的地震報告" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:647 +#: ./lib/app/map/_lib/managers/report.dart:667 msgid "更多" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:995 +#: ./lib/app/map/_lib/managers/report.dart:994 msgid "編號 {number} 顯著有感地震" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:998 +#: ./lib/app/map/_lib/managers/report.dart:997 msgid "小區域有感地震" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1083 +#: ./lib/app/map/_lib/managers/report.dart:1080 msgid "地震規模" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1110 +#: ./lib/app/map/_lib/managers/report.dart:1107 msgid "震源深度" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:886 +#: ./lib/app/map/_lib/managers/report.dart:893 msgid "沒有更多資料" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1030 +#: ./lib/app/map/_lib/managers/report.dart:1029 msgid "報告頁面" msgstr "" -#: ./lib/route/report/report_sheet_content.dart:90 +#: ./lib/route/report/report_sheet_content.dart:88 msgid "重播" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1064 +#: ./lib/app/map/_lib/managers/report.dart:1061 msgid "發震時間" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1132 +#: ./lib/app/map/_lib/managers/report.dart:1129 msgid "各地震度" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1212 +#: ./lib/app/map/_lib/managers/report.dart:1205 msgid "地震報告圖" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1228 +#: ./lib/app/map/_lib/managers/report.dart:1220 msgid "震度圖" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1248 +#: ./lib/app/map/_lib/managers/report.dart:1240 msgid "最大地動加速度圖" msgstr "" -#: ./lib/app/map/_lib/managers/report.dart:1268 +#: ./lib/app/map/_lib/managers/report.dart:1260 msgid "最大地動速度圖" msgstr "" @@ -1443,11 +1471,11 @@ msgstr "" msgid "未知" msgstr "" -#: ./lib/route/announcement/announcement.dart:105 +#: ./lib/route/announcement/announcement.dart:104 msgid "目前沒有公告" msgstr "" -#: ./lib/route/announcement/announcement.dart:246 +#: ./lib/route/announcement/announcement.dart:245 msgid "公告詳情" msgstr "" @@ -1463,302 +1491,302 @@ msgstr "" msgid "儲存圖片時發生錯誤" msgstr "" -#: ./lib/utils/extensions/number.dart:26 +#: ./lib/utils/extensions/number.dart:25 msgid "0級" msgstr "" -#: ./lib/utils/extensions/number.dart:27 +#: ./lib/utils/extensions/number.dart:26 msgid "1級" msgstr "" -#: ./lib/utils/extensions/number.dart:28 +#: ./lib/utils/extensions/number.dart:27 msgid "2級" msgstr "" -#: ./lib/utils/extensions/number.dart:29 +#: ./lib/utils/extensions/number.dart:28 msgid "3級" msgstr "" -#: ./lib/utils/extensions/number.dart:30 +#: ./lib/utils/extensions/number.dart:29 msgid "4級" msgstr "" -#: ./lib/utils/extensions/number.dart:31 +#: ./lib/utils/extensions/number.dart:30 msgid "5弱" msgstr "" -#: ./lib/utils/extensions/number.dart:32 +#: ./lib/utils/extensions/number.dart:31 msgid "5強" msgstr "" -#: ./lib/utils/extensions/number.dart:33 +#: ./lib/utils/extensions/number.dart:32 msgid "6弱" msgstr "" -#: ./lib/utils/extensions/number.dart:34 +#: ./lib/utils/extensions/number.dart:33 msgid "6強" msgstr "" -#: ./lib/utils/extensions/number.dart:35 +#: ./lib/utils/extensions/number.dart:34 msgid "7級" msgstr "" -#: ./lib/utils/weather_icon.dart:285 +#: ./lib/utils/weather_icon.dart:283 msgid "晴" msgstr "" -#: ./lib/utils/weather_icon.dart:286 +#: ./lib/utils/weather_icon.dart:284 msgid "晴有霾" msgstr "" -#: ./lib/utils/weather_icon.dart:287 +#: ./lib/utils/weather_icon.dart:285 msgid "晴有靄" msgstr "" -#: ./lib/utils/weather_icon.dart:288 +#: ./lib/utils/weather_icon.dart:286 msgid "晴有閃電" msgstr "" -#: ./lib/utils/weather_icon.dart:304 +#: ./lib/utils/weather_icon.dart:302 msgid "晴天伴有雷" msgstr "" -#: ./lib/utils/weather_icon.dart:290 +#: ./lib/utils/weather_icon.dart:288 msgid "晴有霧" msgstr "" -#: ./lib/utils/weather_icon.dart:291 +#: ./lib/utils/weather_icon.dart:289 msgid "晴有雨" msgstr "" -#: ./lib/utils/weather_icon.dart:292 +#: ./lib/utils/weather_icon.dart:290 msgid "晴有雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:293 +#: ./lib/utils/weather_icon.dart:291 msgid "晴有大雪" msgstr "" -#: ./lib/utils/weather_icon.dart:294 +#: ./lib/utils/weather_icon.dart:292 msgid "晴有雪珠" msgstr "" -#: ./lib/utils/weather_icon.dart:295 +#: ./lib/utils/weather_icon.dart:293 msgid "晴有冰珠" msgstr "" -#: ./lib/utils/weather_icon.dart:296 +#: ./lib/utils/weather_icon.dart:294 msgid "晴有陣雪" msgstr "" -#: ./lib/utils/weather_icon.dart:297 +#: ./lib/utils/weather_icon.dart:295 msgid "晴陣雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:298 +#: ./lib/utils/weather_icon.dart:296 msgid "晴有雹" msgstr "" -#: ./lib/utils/weather_icon.dart:299 +#: ./lib/utils/weather_icon.dart:297 msgid "晴有雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:300 +#: ./lib/utils/weather_icon.dart:298 msgid "晴有雷雪" msgstr "" -#: ./lib/utils/weather_icon.dart:301 +#: ./lib/utils/weather_icon.dart:299 msgid "晴有雷雹" msgstr "" -#: ./lib/utils/weather_icon.dart:302 +#: ./lib/utils/weather_icon.dart:300 msgid "晴大雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:303 +#: ./lib/utils/weather_icon.dart:301 msgid "晴大雷雹" msgstr "" -#: ./lib/utils/weather_icon.dart:305 +#: ./lib/utils/weather_icon.dart:303 msgid "多雲" msgstr "" -#: ./lib/utils/weather_icon.dart:306 +#: ./lib/utils/weather_icon.dart:304 msgid "多雲有霾" msgstr "" -#: ./lib/utils/weather_icon.dart:307 +#: ./lib/utils/weather_icon.dart:305 msgid "多雲有靄" msgstr "" -#: ./lib/utils/weather_icon.dart:308 +#: ./lib/utils/weather_icon.dart:306 msgid "多雲有閃電" msgstr "" -#: ./lib/utils/weather_icon.dart:324 +#: ./lib/utils/weather_icon.dart:322 msgid "多雲伴有雷" msgstr "" -#: ./lib/utils/weather_icon.dart:310 +#: ./lib/utils/weather_icon.dart:308 msgid "多雲有霧" msgstr "" -#: ./lib/utils/weather_icon.dart:311 +#: ./lib/utils/weather_icon.dart:309 msgid "多雲有雨" msgstr "" -#: ./lib/utils/weather_icon.dart:312 +#: ./lib/utils/weather_icon.dart:310 msgid "多雲有雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:313 +#: ./lib/utils/weather_icon.dart:311 msgid "多雲有大雪" msgstr "" -#: ./lib/utils/weather_icon.dart:314 +#: ./lib/utils/weather_icon.dart:312 msgid "多雲有雪珠" msgstr "" -#: ./lib/utils/weather_icon.dart:315 +#: ./lib/utils/weather_icon.dart:313 msgid "多雲有冰珠" msgstr "" -#: ./lib/utils/weather_icon.dart:316 +#: ./lib/utils/weather_icon.dart:314 msgid "多雲有陣雪" msgstr "" -#: ./lib/utils/weather_icon.dart:317 +#: ./lib/utils/weather_icon.dart:315 msgid "多雲陣雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:318 +#: ./lib/utils/weather_icon.dart:316 msgid "多雲有雹" msgstr "" -#: ./lib/utils/weather_icon.dart:319 +#: ./lib/utils/weather_icon.dart:317 msgid "多雲有雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:320 +#: ./lib/utils/weather_icon.dart:318 msgid "多雲有雷雪" msgstr "" -#: ./lib/utils/weather_icon.dart:321 +#: ./lib/utils/weather_icon.dart:319 msgid "多雲有雷雹" msgstr "" -#: ./lib/utils/weather_icon.dart:322 +#: ./lib/utils/weather_icon.dart:320 msgid "多雲大雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:323 +#: ./lib/utils/weather_icon.dart:321 msgid "多雲大雷雹" msgstr "" -#: ./lib/utils/weather_icon.dart:325 +#: ./lib/utils/weather_icon.dart:323 msgid "陰" msgstr "" -#: ./lib/utils/weather_icon.dart:326 +#: ./lib/utils/weather_icon.dart:324 msgid "陰有霾" msgstr "" -#: ./lib/utils/weather_icon.dart:327 +#: ./lib/utils/weather_icon.dart:325 msgid "陰有靄" msgstr "" -#: ./lib/utils/weather_icon.dart:328 +#: ./lib/utils/weather_icon.dart:326 msgid "陰有閃電" msgstr "" -#: ./lib/utils/weather_icon.dart:344 +#: ./lib/utils/weather_icon.dart:342 msgid "陰天伴有雷" msgstr "" -#: ./lib/utils/weather_icon.dart:330 +#: ./lib/utils/weather_icon.dart:328 msgid "陰有霧" msgstr "" -#: ./lib/utils/weather_icon.dart:331 +#: ./lib/utils/weather_icon.dart:329 msgid "陰有雨" msgstr "" -#: ./lib/utils/weather_icon.dart:332 +#: ./lib/utils/weather_icon.dart:330 msgid "陰有雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:333 +#: ./lib/utils/weather_icon.dart:331 msgid "陰有大雪" msgstr "" -#: ./lib/utils/weather_icon.dart:334 +#: ./lib/utils/weather_icon.dart:332 msgid "陰有雪珠" msgstr "" -#: ./lib/utils/weather_icon.dart:335 +#: ./lib/utils/weather_icon.dart:333 msgid "陰有冰珠" msgstr "" -#: ./lib/utils/weather_icon.dart:336 +#: ./lib/utils/weather_icon.dart:334 msgid "陰有陣雪" msgstr "" -#: ./lib/utils/weather_icon.dart:337 +#: ./lib/utils/weather_icon.dart:335 msgid "陰陣雨雪" msgstr "" -#: ./lib/utils/weather_icon.dart:338 +#: ./lib/utils/weather_icon.dart:336 msgid "陰有雹" msgstr "" -#: ./lib/utils/weather_icon.dart:339 +#: ./lib/utils/weather_icon.dart:337 msgid "陰有雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:340 +#: ./lib/utils/weather_icon.dart:338 msgid "陰有雷雪" msgstr "" -#: ./lib/utils/weather_icon.dart:341 +#: ./lib/utils/weather_icon.dart:339 msgid "陰有雷雹" msgstr "" -#: ./lib/utils/weather_icon.dart:342 +#: ./lib/utils/weather_icon.dart:340 msgid "陰大雷雨" msgstr "" -#: ./lib/utils/weather_icon.dart:343 +#: ./lib/utils/weather_icon.dart:341 msgid "陰大雷雹" msgstr "" -#: ./lib/api/model/location/location.dart:85 +#: ./lib/api/model/location/location.dart:84 msgid "{city}{cityLevel} {town}{townLevel}" msgstr "" -#: ./lib/api/model/location/location.dart:98 +#: ./lib/api/model/location/location.dart:97 msgid "{city} {town}" msgstr "" -#: ./lib/api/model/location/location.dart:113 +#: ./lib/api/model/location/location.dart:112 msgid "{city}{cityLevel}" msgstr "" -#: ./lib/api/model/location/location.dart:130 +#: ./lib/api/model/location/location.dart:129 msgid "{town}{townLevel}" msgstr "" -#: ./lib/widgets/ui/color_picker.dart:363 +#: ./lib/widgets/ui/color_picker.dart:361 msgid "色相" msgstr "" -#: ./lib/widgets/ui/color_picker.dart:379 +#: ./lib/widgets/ui/color_picker.dart:377 msgid "彩度" msgstr "" -#: ./lib/widgets/ui/color_picker.dart:395 +#: ./lib/widgets/ui/color_picker.dart:393 msgid "明度" msgstr "" -#: ./lib/widgets/ui/color_picker.dart:415 +#: ./lib/widgets/ui/color_picker.dart:413 msgid "十六進位值" msgstr "" \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index c20a06e4b..726c8e47b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -12,7 +12,9 @@ linter: flutter_style_todos: error prefer_single_quotes: warning avoid_annotating_with_dynamic: error - public_member_api_docs: warning + prefer_const_constructors: info + prefer_const_constructors_in_immutables: info + prefer_const_declarations: info formatter: page_width: 100 diff --git a/android/gradle.properties b/android/gradle.properties index 9b2416014..540567a6c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true kotlin.jvm.target.validation.mode = IGNORE +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/fonts/GoogleSansFlex.ttf b/assets/fonts/GoogleSansFlex.ttf new file mode 100644 index 000000000..6cd6eaf58 Binary files /dev/null and b/assets/fonts/GoogleSansFlex.ttf differ diff --git a/assets/fonts/NotoSansTC.ttf b/assets/fonts/NotoSansTC.ttf new file mode 100644 index 000000000..2defdb937 Binary files /dev/null and b/assets/fonts/NotoSansTC.ttf differ diff --git a/assets/translations/zh-Hant.po b/assets/translations/zh-Hant.po index 6753f1f6d..2ec760e6d 100644 --- a/assets/translations/zh-Hant.po +++ b/assets/translations/zh-Hant.po @@ -11,215 +11,215 @@ msgstr "" "Language-Team: Chinese Traditional\n" "Language: zh_TW\n" -#: ./lib/core/service.dart:291 +#: ./lib/core/service.dart:285 msgid "正在更新位置" msgstr "正在更新位置" -#: ./lib/core/service.dart:292 +#: ./lib/core/service.dart:286 msgid "取得 GPS 位置中..." msgstr "取得 GPS 位置中..." -#: ./lib/app/settings/notify/page.dart:51 +#: ./lib/app/settings/notify/page.dart:56 msgid "接收全部" msgstr "接收全部" -#: ./lib/app/settings/notify/page.dart:50 +#: ./lib/app/settings/notify/page.dart:65 msgid "關閉" msgstr "關閉" -#: ./lib/app/settings/notify/_widgets/eew_notify_section.dart:51 +#: ./lib/app/settings/notify/_widgets/eew_notify_section.dart:70 msgid "接收類別" msgstr "接收類別" -#: ./lib/app/settings/notify/page.dart:35 +#: ./lib/app/settings/notify/page.dart:55 msgid "所在地震度1以上" msgstr "所在地震度1以上" -#: ./lib/app/settings/notify/page.dart:46 +#: ./lib/app/settings/notify/page.dart:61 msgid "海嘯消息、海嘯警報" msgstr "海嘯消息、海嘯警報" -#: ./lib/app/settings/notify/page.dart:45 +#: ./lib/app/settings/notify/page.dart:60 msgid "只接收海嘯警報" msgstr "只接收海嘯警報" -#: ./lib/app/settings/notify/page.dart:41 +#: ./lib/app/settings/notify/page.dart:66 msgid "接收所在地" msgstr "接收所在地" -#: ./lib/app/settings/notify/page.dart:27 +#: ./lib/app/settings/notify/page.dart:54 msgid "所在地震度4以上" msgstr "所在地震度4以上" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:28 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:33 msgid "音效測試" msgstr "音效測試" -#: ./lib/route/announcement/announcement.dart:81 +#: ./lib/route/announcement/announcement.dart:80 msgid "公告" msgstr "公告" -#: ./lib/app/settings/notify/(5.basic)/announcement/page.dart:32 +#: ./lib/app/settings/notify/(5.basic)/announcement/page.dart:37 msgid "發送公告時" msgstr "發送公告時" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:46 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:51 msgid "音效測試為在裝置上執行的本地通知,僅用於確認裝置在接收通知時是否能正常播放音效。此測試不會向伺服器發送任何請求" msgstr "音效測試為在裝置上執行的本地通知,僅用於確認裝置在接收通知時是否能正常播放音效。此測試不會向伺服器發送任何請求" -#: ./lib/app/settings/notify/page.dart:82 +#: ./lib/app/settings/notify/page.dart:104 msgid "伺服器排隊中,請稍候…" msgstr "伺服器排隊中,請稍候…" -#: ./lib/app/welcome/4-permissions/page.dart:166 +#: ./lib/app/welcome/4-permissions/page.dart:111 msgid "通知" msgstr "通知" -#: ./lib/app/settings/page.dart:182 +#: ./lib/app/settings/page.dart:189 msgid "推播通知設定與通知音效測試" msgstr "推播通知設定與通知音效測試" -#: ./lib/app/home/_widgets/location_not_set_card.dart:33 +#: ./lib/app/home_old/_widgets/location_not_set_card.dart:41 msgid "尚未設定所在地" msgstr "尚未設定所在地" -#: ./lib/app/settings/notify/page.dart:201 +#: ./lib/app/settings/notify/page.dart:208 msgid "請先設定所在地來使用通知功能" msgstr "請先設定所在地來使用通知功能" -#: ./lib/app/settings/notify/page.dart:211 +#: ./lib/app/settings/notify/page.dart:217 msgid "設定所在地" msgstr "設定所在地" -#: ./lib/app/settings/experimental/page.dart:209 +#: ./lib/app/settings/experimental/page.dart:215 msgid "地震速報" msgstr "地震速報" -#: ./lib/app/settings/notify/page.dart:232 +#: ./lib/app/settings/notify/page.dart:238 msgid "緊急地震速報" msgstr "緊急地震速報" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:113 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:125 msgid "地震" msgstr "地震" -#: ./lib/app/map/_lib/managers/monitor.dart:1579 +#: ./lib/app/map/_lib/managers/monitor.dart:1584 msgid "強震監視器" msgstr "強震監視器" -#: ./lib/app/map/_lib/managers/report.dart:1302 +#: ./lib/app/map/_lib/managers/report.dart:1294 msgid "地震報告" msgstr "地震報告" -#: ./lib/app/settings/notify/page.dart:290 +#: ./lib/app/settings/notify/page.dart:297 msgid "震度速報" msgstr "震度速報" -#: ./lib/app/settings/notify/page.dart:304 +#: ./lib/app/settings/notify/page.dart:310 msgid "天氣" msgstr "天氣" -#: ./lib/app/home/_widgets/thunderstorm_card.dart:63 +#: ./lib/app/home_old/_widgets/thunderstorm_card.dart:72 msgid "雷雨即時訊息" msgstr "雷雨即時訊息" -#: ./lib/app/settings/notify/page.dart:334 +#: ./lib/app/settings/notify/page.dart:339 msgid "天氣警特報" msgstr "天氣警特報" -#: ./lib/app/settings/notify/page.dart:354 +#: ./lib/app/settings/notify/page.dart:358 msgid "防災資訊" msgstr "防災資訊" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:129 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:141 msgid "海嘯" msgstr "海嘯" -#: ./lib/app/settings/notify/page.dart:378 +#: ./lib/app/settings/notify/page.dart:383 msgid "海嘯資訊" msgstr "海嘯資訊" -#: ./lib/app/settings/notify/page.dart:390 +#: ./lib/app/settings/notify/page.dart:394 msgid "其他" msgstr "其他" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:31 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:36 msgid "重大" msgstr "重大" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:31 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:36 msgid "海嘯警報發布時" msgstr "海嘯警報發布時" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:37 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:42 msgid "一般" msgstr "一般" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:37 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:42 msgid "海嘯消息發布時" msgstr "海嘯消息發布時" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:41 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:46 msgid "太平洋海嘯消息(無聲通知)" msgstr "太平洋海嘯消息(無聲通知)" -#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:42 +#: ./lib/app/settings/notify/(4.tsunami)/tsunami/page.dart:47 msgid "太平洋海嘯消息發布時" msgstr "太平洋海嘯消息發布時" -#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:35 msgid "強震監視器(一般)" msgstr "強震監視器(一般)" -#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/monitor/page.dart:36 msgid "偵測到晃動" msgstr "偵測到晃動" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:35 msgid "震度速報(一般)" msgstr "震度速報(一般)" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:36 msgid "所在地(鄉鎮)實測震度 3 以上" msgstr "所在地(鄉鎮)實測震度 3 以上" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:36 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:41 msgid "震度速報(無聲通知)" msgstr "震度速報(無聲通知)" -#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:37 +#: ./lib/app/settings/notify/(2.earthquake)/intensity/page.dart:42 msgid "所在地(鄉鎮)實測震度 1 以上" msgstr "所在地(鄉鎮)實測震度 1 以上" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:30 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:35 msgid "地震報告(一般)" msgstr "地震報告(一般)" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:31 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:36 msgid "所在地(縣市)實測震度 3 以上" msgstr "所在地(縣市)實測震度 3 以上" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:36 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:41 msgid "地震報告(無聲通知)" msgstr "地震報告(無聲通知)" -#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:37 +#: ./lib/app/settings/notify/(2.earthquake)/report/page.dart:42 msgid "所在地(縣市)實測震度 1 以上" msgstr "所在地(縣市)實測震度 1 以上" -#: ./lib/app/settings/notify/_lib/utils.dart:15 +#: ./lib/app/settings/notify/_lib/utils.dart:29 msgid "已更新通知設定" msgstr "已更新通知設定" -#: ./lib/app/settings/notify/_lib/utils.dart:22 +#: ./lib/app/settings/notify/_lib/utils.dart:40 msgid "更新通知設定失敗" msgstr "更新通知設定失敗" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:30 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:35 msgid "緊急地震速報(重大)" msgstr "緊急地震速報(重大)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:31 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:36 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 4 以上" @@ -227,11 +227,11 @@ msgstr "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 4 以上" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:36 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:41 msgid "緊急地震速報(一般)" msgstr "緊急地震速報(一般)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:37 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:42 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 2 以上" @@ -239,11 +239,11 @@ msgstr "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 2 以上" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:41 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:46 msgid "緊急地震速報(無聲)" msgstr "緊急地震速報(無聲)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:42 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:47 msgid "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 1 以上" @@ -251,39 +251,39 @@ msgstr "" "最大震度 5 弱以上 且\n" "所在地(鄉鎮)預估震度 1 以上" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:46 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:51 msgid "地震速報(重大)" msgstr "地震速報(重大)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:47 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:52 msgid "所在地(鄉鎮)預估震度 4 以上" msgstr "所在地(鄉鎮)預估震度 4 以上" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:51 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:56 msgid "地震速報(一般)" msgstr "地震速報(一般)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:52 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:57 msgid "所在地(鄉鎮)預估震度 2 以上" msgstr "所在地(鄉鎮)預估震度 2 以上" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:56 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:61 msgid "地震速報(無聲)" msgstr "地震速報(無聲)" -#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:57 +#: ./lib/app/settings/notify/(1.eew)/eew/page.dart:62 msgid "所在地(鄉鎮)預估震度 1 以上" msgstr "所在地(鄉鎮)預估震度 1 以上" -#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:36 msgid "所在地(鄉鎮)發布防災警訊時" msgstr "所在地(鄉鎮)發布防災警訊時" -#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/evacuation/page.dart:42 msgid "所在地(鄉鎮)發布防災資訊時" msgstr "所在地(鄉鎮)發布防災資訊時" -#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:37 msgid "" "所在地(鄉鎮)發布紅色燈號之\n" "天氣警特報" @@ -291,7 +291,7 @@ msgstr "" "所在地(鄉鎮)發布紅色燈號之\n" "天氣警特報" -#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/advisory/page.dart:43 msgid "" "所在地(鄉鎮)發布上述除外燈號之\n" "天氣警特報" @@ -299,63 +299,63 @@ msgstr "" "所在地(鄉鎮)發布上述除外燈號之\n" "天氣警特報" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:32 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:37 msgid "所在地(鄉鎮)發布山區暴雨時" msgstr "所在地(鄉鎮)發布山區暴雨時" -#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:38 +#: ./lib/app/settings/notify/(3.weather)/thunderstorm/page.dart:43 msgid "所在地(鄉鎮)發布雷雨即時訊息時" msgstr "所在地(鄉鎮)發布雷雨即時訊息時" -#: ./lib/app/settings/experimental/page.dart:197 -msgid "啟動時進入強震監視器" -msgstr "啟動時進入強震監視器" - -#: ./lib/app/settings/experimental/page.dart:57 +#: ./lib/app/settings/experimental/page.dart:48 msgid "地震速報不限制非 CWA 來源" msgstr "地震速報不限制非 CWA 來源" -#: ./lib/app/settings/page.dart:428 +#: ./lib/app/settings/experimental/page.dart:203 +msgid "啟動時進入強震監視器" +msgstr "啟動時進入強震監視器" + +#: ./lib/app/settings/page.dart:430 msgid "實驗性功能" msgstr "實驗性功能" -#: ./lib/app/settings/page.dart:429 +#: ./lib/app/settings/page.dart:431 msgid "搶先體驗開發中的新功能" msgstr "搶先體驗開發中的新功能" -#: ./lib/app/settings/experimental/page.dart:154 +#: ./lib/app/settings/experimental/page.dart:160 msgid "注意" msgstr "注意" -#: ./lib/app/settings/experimental/page.dart:162 +#: ./lib/app/settings/experimental/page.dart:168 msgid "這些功能仍在開發中,可能會不穩定或在未來的版本中變更。" msgstr "這些功能仍在開發中,可能會不穩定或在未來的版本中變更。" -#: ./lib/app/settings/experimental/page.dart:188 +#: ./lib/app/settings/experimental/page.dart:194 msgid "啟動行為" msgstr "啟動行為" -#: ./lib/app/settings/experimental/page.dart:198 +#: ./lib/app/settings/experimental/page.dart:204 msgid "開啟 App 時直接進入強震監視器地圖" msgstr "開啟 App 時直接進入強震監視器地圖" -#: ./lib/app/settings/experimental/page.dart:218 +#: ./lib/app/settings/experimental/page.dart:224 msgid "不限制非 CWA 來源" msgstr "不限制非 CWA 來源" -#: ./lib/app/settings/experimental/page.dart:219 +#: ./lib/app/settings/experimental/page.dart:225 msgid "顯示所有來源的地震速報資料" msgstr "顯示所有來源的地震速報資料" -#: ./lib/app/settings/experimental/page.dart:280 +#: ./lib/app/settings/experimental/page.dart:281 msgid "啟用實驗性功能" msgstr "啟用實驗性功能" -#: ./lib/app/settings/experimental/page.dart:286 +#: ./lib/app/settings/experimental/page.dart:287 msgid "你即將啟用:" msgstr "你即將啟用:" -#: ./lib/app/settings/experimental/page.dart:317 +#: ./lib/app/settings/experimental/page.dart:318 msgid "此功能為實驗性質,可能會造成應用程式不穩定或行為異常。如遇問題,請至設定中關閉此功能。" msgstr "此功能為實驗性質,可能會造成應用程式不穩定或行為異常。如遇問題,請至設定中關閉此功能。" @@ -363,15 +363,15 @@ msgstr "此功能為實驗性質,可能會造成應用程式不穩定或行為 msgid "取消" msgstr "取消" -#: ./lib/app/settings/layout/page.dart:272 +#: ./lib/app/settings/layout/page.dart:163 msgid "啟用" msgstr "啟用" -#: ./lib/app/settings/page.dart:151 +#: ./lib/app/settings/page.dart:158 msgid "單位" msgstr "單位" -#: ./lib/app/settings/page.dart:152 +#: ./lib/app/settings/page.dart:159 msgid "調整 DPIP 顯示數值時使用的單位" msgstr "調整 DPIP 顯示數值時使用的單位" @@ -383,131 +383,131 @@ msgstr "使用華氏度" msgid "切換溫度顯示單位為華氏度 (℉)" msgstr "切換溫度顯示單位為華氏度 (℉)" -#: ./lib/app/settings/page.dart:141 +#: ./lib/app/settings/page.dart:148 msgid "語言" msgstr "語言" -#: ./lib/app/settings/page.dart:142 +#: ./lib/app/settings/page.dart:149 msgid "調整 DPIP 的顯示語言" msgstr "調整 DPIP 的顯示語言" -#: ./lib/app/settings/locale/page.dart:41 +#: ./lib/app/settings/locale/page.dart:49 msgid "顯示語言" msgstr "顯示語言" -#: ./lib/app/settings/locale/page.dart:42 +#: ./lib/app/settings/locale/page.dart:50 msgid "系統語言" msgstr "系統語言" -#: ./lib/app/settings/locale/page.dart:52 +#: ./lib/app/settings/locale/page.dart:60 msgid "協助翻譯" msgstr "協助翻譯" -#: ./lib/app/settings/locale/page.dart:53 +#: ./lib/app/settings/locale/page.dart:61 msgid "點擊這裡來幫助我們改進 DPIP 的翻譯" msgstr "點擊這裡來幫助我們改進 DPIP 的翻譯" -#: ./lib/app/settings/locale/select/page.dart:116 +#: ./lib/app/settings/locale/select/page.dart:122 msgid "已翻譯 {translated}・已校對 {approved}" msgstr "已翻譯 {translated}・已校對 {approved}" -#: ./lib/app/settings/locale/select/page.dart:129 +#: ./lib/app/settings/locale/select/page.dart:135 msgid "來源語言" msgstr "來源語言" -#: ./lib/app/settings/locale/select/page.dart:163 +#: ./lib/app/settings/locale/select/page.dart:172 msgid "選擇語言" msgstr "選擇語言" -#: ./lib/app/settings/donate/page.dart:52 +#: ./lib/app/settings/donate/page.dart:59 msgid "無法連線至商店,請稍後再試" msgstr "無法連線至商店,請稍後再試" -#: ./lib/app/settings/donate/page.dart:59 +#: ./lib/app/settings/donate/page.dart:65 msgid "找不到商品,請稍候再試" msgstr "找不到商品,請稍候再試" -#: ./lib/app/map/_lib/managers/report.dart:521 -msgid "重新載入" -msgstr "重新載入" - -#: ./lib/app/settings/donate/page.dart:171 -msgid "正在載入商店物品中" -msgstr "正在載入商店物品中" - -#: ./lib/app/settings/donate/page.dart:225 +#: ./lib/app/settings/donate/page.dart:136 msgid "支持 DPIP" msgstr "支持 DPIP" -#: ./lib/app/settings/donate/page.dart:233 +#: ./lib/app/settings/donate/page.dart:144 msgid "DPIP 作為一款致力於提供即時地震資訊的 App,目前並無廣告或其他盈利模式。您的支持將幫助我們維持伺服器運行與持續開發。" msgstr "DPIP 作為一款致力於提供即時地震資訊的 App,目前並無廣告或其他盈利模式。您的支持將幫助我們維持伺服器運行與持續開發。" -#: ./lib/app/settings/donate/page.dart:260 +#: ./lib/app/settings/donate/page.dart:170 msgid "訂閱制" msgstr "訂閱制" -#: ./lib/app/settings/donate/page.dart:276 +#: ./lib/app/settings/donate/page.dart:189 msgid "推薦" msgstr "推薦" -#: ./lib/app/settings/donate/page.dart:443 +#: ./lib/app/settings/donate/page.dart:353 msgid "{price}/月" msgstr "{price}/月" -#: ./lib/app/settings/donate/page.dart:475 +#: ./lib/app/settings/donate/page.dart:385 msgid "單次支援" msgstr "單次支援" -#: ./lib/app/settings/donate/page.dart:615 +#: ./lib/app/settings/donate/page.dart:520 msgid "恢復購買" msgstr "恢復購買" -#: ./lib/app/settings/donate/page.dart:628 +#: ./lib/app/settings/donate/page.dart:530 msgid "無法連線至 {store},請稍後再試。" msgstr "無法連線至 {store},請稍後再試。" -#: ./lib/app/settings/donate/page.dart:639 +#: ./lib/app/settings/donate/page.dart:541 msgid "正在恢復您購買的訂閱" msgstr "正在恢復您購買的訂閱" -#: ./lib/app/settings/donate/page.dart:644 +#: ./lib/app/settings/donate/page.dart:546 msgid "使用條款" msgstr "使用條款" -#: ./lib/app/settings/donate/page.dart:648 +#: ./lib/app/settings/donate/page.dart:550 msgid "隱私權政策" msgstr "隱私權政策" -#: ./lib/app/settings/proxy/page.dart:51 +#: ./lib/app/map/_lib/managers/report.dart:546 +msgid "重新載入" +msgstr "重新載入" + +#: ./lib/app/settings/donate/page.dart:636 +msgid "正在載入商店物品中" +msgstr "正在載入商店物品中" + +#: ./lib/app/settings/proxy/page.dart:38 msgid "設定已儲存" msgstr "設定已儲存" -#: ./lib/app/settings/page.dart:200 +#: ./lib/app/settings/page.dart:207 msgid "HTTP 代理" msgstr "HTTP 代理" -#: ./lib/app/settings/page.dart:201 +#: ./lib/app/settings/page.dart:208 msgid "調整 HTTP 代理伺服器設定" msgstr "調整 HTTP 代理伺服器設定" -#: ./lib/app/settings/proxy/page.dart:75 +#: ./lib/app/settings/proxy/page.dart:74 msgid "啟用代理" msgstr "啟用代理" -#: ./lib/app/settings/proxy/page.dart:76 +#: ./lib/app/settings/proxy/page.dart:75 msgid "透過代理伺服器發送所有網路請求" msgstr "透過代理伺服器發送所有網路請求" -#: ./lib/app/settings/proxy/page.dart:88 +#: ./lib/app/settings/proxy/page.dart:87 msgid "代理主機" msgstr "代理主機" -#: ./lib/app/settings/proxy/page.dart:102 +#: ./lib/app/settings/proxy/page.dart:101 msgid "代理端口" msgstr "代理端口" -#: ./lib/app/settings/proxy/page.dart:117 +#: ./lib/app/settings/proxy/page.dart:116 msgid "設定儲存後,需要重新啟動應用程式才能生效" msgstr "設定儲存後,需要重新啟動應用程式才能生效" @@ -515,123 +515,123 @@ msgstr "設定儲存後,需要重新啟動應用程式才能生效" msgid "設定" msgstr "設定" -#: ./lib/app/settings/page.dart:71 +#: ./lib/app/settings/page.dart:79 msgid "自訂你的 DPIP 使用體驗" msgstr "自訂你的 DPIP 使用體驗" -#: ./lib/app/welcome/4-permissions/page.dart:174 +#: ./lib/app/welcome/4-permissions/page.dart:119 msgid "位置" msgstr "位置" -#: ./lib/app/settings/location/page.dart:417 +#: ./lib/app/settings/location/page.dart:421 msgid "所在地" msgstr "所在地" -#: ./lib/app/settings/location/page.dart:241 +#: ./lib/app/settings/location/page.dart:245 msgid "設定你的所在地來接收當地的即時資訊" msgstr "設定你的所在地來接收當地的即時資訊" -#: ./lib/app/settings/page.dart:113 +#: ./lib/app/settings/page.dart:120 msgid "介面" msgstr "介面" -#: ./lib/app/settings/layout/page.dart:47 +#: ./lib/app/settings/layout/page.dart:183 msgid "版面" msgstr "版面" -#: ./lib/app/settings/layout/page.dart:48 +#: ./lib/app/settings/layout/page.dart:184 msgid "調整首頁的版面樣式" msgstr "調整首頁的版面樣式" -#: ./lib/app/settings/theme/page.dart:25 +#: ./lib/app/settings/theme/page.dart:32 msgid "主題" msgstr "主題" -#: ./lib/app/settings/theme/page.dart:26 +#: ./lib/app/settings/theme/page.dart:33 msgid "調整 DPIP 整體的外觀與顏色" msgstr "調整 DPIP 整體的外觀與顏色" -#: ./lib/app/settings/map/page.dart:49 +#: ./lib/app/home/layout.dart:93 msgid "地圖" msgstr "地圖" -#: ./lib/app/settings/map/page.dart:50 +#: ./lib/app/settings/map/page.dart:59 msgid "調整地圖的顯示樣式" msgstr "調整地圖的顯示樣式" -#: ./lib/app/settings/page.dart:191 +#: ./lib/app/settings/page.dart:198 msgid "網路" msgstr "網路" -#: ./lib/app/settings/page.dart:210 +#: ./lib/app/settings/page.dart:217 msgid "資訊" msgstr "資訊" -#: ./lib/app/changelog/page.dart:54 +#: ./lib/app/home_old/page.dart:163 msgid "更新日誌" msgstr "更新日誌" -#: ./lib/app/settings/page.dart:230 +#: ./lib/app/settings/page.dart:237 msgid "瀏覽 DPIP 的歷次更新紀錄" msgstr "瀏覽 DPIP 的歷次更新紀錄" -#: ./lib/app/settings/page.dart:240 +#: ./lib/app/settings/page.dart:247 msgid "第三方套件授權" msgstr "第三方套件授權" -#: ./lib/app/settings/page.dart:241 +#: ./lib/app/settings/page.dart:248 msgid "DPIP 的實現歸功於開放原始碼" msgstr "DPIP 的實現歸功於開放原始碼" -#: ./lib/app/settings/page.dart:264 +#: ./lib/app/settings/page.dart:271 msgid "贊助我們" msgstr "贊助我們" -#: ./lib/app/settings/page.dart:267 +#: ./lib/app/settings/page.dart:274 msgid "幫助我們維護伺服器的穩定和長久發展" msgstr "幫助我們維護伺服器的穩定和長久發展" -#: ./lib/app/settings/page.dart:343 +#: ./lib/app/settings/page.dart:349 msgid "下載" msgstr "下載" -#: ./lib/app/settings/page.dart:383 +#: ./lib/app/settings/page.dart:385 msgid "除錯" msgstr "除錯" -#: ./lib/app/settings/page.dart:391 +#: ./lib/app/settings/page.dart:393 msgid "應用程式版本" msgstr "應用程式版本" -#: ./lib/app/settings/page.dart:400 +#: ./lib/app/settings/page.dart:402 msgid "裝置資訊" msgstr "裝置資訊" -#: ./lib/app/settings/page.dart:409 +#: ./lib/app/settings/page.dart:411 msgid "複製通知 Token" msgstr "複製通知 Token" -#: ./lib/app/debug/logs/page.dart:16 +#: ./lib/app/debug/logs/page.dart:22 msgid "App 日誌" msgstr "App 日誌" -#: ./lib/app/settings/page.dart:463 +#: ./lib/app/settings/page.dart:465 msgid "任何資訊應以中央氣象署發布之內容為準" msgstr "任何資訊應以中央氣象署發布之內容為準" -#: ./lib/app/settings/location/page.dart:74 +#: ./lib/app/settings/location/page.dart:85 msgid "無法取得通知權限" msgstr "無法取得通知權限" -#: ./lib/app/settings/location/page.dart:76 +#: ./lib/app/settings/location/page.dart:87 msgid "無法取得位置權限" msgstr "無法取得位置權限" -#: ./lib/app/settings/location/page.dart:77 +#: ./lib/app/settings/location/page.dart:88 msgid "無法取得自啟動權限" msgstr "無法取得自啟動權限" -#: ./lib/app/welcome/4-permissions/page.dart:180 +#: ./lib/app/welcome/4-permissions/page.dart:125 msgid "省電策略" msgstr "省電策略" @@ -639,63 +639,63 @@ msgstr "省電策略" msgid "無法取得權限" msgstr "無法取得權限" -#: ./lib/app/settings/location/page.dart:84 +#: ./lib/app/settings/location/page.dart:94 msgid "自動定位功能需要您允許 DPIP 使用通知權限才能正常運作。請您到應用程式設定中找到並允許「通知」權限後再試一次。" msgstr "自動定位功能需要您允許 DPIP 使用通知權限才能正常運作。請您到應用程式設定中找到並允許「通知」權限後再試一次。" -#: ./lib/app/settings/location/page.dart:86 +#: ./lib/app/settings/location/page.dart:95 msgid "自動定位功能需要您允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到並允許「位置」權限後再試一次。" msgstr "自動定位功能需要您允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到並允許「位置」權限後再試一次。" -#: ./lib/app/settings/location/page.dart:89 +#: ./lib/app/settings/location/page.dart:98 msgid "自動定位功能需要您永遠允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「永遠」後再試一次。" msgstr "自動定位功能需要您永遠允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「永遠」後再試一次。" -#: ./lib/app/settings/location/page.dart:91 +#: ./lib/app/settings/location/page.dart:99 msgid "自動定位功能需要您一律允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「一律允許」後再試一次。" msgstr "自動定位功能需要您一律允許 DPIP 使用位置權限才能正常運作。請您到應用程式設定中找到位置權限設定並選擇「一律允許」後再試一次。" -#: ./lib/app/settings/location/page.dart:93 +#: ./lib/app/settings/location/page.dart:100 msgid "為了獲得更好的自動定位體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景自動設定所在地資訊。" msgstr "為了獲得更好的自動定位體驗,您需要給予「自啟動權限」以便讓 DPIP 在背景自動設定所在地資訊。" -#: ./lib/app/settings/location/page.dart:95 +#: ./lib/app/settings/location/page.dart:101 msgid "為了獲得更好的自動定位體驗,您需要給予「無限制」以便讓 DPIP 在背景自動設定所在地資訊。" msgstr "為了獲得更好的自動定位體驗,您需要給予「無限制」以便讓 DPIP 在背景自動設定所在地資訊。" -#: ./lib/app/settings/location/page.dart:96 +#: ./lib/app/settings/location/page.dart:102 msgid "自動定位功能需要您允許 DPIP 使用權限才能正常運作。請您到應用程式設定中找到並允許「權限」後再試一次。" msgstr "自動定位功能需要您允許 DPIP 使用權限才能正常運作。請您到應用程式設定中找到並允許「權限」後再試一次。" -#: ./lib/app/settings/location/page.dart:174 +#: ./lib/app/settings/location/page.dart:180 msgid "自動啟動" msgstr "自動啟動" -#: ./lib/app/settings/location/page.dart:175 +#: ./lib/app/settings/location/page.dart:181 msgid "為了獲得更好的 DPIP 體驗,請依照步驟啟用自動啟動功能,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" msgstr "為了獲得更好的 DPIP 體驗,請依照步驟啟用自動啟動功能,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" -#: ./lib/app/settings/location/page.dart:199 +#: ./lib/app/settings/location/page.dart:203 msgid "為了獲得更好的 DPIP 體驗,請依照步驟關閉省電策略,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" msgstr "為了獲得更好的 DPIP 體驗,請依照步驟關閉省電策略,以便讓 DPIP 在背景能正常接收資訊以及更新所在地。" -#: ./lib/app/settings/location/page.dart:233 +#: ./lib/app/settings/location/page.dart:237 msgid "一律允許" msgstr "一律允許" -#: ./lib/app/settings/location/page.dart:233 +#: ./lib/app/settings/location/page.dart:237 msgid "永遠" msgstr "永遠" -#: ./lib/app/settings/location/page.dart:253 +#: ./lib/app/settings/location/page.dart:257 msgid "自動更新" msgstr "自動更新" -#: ./lib/app/settings/location/page.dart:254 +#: ./lib/app/settings/location/page.dart:258 msgid "定期更新目前的所在地" msgstr "定期更新目前的所在地" -#: ./lib/app/settings/location/page.dart:263 +#: ./lib/app/settings/location/page.dart:267 msgid "" "自動定位功能將使用您的裝置上的 GPS,即使 DPIP " "關閉或未在使用時,也會根據您的地理位置,自動更新您的所在地,提供即時的天氣和地震資訊,讓您隨時掌握當地最新狀況。" @@ -703,267 +703,429 @@ msgstr "" "自動定位功能將使用您的裝置上的 GPS,即使 DPIP " "關閉或未在使用時,也會根據您的地理位置,自動更新您的所在地,提供即時的天氣和地震資訊,讓您隨時掌握當地最新狀況。" -#: ./lib/app/settings/location/page.dart:334 +#: ./lib/app/settings/location/page.dart:338 msgid "通知功能已被拒絕,請移至設定允許權限。" msgstr "通知功能已被拒絕,請移至設定允許權限。" -#: ./lib/app/settings/location/page.dart:395 +#: ./lib/app/settings/location/page.dart:399 msgid "省電策略已被拒絕,請移至設定允許權限。" msgstr "省電策略已被拒絕,請移至設定允許權限。" -#: ./lib/app/settings/location/select/[city]/page.dart:98 +#: ./lib/app/settings/location/select/[city]/page.dart:109 msgid "設定所在地時發生錯誤,請稍候再試一次。" msgstr "設定所在地時發生錯誤,請稍候再試一次。" -#: ./lib/app/home/_widgets/location_button.dart:233 +#: ./lib/app/home_old/_widgets/location_button.dart:204 msgid "新增地點" msgstr "新增地點" -#: ./lib/app/settings/location/select/page.dart:33 +#: ./lib/app/home/_widgets/location_chip.dart:148 msgid "縣市" msgstr "縣市" -#: ./lib/app/settings/location/select/page.dart:44 +#: ./lib/app/settings/location/select/page.dart:49 msgid "目前所在地" msgstr "目前所在地" -#: ./lib/app/settings/layout/page.dart:56 +#: ./lib/app/settings/layout/page.dart:90 +msgid "停用" +msgstr "停用" + +#: ./lib/app/settings/layout/page.dart:192 msgid "拖曳調整順序" msgstr "拖曳調整順序" -#: ./lib/app/settings/layout/page.dart:100 +#: ./lib/app/settings/layout/page.dart:236 msgid "已停用" msgstr "已停用" -#: ./lib/app/settings/layout/page.dart:135 +#: ./lib/app/settings/layout/page.dart:271 msgid "所有區塊皆已啟用" msgstr "所有區塊皆已啟用" -#: ./lib/app/settings/layout/page.dart:202 -msgid "停用" -msgstr "停用" - -#: ./lib/app/settings/map/page.dart:61 +#: ./lib/app/settings/map/page.dart:70 msgid "初始圖層" msgstr "初始圖層" -#: ./lib/app/settings/map/page.dart:62 +#: ./lib/app/settings/map/page.dart:71 msgid "調整地圖的底圖以及初始顯示的圖層" msgstr "調整地圖的底圖以及初始顯示的圖層" -#: ./lib/app/settings/map/page.dart:74 +#: ./lib/app/settings/map/page.dart:83 msgid "自動縮放" msgstr "自動縮放" -#: ./lib/app/settings/map/page.dart:75 +#: ./lib/app/settings/map/page.dart:84 msgid "接收到檢知時自動縮放地圖" msgstr "接收到檢知時自動縮放地圖" -#: ./lib/app/settings/map/page.dart:101 +#: ./lib/app/settings/map/page.dart:110 msgid "動畫幀率" msgstr "動畫幀率" -#: ./lib/app/settings/map/page.dart:102 +#: ./lib/app/settings/map/page.dart:111 msgid "調整強震監視器震波模擬動畫的流暢度" msgstr "調整強震監視器震波模擬動畫的流暢度" -#: ./lib/app/settings/map/page.dart:136 +#: ./lib/app/settings/map/page.dart:144 msgid "過高的動畫幀率可能會造成卡頓或裝置發熱" msgstr "過高的動畫幀率可能會造成卡頓或裝置發熱" -#: ./lib/app/settings/theme/page.dart:46 +#: ./lib/app/settings/theme/page.dart:53 msgid "主題模式" msgstr "主題模式" -#: ./lib/app/settings/theme/mode/page.dart:63 +#: ./lib/app/settings/theme/mode/page.dart:69 msgid "淺色" msgstr "淺色" -#: ./lib/app/settings/theme/mode/page.dart:64 +#: ./lib/app/settings/theme/mode/page.dart:70 msgid "深色" msgstr "深色" -#: ./lib/app/settings/theme/mode/page.dart:59 +#: ./lib/app/settings/theme/mode/page.dart:67 msgid "跟隨系統主題" msgstr "跟隨系統主題" -#: ./lib/app/settings/theme/color/page.dart:22 +#: ./lib/app/settings/theme/color/page.dart:30 msgid "主題色彩" msgstr "主題色彩" -#: ./lib/app/settings/theme/color/page.dart:43 +#: ./lib/app/settings/theme/color/page.dart:51 msgid "使用系統配色" msgstr "使用系統配色" -#: ./lib/app/settings/theme/color/page.dart:62 +#: ./lib/app/settings/theme/color/page.dart:70 msgid "自訂" msgstr "自訂" -#: ./lib/app/settings/theme/color/page.dart:72 +#: ./lib/app/settings/theme/color/page.dart:79 msgid "自訂色彩" msgstr "自訂色彩" -#: ./lib/app/home/_widgets/thunderstorm_card.dart:84 +#: ./lib/app/map/_lib/managers/radar.dart:614 +msgid "雷達回波" +msgstr "雷達回波" + +#: ./lib/app/home/_widgets/weather_parameters.dart:52 +msgid "相對溼度" +msgstr "相對溼度" + +#: ./lib/app/home/_widgets/weather_parameters.dart:57 +msgid "空氣品質" +msgstr "空氣品質" + +#: ./lib/app/map/_lib/managers/wind.dart:343 +msgid "風向/風速" +msgstr "風向/風速" + +#: ./lib/app/home/_widgets/weather_parameters.dart:68 +msgid "降水量" +msgstr "降水量" + +#: ./lib/app/home/_widgets/location_chip.dart:84 +msgid "清除暫時位置" +msgstr "清除暫時位置" + +#: ./lib/app/home/_widgets/location_chip.dart:159 +msgid "目前選擇地區" +msgstr "目前選擇地區" + +#: ./lib/app/home_old/_widgets/location_button.dart:170 +msgid "快速切換" +msgstr "快速切換" + +#: ./lib/app/home/layout.dart:88 +msgid "首頁" +msgstr "首頁" + +#: ./lib/app/home/layout.dart:98 +msgid "小工具" +msgstr "小工具" + +#: ./lib/app/changelog/page.dart:86 +msgid "發生錯誤" +msgstr "發生錯誤" + +#: ./lib/route/image_viewer/image_viewer.dart:85 +msgid "再試一次" +msgstr "再試一次" + +#: ./lib/app/changelog/page.dart:217 +msgid "目前版本" +msgstr "目前版本" + +#: ./lib/app/welcome/4-permissions/page.dart:383 +msgid "下一步" +msgstr "下一步" + +#: ./lib/app/welcome/1-about/page.dart:73 +msgid "防災資訊平台" +msgstr "防災資訊平台" + +#: ./lib/app/welcome/2-exptech/page.dart:98 +msgid "我們是誰?" +msgstr "我們是誰?" + +#: ./lib/app/welcome/2-exptech/page.dart:105 +msgid "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" +msgstr "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" + +#: ./lib/app/welcome/2-exptech/page.dart:111 +msgid "我們的初衷" +msgstr "我們的初衷" + +#: ./lib/app/welcome/2-exptech/page.dart:118 +msgid "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" +msgstr "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" + +#: ./lib/app/welcome/3-notice/page.dart:50 +msgid "注意事項" +msgstr "注意事項" + +#: ./lib/app/welcome/3-notice/page.dart:70 +msgid "任何資訊應以中央氣象署發布之內容為準。" +msgstr "任何資訊應以中央氣象署發布之內容為準。" + +#: ./lib/app/welcome/3-notice/page.dart:87 +msgid "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" +msgstr "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" + +#: ./lib/app/welcome/3-notice/page.dart:101 +msgid "強烈搖晃有機率比通知早抵達使用者所在地。" +msgstr "強烈搖晃有機率比通知早抵達使用者所在地。" + +#: ./lib/app/welcome/3-notice/page.dart:115 +msgid "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" +msgstr "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" + +#: ./lib/app/welcome/3-notice/page.dart:129 +msgid "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" +msgstr "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" + +#: ./lib/app/welcome/1-about/page.dart:51 +msgid "歡迎使用 DPIP" +msgstr "歡迎使用 DPIP" + +#: ./lib/app/welcome/1-about/page.dart:96 +msgid "" +"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " +"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" +msgstr "" +"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " +"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" + +#: ./lib/app/welcome/4-permissions/page.dart:112 +msgid "在重大災害發生時以通知來傳遞即時防災資訊" +msgstr "在重大災害發生時以通知來傳遞即時防災資訊" + +#: ./lib/app/welcome/4-permissions/page.dart:120 +msgid "使用定位來自動更新所在地設定,提供當地的即時防災資訊" +msgstr "使用定位來自動更新所在地設定,提供當地的即時防災資訊" + +#: ./lib/app/welcome/4-permissions/page.dart:126 +msgid "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" +msgstr "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" + +#: ./lib/route/image_viewer/image_viewer.dart:254 +msgid "儲存" +msgstr "儲存" + +#: ./lib/app/welcome/4-permissions/page.dart:133 +msgid "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" +msgstr "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" + +#: ./lib/app/welcome/4-permissions/page.dart:293 +msgid "權限請求" +msgstr "權限請求" + +#: ./lib/app/welcome/4-permissions/page.dart:294 +msgid "需要使用者手動到設定開啟相關權限。" +msgstr "需要使用者手動到設定開啟相關權限。" + +#: ./lib/route/image_viewer/image_viewer.dart:145 +msgid "確定" +msgstr "確定" + +#: ./lib/app/welcome/4-permissions/page.dart:317 +msgid "需要背景位置權限" +msgstr "需要背景位置權限" + +#: ./lib/app/welcome/4-permissions/page.dart:319 +msgid "" +"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" +"\n" +"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" +msgstr "" +"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" +"\n" +"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" + +#: ./lib/app/welcome/4-permissions/page.dart:325 +msgid "稍後" +msgstr "稍後" + +#: ./lib/app/welcome/4-permissions/page.dart:329 +msgid "前往設定" +msgstr "前往設定" + +#: ./lib/app/welcome/4-permissions/page.dart:406 +msgid "權限" +msgstr "權限" + +#: ./lib/app/welcome/4-permissions/page.dart:419 +msgid "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" +msgstr "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" + +#: ./lib/app/home_old/_widgets/thunderstorm_card.dart:93 msgid "您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。" msgstr "您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。" -#: ./lib/app/home/_widgets/forecast_card.dart:59 +#: ./lib/app/home_old/_widgets/forecast_card.dart:68 msgid "天氣預報(24h)" msgstr "天氣預報(24h)" -#: ./lib/app/home/_widgets/location_out_of_service.dart:28 +#: ./lib/app/home_old/_widgets/location_out_of_service.dart:34 msgid "服務區域外,僅在臺灣各地可用" msgstr "服務區域外,僅在臺灣各地可用" -#: ./lib/app/map/_lib/managers/radar.dart:587 -msgid "雷達回波" -msgstr "雷達回波" - -#: ./lib/app/map/_lib/managers/monitor.dart:497 +#: ./lib/app/map/_lib/managers/monitor.dart:518 msgid "無資料" msgstr "無資料" -#: ./lib/app/home/_widgets/wind_card.dart:372 +#: ./lib/app/home_old/_widgets/wind_card.dart:329 msgid "{wind}級 {Desc}" msgstr "{wind}級 {Desc}" -#: ./lib/app/home/_widgets/wind_card.dart:389 +#: ./lib/app/home_old/_widgets/wind_card.dart:346 msgid "陣風 {speed} m/s" msgstr "陣風 {speed} m/s" -#: ./lib/app/home/_widgets/wind_card.dart:471 +#: ./lib/app/home_old/_widgets/wind_card.dart:408 msgid "無法測量" msgstr "無法測量" -#: ./lib/app/home/_widgets/wind_card.dart:492 +#: ./lib/app/home_old/_widgets/wind_card.dart:429 msgid "指北針不可靠" msgstr "指北針不可靠" -#: ./lib/app/home/_widgets/wind_card.dart:494 +#: ./lib/app/home_old/_widgets/wind_card.dart:431 msgid "指北針準確度下降" msgstr "指北針準確度下降" -#: ./lib/app/home/_widgets/wind_card.dart:495 +#: ./lib/app/home_old/_widgets/wind_card.dart:432 msgid "指北針正常" msgstr "指北針正常" -#: ./lib/app/home/_widgets/wind_card.dart:502 +#: ./lib/app/home_old/_widgets/wind_card.dart:439 msgid "方向精確度" msgstr "方向精確度" -#: ./lib/app/home/_widgets/wind_card.dart:521 +#: ./lib/app/home_old/_widgets/wind_card.dart:458 msgid "正常範圍:±0-15°" msgstr "正常範圍:±0-15°" -#: ./lib/app/home/_widgets/wind_card.dart:538 +#: ./lib/app/home_old/_widgets/wind_card.dart:475 msgid "附近有強磁場干擾,指北針方向可能完全不準確。請遠離磁鐵、電子裝置或金屬物品。" msgstr "附近有強磁場干擾,指北針方向可能完全不準確。請遠離磁鐵、電子裝置或金屬物品。" -#: ./lib/app/home/_widgets/wind_card.dart:539 +#: ./lib/app/home_old/_widgets/wind_card.dart:476 msgid "附近可能有磁場干擾,指北針方向可能有偏差。" msgstr "附近可能有磁場干擾,指北針方向可能有偏差。" -#: ./lib/route/image_viewer/image_viewer.dart:145 -msgid "確定" -msgstr "確定" - -#: ./lib/app/home/_widgets/location_button.dart:29 +#: ./lib/app/home_old/_widgets/location_button.dart:38 msgid "尚未設定" msgstr "尚未設定" -#: ./lib/app/home/_widgets/location_button.dart:138 -msgid "切換區域" -msgstr "切換區域" - -#: ./lib/app/home/_widgets/location_button.dart:144 -msgid "位置設定" -msgstr "位置設定" - -#: ./lib/app/home/_widgets/location_button.dart:195 -msgid "快速切換" -msgstr "快速切換" - -#: ./lib/app/home/_widgets/location_button.dart:240 +#: ./lib/app/home_old/_widgets/location_button.dart:211 msgid "選擇縣市" msgstr "選擇縣市" -#: ./lib/app/home/_widgets/location_button.dart:250 +#: ./lib/app/home_old/_widgets/location_button.dart:221 msgid "目前選擇" msgstr "目前選擇" -#: ./lib/app/home/page.dart:888 +#: ./lib/app/home_old/_widgets/location_button.dart:279 +msgid "切換區域" +msgstr "切換區域" + +#: ./lib/app/home_old/_widgets/location_button.dart:285 +msgid "位置設定" +msgstr "位置設定" + +#: ./lib/app/home_old/page.dart:630 msgid "濕度" msgstr "濕度" -#: ./lib/app/home/page.dart:916 +#: ./lib/app/home_old/page.dart:658 msgid "風速" msgstr "風速" -#: ./lib/app/home/_widgets/weather_header.dart:181 +#: ./lib/app/home_old/_widgets/weather_header.dart:199 msgid "風向" msgstr "風向" -#: ./lib/app/home/_widgets/weather_header.dart:190 +#: ./lib/app/home_old/_widgets/weather_header.dart:206 msgid "風級" msgstr "風級" -#: ./lib/app/home/page.dart:895 +#: ./lib/app/home_old/page.dart:637 msgid "氣壓" msgstr "氣壓" -#: ./lib/app/home/page.dart:902 +#: ./lib/app/home_old/page.dart:644 msgid "降雨" msgstr "降雨" -#: ./lib/app/home/page.dart:909 +#: ./lib/app/home_old/page.dart:651 msgid "能見度" msgstr "能見度" -#: ./lib/app/home/page.dart:923 +#: ./lib/app/home_old/page.dart:665 msgid "陣風" msgstr "陣風" -#: ./lib/app/home/_widgets/weather_header.dart:233 +#: ./lib/app/home_old/_widgets/weather_header.dart:243 msgid "陣風級" msgstr "陣風級" -#: ./lib/app/home/_widgets/weather_header.dart:241 +#: ./lib/app/home_old/_widgets/weather_header.dart:251 msgid "日照" msgstr "日照" -#: ./lib/app/home/_widgets/hero_weather.dart:142 +#: ./lib/app/home_old/_widgets/hero_weather.dart:151 msgid "體感 {feelsLike}°" msgstr "體感 {feelsLike}°" -#: ./lib/app/home/_widgets/hero_weather.dart:185 +#: ./lib/app/home_old/_widgets/hero_weather.dart:194 msgid "無天氣資料" msgstr "無天氣資料" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:14 +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:32 msgid "全國 · 生效中" msgstr "全國 · 生效中" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:16 +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:34 msgid "全國 · 歷史" msgstr "全國 · 歷史" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:18 +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:36 msgid "所在地 · 生效中" msgstr "所在地 · 生效中" -#: ./lib/app/home/_widgets/mode_toggle_button.dart:20 +#: ./lib/app/home_old/_widgets/mode_toggle_button.dart:38 msgid "所在地 · 歷史" msgstr "所在地 · 歷史" -#: ./lib/app/map/_lib/managers/monitor.dart:1258 +#: ./lib/app/map/_lib/managers/monitor.dart:1274 msgid "EEW" msgstr "EEW" -#: ./lib/app/map/_lib/managers/monitor.dart:1397 +#: ./lib/app/map/_lib/managers/monitor.dart:1407 msgid "第 {serial} 報" msgstr "第 {serial} 報" -#: ./lib/app/map/_lib/managers/monitor.dart:1415 +#: ./lib/app/map/_lib/managers/monitor.dart:1425 msgid "" "{time} 左右,{location}附近發生有感地震,預估規模 " "M{magnitude}、所在地最大震度{intensity}。" @@ -971,7 +1133,7 @@ msgstr "" "{time} 左右,{location}附近發生有感地震,預估規模 " "M{magnitude}、所在地最大震度{intensity}。" -#: ./lib/app/map/_lib/managers/monitor.dart:1423 +#: ./lib/app/map/_lib/managers/monitor.dart:1433 msgid "" "{time} 左右,{location}附近發生有感地震,預估規模 " "M{magnitude}、深度{depth}公里。" @@ -979,453 +1141,319 @@ msgstr "" "{time} 左右,{location}附近發生有感地震,預估規模 " "M{magnitude}、深度{depth}公里。" -#: ./lib/app/map/_lib/managers/monitor.dart:1469 +#: ./lib/app/map/_lib/managers/monitor.dart:1479 msgid "所在地預估" msgstr "所在地預估" -#: ./lib/app/map/_lib/managers/monitor.dart:1505 +#: ./lib/app/map/_lib/managers/monitor.dart:1515 msgid "震波" msgstr "震波" -#: ./lib/app/map/_lib/managers/monitor.dart:1527 +#: ./lib/app/map/_lib/managers/monitor.dart:1535 msgid " 秒" msgstr " 秒" -#: ./lib/app/map/_lib/managers/monitor.dart:1544 +#: ./lib/app/map/_lib/managers/monitor.dart:1551 msgid "抵達" msgstr "抵達" -#: ./lib/app/home/page.dart:195 +#: ./lib/app/home_old/page.dart:158 msgid "已更新至 {version}" msgstr "已更新至 {version}" -#: ./lib/utils/weather_icon.dart:284 +#: ./lib/utils/weather_icon.dart:282 msgid "取得天氣異常" msgstr "取得天氣異常" -#: ./lib/app/home/page.dart:366 +#: ./lib/app/home_old/page.dart:331 msgid "取得歷史資訊異常" msgstr "取得歷史資訊異常" -#: ./lib/app/home/page.dart:777 +#: ./lib/app/home_old/page.dart:571 msgid "上午" msgstr "上午" -#: ./lib/app/home/page.dart:777 +#: ./lib/app/home_old/page.dart:571 msgid "下午" msgstr "下午" -#: ./lib/app/changelog/page.dart:76 -msgid "發生錯誤" -msgstr "發生錯誤" - -#: ./lib/route/image_viewer/image_viewer.dart:85 -msgid "再試一次" -msgstr "再試一次" - -#: ./lib/app/changelog/page.dart:203 -msgid "目前版本" -msgstr "目前版本" - -#: ./lib/app/welcome/4-permissions/page.dart:403 -msgid "下一步" -msgstr "下一步" - -#: ./lib/app/welcome/1-about/page.dart:68 -msgid "防災資訊平台" -msgstr "防災資訊平台" - -#: ./lib/app/welcome/2-exptech/page.dart:93 -msgid "我們是誰?" -msgstr "我們是誰?" - -#: ./lib/app/welcome/2-exptech/page.dart:100 -msgid "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" -msgstr "ExpTech Studio 是一群大部分由學生組成,平均年齡未滿 20 歲、人數超過 15 + 的團體。成員來自臺灣北中南、日本、韓國、中國的學生。" - -#: ./lib/app/welcome/2-exptech/page.dart:106 -msgid "我們的初衷" -msgstr "我們的初衷" - -#: ./lib/app/welcome/2-exptech/page.dart:113 -msgid "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" -msgstr "成立初衷是招募一群對電腦及科技有興趣及能力的同學,後來發展至校外,並逐漸形成現在的樣子。" - -#: ./lib/app/welcome/3-notice/page.dart:45 -msgid "注意事項" -msgstr "注意事項" - -#: ./lib/app/welcome/3-notice/page.dart:65 -msgid "任何資訊應以中央氣象署發布之內容為準。" -msgstr "任何資訊應以中央氣象署發布之內容為準。" - -#: ./lib/app/welcome/3-notice/page.dart:82 -msgid "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" -msgstr "根據網路狀態、伺服器狀態、應用程式狀態、上游資料來源狀態等,有收不到資訊的可能性,我們會盡力避免此類情況,但不保證一定不會發生。" - -#: ./lib/app/welcome/3-notice/page.dart:97 -msgid "強烈搖晃有機率比通知早抵達使用者所在地。" -msgstr "強烈搖晃有機率比通知早抵達使用者所在地。" - -#: ./lib/app/welcome/3-notice/page.dart:111 -msgid "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" -msgstr "地震速報為快速計算之結果,可能存在較大誤差,應理解並謹慎使用。" - -#: ./lib/app/welcome/3-notice/page.dart:125 -msgid "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" -msgstr "任何不被官方所認可的行為均有可能承擔法律風險,請務必遵守相關規範。" - -#: ./lib/app/welcome/1-about/page.dart:46 -msgid "歡迎使用 DPIP" -msgstr "歡迎使用 DPIP" - -#: ./lib/app/welcome/1-about/page.dart:91 -msgid "" -"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " -"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" -msgstr "" -"DPIP 是一款由臺灣本土團隊設計的 App,整合 TREM-Net (臺灣即時地震觀測網) " -"之資訊,以及中央氣象署資料,提供一個整合、單一且便利的防災資訊應用程式。" - -#: ./lib/app/welcome/4-permissions/page.dart:167 -msgid "在重大災害發生時以通知來傳遞即時防災資訊" -msgstr "在重大災害發生時以通知來傳遞即時防災資訊" - -#: ./lib/app/welcome/4-permissions/page.dart:175 -msgid "使用定位來自動更新所在地設定,提供當地的即時防災資訊" -msgstr "使用定位來自動更新所在地設定,提供當地的即時防災資訊" - -#: ./lib/app/welcome/4-permissions/page.dart:181 -msgid "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" -msgstr "允許 DPIP 在背景中持續運行,以便即時防災通知資訊。" - -#: ./lib/route/image_viewer/image_viewer.dart:255 -msgid "儲存" -msgstr "儲存" - -#: ./lib/app/welcome/4-permissions/page.dart:188 -msgid "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" -msgstr "用於儲存中央氣象署或 ExpTech 提供之資料視覺化圖片" - -#: ./lib/app/welcome/4-permissions/page.dart:352 -msgid "權限請求" -msgstr "權限請求" - -#: ./lib/app/welcome/4-permissions/page.dart:353 -msgid "需要使用者手動到設定開啟相關權限。" -msgstr "需要使用者手動到設定開啟相關權限。" - -#: ./lib/app/welcome/4-permissions/page.dart:376 -msgid "需要背景位置權限" -msgstr "需要背景位置權限" - -#: ./lib/app/welcome/4-permissions/page.dart:378 -msgid "" -"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" -"\n" -"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" -msgstr "" -"為了在背景持續提供即時防災資訊,DPIP 需要「永遠允許」位置權限。\n" -"\n" -"接下來系統會引導您到設定頁面,請選擇「永遠允許」選項。" - -#: ./lib/app/welcome/4-permissions/page.dart:384 -msgid "稍後" -msgstr "稍後" - -#: ./lib/app/welcome/4-permissions/page.dart:388 -msgid "前往設定" -msgstr "前往設定" - -#: ./lib/app/welcome/4-permissions/page.dart:426 -msgid "權限" -msgstr "權限" - -#: ./lib/app/welcome/4-permissions/page.dart:439 -msgid "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" -msgstr "我們一直和使用者站在一起,為使用者的隱私而不斷努力。" - -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:84 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:96 msgid "地圖圖層" msgstr "地圖圖層" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:85 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:97 msgid "選擇要顯示的地圖圖層" msgstr "選擇要顯示的地圖圖層" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:91 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:103 msgid "底圖" msgstr "底圖" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:97 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:109 msgid "簡單" msgstr "簡單" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:119 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:131 msgid "監視器" msgstr "監視器" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:124 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:136 msgid "報告" msgstr "報告" -#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:135 +#: ./lib/app/map/_widgets/layer_toggle_sheet.dart:147 msgid "氣象" msgstr "氣象" -#: ./lib/app/map/_lib/managers/temperature.dart:453 +#: ./lib/app/map/_lib/managers/temperature.dart:480 msgid "氣溫" msgstr "氣溫" -#: ./lib/app/map/_lib/managers/precipitation.dart:577 +#: ./lib/app/map/_lib/managers/precipitation.dart:587 msgid "降水" msgstr "降水" -#: ./lib/app/map/_lib/managers/wind.dart:320 -msgid "風向/風速" -msgstr "風向/風速" - -#: ./lib/app/map/_lib/managers/lightning.dart:294 +#: ./lib/app/map/_lib/managers/lightning.dart:302 msgid "閃電" msgstr "閃電" -#: ./lib/app/map/_widgets/map_legend.dart:202 +#: ./lib/app/map/_widgets/map_legend.dart:250 msgid "單位:{unit}" msgstr "單位:{unit}" -#: ./lib/app/map/_lib/managers/tsunami.dart:485 +#: ./lib/app/map/_lib/managers/tsunami.dart:494 msgid "近期無海嘯資訊" msgstr "近期無海嘯資訊" -#: ./lib/app/map/_lib/managers/tsunami.dart:486 +#: ./lib/app/map/_lib/managers/tsunami.dart:494 msgid "海嘯警報" msgstr "海嘯警報" -#: ./lib/app/map/_lib/managers/tsunami.dart:496 +#: ./lib/app/map/_lib/managers/tsunami.dart:504 msgid "{id}號 第{serial}報" msgstr "{id}號 第{serial}報" -#: ./lib/app/map/_lib/managers/tsunami.dart:551 +#: ./lib/app/map/_lib/managers/tsunami.dart:558 msgid "發布" msgstr "發布" -#: ./lib/app/map/_lib/managers/tsunami.dart:553 +#: ./lib/app/map/_lib/managers/tsunami.dart:560 msgid "更新" msgstr "更新" -#: ./lib/app/map/_lib/managers/tsunami.dart:554 +#: ./lib/app/map/_lib/managers/tsunami.dart:561 msgid "解除" msgstr "解除" -#: ./lib/app/map/_lib/managers/tsunami.dart:607 +#: ./lib/app/map/_lib/managers/tsunami.dart:612 msgid "預估海嘯到達時間及波高" msgstr "預估海嘯到達時間及波高" -#: ./lib/app/map/_lib/managers/tsunami.dart:626 +#: ./lib/app/map/_lib/managers/tsunami.dart:630 msgid "各地觀測到的海嘯" msgstr "各地觀測到的海嘯" -#: ./lib/app/map/_lib/managers/tsunami.dart:641 +#: ./lib/app/map/_lib/managers/tsunami.dart:645 msgid "地震資訊" msgstr "地震資訊" -#: ./lib/app/map/_lib/managers/tsunami.dart:654 +#: ./lib/app/map/_lib/managers/tsunami.dart:657 msgid "發生時間" msgstr "發生時間" -#: ./lib/app/map/_lib/managers/report.dart:1072 +#: ./lib/app/map/_lib/managers/report.dart:1069 msgid "位於" msgstr "位於" -#: ./lib/app/map/_lib/managers/tsunami.dart:736 +#: ./lib/app/map/_lib/managers/tsunami.dart:730 msgid "規模" msgstr "規模" -#: ./lib/app/map/_lib/managers/tsunami.dart:765 +#: ./lib/app/map/_lib/managers/tsunami.dart:753 msgid "深度" msgstr "深度" -#: ./lib/app/map/_lib/managers/radar.dart:744 +#: ./lib/app/map/_lib/managers/radar.dart:750 msgid "長按設定播放起點" msgstr "長按設定播放起點" -#: ./lib/app/map/_lib/managers/radar.dart:760 +#: ./lib/app/map/_lib/managers/radar.dart:766 msgid "目前時間" msgstr "目前時間" -#: ./lib/app/map/_lib/managers/radar.dart:765 +#: ./lib/app/map/_lib/managers/radar.dart:771 msgid "播放起點" msgstr "播放起點" -#: ./lib/app/map/_lib/managers/radar.dart:1099 +#: ./lib/app/map/_lib/managers/radar.dart:1100 msgid "播放進度" msgstr "播放進度" -#: ./lib/app/map/_lib/managers/lightning.dart:393 +#: ./lib/app/map/_lib/managers/lightning.dart:399 msgid "5 分鐘內對地閃電" msgstr "5 分鐘內對地閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:401 +#: ./lib/app/map/_lib/managers/lightning.dart:407 msgid "10 分鐘內對地閃電" msgstr "10 分鐘內對地閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:409 +#: ./lib/app/map/_lib/managers/lightning.dart:415 msgid "30 分鐘內對地閃電" msgstr "30 分鐘內對地閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:417 +#: ./lib/app/map/_lib/managers/lightning.dart:423 msgid "60 分鐘內對地閃電" msgstr "60 分鐘內對地閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:425 +#: ./lib/app/map/_lib/managers/lightning.dart:431 msgid "5 分鐘內雲間閃電" msgstr "5 分鐘內雲間閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:433 +#: ./lib/app/map/_lib/managers/lightning.dart:439 msgid "10 分鐘內雲間閃電" msgstr "10 分鐘內雲間閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:441 +#: ./lib/app/map/_lib/managers/lightning.dart:447 msgid "30 分鐘內雲間閃電" msgstr "30 分鐘內雲間閃電" -#: ./lib/app/map/_lib/managers/lightning.dart:449 +#: ./lib/app/map/_lib/managers/lightning.dart:455 msgid "60 分鐘內雲間閃電" msgstr "60 分鐘內雲間閃電" -#: ./lib/app/map/_lib/managers/precipitation.dart:396 +#: ./lib/app/map/_lib/managers/precipitation.dart:420 msgid "今日" msgstr "今日" -#: ./lib/app/map/_lib/managers/precipitation.dart:397 +#: ./lib/app/map/_lib/managers/precipitation.dart:421 msgid "10 分鐘" msgstr "10 分鐘" -#: ./lib/app/map/_lib/managers/precipitation.dart:398 +#: ./lib/app/map/_lib/managers/precipitation.dart:422 msgid "1 小時" msgstr "1 小時" -#: ./lib/app/map/_lib/managers/precipitation.dart:399 +#: ./lib/app/map/_lib/managers/precipitation.dart:423 msgid "3 小時" msgstr "3 小時" -#: ./lib/app/map/_lib/managers/precipitation.dart:400 +#: ./lib/app/map/_lib/managers/precipitation.dart:424 msgid "6 小時" msgstr "6 小時" -#: ./lib/app/map/_lib/managers/precipitation.dart:401 +#: ./lib/app/map/_lib/managers/precipitation.dart:425 msgid "12 小時" msgstr "12 小時" -#: ./lib/app/map/_lib/managers/precipitation.dart:402 +#: ./lib/app/map/_lib/managers/precipitation.dart:426 msgid "24 小時" msgstr "24 小時" -#: ./lib/app/map/_lib/managers/precipitation.dart:403 +#: ./lib/app/map/_lib/managers/precipitation.dart:427 msgid "2 天" msgstr "2 天" -#: ./lib/app/map/_lib/managers/precipitation.dart:404 +#: ./lib/app/map/_lib/managers/precipitation.dart:428 msgid "3 天" msgstr "3 天" -#: ./lib/app/map/_lib/managers/monitor.dart:474 +#: ./lib/app/map/_lib/managers/monitor.dart:495 msgid "海外測站" msgstr "海外測站" -#: ./lib/app/map/_lib/managers/monitor.dart:494 +#: ./lib/app/map/_lib/managers/monitor.dart:515 msgid "即時震度:" msgstr "即時震度:" -#: ./lib/app/map/_lib/managers/monitor.dart:517 +#: ./lib/app/map/_lib/managers/monitor.dart:538 msgid "地動加速度:" msgstr "地動加速度:" -#: ./lib/app/map/_lib/managers/monitor.dart:541 +#: ./lib/app/map/_lib/managers/monitor.dart:562 msgid "地動速度:" msgstr "地動速度:" -#: ./lib/app/map/_lib/managers/monitor.dart:1331 +#: ./lib/app/map/_lib/managers/monitor.dart:1346 msgid "規模 M{magnitude},所在地預估{intensity}" msgstr "規模 M{magnitude},所在地預估{intensity}" -#: ./lib/app/map/_lib/managers/monitor.dart:1349 +#: ./lib/app/map/_lib/managers/monitor.dart:1362 msgid "{countdown}秒後抵達" msgstr "{countdown}秒後抵達" -#: ./lib/app/map/_lib/managers/monitor.dart:1352 +#: ./lib/app/map/_lib/managers/monitor.dart:1365 msgid "已抵達" msgstr "已抵達" -#: ./lib/app/map/_lib/managers/monitor.dart:1364 +#: ./lib/app/map/_lib/managers/monitor.dart:1376 msgid "規模 M{magnitude},深度{depth}公里" msgstr "規模 M{magnitude},深度{depth}公里" -#: ./lib/app/map/_lib/managers/monitor.dart:1591 +#: ./lib/app/map/_lib/managers/monitor.dart:1594 msgid "目前沒有生效中的地震速報" msgstr "目前沒有生效中的地震速報" -#: ./lib/app/map/_lib/managers/report.dart:516 +#: ./lib/app/map/_lib/managers/report.dart:541 msgid "CWA 正在製圖中" msgstr "CWA 正在製圖中" -#: ./lib/app/map/_lib/managers/report.dart:639 +#: ./lib/app/map/_lib/managers/report.dart:660 msgid "近期的地震報告" msgstr "近期的地震報告" -#: ./lib/app/map/_lib/managers/report.dart:647 +#: ./lib/app/map/_lib/managers/report.dart:667 msgid "更多" msgstr "更多" -#: ./lib/app/map/_lib/managers/report.dart:995 +#: ./lib/app/map/_lib/managers/report.dart:994 msgid "編號 {number} 顯著有感地震" msgstr "編號 {number} 顯著有感地震" -#: ./lib/app/map/_lib/managers/report.dart:998 +#: ./lib/app/map/_lib/managers/report.dart:997 msgid "小區域有感地震" msgstr "小區域有感地震" -#: ./lib/app/map/_lib/managers/report.dart:1083 +#: ./lib/app/map/_lib/managers/report.dart:1080 msgid "地震規模" msgstr "地震規模" -#: ./lib/app/map/_lib/managers/report.dart:1110 +#: ./lib/app/map/_lib/managers/report.dart:1107 msgid "震源深度" msgstr "震源深度" -#: ./lib/app/map/_lib/managers/report.dart:886 +#: ./lib/app/map/_lib/managers/report.dart:893 msgid "沒有更多資料" msgstr "沒有更多資料" -#: ./lib/app/map/_lib/managers/report.dart:1030 +#: ./lib/app/map/_lib/managers/report.dart:1029 msgid "報告頁面" msgstr "報告頁面" -#: ./lib/route/report/report_sheet_content.dart:90 +#: ./lib/route/report/report_sheet_content.dart:88 msgid "重播" msgstr "重播" -#: ./lib/app/map/_lib/managers/report.dart:1064 +#: ./lib/app/map/_lib/managers/report.dart:1061 msgid "發震時間" msgstr "發震時間" -#: ./lib/app/map/_lib/managers/report.dart:1132 +#: ./lib/app/map/_lib/managers/report.dart:1129 msgid "各地震度" msgstr "各地震度" -#: ./lib/app/map/_lib/managers/report.dart:1212 +#: ./lib/app/map/_lib/managers/report.dart:1205 msgid "地震報告圖" msgstr "地震報告圖" -#: ./lib/app/map/_lib/managers/report.dart:1228 +#: ./lib/app/map/_lib/managers/report.dart:1220 msgid "震度圖" msgstr "震度圖" -#: ./lib/app/map/_lib/managers/report.dart:1248 +#: ./lib/app/map/_lib/managers/report.dart:1240 msgid "最大地動加速度圖" msgstr "最大地動加速度圖" -#: ./lib/app/map/_lib/managers/report.dart:1268 +#: ./lib/app/map/_lib/managers/report.dart:1260 msgid "最大地動速度圖" msgstr "最大地動速度圖" @@ -1477,11 +1505,11 @@ msgstr "氣象相關" msgid "未知" msgstr "未知" -#: ./lib/route/announcement/announcement.dart:105 +#: ./lib/route/announcement/announcement.dart:104 msgid "目前沒有公告" msgstr "目前沒有公告" -#: ./lib/route/announcement/announcement.dart:246 +#: ./lib/route/announcement/announcement.dart:245 msgid "公告詳情" msgstr "公告詳情" @@ -1497,302 +1525,302 @@ msgstr "已儲存圖片" msgid "儲存圖片時發生錯誤" msgstr "儲存圖片時發生錯誤" -#: ./lib/utils/extensions/number.dart:26 +#: ./lib/utils/extensions/number.dart:25 msgid "0級" msgstr "0級" -#: ./lib/utils/extensions/number.dart:27 +#: ./lib/utils/extensions/number.dart:26 msgid "1級" msgstr "1級" -#: ./lib/utils/extensions/number.dart:28 +#: ./lib/utils/extensions/number.dart:27 msgid "2級" msgstr "2級" -#: ./lib/utils/extensions/number.dart:29 +#: ./lib/utils/extensions/number.dart:28 msgid "3級" msgstr "3級" -#: ./lib/utils/extensions/number.dart:30 +#: ./lib/utils/extensions/number.dart:29 msgid "4級" msgstr "4級" -#: ./lib/utils/extensions/number.dart:31 +#: ./lib/utils/extensions/number.dart:30 msgid "5弱" msgstr "5弱" -#: ./lib/utils/extensions/number.dart:32 +#: ./lib/utils/extensions/number.dart:31 msgid "5強" msgstr "5強" -#: ./lib/utils/extensions/number.dart:33 +#: ./lib/utils/extensions/number.dart:32 msgid "6弱" msgstr "6弱" -#: ./lib/utils/extensions/number.dart:34 +#: ./lib/utils/extensions/number.dart:33 msgid "6強" msgstr "6強" -#: ./lib/utils/extensions/number.dart:35 +#: ./lib/utils/extensions/number.dart:34 msgid "7級" msgstr "7級" -#: ./lib/utils/weather_icon.dart:285 +#: ./lib/utils/weather_icon.dart:283 msgid "晴" msgstr "晴" -#: ./lib/utils/weather_icon.dart:286 +#: ./lib/utils/weather_icon.dart:284 msgid "晴有霾" msgstr "晴有霾" -#: ./lib/utils/weather_icon.dart:287 +#: ./lib/utils/weather_icon.dart:285 msgid "晴有靄" msgstr "晴有靄" -#: ./lib/utils/weather_icon.dart:288 +#: ./lib/utils/weather_icon.dart:286 msgid "晴有閃電" msgstr "晴有閃電" -#: ./lib/utils/weather_icon.dart:304 +#: ./lib/utils/weather_icon.dart:302 msgid "晴天伴有雷" msgstr "晴天伴有雷" -#: ./lib/utils/weather_icon.dart:290 +#: ./lib/utils/weather_icon.dart:288 msgid "晴有霧" msgstr "晴有霧" -#: ./lib/utils/weather_icon.dart:291 +#: ./lib/utils/weather_icon.dart:289 msgid "晴有雨" msgstr "晴有雨" -#: ./lib/utils/weather_icon.dart:292 +#: ./lib/utils/weather_icon.dart:290 msgid "晴有雨雪" msgstr "晴有雨雪" -#: ./lib/utils/weather_icon.dart:293 +#: ./lib/utils/weather_icon.dart:291 msgid "晴有大雪" msgstr "晴有大雪" -#: ./lib/utils/weather_icon.dart:294 +#: ./lib/utils/weather_icon.dart:292 msgid "晴有雪珠" msgstr "晴有雪珠" -#: ./lib/utils/weather_icon.dart:295 +#: ./lib/utils/weather_icon.dart:293 msgid "晴有冰珠" msgstr "晴有冰珠" -#: ./lib/utils/weather_icon.dart:296 +#: ./lib/utils/weather_icon.dart:294 msgid "晴有陣雪" msgstr "晴有陣雪" -#: ./lib/utils/weather_icon.dart:297 +#: ./lib/utils/weather_icon.dart:295 msgid "晴陣雨雪" msgstr "晴陣雨雪" -#: ./lib/utils/weather_icon.dart:298 +#: ./lib/utils/weather_icon.dart:296 msgid "晴有雹" msgstr "晴有雹" -#: ./lib/utils/weather_icon.dart:299 +#: ./lib/utils/weather_icon.dart:297 msgid "晴有雷雨" msgstr "晴有雷雨" -#: ./lib/utils/weather_icon.dart:300 +#: ./lib/utils/weather_icon.dart:298 msgid "晴有雷雪" msgstr "晴有雷雪" -#: ./lib/utils/weather_icon.dart:301 +#: ./lib/utils/weather_icon.dart:299 msgid "晴有雷雹" msgstr "晴有雷雹" -#: ./lib/utils/weather_icon.dart:302 +#: ./lib/utils/weather_icon.dart:300 msgid "晴大雷雨" msgstr "晴大雷雨" -#: ./lib/utils/weather_icon.dart:303 +#: ./lib/utils/weather_icon.dart:301 msgid "晴大雷雹" msgstr "晴大雷雹" -#: ./lib/utils/weather_icon.dart:305 +#: ./lib/utils/weather_icon.dart:303 msgid "多雲" msgstr "多雲" -#: ./lib/utils/weather_icon.dart:306 +#: ./lib/utils/weather_icon.dart:304 msgid "多雲有霾" msgstr "多雲有霾" -#: ./lib/utils/weather_icon.dart:307 +#: ./lib/utils/weather_icon.dart:305 msgid "多雲有靄" msgstr "多雲有靄" -#: ./lib/utils/weather_icon.dart:308 +#: ./lib/utils/weather_icon.dart:306 msgid "多雲有閃電" msgstr "多雲有閃電" -#: ./lib/utils/weather_icon.dart:324 +#: ./lib/utils/weather_icon.dart:322 msgid "多雲伴有雷" msgstr "多雲伴有雷" -#: ./lib/utils/weather_icon.dart:310 +#: ./lib/utils/weather_icon.dart:308 msgid "多雲有霧" msgstr "多雲有霧" -#: ./lib/utils/weather_icon.dart:311 +#: ./lib/utils/weather_icon.dart:309 msgid "多雲有雨" msgstr "多雲有雨" -#: ./lib/utils/weather_icon.dart:312 +#: ./lib/utils/weather_icon.dart:310 msgid "多雲有雨雪" msgstr "多雲有雨雪" -#: ./lib/utils/weather_icon.dart:313 +#: ./lib/utils/weather_icon.dart:311 msgid "多雲有大雪" msgstr "多雲有大雪" -#: ./lib/utils/weather_icon.dart:314 +#: ./lib/utils/weather_icon.dart:312 msgid "多雲有雪珠" msgstr "多雲有雪珠" -#: ./lib/utils/weather_icon.dart:315 +#: ./lib/utils/weather_icon.dart:313 msgid "多雲有冰珠" msgstr "多雲有冰珠" -#: ./lib/utils/weather_icon.dart:316 +#: ./lib/utils/weather_icon.dart:314 msgid "多雲有陣雪" msgstr "多雲有陣雪" -#: ./lib/utils/weather_icon.dart:317 +#: ./lib/utils/weather_icon.dart:315 msgid "多雲陣雨雪" msgstr "多雲陣雨雪" -#: ./lib/utils/weather_icon.dart:318 +#: ./lib/utils/weather_icon.dart:316 msgid "多雲有雹" msgstr "多雲有雹" -#: ./lib/utils/weather_icon.dart:319 +#: ./lib/utils/weather_icon.dart:317 msgid "多雲有雷雨" msgstr "多雲有雷雨" -#: ./lib/utils/weather_icon.dart:320 +#: ./lib/utils/weather_icon.dart:318 msgid "多雲有雷雪" msgstr "多雲有雷雪" -#: ./lib/utils/weather_icon.dart:321 +#: ./lib/utils/weather_icon.dart:319 msgid "多雲有雷雹" msgstr "多雲有雷雹" -#: ./lib/utils/weather_icon.dart:322 +#: ./lib/utils/weather_icon.dart:320 msgid "多雲大雷雨" msgstr "多雲大雷雨" -#: ./lib/utils/weather_icon.dart:323 +#: ./lib/utils/weather_icon.dart:321 msgid "多雲大雷雹" msgstr "多雲大雷雹" -#: ./lib/utils/weather_icon.dart:325 +#: ./lib/utils/weather_icon.dart:323 msgid "陰" msgstr "陰" -#: ./lib/utils/weather_icon.dart:326 +#: ./lib/utils/weather_icon.dart:324 msgid "陰有霾" msgstr "陰有霾" -#: ./lib/utils/weather_icon.dart:327 +#: ./lib/utils/weather_icon.dart:325 msgid "陰有靄" msgstr "陰有靄" -#: ./lib/utils/weather_icon.dart:328 +#: ./lib/utils/weather_icon.dart:326 msgid "陰有閃電" msgstr "陰有閃電" -#: ./lib/utils/weather_icon.dart:344 +#: ./lib/utils/weather_icon.dart:342 msgid "陰天伴有雷" msgstr "陰天伴有雷" -#: ./lib/utils/weather_icon.dart:330 +#: ./lib/utils/weather_icon.dart:328 msgid "陰有霧" msgstr "陰有霧" -#: ./lib/utils/weather_icon.dart:331 +#: ./lib/utils/weather_icon.dart:329 msgid "陰有雨" msgstr "陰有雨" -#: ./lib/utils/weather_icon.dart:332 +#: ./lib/utils/weather_icon.dart:330 msgid "陰有雨雪" msgstr "陰有雨雪" -#: ./lib/utils/weather_icon.dart:333 +#: ./lib/utils/weather_icon.dart:331 msgid "陰有大雪" msgstr "陰有大雪" -#: ./lib/utils/weather_icon.dart:334 +#: ./lib/utils/weather_icon.dart:332 msgid "陰有雪珠" msgstr "陰有雪珠" -#: ./lib/utils/weather_icon.dart:335 +#: ./lib/utils/weather_icon.dart:333 msgid "陰有冰珠" msgstr "陰有冰珠" -#: ./lib/utils/weather_icon.dart:336 +#: ./lib/utils/weather_icon.dart:334 msgid "陰有陣雪" msgstr "陰有陣雪" -#: ./lib/utils/weather_icon.dart:337 +#: ./lib/utils/weather_icon.dart:335 msgid "陰陣雨雪" msgstr "陰陣雨雪" -#: ./lib/utils/weather_icon.dart:338 +#: ./lib/utils/weather_icon.dart:336 msgid "陰有雹" msgstr "陰有雹" -#: ./lib/utils/weather_icon.dart:339 +#: ./lib/utils/weather_icon.dart:337 msgid "陰有雷雨" msgstr "陰有雷雨" -#: ./lib/utils/weather_icon.dart:340 +#: ./lib/utils/weather_icon.dart:338 msgid "陰有雷雪" msgstr "陰有雷雪" -#: ./lib/utils/weather_icon.dart:341 +#: ./lib/utils/weather_icon.dart:339 msgid "陰有雷雹" msgstr "陰有雷雹" -#: ./lib/utils/weather_icon.dart:342 +#: ./lib/utils/weather_icon.dart:340 msgid "陰大雷雨" msgstr "陰大雷雨" -#: ./lib/utils/weather_icon.dart:343 +#: ./lib/utils/weather_icon.dart:341 msgid "陰大雷雹" msgstr "陰大雷雹" -#: ./lib/api/model/location/location.dart:85 +#: ./lib/api/model/location/location.dart:84 msgid "{city}{cityLevel} {town}{townLevel}" msgstr "{city}{cityLevel} {town}{townLevel}" -#: ./lib/api/model/location/location.dart:98 +#: ./lib/api/model/location/location.dart:97 msgid "{city} {town}" msgstr "{city} {town}" -#: ./lib/api/model/location/location.dart:113 +#: ./lib/api/model/location/location.dart:112 msgid "{city}{cityLevel}" msgstr "{city}{cityLevel}" -#: ./lib/api/model/location/location.dart:130 +#: ./lib/api/model/location/location.dart:129 msgid "{town}{townLevel}" msgstr "{town}{townLevel}" -#: ./lib/widgets/ui/color_picker.dart:363 +#: ./lib/widgets/ui/color_picker.dart:361 msgid "色相" msgstr "色相" -#: ./lib/widgets/ui/color_picker.dart:379 +#: ./lib/widgets/ui/color_picker.dart:377 msgid "彩度" msgstr "彩度" -#: ./lib/widgets/ui/color_picker.dart:395 +#: ./lib/widgets/ui/color_picker.dart:393 msgid "明度" msgstr "明度" -#: ./lib/widgets/ui/color_picker.dart:415 +#: ./lib/widgets/ui/color_picker.dart:413 msgid "十六進位值" msgstr "十六進位值" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 407d35693..bcd67660b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,8 +9,6 @@ PODS: - Flutter - IosAwnCore (~> 0.10.0) - IosAwnFcmCore (~> 0.10.1) - - clipboard (3.0.9): - - Flutter - device_info_plus (0.0.1): - Flutter - Firebase/CoreOnly (11.15.0): @@ -168,7 +166,6 @@ PODS: DEPENDENCIES: - awesome_notifications (from `.symlinks/plugins/awesome_notifications/ios`) - awesome_notifications_fcm (from `.symlinks/plugins/awesome_notifications_fcm/ios`) - - clipboard (from `.symlinks/plugins/clipboard/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) @@ -213,8 +210,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/awesome_notifications/ios" awesome_notifications_fcm: :path: ".symlinks/plugins/awesome_notifications_fcm/ios" - clipboard: - :path: ".symlinks/plugins/clipboard/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" firebase_core: @@ -255,7 +250,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: awesome_notifications: 0f432b28098d193920b11a44cfa9d2d9313a3888 awesome_notifications_fcm: ad14f584c81e2488ae4310ab96331327dcbb5368 - clipboard: 85f02e190f24debf05943aff98c26644b2a90245 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 @@ -290,7 +284,7 @@ SPEC CHECKSUMS: shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - zstandard_ios: b20c54862b56c31c7a47074b79fcf181780268b0 + zstandard_ios: 09cab18e0ee494020cc24d82a95c5bf4252d240c PODFILE CHECKSUM: 3d88bce62bfe048ac33ca00d3fb1bc02caeda4d3 diff --git a/lib/api/endpoints/device.dart b/lib/api/endpoints/device.dart index 3c0245c76..2dac44a95 100644 --- a/lib/api/endpoints/device.dart +++ b/lib/api/endpoints/device.dart @@ -50,9 +50,7 @@ mixin DeviceEndpoints { final res = await _dio.get( 'https://api-1.exptech.dev/api/v2/notify/$token', ); - return NotifySettings.fromJson( - (res.data as List).map((e) => e as int).toList(), - ); + return NotifySettings.fromJson((res.data as List).cast()); } /// 設定通知 @@ -78,9 +76,7 @@ mixin DeviceEndpoints { final res = await _dio.get( 'https://api-1.exptech.dev/api/v2/notify/$token/${channel.index}/${status.index}', ); - return NotifySettings.fromJson( - (res.data as List).map((e) => e as int).toList(), - ); + return NotifySettings.fromJson((res.data as List).cast()); } /// Reports network diagnostics to the server. diff --git a/lib/api/endpoints/earthquake.dart b/lib/api/endpoints/earthquake.dart index b88f605d7..aba3ecf9d 100644 --- a/lib/api/endpoints/earthquake.dart +++ b/lib/api/endpoints/earthquake.dart @@ -63,10 +63,12 @@ mixin EarthquakeEndpoints { ? 'https://api.core.exptech.dev/api/v2/eq/eew/${time ~/ 1000}' : '${lb}/v2/eq/eew'; final res = await _dio.get(url); - final eewList = (res.data as List).map( + Iterable eewList = (res.data as List).map( (e) => Eew.fromMap(e as Map), ); - if (Preference.experimentalEewAllSource == true) return eewList.toList(); - return eewList.where((e) => e.agency == 'cwa').toList(); + if (Preference.experimental__eewAllSource != true) { + eewList = eewList.where((e) => e.agency == 'cwa'); + } + return eewList.toList(); } } diff --git a/lib/app.dart b/lib/app.dart index f265dd5ac..4b013fcec 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -28,6 +28,7 @@ import 'main.dart'; class DpipApp extends StatefulWidget { /// Creates a new [DpipApp] instance. final String? initialShortcut; + const DpipApp({super.key, this.initialShortcut}); @override @@ -37,61 +38,49 @@ class DpipApp extends StatefulWidget { class _DpipAppState extends State with WidgetsBindingObserver { bool _hasHandledInitialShortcut = false; - Future _checkUpdate() async { - if (kDebugMode) return; - - try { - if (Platform.isAndroid) { - final info = await InAppUpdate.checkForUpdate(); + Future _checkNotificationPermission() async { + if (Platform.isAndroid) return; + await fcmReadyCompleter.future; + final status = (await FirebaseMessaging.instance.getNotificationSettings()).authorizationStatus; + final allowed = status == .authorized || status == .provisional; - if (info.updateAvailability != UpdateAvailability.updateAvailable) return; + if (Preference.isFirstLaunch || allowed) return; - if (info.immediateUpdateAllowed) { - InAppUpdate.performImmediateUpdate(); - } else if (info.flexibleUpdateAllowed) { - final updateResult = await InAppUpdate.startFlexibleUpdate(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && router.routerDelegate.navigatorKey.currentContext != null) { + const WelcomePermissionsRoute().go(context); + } + }); + } - if (updateResult != AppUpdateResult.success) return; + Future _checkUpdate() async { + if (kDebugMode || !Platform.isAndroid) return; - InAppUpdate.completeFlexibleUpdate(); - } + try { + final info = await InAppUpdate.checkForUpdate(); + if (info.updateAvailability != UpdateAvailability.updateAvailable) return; + + if (info.immediateUpdateAllowed) { + InAppUpdate.performImmediateUpdate(); + } else if (info.flexibleUpdateAllowed) { + final updateResult = await InAppUpdate.startFlexibleUpdate(); + if (updateResult != AppUpdateResult.success) return; + InAppUpdate.completeFlexibleUpdate(); } } catch (e, s) { TalkerManager.instance.error('_DpipState._checkUpdate', e, s); } } - Future _checkNotificationPermission() async { - if (Platform.isAndroid) return; - await fcmReadyCompleter.future; - bool notificationAllowed = false; - final settings = await FirebaseMessaging.instance.getNotificationSettings(); - notificationAllowed = - settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional; - - if (!Preference.isFirstLaunch && !notificationAllowed) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = router.routerDelegate.navigatorKey.currentContext; - if (ctx != null && mounted) { - WelcomePermissionsRoute().go(context); - } - }); - } - } - void _tryHandleInitialShortcut() { if (_hasHandledInitialShortcut) return; if (widget.initialShortcut == null) return; - - final ctx = router.routerDelegate.navigatorKey.currentContext; - if (ctx == null) return; + if (router.routerDelegate.navigatorKey.currentContext == null) return; _hasHandledInitialShortcut = true; - switch (widget.initialShortcut) { - case 'monitor': - MapRoute(layers: MapLayer.monitor.name).push(context); - break; + if (widget.initialShortcut == 'monitor') { + MapRoute(layers: MapLayer.monitor.name).push(context); } } @@ -110,9 +99,10 @@ class _DpipAppState extends State with WidgetsBindingObserver { _checkNotificationPermission(); WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 500), () { - handlePendingNotificationNavigation(context); - }); + Future.delayed( + const Duration(milliseconds: 500), + () => handlePendingNotificationNavigation(context), + ); _tryHandleInitialShortcut(); }); } @@ -130,62 +120,71 @@ class _DpipAppState extends State with WidgetsBindingObserver { ), ); - ThemeData lightTheme = .new( - colorSchemeSeed: model.themeColor ?? lightDynamic?.primary, - brightness: .light, - snackBarTheme: const .new(behavior: .floating), - pageTransitionsTheme: kZoomPageTransitionsTheme, - switchTheme: switchTheme, - // TODO(kamiya4047): Opt-in to new Material 3 update, remove this after it becomes the default option - sliderTheme: const .new(year2023: false), - progressIndicatorTheme: const .new(year2023: false), - ); - ThemeData darkTheme = .new( - colorSchemeSeed: model.themeColor ?? darkDynamic?.primary, - brightness: .dark, - snackBarTheme: const .new(behavior: .floating), - pageTransitionsTheme: kZoomPageTransitionsTheme, - switchTheme: switchTheme, - // TODO(kamiya4047): Opt-in to new Material 3 update, remove this after it becomes the default option - sliderTheme: const .new(year2023: false), - progressIndicatorTheme: const .new(year2023: false), + final cardTheme = CardThemeData( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), ); - final fontTextTheme = switch (I18n.locale.toLanguageTag()) { - 'zh-Hans' => GoogleFonts.notoSansScTextTheme, - 'ja' => GoogleFonts.notoSansJpTextTheme, - 'ko' => GoogleFonts.notoSansKrTextTheme, - 'vi' => GoogleFonts.notoSansTextTheme, - 'ru' => GoogleFonts.notoSansTextTheme, - _ => GoogleFonts.notoSansTcTextTheme, + final notoFallback = switch (I18n.locale.toLanguageTag()) { + 'zh-Hans' => GoogleFonts.notoSansSc().fontFamily!, + 'ja' => GoogleFonts.notoSansJp().fontFamily!, + 'ko' => GoogleFonts.notoSansKr().fontFamily!, + 'vi' || 'ru' => GoogleFonts.notoSans().fontFamily!, + _ => GoogleFonts.notoSansTc().fontFamily!, }; - lightTheme = lightTheme.copyWith( - textTheme: GoogleFonts.latoTextTheme( - fontTextTheme(lightTheme.textTheme), - ), + TextStyle applyFlex(TextStyle? base) => (base ?? const TextStyle()).copyWith( + fontFamily: 'Google Sans Flex', + fontFamilyFallback: [...?base?.fontFamilyFallback, notoFallback], ); - darkTheme = darkTheme.copyWith( - textTheme: GoogleFonts.latoTextTheme( - fontTextTheme(darkTheme.textTheme), - ), + + TextTheme buildTextTheme(TextTheme base) => base.copyWith( + displayLarge: applyFlex(base.displayLarge), + displayMedium: applyFlex(base.displayMedium), + displaySmall: applyFlex(base.displaySmall), + headlineLarge: applyFlex(base.headlineLarge), + headlineMedium: applyFlex(base.headlineMedium), + headlineSmall: applyFlex(base.headlineSmall), + titleLarge: applyFlex(base.titleLarge), + titleMedium: applyFlex(base.titleMedium), + titleSmall: applyFlex(base.titleSmall), + bodyLarge: applyFlex(base.bodyLarge), + bodyMedium: applyFlex(base.bodyMedium), + bodySmall: applyFlex(base.bodySmall), + labelLarge: applyFlex(base.labelLarge), + labelMedium: applyFlex(base.labelMedium), + labelSmall: applyFlex(base.labelSmall), ); + ThemeData buildTheme(Brightness brightness, Color? seed) { + final ThemeData base = .new( + colorSchemeSeed: seed, + brightness: brightness, + snackBarTheme: const .new(behavior: .floating), + pageTransitionsTheme: kZoomPageTransitionsTheme, + cardTheme: cardTheme, + switchTheme: switchTheme, + // TODO(kamiya4047): Opt-in to new Material 3 update, remove this after it becomes the default option + sliderTheme: const .new(year2023: false), + progressIndicatorTheme: const .new(year2023: false), + ); + return base.copyWith(textTheme: buildTextTheme(base.textTheme)); + } + + final seed = model.themeColor; + return MaterialApp.router( builder: (context, child) { - final mediaQueryData = MediaQuery.of(context); - final scale = mediaQueryData.textScaler.clamp( - minScaleFactor: 0.5, - maxScaleFactor: 1.2, - ); + final mq = MediaQuery.of(context); return MediaQuery( - data: mediaQueryData.copyWith(textScaler: scale), + data: mq.copyWith( + textScaler: mq.textScaler.clamp(minScaleFactor: 0.5, maxScaleFactor: 1.2), + ), child: child!, ); }, title: 'DPIP', - theme: lightTheme, - darkTheme: darkTheme, + theme: buildTheme(.light, seed ?? lightDynamic?.primary), + darkTheme: buildTheme(.dark, seed ?? darkDynamic?.primary), themeMode: model.themeMode, localizationsDelegates: I18n.localizationsDelegates, supportedLocales: I18n.supportedLocales, diff --git a/lib/app/changelog/_widgets/update_card.dart b/lib/app/changelog/_widgets/update_card.dart index 772c8977a..ca035b8ca 100644 --- a/lib/app/changelog/_widgets/update_card.dart +++ b/lib/app/changelog/_widgets/update_card.dart @@ -30,6 +30,8 @@ class UpdateCard extends StatelessWidget { @override Widget build(BuildContext context) { + final primary = context.theme.primaryColor; + final bodyMedium = context.texts.bodyMedium; return Card( elevation: 8, shape: RoundedRectangleBorder( @@ -41,10 +43,7 @@ class UpdateCard extends StatelessWidget { gradient: LinearGradient( begin: .topLeft, end: .bottomRight, - colors: [ - context.theme.primaryColor.withValues(alpha: 0.1), - context.theme.primaryColor.withValues(alpha: 0.3), - ], + colors: [primary.withValues(alpha: 0.1), primary.withValues(alpha: 0.3)], ), ), child: Padding( @@ -58,17 +57,13 @@ class UpdateCard extends StatelessWidget { children: [ Row( children: [ - Icon( - Icons.new_releases, - size: 28, - color: context.theme.primaryColor, - ), + Icon(Icons.new_releases, size: 28, color: primary), const SizedBox(width: 10), Text( title, style: context.texts.titleLarge?.copyWith( fontWeight: .bold, - color: context.theme.primaryColor, + color: primary, ), ), ], @@ -76,8 +71,8 @@ class UpdateCard extends StatelessWidget { const SizedBox(height: 12), Text( description, - style: context.texts.bodyMedium?.copyWith( - color: context.texts.bodyMedium?.color?.withValues(alpha: 0.8), + style: bodyMedium?.copyWith( + color: bodyMedium.color?.withValues(alpha: 0.8), ), ), ], diff --git a/lib/app/changelog/page.dart b/lib/app/changelog/page.dart index f6c583588..c529953ff 100644 --- a/lib/app/changelog/page.dart +++ b/lib/app/changelog/page.dart @@ -36,10 +36,7 @@ class _ChangelogPageState extends State { Result, String>? releases; Future _refresh() async { - if (_refreshIndicatorKey.currentState case final state?) { - state.show(); - } - + _refreshIndicatorKey.currentState?.show(); final result = await ExpTech().getReleases(); setState(() => releases = result); } @@ -181,14 +178,8 @@ class _ReleaseHeaderDelegate extends SliverPersistentHeaderDelegate { spacing: 16, children: [ ContainedIcon( - switch (release.prerelease) { - true => Symbols.experiment_rounded, - false => Symbols.package_2_rounded, - }, - color: switch (release.prerelease) { - true => Colors.orangeAccent, - false => Colors.greenAccent, - }, + release.prerelease ? Symbols.experiment_rounded : Symbols.package_2_rounded, + color: release.prerelease ? Colors.orangeAccent : Colors.greenAccent, size: 28, ), Expanded( diff --git a/lib/app/home/_widgets/date_timeline_item.dart b/lib/app/home/_widgets/date_timeline_item.dart index 4ed3e2c98..c67cfd09e 100644 --- a/lib/app/home/_widgets/date_timeline_item.dart +++ b/lib/app/home/_widgets/date_timeline_item.dart @@ -73,6 +73,7 @@ class DateTimelineItem extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: .circular(16)), elevation: 8, items: availableModes.map((m) { + final isSelected = mode == m; return PopupMenuItem( value: m, child: Row( @@ -81,13 +82,13 @@ class DateTimelineItem extends StatelessWidget { Icon( m.icon, size: 20, - color: mode == m ? context.colors.primary : context.colors.onSurfaceVariant, + color: isSelected ? context.colors.primary : context.colors.onSurfaceVariant, ), Text( m.label, style: context.texts.bodyMedium?.copyWith( - color: mode == m ? context.colors.primary : context.colors.onSurface, - fontWeight: mode == m ? .bold : .normal, + color: isSelected ? context.colors.primary : context.colors.onSurface, + fontWeight: isSelected ? .bold : .normal, ), ), ], diff --git a/lib/app/home/_widgets/eew_card.dart b/lib/app/home/_widgets/eew_card.dart index 46f459b63..2a7b92027 100644 --- a/lib/app/home/_widgets/eew_card.dart +++ b/lib/app/home/_widgets/eew_card.dart @@ -45,6 +45,22 @@ class _EewCardState extends State { Timer? _timer; + String _buildAlertText() { + final info = widget.data.info; + final commonArgs = { + 'time': info.time.toSimpleDateTimeString(), + 'location': info.location, + 'magnitude': info.magnitude.toStringAsFixed(1), + }; + return localIntensity != null + ? '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' + .i18n + .args({...commonArgs, 'intensity': localIntensity!.asIntensityLabel}) + : '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' + .i18n + .args({...commonArgs, 'depth': info.depth.toStringAsFixed(1)}); + } + void _updateCountdown() { if (localArrivalTime == null) return; @@ -152,23 +168,7 @@ class _EewCardState extends State { Padding( padding: const .only(top: 8), child: StyledText( - text: localIntensity != null - ? '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' - .i18n - .args({ - 'time': widget.data.info.time.toSimpleDateTimeString(), - 'location': widget.data.info.location, - 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), - 'intensity': localIntensity!.asIntensityLabel, - }) - : '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' - .i18n - .args({ - 'time': widget.data.info.time.toSimpleDateTimeString(), - 'location': widget.data.info.location, - 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), - 'depth': widget.data.info.depth.toStringAsFixed(1), - }), + text: _buildAlertText(), style: context.texts.bodyLarge!.copyWith( color: context.colors.onErrorContainer, ), diff --git a/lib/app/home/_widgets/hero_weather.dart b/lib/app/home/_widgets/hero_weather.dart index c2dddd6e3..cfd6a5357 100644 --- a/lib/app/home/_widgets/hero_weather.dart +++ b/lib/app/home/_widgets/hero_weather.dart @@ -120,7 +120,7 @@ class HeroWeather extends StatelessWidget { Icon( _getWeatherIcon(data.weatherCode), size: 24, - color: Colors.white.withValues(alpha: 1), + color: Colors.white, shadows: [ Shadow( color: Colors.black.withValues(alpha: 0.3), @@ -133,7 +133,7 @@ class HeroWeather extends StatelessWidget { Text( data.weather, style: context.texts.titleMedium?.copyWith( - color: Colors.white.withValues(alpha: 1), + color: Colors.white, fontWeight: .w400, shadows: [ Shadow( @@ -152,7 +152,7 @@ class HeroWeather extends StatelessWidget { 'feelsLike': feelsLike.round(), }), style: context.texts.bodyMedium?.copyWith( - color: Colors.white.withValues(alpha: 1), + color: Colors.white, shadows: [ Shadow( color: Colors.black.withValues(alpha: 0.3), @@ -203,16 +203,15 @@ class HeroWeather extends StatelessWidget { } /// Returns the appropriate [IconData] for the given CWA weather [code]. - IconData _getWeatherIcon(int code) { - if (code >= 1 && code <= 3) return Symbols.clear_day_rounded; - if (code >= 4 && code <= 7) return Symbols.partly_cloudy_day_rounded; - if (code >= 8 && code <= 14) return Symbols.cloud_rounded; - if (code >= 15 && code <= 22) return Symbols.rainy_rounded; - if (code >= 23 && code <= 28) return Symbols.rainy_heavy_rounded; - if (code >= 29 && code <= 35) return Symbols.thunderstorm_rounded; - if (code >= 36 && code <= 41) return Symbols.weather_snowy_rounded; - if (code >= 42) return Symbols.foggy_rounded; - - return Symbols.cloud_rounded; - } + IconData _getWeatherIcon(int code) => switch (code) { + >= 1 && <= 3 => Symbols.clear_day_rounded, + >= 4 && <= 7 => Symbols.partly_cloudy_day_rounded, + >= 8 && <= 14 => Symbols.cloud_rounded, + >= 15 && <= 22 => Symbols.rainy_rounded, + >= 23 && <= 28 => Symbols.rainy_heavy_rounded, + >= 29 && <= 35 => Symbols.thunderstorm_rounded, + >= 36 && <= 41 => Symbols.weather_snowy_rounded, + >= 42 => Symbols.foggy_rounded, + _ => Symbols.cloud_rounded, + }; } diff --git a/lib/app/home/_widgets/history_timeline_item.dart b/lib/app/home/_widgets/history_timeline_item.dart index 1857fd61b..f718b45a6 100644 --- a/lib/app/home/_widgets/history_timeline_item.dart +++ b/lib/app/home/_widgets/history_timeline_item.dart @@ -39,6 +39,7 @@ class HistoryTimelineItem extends StatelessWidget { @override Widget build(BuildContext context) { final hasDetail = shouldShowArrow(history); + final alpha = expired ? 0.6 : 1.0; return Container( margin: const .fromLTRB(16, 4, 16, 4), @@ -98,7 +99,7 @@ class HistoryTimelineItem extends StatelessWidget { ), style: context.texts.labelSmall?.copyWith( color: context.colors.onSurfaceVariant.withValues( - alpha: expired ? 0.6 : 1, + alpha: alpha, ), ), ), @@ -107,7 +108,7 @@ class HistoryTimelineItem extends StatelessWidget { history.text.content['all']!.subtitle, style: context.texts.titleSmall?.copyWith( color: context.colors.onSurface.withValues( - alpha: expired ? 0.6 : 1, + alpha: alpha, ), fontWeight: .w600, ), @@ -119,7 +120,7 @@ class HistoryTimelineItem extends StatelessWidget { history.text.description['all']!, style: context.texts.bodySmall?.copyWith( color: context.colors.onSurfaceVariant.withValues( - alpha: expired ? 0.6 : 1, + alpha: alpha, ), ), maxLines: 2, diff --git a/lib/app/home/_widgets/location_button.dart b/lib/app/home/_widgets/location_button.dart index 3ca2caa8a..51ae0d801 100644 --- a/lib/app/home/_widgets/location_button.dart +++ b/lib/app/home/_widgets/location_button.dart @@ -31,14 +31,7 @@ class LocationButton extends StatelessWidget { final favorited = settingsLocation.favorited; final temporaryCode = homeLocation.temporaryCode; final displayCode = temporaryCode ?? savedCode; - final location = Global.location[displayCode]; - - late String content; - if (location == null) { - content = '尚未設定'.i18n; - } else { - content = location.dynamicName; - } + final content = Global.location[displayCode]?.dynamicName ?? '尚未設定'.i18n; return BlurredTextButton( onPressed: () => _showLocationMenu( @@ -74,11 +67,7 @@ class LocationButton extends StatelessWidget { currentCode: currentCode, onLocationSelected: (code) { sheetContext.navigator.pop(); - if (code == savedCode) { - model.setTemporaryCode(null); - } else { - model.setTemporaryCode(code); - } + model.setTemporaryCode(code == savedCode ? null : code); }, onAddLocationPressed: () { sheetContext.navigator.pop(); diff --git a/lib/app/home/_widgets/mode_toggle_button.dart b/lib/app/home/_widgets/mode_toggle_button.dart index ee03b906c..dbaae9de9 100644 --- a/lib/app/home/_widgets/mode_toggle_button.dart +++ b/lib/app/home/_widgets/mode_toggle_button.dart @@ -104,6 +104,7 @@ class ModeToggleButton extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: .circular(16)), elevation: 8, items: HomeMode.values.map((mode) { + final isSelected = currentMode == mode; return PopupMenuItem( value: mode, child: Row( @@ -112,15 +113,13 @@ class ModeToggleButton extends StatelessWidget { Icon( mode.icon, size: 20, - color: currentMode == mode - ? context.colors.primary - : context.colors.onSurfaceVariant, + color: isSelected ? context.colors.primary : context.colors.onSurfaceVariant, ), Text( mode.label, style: context.texts.bodyMedium?.copyWith( - color: currentMode == mode ? context.colors.primary : context.colors.onSurface, - fontWeight: currentMode == mode ? .bold : .normal, + color: isSelected ? context.colors.primary : context.colors.onSurface, + fontWeight: isSelected ? .bold : .normal, ), ), ], diff --git a/lib/app/home/_widgets/thunderstorm_card.dart b/lib/app/home/_widgets/thunderstorm_card.dart index dbc839913..a7caba34c 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -26,6 +26,7 @@ class ThunderstormCard extends StatelessWidget { @override Widget build(BuildContext context) { + final extended = context.theme.extendedColors; return ResponsiveContainer( maxWidth: 720, child: Stack( @@ -33,11 +34,8 @@ class ThunderstormCard extends StatelessWidget { IgnorePointer( child: Container( decoration: BoxDecoration( - color: context.theme.extendedColors.blueContainer, - border: Border.all( - color: context.theme.extendedColors.blue, - width: 2, - ), + color: extended.blueContainer, + border: Border.all(color: extended.blue, width: 2), borderRadius: .circular(16), ), padding: const .all(12), @@ -54,7 +52,7 @@ class ThunderstormCard extends StatelessWidget { children: [ Container( decoration: BoxDecoration( - color: context.theme.extendedColors.blue, + color: extended.blue, borderRadius: .circular(8), ), padding: const .fromLTRB(8, 6, 12, 6), @@ -64,14 +62,14 @@ class ThunderstormCard extends StatelessWidget { children: [ Icon( Symbols.thunderstorm_rounded, - color: context.theme.extendedColors.onBlue, + color: extended.onBlue, weight: 700, size: 22, ), Text( '雷雨即時訊息'.i18n, style: context.texts.labelLarge!.copyWith( - color: context.theme.extendedColors.onBlue, + color: extended.onBlue, fontWeight: .bold, ), ), @@ -94,13 +92,11 @@ class ThunderstormCard extends StatelessWidget { 'time': history.time.expiresAt.toSimpleDateTimeString(), }), style: context.texts.bodyLarge!.copyWith( - color: context.theme.extendedColors.onBlueContainer, + color: extended.onBlueContainer, ), tags: { 'bold': StyledTextTag( - style: const TextStyle( - fontWeight: .bold, - ), + style: const TextStyle(fontWeight: .bold), ), }, ), @@ -118,9 +114,7 @@ class ThunderstormCard extends StatelessWidget { builder: (context) => ThunderstormPage(item: history), ), ), - splashColor: context.theme.extendedColors.blue.withValues( - alpha: 0.2, - ), + splashColor: extended.blue.withValues(alpha: 0.2), borderRadius: .circular(16), ), ), diff --git a/lib/app/home/_widgets/wind_card.dart b/lib/app/home/_widgets/wind_card.dart index ec2e118fb..1252ae142 100644 --- a/lib/app/home/_widgets/wind_card.dart +++ b/lib/app/home/_widgets/wind_card.dart @@ -404,6 +404,7 @@ class _WindCardState extends State with WidgetsBindingObserver, RouteA void _showMagneticFieldInfo(BuildContext context) { final hasDanger = _compassHasDanger; final hasWarning = _compassHasWarning; + final statusColor = _compassStatusColor; final valueText = _compassAccuracy < 0 ? '無法測量'.i18n : '±${_compassAccuracy.toStringAsFixed(1)}°'; @@ -417,11 +418,7 @@ class _WindCardState extends State with WidgetsBindingObserver, RouteA : hasWarning ? Symbols.warning_rounded : Symbols.check_circle_rounded, - color: hasDanger - ? Colors.red - : hasWarning - ? Colors.orange - : Colors.green, + color: statusColor, size: 48, ), title: Text( @@ -446,11 +443,7 @@ class _WindCardState extends State with WidgetsBindingObserver, RouteA valueText, style: context.texts.headlineSmall?.copyWith( fontWeight: .bold, - color: hasDanger - ? Colors.red - : hasWarning - ? Colors.orange - : Colors.green, + color: statusColor, ), ), const SizedBox(height: 12), @@ -465,9 +458,7 @@ class _WindCardState extends State with WidgetsBindingObserver, RouteA Container( padding: const .all(12), decoration: BoxDecoration( - color: (hasDanger ? Colors.red : Colors.orange).withValues( - alpha: 0.1, - ), + color: statusColor.withValues(alpha: 0.1), borderRadius: .circular(8), ), child: Text( diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index df5fd690d..58f1eb58f 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -96,36 +96,35 @@ class _HomePageState extends State with WidgetsBindingObserver { void _measureFirstCard() { WidgetsBinding.instance.addPostFrameCallback((_) { final cardContext = _firstCardKey.currentContext; - if (cardContext != null) { - final box = cardContext.findRenderObject() as RenderBox?; - if (box != null && box.hasSize) { - final height = box.size.height + 28; - final isFirstMeasure = _measuredFirstCardHeight == null; - if (isFirstMeasure || ((_measuredFirstCardHeight! - height).abs() > 1)) { - _sheetController.removeListener(_onSheetChanged); - _sheetController.dispose(); - - setState(() { - _measuredFirstCardHeight = height; - _sheetKey = UniqueKey(); - _sheetController = DraggableScrollableController(); - }); - - _sheetController.addListener(_onSheetChanged); - - if (isFirstMeasure && mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final screenHeight = context.dimension.height; - final targetSize = (height / screenHeight).clamp(0.25, 0.6); - _sheetController.animateTo( - targetSize, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - }); - } - } - } + if (cardContext == null) return; + final box = cardContext.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + + final height = box.size.height + 28; + final isFirstMeasure = _measuredFirstCardHeight == null; + if (!isFirstMeasure && (_measuredFirstCardHeight! - height).abs() <= 1) return; + + _sheetController.removeListener(_onSheetChanged); + _sheetController.dispose(); + + setState(() { + _measuredFirstCardHeight = height; + _sheetKey = UniqueKey(); + _sheetController = DraggableScrollableController(); + }); + + _sheetController.addListener(_onSheetChanged); + + if (isFirstMeasure && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final screenHeight = context.dimension.height; + final targetSize = (height / screenHeight).clamp(0.25, 0.6); + _sheetController.animateTo( + targetSize, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + }); } }); } @@ -841,25 +840,15 @@ class _HomePageState extends State with WidgetsBindingObserver { final currentSize = _sheetController.size; final velocity = details.primaryVelocity ?? 0; - double targetSnap; + final double targetSnap; if (velocity.abs() > 500) { - if (velocity > 0) { - targetSnap = - snapSizes.where((s) => s < currentSize).lastOrNull ?? snapSizes.first; - } else { - targetSnap = - snapSizes.where((s) => s > currentSize).firstOrNull ?? snapSizes.last; - } + targetSnap = velocity > 0 + ? (snapSizes.where((s) => s < currentSize).lastOrNull ?? snapSizes.first) + : (snapSizes.where((s) => s > currentSize).firstOrNull ?? snapSizes.last); } else { - targetSnap = snapSizes.first; - double minDist = (currentSize - targetSnap).abs(); - for (final snap in snapSizes) { - final dist = (currentSize - snap).abs(); - if (dist < minDist) { - minDist = dist; - targetSnap = snap; - } - } + targetSnap = snapSizes.reduce( + (a, b) => (currentSize - a).abs() < (currentSize - b).abs() ? a : b, + ); } _sheetController.animateTo( @@ -892,7 +881,8 @@ class _HomePageState extends State with WidgetsBindingObserver { return BlurredIconButton( icon: const Icon(Symbols.map_rounded), tooltip: '地圖', - onPressed: () => MapRoute(layers: layers.map((l) => l.name).join(',')).push(context), + onPressed: () => + MapRoute(layers: layers.map((l) => l.name).join(',')).push(context), elevation: 2, ); }, @@ -1006,6 +996,11 @@ class _StationChip extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = context.theme.brightness == .dark; + final borderColor = (isDark ? Colors.white : Colors.black).withValues(alpha: 0.25); + final labelColor = isDark ? Colors.white : const Color.fromARGB(255, 90, 90, 90); + final valueColor = isDark ? Colors.white : const Color.fromARGB(255, 60, 60, 60); + return ClipRRect( borderRadius: .circular(8), child: BackdropFilter( @@ -1015,12 +1010,7 @@ class _StationChip extends StatelessWidget { decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: .circular(8), - border: Border.all( - color: context.theme.brightness == .dark - ? Colors.white.withValues(alpha: 0.25) - : const Color.fromARGB(255, 0, 0, 0).withValues(alpha: 0.25), - width: 0.5, - ), + border: Border.all(color: borderColor, width: 0.5), ), child: Column( mainAxisSize: .min, @@ -1028,9 +1018,7 @@ class _StationChip extends StatelessWidget { Text( label, style: context.texts.labelSmall?.copyWith( - color: context.theme.brightness == .dark - ? Colors.white - : const Color.fromARGB(255, 90, 90, 90), + color: labelColor, fontWeight: .w700, fontSize: 9, ), @@ -1038,9 +1026,7 @@ class _StationChip extends StatelessWidget { Text( value, style: context.texts.bodySmall?.copyWith( - color: context.theme.brightness == .dark - ? Colors.white - : const Color.fromARGB(255, 60, 60, 60), + color: valueColor, fontWeight: .w600, fontSize: 12, ), diff --git a/lib/app/map/_lib/managers/lightning.dart b/lib/app/map/_lib/managers/lightning.dart index 70aeadcb0..1d4fe249f 100644 --- a/lib/app/map/_lib/managers/lightning.dart +++ b/lib/app/map/_lib/managers/lightning.dart @@ -114,10 +114,9 @@ class LightningMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.lightning(time); final layerId = MapLayerIds.lightning(time); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (!isSourceExists) { late final List lightningData; diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index 208903738..721b30cc3 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -59,7 +59,8 @@ class MonitorMapLayerManager extends MapLayerManager { static const double kLabelLineHeight = 1.2; /// Whether data has been received within the last 12 seconds. - bool get dataStatus => _dataStatus(); + bool get dataStatus => + (GlobalProviders.data.currentTime - (_lastDataReceivedTime ?? 0)) < 12000; /// The most recently measured round-trip latency in milliseconds. double get ping => _ping; @@ -81,6 +82,11 @@ class MonitorMapLayerManager extends MapLayerManager { String? _lastEewId; int? _lastEewSerial; + // Cached visibility states - avoid redundant setLayerVisibility platform calls. + bool? _lastRtsLayersVisible; + bool? _lastIntensityLayersVisible; + bool? _lastBoxLayerVisible; + late final String _rtsSourceId = MapSourceIds.rts(); late final String _rtsLayerId = MapLayerIds.rts(); late final String _intensitySourceId = MapSourceIds.intensity(); @@ -108,10 +114,6 @@ class MonitorMapLayerManager extends MapLayerManager { _setupBlinkTimer(); } - bool _dataStatus() { - return (GlobalProviders.data.currentTime - (_lastDataReceivedTime ?? 0)) < 12000; - } - /// The timestamp of the most recently processed RTS data packet. final currentRtsTime = ValueNotifier(GlobalProviders.data.syncTime); @@ -177,26 +179,18 @@ class MonitorMapLayerManager extends MapLayerManager { final hasEew = GlobalProviders.data.activeEew.isNotEmpty; final shouldBlinkEpicenter = hasEew; - // Boxes blinking - only toggle if conditions allow, otherwise hide - if (shouldBlinkBoxes) { - _isBoxVisible = !_isBoxVisible; - await controller.setLayerVisibility(_boxLayerId, _isBoxVisible); - } else { - _isBoxVisible = false; - await controller.setLayerVisibility(_boxLayerId, false); - } - - // Epicenter blinking - independent of boxes - if (shouldBlinkEpicenter) { - _isEpicenterVisible = !_isEpicenterVisible; - await controller.setLayerVisibility( - _epicenterLayerId, - _isEpicenterVisible, - ); - } else { - _isEpicenterVisible = false; - await controller.setLayerVisibility(_epicenterLayerId, false); - } + // Boxes blinking - toggle when conditions allow, otherwise hide. + // Skip the platform call when state didn't change. + final nextBoxVisible = shouldBlinkBoxes && !_isBoxVisible; + final nextEpicenterVisible = shouldBlinkEpicenter && !_isEpicenterVisible; + await Future.wait([ + if (nextBoxVisible != _isBoxVisible) + controller.setLayerVisibility(_boxLayerId, nextBoxVisible), + if (nextEpicenterVisible != _isEpicenterVisible) + controller.setLayerVisibility(_epicenterLayerId, nextEpicenterVisible), + ]); + _isBoxVisible = nextBoxVisible; + _isEpicenterVisible = nextEpicenterVisible; } catch (e, s) { TalkerManager.instance.error( 'MonitorMapLayerManager._blinkTimer', @@ -893,53 +887,40 @@ class MonitorMapLayerManager extends MapLayerManager { final hasIntensityData = (_cachedIntensityGeoJson?['features'] as List?)?.isNotEmpty ?? false; final hasBoxData = (_cachedBoxGeoJson?['features'] as List?)?.isNotEmpty ?? false; + final rtsVisible = hasRtsData && !hasBox; + final intensityVisible = hasIntensityData && hasBox; + final boxVisible = hasBoxData && hasBox; + await Future.wait([ if (hasRtsData && existingSources.contains(_rtsSourceId)) controller.setGeoJsonSource(_rtsSourceId, _cachedRtsGeoJson!), if (hasIntensityData && existingSources.contains(_intensitySourceId)) - controller.setGeoJsonSource( - _intensitySourceId, - _cachedIntensityGeoJson!, - ), + controller.setGeoJsonSource(_intensitySourceId, _cachedIntensityGeoJson!), if (hasIntensityData && existingSources.contains(_intensity0SourceId)) - controller.setGeoJsonSource( - _intensity0SourceId, - _cachedIntensityGeoJson!, - ), + controller.setGeoJsonSource(_intensity0SourceId, _cachedIntensityGeoJson!), if (hasBoxData && existingSources.contains(_boxSourceId)) controller.setGeoJsonSource(_boxSourceId, _cachedBoxGeoJson!), - controller.setLayerVisibility(_rtsLayerId, hasRtsData && !hasBox), - controller.setLayerVisibility( - '$_rtsLayerId-label-id', - hasRtsData && !hasBox, - ), - controller.setLayerVisibility( - '$_rtsLayerId-label-loc', - hasRtsData && !hasBox, - ), - controller.setLayerVisibility( - '$_rtsLayerId-label-detail-i', - hasRtsData && !hasBox, - ), - controller.setLayerVisibility( - '$_rtsLayerId-label-detail-pga', - hasRtsData && !hasBox, - ), - controller.setLayerVisibility( - '$_rtsLayerId-label-detail-pgv', - hasRtsData && !hasBox, - ), - controller.setLayerVisibility( - _intensityLayerId, - hasIntensityData && hasBox, - ), - controller.setLayerVisibility( - _intensity0LayerId, - hasIntensityData && hasBox, - ), - controller.setLayerVisibility(_boxLayerId, hasBoxData && hasBox), + // RTS layers share visibility - skip the 6 platform calls if unchanged. + if (rtsVisible != _lastRtsLayersVisible) ...[ + controller.setLayerVisibility(_rtsLayerId, rtsVisible), + controller.setLayerVisibility('$_rtsLayerId-label-id', rtsVisible), + controller.setLayerVisibility('$_rtsLayerId-label-loc', rtsVisible), + controller.setLayerVisibility('$_rtsLayerId-label-detail-i', rtsVisible), + controller.setLayerVisibility('$_rtsLayerId-label-detail-pga', rtsVisible), + controller.setLayerVisibility('$_rtsLayerId-label-detail-pgv', rtsVisible), + ], + if (intensityVisible != _lastIntensityLayersVisible) ...[ + controller.setLayerVisibility(_intensityLayerId, intensityVisible), + controller.setLayerVisibility(_intensity0LayerId, intensityVisible), + ], + if (boxVisible != _lastBoxLayerVisible) + controller.setLayerVisibility(_boxLayerId, boxVisible), ]); + + _lastRtsLayersVisible = rtsVisible; + _lastIntensityLayersVisible = intensityVisible; + _lastBoxLayerVisible = boxVisible; } catch (e, s) { TalkerManager.instance.error( 'MonitorMapLayerManager._updateRtsFromCache', @@ -1190,6 +1171,12 @@ class MonitorMapLayerManager extends MapLayerManager { } didSetup = false; + // Reset cached visibility so the next setup re-applies it. + _lastRtsLayersVisible = null; + _lastIntensityLayersVisible = null; + _lastBoxLayerVisible = null; + _isBoxVisible = true; + _isEpicenterVisible = true; } @override diff --git a/lib/app/map/_lib/managers/precipitation.dart b/lib/app/map/_lib/managers/precipitation.dart index bf14fdd0d..0132c98c2 100644 --- a/lib/app/map/_lib/managers/precipitation.dart +++ b/lib/app/map/_lib/managers/precipitation.dart @@ -137,13 +137,14 @@ class PrecipitationMapLayerManager extends MapLayerManager { final showLayerId = '$layerId-$interval'; final hideLayerId = '$layerId-${currentPrecipitationInterval.value}'; - await controller.setLayerVisibility(showLayerId, true); - await controller.setLayerVisibility('$showLayerId-label-name', true); - await controller.setLayerVisibility('$showLayerId-label-value', true); - - await controller.setLayerVisibility(hideLayerId, false); - await controller.setLayerVisibility('$hideLayerId-label-name', false); - await controller.setLayerVisibility('$hideLayerId-label-value', false); + await Future.wait([ + controller.setLayerVisibility(showLayerId, true), + controller.setLayerVisibility('$showLayerId-label-name', true), + controller.setLayerVisibility('$showLayerId-label-value', true), + controller.setLayerVisibility(hideLayerId, false), + controller.setLayerVisibility('$hideLayerId-label-name', false), + controller.setLayerVisibility('$hideLayerId-label-value', false), + ]); currentPrecipitationInterval.value = interval; } catch (e, s) { @@ -208,10 +209,9 @@ class PrecipitationMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.precipitation(time); final layerId = MapLayerIds.precipitation(time); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (!isSourceExists) { late final List rainData; @@ -235,6 +235,9 @@ class PrecipitationMapLayerManager extends MapLayerManager { } if (!isLayerExists) { + final activeInterval = currentPrecipitationInterval.value; + final outlineHex = colors.outlineVariant.toHexStringRGB(); + final onSurfaceVariantHex = colors.onSurfaceVariant.toHexStringRGB(); final Map properties = { for (final interval in precipitationIntervals) ...({ @@ -266,23 +269,23 @@ class PrecipitationMapLayerManager extends MapLayerManager { ], circleRadius: kCircleIconSize, circleOpacity: 0.75, - circleStrokeColor: colors.outlineVariant.toHexStringRGB(), + circleStrokeColor: outlineHex, circleStrokeWidth: 0.5, circleStrokeOpacity: 0.75, - visibility: interval == currentPrecipitationInterval.value ? 'visible' : 'none', + visibility: interval == activeInterval ? 'visible' : 'none', ), '$interval-label-name': SymbolLayerProperties( textField: [Expressions.get, 'name'], textSize: 10, - textColor: colors.onSurfaceVariant.toHexStringRGB(), - textHaloColor: colors.outlineVariant.toHexStringRGB(), + textColor: onSurfaceVariantHex, + textHaloColor: outlineHex, textHaloWidth: 1, textFont: ['Noto Sans TC Bold'], textOffset: [0, kLabelBaseOffset], textAnchor: 'top', textAllowOverlap: true, textIgnorePlacement: true, - visibility: interval == currentPrecipitationInterval.value ? 'visible' : 'none', + visibility: interval == activeInterval ? 'visible' : 'none', ), '$interval-label-value': SymbolLayerProperties( textField: [ @@ -291,15 +294,15 @@ class PrecipitationMapLayerManager extends MapLayerManager { 'mm', ], textSize: 10, - textColor: colors.onSurfaceVariant.toHexStringRGB(), - textHaloColor: colors.outlineVariant.toHexStringRGB(), + textColor: onSurfaceVariantHex, + textHaloColor: outlineHex, textHaloWidth: 1, textFont: ['Noto Sans TC Bold'], - textOffset: [0, kLabelBaseOffset + kLabelLineHeight * 1], + textOffset: [0, kLabelBaseOffset + kLabelLineHeight], textAnchor: 'top', textAllowOverlap: true, textIgnorePlacement: true, - visibility: interval == currentPrecipitationInterval.value ? 'visible' : 'none', + visibility: interval == activeInterval ? 'visible' : 'none', ), }), }; @@ -339,14 +342,14 @@ class PrecipitationMapLayerManager extends MapLayerManager { if (!visible) return; final layerId = MapLayerIds.precipitation(currentPrecipitationTime.value); - final hideLayerId = '$layerId-${currentPrecipitationInterval.value}'; - final hideNameLayerId = '$layerId-${currentPrecipitationInterval.value}-label-name'; - final hideValueLayerId = '$layerId-${currentPrecipitationInterval.value}-label-value'; + final base = '$layerId-${currentPrecipitationInterval.value}'; try { - await controller.setLayerVisibility(hideLayerId, false); - await controller.setLayerVisibility(hideNameLayerId, false); - await controller.setLayerVisibility(hideValueLayerId, false); + await Future.wait([ + controller.setLayerVisibility(base, false), + controller.setLayerVisibility('$base-label-name', false), + controller.setLayerVisibility('$base-label-value', false), + ]); visible = false; } catch (e, s) { @@ -359,14 +362,14 @@ class PrecipitationMapLayerManager extends MapLayerManager { if (visible) return; final layerId = MapLayerIds.precipitation(currentPrecipitationTime.value); - final showLayerId = '$layerId-${currentPrecipitationInterval.value}'; - final showNameLayerId = '$layerId-${currentPrecipitationInterval.value}-label-name'; - final showValueLayerId = '$layerId-${currentPrecipitationInterval.value}-label-value'; + final base = '$layerId-${currentPrecipitationInterval.value}'; try { - await controller.setLayerVisibility(showLayerId, true); - await controller.setLayerVisibility(showNameLayerId, true); - await controller.setLayerVisibility(showValueLayerId, true); + await Future.wait([ + controller.setLayerVisibility(base, true), + controller.setLayerVisibility('$base-label-name', true), + controller.setLayerVisibility('$base-label-value', true), + ]); await _focus(); diff --git a/lib/app/map/_lib/managers/radar.dart b/lib/app/map/_lib/managers/radar.dart index 755fd462e..986c21345 100644 --- a/lib/app/map/_lib/managers/radar.dart +++ b/lib/app/map/_lib/managers/radar.dart @@ -210,8 +210,9 @@ class RadarMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.radar(time); final layerId = MapLayerIds.radar(time); - final isSourceExists = (await controller.getSourceIds()).contains(sourceId); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (!isSourceExists) { final properties = RasterSourceProperties( @@ -260,28 +261,22 @@ class RadarMapLayerManager extends MapLayerManager { final layersToPreload = []; for (int i = 1; i <= 3; i++) { - if (currentIndex - i >= 0) { - layersToPreload.add(radarList[currentIndex - i]); - } - if (currentIndex + i < radarList.length) { - layersToPreload.add(radarList[currentIndex + i]); - } + final prev = currentIndex - i; + final next = currentIndex + i; + if (prev >= 0) layersToPreload.add(radarList[prev]); + if (next < radarList.length) layersToPreload.add(radarList[next]); } - for (final time in layersToPreload) { - if (!_preloadedLayers.contains(time)) { + await Future.wait( + layersToPreload.where((t) => !_preloadedLayers.contains(t)).map((time) async { try { await _setupAndShowLayer(time); await _hideLayer(time); } catch (e, s) { - TalkerManager.instance.error( - 'Failed to preload radar layer: $time', - e, - s, - ); + TalkerManager.instance.error('Failed to preload radar layer: $time', e, s); } - } - } + }), + ); } /// Starts auto-play if stopped, or stops it if currently running. @@ -1082,11 +1077,9 @@ class _RadarProgressBar extends StatelessWidget { return const SizedBox.shrink(); } - double progress = 0.0; - if (startIndex != endIndex) { - progress = (startIndex - currentIndex) / (startIndex - endIndex); - progress = progress.clamp(0.0, 1.0); - } + final progress = startIndex == endIndex + ? 0.0 + : ((startIndex - currentIndex) / (startIndex - endIndex)).clamp(0.0, 1.0); return Row( spacing: 8, diff --git a/lib/app/map/_lib/managers/report.dart b/lib/app/map/_lib/managers/report.dart index d39677bf7..d4bb4aacd 100644 --- a/lib/app/map/_lib/managers/report.dart +++ b/lib/app/map/_lib/managers/report.dart @@ -224,10 +224,9 @@ class ReportMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.report(); final layerId = MapLayerIds.report(); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (isSourceExists && isLayerExists) return; @@ -382,10 +381,9 @@ class ReportMapLayerManager extends MapLayerManager { report.time.millisecondsSinceEpoch.toString(), ); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (isSourceExists && isLayerExists) return; @@ -437,10 +435,9 @@ class ReportMapLayerManager extends MapLayerManager { report.time.millisecondsSinceEpoch.toString(), ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (isLayerExists) { await controller.removeLayer(layerId); diff --git a/lib/app/map/_lib/managers/temperature.dart b/lib/app/map/_lib/managers/temperature.dart index 8c66d0476..2b30fe8b0 100644 --- a/lib/app/map/_lib/managers/temperature.dart +++ b/lib/app/map/_lib/managers/temperature.dart @@ -163,10 +163,9 @@ class TemperatureMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.temperature(time); final layerId = MapLayerIds.temperature(time); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (!isSourceExists) { late final List weatherData; @@ -263,7 +262,7 @@ class TemperatureMapLayerManager extends MapLayerManager { textHaloColor: colors.outlineVariant.toHexStringRGB(), textHaloWidth: 1, textFont: ['Noto Sans TC Bold'], - textOffset: [0, kLabelBaseOffset + kLabelLineHeight * 1], + textOffset: [0, kLabelBaseOffset + kLabelLineHeight], textAnchor: 'top', textAllowOverlap: true, textIgnorePlacement: true, @@ -307,9 +306,11 @@ class TemperatureMapLayerManager extends MapLayerManager { final layerId = MapLayerIds.temperature(currentTemperatureTime.value); try { - await controller.setLayerVisibility(layerId, false); - await controller.setLayerVisibility('$layerId-label-name', false); - await controller.setLayerVisibility('$layerId-label-value', false); + await Future.wait([ + controller.setLayerVisibility(layerId, false), + controller.setLayerVisibility('$layerId-label-name', false), + controller.setLayerVisibility('$layerId-label-value', false), + ]); visible = false; } catch (e, s) { @@ -324,9 +325,11 @@ class TemperatureMapLayerManager extends MapLayerManager { final layerId = MapLayerIds.temperature(currentTemperatureTime.value); try { - await controller.setLayerVisibility(layerId, true); - await controller.setLayerVisibility('$layerId-label-name', true); - await controller.setLayerVisibility('$layerId-label-value', true); + await Future.wait([ + controller.setLayerVisibility(layerId, true), + controller.setLayerVisibility('$layerId-label-name', true), + controller.setLayerVisibility('$layerId-label-value', true), + ]); await _focus(); diff --git a/lib/app/map/_lib/managers/tsunami.dart b/lib/app/map/_lib/managers/tsunami.dart index 4e257fb94..55f0ef305 100644 --- a/lib/app/map/_lib/managers/tsunami.dart +++ b/lib/app/map/_lib/managers/tsunami.dart @@ -20,7 +20,6 @@ import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/extensions/number.dart'; import 'package:dpip/utils/log.dart'; import 'package:dpip/widgets/map/map.dart'; -import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:i18n_extension/i18n_extension.dart'; import 'package:intl/intl.dart'; @@ -45,10 +44,9 @@ class TsunamiMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.tsunami(currentTsunami.value); final layerId = MapLayerIds.tsunami(currentTsunami.value); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (isSourceExists && isLayerExists) return; @@ -173,23 +171,12 @@ class _TsunamiMapLayerSheetState extends State { setState(() {}); } - String heightToColor(int height) { - Color color; - if (height == 3) { - color = const Color(0xFFE543FF); - } else if (height == 2) { - color = const Color(0xFFC90000); - } else if (height == 1) { - color = const Color(0xFFFFC900); - } else { - color = const Color(0xFF00AAFF); - } - return '#${color.hex}'; - } - - DateTime _convertTimestamp(int timestamp) { - return DateTime.fromMillisecondsSinceEpoch(timestamp); - } + String heightToColor(int height) => switch (height) { + 3 => '#E543FF', + 2 => '#C90000', + 1 => '#FFC900', + _ => '#00AAFF', + }; Future addTsunamiObservationPoints(Tsunami tsunami) async { await _mapController.removeLayer('tsunami-actual-circles'); @@ -238,7 +225,7 @@ class _TsunamiMapLayerSheetState extends State { 'waveHeight': actualStation.waveHeight, 'arrivalTime': DateFormat( 'MM/dd HH:mm', - ).format(_convertTimestamp(actualStation.arrivalTime)), + ).format(DateTime.fromMillisecondsSinceEpoch(actualStation.arrivalTime)), }, 'geometry': { 'type': 'Point', @@ -377,11 +364,11 @@ class _TsunamiMapLayerSheetState extends State { _tsunami_id = id.split('-')[0]; _tsunami_serial = int.parse(id.split('-')[1]); tsunami = await ExpTech().getTsunami(id); - (tsunami?.status == 0) - ? tsunamiStatus = '發布' - : (tsunami?.status == 1) - ? tsunamiStatus = '更新' - : tsunamiStatus = '解除'; + tsunamiStatus = switch (tsunami?.status) { + 0 => '發布', + 1 => '更新', + _ => '解除', + }; final List options = generateTsunamiOptions(); if (options.isNotEmpty && _selectedOption == null) { diff --git a/lib/app/map/_lib/managers/wind.dart b/lib/app/map/_lib/managers/wind.dart index f5ccb5b12..21d2d4a3d 100644 --- a/lib/app/map/_lib/managers/wind.dart +++ b/lib/app/map/_lib/managers/wind.dart @@ -120,10 +120,9 @@ class WindMapLayerManager extends MapLayerManager { final sourceId = MapSourceIds.wind(time); final layerId = MapLayerIds.wind(time); - final isSourceExists = (await controller.getSourceIds()).contains( - sourceId, - ); - final isLayerExists = (await controller.getLayerIds()).contains(layerId); + final ids = await Future.wait([controller.getSourceIds(), controller.getLayerIds()]); + final isSourceExists = ids[0].contains(sourceId); + final isLayerExists = ids[1].contains(layerId); if (!isSourceExists) { late final List weatherData; @@ -196,7 +195,7 @@ class WindMapLayerManager extends MapLayerManager { textHaloColor: colors.outlineVariant.toHexStringRGB(), textHaloWidth: 1, textFont: ['Noto Sans TC Bold'], - textOffset: [0, kLabelBaseOffset + kLabelLineHeight * 1], + textOffset: [0, kLabelBaseOffset + kLabelLineHeight], textAnchor: 'top', textAllowOverlap: true, textIgnorePlacement: true, @@ -239,13 +238,12 @@ class WindMapLayerManager extends MapLayerManager { final layerId = MapLayerIds.wind(currentWindTime.value); - final nameLayerId = '$layerId-label-name'; - final valueLayerId = '$layerId-label-value'; - try { - await controller.setLayerVisibility(layerId, false); - await controller.setLayerVisibility(nameLayerId, false); - await controller.setLayerVisibility(valueLayerId, false); + await Future.wait([ + controller.setLayerVisibility(layerId, false), + controller.setLayerVisibility('$layerId-label-name', false), + controller.setLayerVisibility('$layerId-label-value', false), + ]); visible = false; } catch (e, s) { @@ -259,13 +257,12 @@ class WindMapLayerManager extends MapLayerManager { final layerId = MapLayerIds.wind(currentWindTime.value); - final nameLayerId = '$layerId-label-name'; - final valueLayerId = '$layerId-label-value'; - try { - await controller.setLayerVisibility(layerId, true); - await controller.setLayerVisibility(nameLayerId, true); - await controller.setLayerVisibility(valueLayerId, true); + await Future.wait([ + controller.setLayerVisibility(layerId, true), + controller.setLayerVisibility('$layerId-label-name', true), + controller.setLayerVisibility('$layerId-label-value', true), + ]); visible = true; diff --git a/lib/app/map/_lib/utils.dart b/lib/app/map/_lib/utils.dart index 7d7128c7a..3a48af86f 100644 --- a/lib/app/map/_lib/utils.dart +++ b/lib/app/map/_lib/utils.dart @@ -98,6 +98,8 @@ bool isValidLayerCombination(Set layers) { return false; } +String _scopedId(String base, String? suffix) => suffix == null ? base : '$base-$suffix'; + /// Provides stable MapLibre GeoJSON source ID strings for each data type. /// /// Pass an optional time or code suffix to obtain a time-specific ID, @@ -106,35 +108,34 @@ class MapSourceIds { const MapSourceIds._(); /// Returns the source ID for radar data, optionally scoped to [time]. - static String radar([String? time]) => time == null ? 'radar' : 'radar-$time'; + static String radar([String? time]) => _scopedId('radar', time); /// Returns the source ID for earthquake report data, optionally scoped to /// [time]. - static String report([String? time]) => time == null ? 'report' : 'report-$time'; + static String report([String? time]) => _scopedId('report', time); /// Returns the source ID for tsunami data, optionally scoped to [code]. - static String tsunami([String? code]) => code == null ? 'tsunami' : 'tsunami-$code'; + static String tsunami([String? code]) => _scopedId('tsunami', code); /// Returns the source ID for real-time seismograph (RTS) data, optionally /// scoped to [time]. - static String rts([String? time]) => time == null ? 'rts' : 'rts-$time'; + static String rts([String? time]) => _scopedId('rts', time); /// Returns the source ID for EEW data, optionally scoped to [code]. - static String eew([String? code]) => code == null ? 'eew' : 'eew-$code'; + static String eew([String? code]) => _scopedId('eew', code); /// Returns the source ID for temperature data, optionally scoped to [time]. - static String temperature([String? time]) => time == null ? 'temperature' : 'temperature-$time'; + static String temperature([String? time]) => _scopedId('temperature', time); /// Returns the source ID for precipitation data, optionally scoped to /// [time]. - static String precipitation([String? time]) => - time == null ? 'precipitation' : 'precipitation-$time'; + static String precipitation([String? time]) => _scopedId('precipitation', time); /// Returns the source ID for wind data, optionally scoped to [time]. - static String wind([String? time]) => time == null ? 'wind' : 'wind-$time'; + static String wind([String? time]) => _scopedId('wind', time); /// Returns the source ID for lightning data, optionally scoped to [time]. - static String lightning([String? time]) => time == null ? 'lightning' : 'lightning-$time'; + static String lightning([String? time]) => _scopedId('lightning', time); /// Returns the source ID for seismic intensity polygon data. static String intensity() => 'intensity'; @@ -154,34 +155,33 @@ class MapLayerIds { const MapLayerIds._(); /// Returns the layer ID for radar data, optionally scoped to [time]. - static String radar([String? time]) => time == null ? 'radar' : 'radar-$time'; + static String radar([String? time]) => _scopedId('radar', time); /// Returns the layer ID for earthquake report data, optionally scoped to /// [time]. - static String report([String? time]) => time == null ? 'report' : 'report-$time'; + static String report([String? time]) => _scopedId('report', time); /// Returns the layer ID for tsunami data, optionally scoped to [code]. - static String tsunami([String? code]) => code == null ? 'tsunami' : 'tsunami-$code'; + static String tsunami([String? code]) => _scopedId('tsunami', code); /// Returns the layer ID for real-time seismograph (RTS) data, optionally /// scoped to [time]. - static String rts([String? time]) => time == null ? 'rts' : 'rts-$time'; + static String rts([String? time]) => _scopedId('rts', time); /// Returns the layer ID for EEW data, optionally scoped to [code]. - static String eew([String? code]) => code == null ? 'eew' : 'eew-$code'; + static String eew([String? code]) => _scopedId('eew', code); /// Returns the layer ID for temperature data, optionally scoped to [time]. - static String temperature([String? time]) => time == null ? 'temperature' : 'temperature-$time'; + static String temperature([String? time]) => _scopedId('temperature', time); /// Returns the layer ID for precipitation data, optionally scoped to [time]. - static String precipitation([String? time]) => - time == null ? 'precipitation' : 'precipitation-$time'; + static String precipitation([String? time]) => _scopedId('precipitation', time); /// Returns the layer ID for wind data, optionally scoped to [time]. - static String wind([String? time]) => time == null ? 'wind' : 'wind-$time'; + static String wind([String? time]) => _scopedId('wind', time); /// Returns the layer ID for lightning data, optionally scoped to [time]. - static String lightning([String? time]) => time == null ? 'lightning' : 'lightning-$time'; + static String lightning([String? time]) => _scopedId('lightning', time); /// Returns the layer ID for seismic intensity polygon data. static String intensity() => 'intensity'; @@ -197,15 +197,12 @@ class MapLayerIds { /// /// Preserves layers listed in [BaseMapLayerIds.values] and the `map` source. Future cleanupMap(MapLibreMapController controller) async { - final layerIds = (await controller.getLayerIds()).cast() - ..removeWhere((v) => BaseMapLayerIds.values().contains(v)); - final sourceIds = (await controller.getSourceIds())..removeWhere((v) => v == 'map'); - - for (final layerId in layerIds) { - await controller.removeLayer(layerId); - } - - for (final sourceId in sourceIds) { - await controller.removeSource(sourceId); - } + final ids = await Future.wait([controller.getLayerIds(), controller.getSourceIds()]); + final baseLayers = BaseMapLayerIds.values().toSet(); + final layerIds = ids[0].cast().where((v) => !baseLayers.contains(v)); + final sourceIds = ids[1].cast().where((v) => v != 'map'); + + // Layers must be removed before their sources. + await Future.wait(layerIds.map(controller.removeLayer)); + await Future.wait(sourceIds.map(controller.removeSource)); } diff --git a/lib/app/map/_widgets/map_legend.dart b/lib/app/map/_widgets/map_legend.dart index e0658fa9a..94ea5b5bd 100644 --- a/lib/app/map/_widgets/map_legend.dart +++ b/lib/app/map/_widgets/map_legend.dart @@ -92,12 +92,13 @@ class ColorLegend extends StatelessWidget { @override Widget build(BuildContext context) { final items = reverse ? this.items.reversed.toList() : this.items; - final visibleItems = items.where((item) => !item.hidden).toList(); + final visibleCount = items.where((item) => !item.hidden).length; + int visibleCounter = 0; final children = items.mapIndexed((index, item) { if (item.hidden) return const SizedBox.shrink(); - final visibleIndex = visibleItems.indexOf(item); + final visibleIndex = visibleCounter++; final previous = index == 0 ? null : items.elementAtOrNull(index - 1); final next = items.elementAtOrNull(index + 1); @@ -134,7 +135,7 @@ class ColorLegend extends StatelessWidget { ), borderRadius: visibleIndex == 0 ? const .vertical(top: Radius.circular(8)) - : (visibleIndex + 1) == visibleItems.length + : (visibleIndex + 1) == visibleCount ? const .vertical(bottom: Radius.circular(8)) : null, ), diff --git a/lib/app/map/page.dart b/lib/app/map/page.dart index 83ce0e586..4946772a7 100644 --- a/lib/app/map/page.dart +++ b/lib/app/map/page.dart @@ -62,8 +62,14 @@ class MapPage extends StatefulWidget { /// Optional configuration controlling the initial layer and report state. final MapPageOptions? options; + /// Whether to display the floating back button on the top-left. + /// + /// Set to `false` when embedding the map as a shell tab where the bottom + /// nav already provides navigation. + final bool showBackButton; + /// Creates a [MapPage] with optional [options]. - const MapPage({super.key, this.options}); + const MapPage({super.key, this.options, this.showBackButton = true}); /// Returns the route path for this page, including any query parameters /// derived from [options]. @@ -148,31 +154,12 @@ class _MapPageState extends State with TickerProviderStateMixin { } void _setupWeatherLayerTimeSync() { - final temperatureManager = getLayerManager( - MapLayer.temperature, - ); - temperatureManager?.onTimeChanged = (time) { - syncTimeToRadar(time); - }; - - final precipitationManager = getLayerManager( - MapLayer.precipitation, - ); - precipitationManager?.onTimeChanged = (time) { - syncTimeToRadar(time); - }; - - final windManager = getLayerManager(MapLayer.wind); - windManager?.onTimeChanged = (time) { - syncTimeToRadar(time); - }; - - final lightningManager = getLayerManager( - MapLayer.lightning, - ); - lightningManager?.onTimeChanged = (time) { - syncTimeToRadar(time); - }; + getLayerManager(MapLayer.temperature)?.onTimeChanged = + syncTimeToRadar; + getLayerManager(MapLayer.precipitation)?.onTimeChanged = + syncTimeToRadar; + getLayerManager(MapLayer.wind)?.onTimeChanged = syncTimeToRadar; + getLayerManager(MapLayer.lightning)?.onTimeChanged = syncTimeToRadar; } Future _syncRadarTimeOnCombination(MapLayer newLayer) async { @@ -284,13 +271,14 @@ class _MapPageState extends State with TickerProviderStateMixin { Future setLayers(Set layers) async { if (!mounted) return; - for (final layer in layers) { - final manager = _managers[layer]; - if (manager != null) { + await Future.wait( + layers.map((layer) async { + final manager = _managers[layer]; + if (manager == null) return; if (!manager.didSetup) await manager.setup(); await manager.show(); - } - } + }), + ); setState(() => _activeLayers = layers); } @@ -385,7 +373,7 @@ class _MapPageState extends State with TickerProviderStateMixin { onLayerChanged: toggleLayer, onBaseMapChanged: setBaseMapType, ), - PositionedBackButton(onPressed: _handleBack), + if (widget.showBackButton) PositionedBackButton(onPressed: _handleBack), ..._activeLayers.map((layer) { final manager = _managers[layer]; if (manager != null) { diff --git a/lib/app/new_home/_models/home_model.dart b/lib/app/new_home/_models/home_model.dart new file mode 100644 index 000000000..abd9de420 --- /dev/null +++ b/lib/app/new_home/_models/home_model.dart @@ -0,0 +1,136 @@ +/// Provider model for the home page weather data and temporary location override. +library; + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:dpip/api/model/history/history.dart'; +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Manages weather data and a temporary location override for the home page. +/// +/// Pass the app-level [SettingsLocationModel] at construction; the model +/// subscribes to it internally and re-fetches whenever the persisted location +/// changes (unless a temporary override is active). +/// +/// Call [setTemporaryCode] to temporarily show weather for a different location. +/// Call [startAutoRefresh] once on page init to begin a 30-minute refresh cycle. +class HomeModel extends ChangeNotifier { + static const _autoRefreshInterval = Duration(minutes: 30); + + final SettingsLocationModel _settingsLocation; + String? _temporaryCode; + RealtimeWeather? _weather; + List _alerts = const []; + Map? _forecast; + Timer? _autoRefreshTimer; + + /// Creates a [HomeModel] backed by [settingsLocation]. + /// + /// Attaches a listener so the model re-fetches automatically when the + /// persisted location changes. + HomeModel(this._settingsLocation) { + _settingsLocation.addListener(_onSettingsLocationChanged); + } + + void _onSettingsLocationChanged() { + if (_temporaryCode == null) _doRefresh(); + } + + /// Runs [task] and logs failures under [tag]; returns `null` on error. + static Future _safe(String tag, Future Function() task) async { + try { + return await task(); + } catch (e) { + TalkerManager.instance.error('HomeModel $tag', e); + return null; + } + } + + Future _doRefresh() async { + final code = _temporaryCode ?? _settingsLocation.code; + final loc = code != null ? Global.location[code] : null; + final lat = loc?.lat ?? _settingsLocation.coordinates?.latitude; + final lon = loc?.lng ?? _settingsLocation.coordinates?.longitude; + if (lat == null || lon == null) return; + + // Fetch weather + alerts + forecast in parallel. + final results = await Future.wait([ + _safe('weather', () => Global.api.getWeatherRealtimeByCoords(lat, lon)), + if (code != null) _safe('alerts', () => Global.api.getRealtimeRegion(code)) else Future.value(null), + if (code != null) _safe('forecast', () => Global.api.getWeatherForecast(code)) else Future.value(null), + ]); + + if (results[0] is RealtimeWeather) _weather = results[0] as RealtimeWeather; + _alerts = results[1] is List + ? (results[1]! as List).sorted((a, b) => b.time.send.compareTo(a.time.send)) + : const []; + if (results[2] is Map) _forecast = results[2] as Map; + notifyListeners(); + } + + /// The most recently fetched weather data, or `null` if not yet loaded. + RealtimeWeather? get weather => _weather; + + /// The most recent realtime alerts for the active location, sorted newest first. + List get alerts => _alerts; + + /// The most recent thunderstorm alert, or `null` when none active. + History? get thunderstorm => + _alerts.firstWhereOrNull((e) => e.type == HistoryType.thunderstorm); + + /// The 24-hour weather forecast for the active location, or `null` if missing. + Map? get forecast => _forecast; + + /// The currently active temporary location code, or `null` when unset. + String? get temporaryCode => _temporaryCode; + + /// Temporarily overrides the location to [code] and refreshes weather data. + /// + /// Pass `null` to clear the override and revert to the persisted location. + void setTemporaryCode(String? code) { + if (_temporaryCode == code) return; + _temporaryCode = code; + notifyListeners(); + _doRefresh(); + } + + /// Manually triggers a weather data refresh. + Future manualRefresh() => _doRefresh(); + + /// Starts the 30-minute auto-refresh timer. + /// + /// Safe to call multiple times; cancels any existing timer first. + void startAutoRefresh() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = Timer.periodic(_autoRefreshInterval, (_) => _doRefresh()); + _doRefresh(); + } + + /// Cancels the auto-refresh timer. + void stopAutoRefresh() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + } + + @override + void dispose() { + _settingsLocation.removeListener(_onSettingsLocationChanged); + stopAutoRefresh(); + super.dispose(); + } +} + +/// Extension on [BuildContext] for ergonomic [HomeModel] access. +extension HomeModelExtension on BuildContext { + /// Watches [HomeModel] and rebuilds the calling widget when it notifies. + HomeModel get useHome => watch(); + + /// Reads [HomeModel] without subscribing to updates. + HomeModel get home => read(); +} diff --git a/lib/app/new_home/_models/weather_params.dart b/lib/app/new_home/_models/weather_params.dart new file mode 100644 index 000000000..5bd47157b --- /dev/null +++ b/lib/app/new_home/_models/weather_params.dart @@ -0,0 +1,77 @@ +/// Continuous weights derived from realtime weather data, used to drive the +/// procedural sky background. +library; + +import 'package:dpip/api/model/weather_schema.dart'; + +/// Resolves a time-of-day scene index for the sky shader. +/// +/// `0` = day, `1` = night, `2` = dawn, `3` = sunset. +int resolveSkyScene(int hour) { + if (hour < 5 || hour >= 19) return 1; + if (hour < 7) return 2; + if (hour >= 17) return 3; + return 0; +} + +/// Cloud coverage in `[0, 1]`. +/// +/// Combines weather code (`2xx` partly cloudy = `0.50`, `3xx` overcast = `0.85`) +/// with a humidity boost above 60% and a small extra weight for cold humid air +/// (low temperature with high humidity tends to thicken cloud). +double cloudWeight(RealtimeWeatherData? d) { + if (d == null) return 0; + final base = switch (d.weatherCode ~/ 100) { + 2 => 0.50, + 3 => 0.85, + _ => 0.0, + }; + final humidityBoost = ((d.humidity - 60).clamp(0, 40) / 40.0) * 0.15; + final coldHumid = (d.temperature < 15 && d.humidity > 80) ? 0.10 : 0.0; + return (base + humidityBoost + coldHumid).clamp(0.0, 1.0); +} + +/// Rain intensity in `[0, 1]`. +/// +/// Takes the maximum of two signals: the weather-code precipitation type +/// (showers, thunderstorm, etc.) and the measured rainfall in mm/h, capped at +/// 5 mm/h for normalization. +double rainWeight(RealtimeWeatherData? d) { + if (d == null) return 0; + final fromCode = switch (d.weatherCode % 100) { + 3 || 4 => 0.55, + 6 || 11 => 0.45, + 7 || 12 => 0.40, + 14 || 17 => 0.65, + 18 || 19 => 0.85, + _ => 0.0, + }; + final fromAmount = (d.rain / 5.0).clamp(0.0, 1.0); + return fromCode > fromAmount ? fromCode : fromAmount; +} + +/// Wind strength in `[0, 1]` from the Beaufort scale, normalized at force 8. +double windWeight(RealtimeWeatherData? d) { + if (d == null) return 0.3; + return (d.wind.beaufort / 8.0).clamp(0.0, 1.0); +} + +/// Time-of-day salutation for the given [hour] (0–23, local time). +/// +/// Used in both the top-of-page [Greeting] and the [AssistantHint] hint text. +String greetingForHour(int hour) => switch (hour) { + < 6 => '夜深了', + < 12 => '早安', + < 18 => '午安', + _ => '晚安', + }; + +/// Solar phase in `[0, 1]` across the visible window from 5am to 7pm. +/// +/// `0.0` ≈ sunrise at the eastern edge, `0.5` ≈ noon overhead, +/// `1.0` ≈ sunset at the western edge. The shader maps this to a horizontal +/// arc, so the sun is no longer pinned to the screen center. +double sunPhase(DateTime now) { + final h = now.hour + now.minute / 60.0; + return ((h - 5.0) / 14.0).clamp(0.0, 1.0); +} diff --git a/lib/app/new_home/_widgets/all_observation_average.dart b/lib/app/new_home/_widgets/all_observation_average.dart new file mode 100644 index 000000000..5af244c00 --- /dev/null +++ b/lib/app/new_home/_widgets/all_observation_average.dart @@ -0,0 +1,91 @@ +/// A card widget showing the nearest-station temperature and humidity summary. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +/// Displays temperature and humidity from the nearest CWA weather station. +/// +/// The left column shows TREM network data (currently unavailable in this +/// context, displayed as "--"). The right column shows CWA nearest-station +/// readings from [HomeModel]. Rebuilds only when temperature or humidity change. +class AllObservationAverage extends StatelessWidget { + /// Creates an [AllObservationAverage] widget. + const AllObservationAverage({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return d != null ? (d.temperature, d.humidity) : null; + }, + builder: (context, data, _) { + const tremLabel = '--° / --%'; + final cwaLabel = data != null + ? '${data.$1.toStringAsFixed(1)}° / ${data.$2.round()}%' + : '--° / --%'; + + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + Padding( + padding: const .symmetric(horizontal: 8), + child: LabelText.large( + '所有測站平均', + color: Colors.white, + shadows: kElevationToShadow[1], + ), + ), + Card( + child: Padding( + padding: const .all(12), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Row( + spacing: 8, + children: [ + Icon( + Symbols.cell_tower_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Text(tremLabel, style: const TextStyle(fontSize: 16)), + ], + ), + ), + const VerticalDivider(width: 24), + Expanded( + child: Row( + spacing: 8, + children: [ + Icon( + Symbols.globe_asia_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Text(cwaLabel, style: const TextStyle(fontSize: 16)), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/app/new_home/_widgets/assistant_hint.dart b/lib/app/new_home/_widgets/assistant_hint.dart new file mode 100644 index 000000000..2c3f06d1f --- /dev/null +++ b/lib/app/new_home/_widgets/assistant_hint.dart @@ -0,0 +1,79 @@ +/// A card widget that shows a contextual weather hint message. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_models/weather_params.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +/// Displays a time- and weather-aware hint message. +/// +/// Generates a contextual greeting and temperature comment based on the current +/// hour and the latest weather data. Rebuilds only when the temperature or +/// weather description changes. +class AssistantHint extends StatelessWidget { + /// Creates an [AssistantHint] widget. + const AssistantHint({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return d != null ? (d.temperature, d.weather) : null; + }, + builder: (context, data, _) { + final text = data != null ? _buildHintText(data.$1, data.$2) : '載入天氣資料中…'; + + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .all(12), + child: Row( + spacing: 8, + crossAxisAlignment: .start, + children: [ + Icon( + Symbols.auto_awesome_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 16, color: context.colors.onSurfaceVariant), + maxLines: 2, + overflow: .ellipsis, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +String _buildHintText(double temperature, String weather) { + final greeting = greetingForHour(DateTime.now().hour); + + final tempComment = switch (temperature) { + < 10 => '天氣寒冷,注意保暖。', + < 20 => '氣溫涼爽,適合外出。', + < 26 => '氣溫舒適,天氣宜人。', + < 30 => '氣溫偏高,多補充水分。', + _ => '高溫注意,避免長時間戶外活動。', + }; + + return '$greeting,目前$weather,氣溫 ${temperature.toStringAsFixed(1)}°C。$tempComment'; +} diff --git a/lib/app/new_home/_widgets/day_cycle.dart b/lib/app/new_home/_widgets/day_cycle.dart new file mode 100644 index 000000000..adb698e23 --- /dev/null +++ b/lib/app/new_home/_widgets/day_cycle.dart @@ -0,0 +1,289 @@ +/// A card widget displaying the sun's daily arc with sunrise and sunset times. +library; + +import 'dart:math'; + +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +/// A card that renders the sun's arc from sunrise to sunset. +/// +/// Shows the full day arc as a track, with the elapsed portion highlighted in +/// amber up to the sun's current position. Sunrise time appears bottom-left, +/// sunset time bottom-right. +class DayCycle extends StatelessWidget { + const DayCycle({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Row( + spacing: 4, + children: [ + const Icon( + Symbols.wb_twilight_rounded, + fill: 1, + color: Colors.orangeAccent, + ), + BodyText.large('日出日落', weight: .bold), + ], + ), + const SizedBox(height: 16), + _SunCycleGraph( + sunrise: const TimeOfDay(hour: 5, minute: 30), + sunset: const TimeOfDay(hour: 18, minute: 30), + now: TimeOfDay.now(), + ), + ], + ), + ), + ); + } +} + +class _SunCycleGraph extends StatelessWidget { + final TimeOfDay now; + final TimeOfDay sunrise; + final TimeOfDay sunset; + + const _SunCycleGraph({ + required this.sunrise, + required this.sunset, + required this.now, + }); + + String _formatTime(TimeOfDay t) { + return '${t.hour}:${t.minute}'; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 140, + child: CustomPaint( + painter: _SunArcPainter( + now: now, + sunrise: sunrise, + sunset: sunset, + primaryColor: context.colors.primary, + surfaceVariantColor: context.colors.outlineVariant, + ), + size: Size.infinite, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Column( + crossAxisAlignment: .start, + children: [ + Row( + spacing: 4, + children: [ + const Icon( + Symbols.sunny_rounded, + fill: 1, + size: 20, + color: Colors.orangeAccent, + ), + LabelText.medium( + '日出', + color: context.colors.onSurfaceVariant, + ), + ], + ), + BodyText.large( + _formatTime(sunrise), + weight: .bold, + ), + ], + ), + Column( + crossAxisAlignment: .end, + children: [ + Row( + spacing: 4, + children: [ + const Icon( + Symbols.wb_twilight_rounded, + fill: 1, + size: 20, + color: Colors.indigoAccent, + ), + LabelText.medium( + '日落', + color: context.colors.onSurfaceVariant, + ), + ], + ), + BodyText.large( + _formatTime(sunset), + weight: .bold, + ), + ], + ), + ], + ), + ], + ); + } +} + +class _SunArcPainter extends CustomPainter { + final TimeOfDay now; + final Color primaryColor; + final TimeOfDay sunrise; + final TimeOfDay sunset; + final Color surfaceVariantColor; + + const _SunArcPainter({ + required this.now, + required this.sunrise, + required this.sunset, + required this.primaryColor, + required this.surfaceVariantColor, + }); + + /// Builds a sine arch path from x=0 to x=[maxT/π × width]. + /// + /// [maxT] is the upper bound of the parameter t ∈ [0, π]. + Path _buildSinePath(double maxT, double width, double cy, double peakHeight) { + const steps = 100; + final path = Path(); + for (var i = 0; i <= steps; i++) { + final t = (i / steps) * maxT; + final x = (t / pi) * width; + final y = cy - peakHeight * sin(t); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + return path; + } + + double _toMinutes(TimeOfDay t) => t.hour * 60.0 + t.minute; + + @override + void paint(Canvas canvas, Size size) { + const topPad = 20.0; + const bottomPad = 8.0; + final width = size.width; + final cy = size.height - bottomPad; + final peakHeight = cy - topPad; + + final sunriseMin = _toMinutes(sunrise); + final sunsetMin = _toMinutes(sunset); + final nowMin = _toMinutes(now); + final progress = ((nowMin - sunriseMin) / (sunsetMin - sunriseMin)).clamp(0.0, 1.0); + + final fullPath = _buildSinePath(pi, width, cy, peakHeight); + + // Sky gradient: fills the dome under the arc. + final skyPath = Path.from(fullPath) + ..lineTo(width, cy) + ..lineTo(0, cy) + ..close(); + canvas.drawPath( + skyPath, + Paint() + ..shader = LinearGradient( + begin: .topCenter, + end: .bottomCenter, + colors: [ + primaryColor.withValues(alpha: 0.12), + primaryColor.withValues(alpha: 0.0), + ], + ).createShader(Rect.fromLTWH(0, topPad, width, peakHeight)), + ); + + // Horizon line. + canvas.drawLine( + Offset(0, cy), + Offset(width, cy), + Paint() + ..color = surfaceVariantColor + ..strokeWidth = 1, + ); + + // Full arc track (faint). + canvas.drawPath( + fullPath, + Paint() + ..color = surfaceVariantColor + ..strokeWidth = 2 + ..style = .stroke + ..strokeCap = .round, + ); + + // Elapsed arc (amber). + if (progress > 0) { + canvas.drawPath( + _buildSinePath(pi * progress, width, cy, peakHeight), + Paint() + ..color = Colors.amber + ..strokeWidth = 3 + ..style = .stroke + ..strokeCap = .round, + ); + } + + // Sun position along the sine curve. + final sunOffset = Offset( + progress * width, + cy - peakHeight * sin(pi * progress), + ); + + // Glow layers. + canvas.drawCircle(sunOffset, 18, Paint()..color = Colors.amber.withValues(alpha: 0.1)); + canvas.drawCircle(sunOffset, 12, Paint()..color = Colors.amber.withValues(alpha: 0.2)); + + // Sun disc. + canvas.drawCircle(sunOffset, 7, Paint()..color = Colors.amber); + canvas.drawCircle( + sunOffset, + 7, + Paint() + ..color = Colors.white.withValues(alpha: 0.45) + ..style = .stroke + ..strokeWidth = 1.5, + ); + + // Endpoint dots at the horizon. + canvas.drawCircle(Offset(0, cy), 3.5, Paint()..color = Colors.amber.withValues(alpha: 0.7)); + canvas.drawCircle( + Offset(width, cy), + 3.5, + Paint()..color = surfaceVariantColor.withValues(alpha: 0.7), + ); + } + + @override + bool shouldRepaint(_SunArcPainter old) { + return old.now != now || + old.sunrise != sunrise || + old.sunset != sunset || + old.primaryColor != primaryColor || + old.surfaceVariantColor != surfaceVariantColor; + } +} diff --git a/lib/app/new_home/_widgets/eew_alert.dart b/lib/app/new_home/_widgets/eew_alert.dart new file mode 100644 index 000000000..89d1d1fa5 --- /dev/null +++ b/lib/app/new_home/_widgets/eew_alert.dart @@ -0,0 +1,346 @@ +/// Earthquake Early Warning alert card for the new home page. +library; + +import 'dart:async'; + +import 'package:dpip/api/model/eew.dart'; +import 'package:dpip/core/eew.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/models/data.dart'; +import 'package:dpip/router.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/number.dart'; +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +/// Foreground/border alpha for the muted onErrorContainer text style. +const double _mutedAlpha = 0.7; + +/// Displays the active EEW alert(s) as compact tappable cards. +/// +/// Renders nothing when no EEW is active. Tap navigates to the monitor map. +class EewAlerts extends StatelessWidget { + /// Creates an [EewAlerts]. + const EewAlerts({super.key}); + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (_, m) => m.eew, + builder: (context, eews, _) { + if (eews.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const .symmetric(horizontal: 12), + child: Column( + mainAxisSize: .min, + children: [for (final e in eews) _EewCard(eew: e)], + ), + ); + }, + ); + } +} + +class _EewCard extends StatefulWidget { + final Eew eew; + const _EewCard({required this.eew}); + + @override + State<_EewCard> createState() => _EewCardState(); +} + +class _EewCardState extends State<_EewCard> { + int? _localIntensity; + int? _arrivalMs; + int _countdown = 0; + Timer? _ticker; + + void _tick() { + final arrival = _arrivalMs; + if (arrival == null) return; + final remaining = ((arrival - GlobalProviders.data.currentTime) / 1000).floor(); + if (remaining < -1) return; + if (mounted && remaining != _countdown) setState(() => _countdown = remaining); + } + + @override + void initState() { + super.initState(); + final coords = GlobalProviders.location.coordinates; + if (coords != null) { + final info = eewLocationInfo( + widget.eew.info.magnitude, + widget.eew.info.depth, + widget.eew.info.latitude, + widget.eew.info.longitude, + coords.latitude, + coords.longitude, + ); + _localIntensity = intensityFloatToInt(info.i); + _arrivalMs = (widget.eew.info.time + + sWaveTimeByDistance(widget.eew.info.depth, info.dist)) + .floor(); + } + _tick(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final onContainer = colors.onErrorContainer; + final muted = onContainer.withValues(alpha: _mutedAlpha); + final eq = widget.eew.info; + final intensity = _localIntensity; + + return Padding( + padding: const .symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + clipBehavior: Clip.antiAlias, + borderRadius: .circular(24), + child: InkWell( + onTap: () => const MapRoute(layers: 'monitor').push(context), + splashColor: colors.error.withValues(alpha: 0.18), + child: Ink( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: .topLeft, + end: .bottomRight, + colors: [ + colors.errorContainer, + Color.alphaBlend( + colors.error.withValues(alpha: 0.18), + colors.errorContainer, + ), + ], + ), + border: Border.all(color: colors.error, width: 1.5), + borderRadius: .circular(24), + boxShadow: [ + BoxShadow( + color: colors.error.withValues(alpha: 0.25), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + padding: const .all(16), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + _Header(serial: widget.eew.serial, onContainer: onContainer, error: colors.error, onError: colors.onError), + const SizedBox(height: 12), + Text( + eq.location, + style: context.texts.titleLarge?.copyWith( + color: onContainer, + fontWeight: .w700, + ), + ), + const SizedBox(height: 4), + Row( + spacing: 8, + children: [ + _Chip(label: 'M ${eq.magnitude.toStringAsFixed(1)}', color: onContainer), + _Chip(label: '${eq.depth.toStringAsFixed(0)} km', color: onContainer), + ], + ), + const SizedBox(height: 12), + IntrinsicHeight( + child: Row( + crossAxisAlignment: .stretch, + children: [ + Expanded( + child: _InfoBlock( + label: '所在地預估'.i18n, + muted: muted, + child: Text( + intensity?.asIntensityLabel ?? '—', + style: context.texts.displaySmall?.copyWith( + color: onContainer, + fontWeight: .w800, + height: 1.0, + ), + ), + ), + ), + VerticalDivider( + color: onContainer.withValues(alpha: 0.25), + width: 24, + ), + Expanded( + child: _InfoBlock( + label: '震波抵達'.i18n, + align: .end, + muted: muted, + child: _countdown <= 0 + ? Text( + '抵達'.i18n, + style: context.texts.displaySmall?.copyWith( + color: onContainer, + fontWeight: .w800, + height: 1.0, + ), + ) + : _Countdown(seconds: _countdown, onContainer: onContainer, muted: muted), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } +} + +class _Header extends StatelessWidget { + final int serial; + final Color onContainer; + final Color error; + final Color onError; + + const _Header({ + required this.serial, + required this.onContainer, + required this.error, + required this.onError, + }); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 8, + children: [ + Container( + padding: const .symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: error, borderRadius: .circular(999)), + child: Row( + mainAxisSize: .min, + spacing: 4, + children: [ + Icon(Symbols.crisis_alert_rounded, color: onError, size: 16, weight: 700), + Text( + 'EEW'.i18n, + style: context.texts.labelMedium?.copyWith( + color: onError, + fontWeight: .w800, + ), + ), + ], + ), + ), + Expanded( + child: Text( + '第 {serial} 報'.i18n.args({'serial': serial}), + style: context.texts.labelLarge?.copyWith( + color: onContainer, + fontWeight: .w600, + ), + ), + ), + Icon(Symbols.chevron_right_rounded, color: onContainer), + ], + ); + } +} + +class _InfoBlock extends StatelessWidget { + final String label; + final Widget child; + final Color muted; + final CrossAxisAlignment align; + + const _InfoBlock({ + required this.label, + required this.child, + required this.muted, + this.align = .start, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: align, + children: [ + Text(label, style: context.texts.labelMedium?.copyWith(color: muted)), + child, + ], + ); + } +} + +class _Countdown extends StatelessWidget { + final int seconds; + final Color onContainer; + final Color muted; + + const _Countdown({ + required this.seconds, + required this.onContainer, + required this.muted, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: .end, + crossAxisAlignment: .baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$seconds', + style: context.texts.displayMedium?.copyWith( + color: onContainer, + fontWeight: .w800, + height: 1.0, + ), + ), + const SizedBox(width: 4), + Text( + '秒'.i18n, + style: context.texts.titleMedium?.copyWith(color: muted), + ), + ], + ); + } +} + +class _Chip extends StatelessWidget { + final String label; + final Color color; + + const _Chip({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const .symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: .circular(6), + ), + child: Text( + label, + style: context.texts.labelMedium?.copyWith( + color: color, + fontWeight: .w600, + ), + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/events_timeline.dart b/lib/app/new_home/_widgets/events_timeline.dart new file mode 100644 index 000000000..c2f59c8f2 --- /dev/null +++ b/lib/app/new_home/_widgets/events_timeline.dart @@ -0,0 +1,184 @@ +/// Compact events timeline shown at the bottom of the new home page. +library; + +import 'package:collection/collection.dart'; +import 'package:dpip/api/model/history/history.dart'; +import 'package:dpip/app/home/_widgets/history_timeline_item.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/layout.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/datetime.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:timezone/timezone.dart'; + +/// Compact events list grouped by date, reusing the original +/// [HistoryTimelineItem] for visual continuity. +class EventsTimeline extends StatelessWidget { + /// Maximum events to display inline; tapping "查看全部" opens the full list. + final int maxItems; + + /// Creates an [EventsTimeline]. + const EventsTimeline({super.key, this.maxItems = 5}); + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (_, m) => m.alerts, + builder: (context, events, _) { + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 8), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: .start, + children: [ + InkWell( + onTap: () => NewHomeShell.of(context)?.setTab(tabEvents), + child: Padding( + padding: const .fromLTRB(16, 12, 12, 8), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: .circular(10), + ), + child: Icon( + Symbols.history_rounded, + color: context.colors.onSecondaryContainer, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '事件'.i18n, + style: context.texts.titleMedium?.copyWith( + fontWeight: .w700, + ), + ), + ), + Text( + '更多'.i18n, + style: context.texts.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: .w600, + ), + ), + Icon( + Symbols.chevron_right_rounded, + color: context.colors.primary, + size: 20, + ), + ], + ), + ), + ), + if (events.isEmpty) + Padding( + padding: const .fromLTRB(16, 8, 16, 24), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: .circle, + color: context.colors.outlineVariant, + ), + ), + const SizedBox(width: 16), + Text( + TZDateTime.now(UTC).toLocaleFullDateString(context), + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '尚無近期事件'.i18n, + style: context.texts.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ), + ], + ), + ) + else + _GroupedList(events: events.take(maxItems).toList()), + ], + ), + ), + ); + }, + ); + } +} + +class _GroupedList extends StatelessWidget { + final List events; + const _GroupedList({required this.events}); + + @override + Widget build(BuildContext context) { + final grouped = groupBy( + events, + (History e) => e.time.send.toLocaleFullDateString(context), + ).entries.sorted((a, b) => b.key.compareTo(a.key)).toList(); + + return Padding( + padding: const .symmetric(vertical: 4), + child: Column( + children: [ + for (final entry in grouped) ...[ + _DateChip(date: entry.key), + for (final item in entry.value) + HistoryTimelineItem( + history: item, + expired: item.isExpired, + last: item == grouped.last.value.last, + ), + ], + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _DateChip extends StatelessWidget { + final String date; + const _DateChip({required this.date}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Container( + padding: const .symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: .circular(8), + ), + child: Text( + date, + style: context.texts.labelMedium?.copyWith( + color: context.colors.onSecondaryContainer, + fontWeight: .w600, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/forecast.dart b/lib/app/new_home/_widgets/forecast.dart new file mode 100644 index 000000000..71b7cd43e --- /dev/null +++ b/lib/app/new_home/_widgets/forecast.dart @@ -0,0 +1,169 @@ +/// 24 小時預報。 +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +const double _kColumnWidth = 56.0; + +class Forecast extends StatelessWidget { + const Forecast({super.key}); + + @override + Widget build(BuildContext context) { + return Selector?>( + selector: (_, m) => m.forecast, + builder: (context, forecast, _) { + if (forecast == null) return const SizedBox.shrink(); + try { + final data = forecast['forecast'] as List?; + if (data == null || data.isEmpty) return const SizedBox.shrink(); + + return ResponsiveContainer( + child: Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .symmetric(vertical: 14), + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: const .symmetric(horizontal: 14), + child: Text( + '24 小時預報'.i18n, + style: context.texts.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: .w600, + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: .horizontal, + padding: const .symmetric(horizontal: 6), + child: Row( + crossAxisAlignment: .start, + children: [ + for (var i = 0; i < data.length; i++) + _HourColumn( + item: data[i] as Map, + isNow: i == 0, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } catch (e, s) { + TalkerManager.instance.error('Failed to render forecast card', e, s); + } + return const SizedBox.shrink(); + }, + ); + } +} + +class _HourColumn extends StatelessWidget { + final Map item; + final bool isNow; + + const _HourColumn({required this.item, required this.isNow}); + + static (IconData, Color?) _weatherIcon(BuildContext context, String weather) => switch (weather) { + final s when s.contains('晴') => (Symbols.sunny_rounded, Colors.orangeAccent), + final s when s.contains('雨') => (Symbols.rainy_light_rounded, Colors.blueAccent), + final s when s.contains('雲') || s.contains('陰') => ( + Symbols.cloud_rounded, + context.colors.onSurfaceVariant, + ), + final s when s.contains('雷') => (Symbols.flash_on_rounded, Colors.amber), + final s when s.contains('雪') => (Symbols.snowflake_rounded, Colors.lightBlue[200]), + _ => (Symbols.wb_cloudy_rounded, context.colors.onSurfaceVariant), + }; + + @override + Widget build(BuildContext context) { + final time = item['time'] as String? ?? ''; + final weather = item['weather'] as String? ?? ''; + final temp = (item['temperature'] as num?)?.toDouble() ?? 0.0; + final pop = item['pop'] as int? ?? 0; + + final (icon, color) = _weatherIcon(context, weather); + + final primaryColor = isNow + ? context.colors.onSurface + : context.colors.onSurface.withValues(alpha: 0.82); + final labelColor = isNow + ? context.colors.onSurface + : context.colors.onSurfaceVariant.withValues(alpha: 0.75); + + return SizedBox( + width: _kColumnWidth, + child: Column( + mainAxisSize: .min, + children: [ + Text( + isNow ? '現在'.i18n : time, + style: context.texts.labelSmall?.copyWith( + color: labelColor, + fontWeight: isNow ? .w700 : .w500, + height: 1, + ), + ), + const SizedBox(height: 10), + Icon(icon, color: color, fill: 1, size: 24), + if (pop > 0) ...[ + const SizedBox(height: 6), + Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: [ + Icon( + Symbols.water_drop_rounded, + size: 10, + color: Colors.blueAccent.withValues(alpha: 0.85), + ), + const SizedBox(width: 2), + Text( + '$pop%', + style: TextStyle( + fontSize: 10, + color: Colors.blueAccent.withValues(alpha: 0.85), + fontWeight: .w600, + height: 1, + ), + ), + ], + ), + ], + const SizedBox(height: 10), + Text( + '${temp.round()}°', + style: context.texts.titleMedium?.copyWith( + fontWeight: isNow ? .w700 : .w600, + color: primaryColor, + height: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/forecast_strip.dart b/lib/app/new_home/_widgets/forecast_strip.dart new file mode 100644 index 000000000..fd28aa56b --- /dev/null +++ b/lib/app/new_home/_widgets/forecast_strip.dart @@ -0,0 +1,412 @@ +/// Horizontal 24-hour weather forecast with a continuous temperature curve. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +/// Width allotted to a single hourly column inside the scroll viewport. +const double _columnWidth = 56; + +/// Height of the temperature curve area (above the icons row). +const double _chartHeight = 86; + +/// Top padding inside the chart so labels don't clip the card edge. +const double _chartTopPad = 22; + +/// Bottom padding inside the chart so dots clear the icons row. +const double _chartBottomPad = 12; + +/// Parsed hourly forecast entry — kept lightweight for the painter. +class _Hour { + final String time; + final String weather; + final int pop; + final double temp; + + const _Hour({ + required this.time, + required this.weather, + required this.pop, + required this.temp, + }); + + factory _Hour.from(Map m) => _Hour( + time: (m['time'] as String?) ?? '', + weather: (m['weather'] as String?) ?? '', + pop: (m['pop'] as num?)?.toInt() ?? 0, + temp: (m['temperature'] as num?)?.toDouble() ?? 0.0, + ); +} + +/// A horizontally-scrolling 24-hour forecast strip with a smooth temperature +/// curve overlay (Apple Weather-style). +/// +/// Reads from [HomeModel.forecast]. Renders nothing when forecast data is +/// missing or malformed. +class ForecastStrip extends StatelessWidget { + /// Creates a [ForecastStrip]. + const ForecastStrip({super.key}); + + @override + Widget build(BuildContext context) { + return Selector?>( + selector: (_, m) => m.forecast, + builder: (context, forecast, _) { + final raw = forecast?['forecast'] as List?; + if (raw == null || raw.isEmpty) return const SizedBox.shrink(); + + final hours = [for (final m in raw) _Hour.from(m as Map)]; + var minTemp = double.infinity; + var maxTemp = double.negativeInfinity; + for (final h in hours) { + if (h.temp < minTemp) minTemp = h.temp; + if (h.temp > maxTemp) maxTemp = h.temp; + } + if (maxTemp - minTemp < 1) { + // Avoid a flat-line look when the day is isothermal. + minTemp -= 1; + maxTemp += 1; + } + + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 8), + child: Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const .symmetric(vertical: 12), + child: Column( + crossAxisAlignment: .start, + children: [ + _Header(low: minTemp, high: maxTemp), + const SizedBox(height: 8), + SizedBox( + height: _chartHeight + 78, + child: SingleChildScrollView( + scrollDirection: .horizontal, + padding: const .symmetric(horizontal: 12), + child: _Chart( + hours: hours, + minTemp: minTemp, + maxTemp: maxTemp, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _Header extends StatelessWidget { + final double low; + final double high; + + const _Header({required this.low, required this.high}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return Padding( + padding: const .fromLTRB(16, 0, 16, 4), + child: Row( + children: [ + Icon(Symbols.schedule_rounded, size: 18, color: colors.primary), + const SizedBox(width: 8), + Text( + '24 小時預報'.i18n, + style: context.texts.titleSmall?.copyWith(fontWeight: .w700), + ), + const Spacer(), + _TempRange(label: '最低'.i18n, value: low, color: _coolColor), + const SizedBox(width: 12), + _TempRange(label: '最高'.i18n, value: high, color: _warmColor), + ], + ), + ); + } +} + +class _TempRange extends StatelessWidget { + final String label; + final double value; + final Color color; + + const _TempRange({required this.label, required this.value, required this.color}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: .min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: .circle), + ), + const SizedBox(width: 4), + Text( + '$label ${value.round()}°', + style: context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: .w600, + ), + ), + ], + ); + } +} + +class _Chart extends StatelessWidget { + final List<_Hour> hours; + final double minTemp; + final double maxTemp; + + const _Chart({ + required this.hours, + required this.minTemp, + required this.maxTemp, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final width = hours.length * _columnWidth; + + return SizedBox( + width: width, + child: Stack( + children: [ + // Smooth curve + filled area + dots + temperature labels. + Positioned( + left: 0, + right: 0, + top: 0, + height: _chartHeight, + child: CustomPaint( + painter: _CurvePainter( + hours: hours, + minTemp: minTemp, + maxTemp: maxTemp, + labelStyle: context.texts.labelMedium!.copyWith( + color: colors.onSurface, + fontWeight: .w700, + ), + ), + ), + ), + // Icon + pop% + time row, anchored under the chart. + Positioned( + left: 0, + right: 0, + top: _chartHeight, + child: Row( + children: [ + for (final h in hours) + SizedBox( + width: _columnWidth, + child: _HourColumn(hour: h), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _HourColumn extends StatelessWidget { + final _Hour hour; + const _HourColumn({required this.hour}); + + static (IconData, Color) _iconFor(String weather) => switch (weather) { + final s when s.contains('晴') => (Symbols.sunny_rounded, Colors.orangeAccent), + final s when s.contains('雷') => (Symbols.thunderstorm_rounded, Colors.amber), + final s when s.contains('雨') => (Symbols.rainy_rounded, Colors.lightBlue), + final s when s.contains('雪') => (Symbols.snowflake_rounded, Colors.lightBlueAccent), + final s when s.contains('雲') || s.contains('陰') => (Symbols.cloud_rounded, Colors.blueGrey), + _ => (Symbols.wb_cloudy_rounded, Colors.grey), + }; + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final (icon, iconColor) = _iconFor(hour.weather); + + return Padding( + padding: const .symmetric(vertical: 8), + child: Column( + mainAxisSize: .min, + children: [ + Icon(icon, color: iconColor, fill: 1, size: 22), + const SizedBox(height: 6), + SizedBox( + height: 14, + child: hour.pop > 0 + ? Row( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: [ + const Icon( + Symbols.water_drop_rounded, + size: 10, + color: Colors.lightBlue, + ), + const SizedBox(width: 2), + Text( + '${hour.pop}%', + style: context.texts.labelSmall?.copyWith( + color: Colors.lightBlue, + fontWeight: .w600, + ), + ), + ], + ) + : null, + ), + const SizedBox(height: 4), + Text( + hour.time, + style: context.texts.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +/// Cool end of the temperature gradient (≤ minTemp). +const Color _coolColor = Color(0xFF42A5F5); + +/// Warm end of the temperature gradient (≥ maxTemp). +const Color _warmColor = Color(0xFFFF7043); + +/// Paints a smooth temperature curve with dots and labels. +/// +/// Uses a quadratic-bezier midpoint smoothing technique: each curve segment +/// passes through midpoints between consecutive data points using the actual +/// point as the control. Produces a clean Apple-Weather-style line. +class _CurvePainter extends CustomPainter { + final List<_Hour> hours; + final double minTemp; + final double maxTemp; + final TextStyle labelStyle; + + const _CurvePainter({ + required this.hours, + required this.minTemp, + required this.maxTemp, + required this.labelStyle, + }); + + @override + void paint(Canvas canvas, Size size) { + if (hours.isEmpty) return; + + final range = maxTemp - minTemp; + final chartUsable = size.height - _chartTopPad - _chartBottomPad; + Offset pointFor(int i) { + final h = hours[i]; + final pct = (h.temp - minTemp) / range; + final x = i * _columnWidth + _columnWidth / 2; + final y = _chartTopPad + (1 - pct) * chartUsable; + return Offset(x, y); + } + + final points = [for (var i = 0; i < hours.length; i++) pointFor(i)]; + + // Smooth curve through points via midpoint quadratic bezier. + final curve = Path()..moveTo(points.first.dx, points.first.dy); + for (var i = 0; i < points.length - 1; i++) { + final p1 = points[i]; + final p2 = points[i + 1]; + final mid = Offset((p1.dx + p2.dx) / 2, (p1.dy + p2.dy) / 2); + curve.quadraticBezierTo(p1.dx, p1.dy, mid.dx, mid.dy); + } + curve.lineTo(points.last.dx, points.last.dy); + + // Vertical gradient: warm at the top of the chart (high temps), cool at + // the bottom (low temps). Each segment of the curve picks up the colour + // that matches its own height — semantically correct regardless of which + // way the temperature trend runs. + final chartRect = Rect.fromLTWH(0, _chartTopPad, size.width, chartUsable); + + // Soft filled area below the curve. + final fill = Path.from(curve) + ..lineTo(points.last.dx, size.height) + ..lineTo(points.first.dx, size.height) + ..close(); + canvas.drawPath( + fill, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _warmColor.withValues(alpha: 0.28), + _coolColor.withValues(alpha: 0.04), + ], + ).createShader(chartRect), + ); + + // Curve stroke uses the same vertical gradient at full opacity. + canvas.drawPath( + curve, + Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_warmColor, _coolColor], + ).createShader(chartRect) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + // Dots + labels per point. Dot colour is computed from the same vertical + // ramp so a dot's appearance matches the underlying curve. + final dotPaintInner = Paint()..color = Colors.white; + for (var i = 0; i < hours.length; i++) { + final p = points[i]; + // Map the dot's Y position into the [_warmColor, _coolColor] ramp + // exactly like the curve gradient does. + final tempColor = Color.lerp( + _warmColor, + _coolColor, + ((p.dy - _chartTopPad) / chartUsable).clamp(0.0, 1.0), + )!; + + // Dot: outer color ring + inner white. + canvas.drawCircle(p, 4.5, Paint()..color = tempColor); + canvas.drawCircle(p, 2.2, dotPaintInner); + + // Temperature label above the dot. + final label = TextPainter( + text: TextSpan(text: '${hours[i].temp.round()}°', style: labelStyle), + textDirection: TextDirection.ltr, + )..layout(); + label.paint( + canvas, + Offset(p.dx - label.width / 2, p.dy - label.height - 8), + ); + } + } + + @override + bool shouldRepaint(_CurvePainter old) => + old.hours != hours || + old.minTemp != minTemp || + old.maxTemp != maxTemp || + old.labelStyle != labelStyle; +} diff --git a/lib/app/new_home/_widgets/greeting.dart b/lib/app/new_home/_widgets/greeting.dart new file mode 100644 index 000000000..7b6b26a9c --- /dev/null +++ b/lib/app/new_home/_widgets/greeting.dart @@ -0,0 +1,25 @@ +/// A greeting widget that displays a time-aware salutation. +library; + +import 'package:dpip/app/new_home/_models/weather_params.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; + +/// Displays a greeting that changes based on the current hour. +class Greeting extends StatelessWidget { + /// Creates a [Greeting] widget. + const Greeting({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .all(16), + child: TitleText.large( + greetingForHour(DateTime.now().hour).i18n, + color: Colors.white, + shadows: kElevationToShadow[2], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/location_chip.dart b/lib/app/new_home/_widgets/location_chip.dart new file mode 100644 index 000000000..f6e96b09a --- /dev/null +++ b/lib/app/new_home/_widgets/location_chip.dart @@ -0,0 +1,317 @@ +/// A chip widget that displays the current location and supports temporary overrides. +library; + +import 'package:collection/collection.dart'; +import 'package:dpip/api/model/location/location.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/router.dart'; +import 'package:dpip/utils/constants.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color.dart'; +import 'package:dpip/widgets/list/segmented_list.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +/// Displays the effective location as a tappable chip. +/// +/// Tapping opens a two-level city→district picker to set a temporary location +/// override. A close button appears when an override is active, reverting to +/// the persisted location on tap. +class LocationChip extends StatelessWidget { + /// Creates a [LocationChip]. + const LocationChip({super.key}); + + void _showLocationPicker(BuildContext context) { + final homeModel = context.home; + showModalBottomSheet( + context: context, + isScrollControlled: true, + sheetAnimationStyle: kEmphasizedAnimationStyle, + constraints: context.bottomSheetConstraints, + builder: (_) => _LocationPickerSheet(homeModel), + ); + } + + @override + Widget build(BuildContext context) { + return Selector2( + selector: (_, home, settings) => (home.temporaryCode, settings.code), + builder: (context, data, _) { + final (temporaryCode, settingsCode) = data; + + final code = temporaryCode ?? settingsCode; + final hasOverride = code != settingsCode; + + final location = switch (code) { + final code? => Global.location[code], + _ => null, + }; + + final displayName = switch (location) { + final location? => location.cityTownWithLevel, + _ => '未設定', + }; + + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + FilledButton.icon( + onPressed: () => _showLocationPicker(context), + icon: const Icon(Symbols.location_on_rounded, fill: 1), + label: Text(displayName), + style: FilledButton.styleFrom( + elevation: hasOverride ? 4 : 0, + backgroundColor: hasOverride + ? context.colors.primaryContainer + : context.colors.surfaceContainerLow / 80, + foregroundColor: hasOverride + ? context.colors.onPrimaryContainer + : context.colors.onSurface, + ), + ), + if (hasOverride) + IconButton( + onPressed: () => context.home.setTemporaryCode(null), + icon: Icon( + Symbols.close_rounded, + shadows: kElevationToShadow[4], + ), + tooltip: '清除暫時位置'.i18n, + style: IconButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _LocationPickerSheet extends StatefulWidget { + final HomeModel homeModel; + + const _LocationPickerSheet(this.homeModel); + + @override + State<_LocationPickerSheet> createState() => _LocationPickerSheetState(); +} + +class _LocationPickerSheetState extends State<_LocationPickerSheet> { + final _scrollController = ScrollController(); + String? _selectedCity; + + List<(String cityWithLevel, Location location)> get _cities { + final seen = {}; + return [ + for (final entry in Global.location.entries) + if (seen.add(entry.value.cityWithLevel)) (entry.value.cityWithLevel, entry.value), + ]; + } + + List<(String code, Location location)> get _towns { + final city = _selectedCity; + if (city == null) return []; + return [ + for (final entry in Global.location.entries) + if (entry.value.cityWithLevel == city) (entry.key, entry.value), + ]; + } + + void dismissSheet() => Navigator.of(context).popUntil((route) { + if (route.settings is Page) return true; + return false; + }); + + void selectCode(String code) { + widget.homeModel.setTemporaryCode(code); + dismissSheet(); + } + + void selectCity(String name) { + setState(() => _selectedCity = name); + _scrollController.jumpTo(0); + } + + Widget _buildCityList() { + final cities = _cities; + final resolvedCode = widget.homeModel.temporaryCode ?? context.location.code; + final resolvedLocation = Global.location[resolvedCode]; + + return SegmentedList.builder( + label: Text('縣市'.i18n), + itemCount: cities.length, + itemBuilder: (context, index) { + final (cityName, _) = cities[index]; + + final isSelected = resolvedLocation?.cityWithLevel == cityName; + + return SegmentedListTile( + isFirst: index == 0, + isLast: index == cities.length - 1, + title: Text(cityName), + subtitle: isSelected ? Text('目前選擇地區'.i18n) : null, + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => selectCity(cityName), + ); + }, + ); + } + + Widget _buildTownList() { + final towns = _towns; + final temporaryCode = widget.homeModel.temporaryCode; + final currentCode = context.location.code; + + return SegmentedList.builder( + label: Text(towns.first.$2.cityWithLevel), + itemCount: towns.length, + itemBuilder: (context, index) { + final (code, location) = towns[index]; + + final isCurrentCode = code == currentCode; + final isSelected = (temporaryCode == null && isCurrentCode) || code == temporaryCode; + final isOverride = temporaryCode != null && !isCurrentCode; + + final lng = location.lng.toStringAsFixed(2); + final lat = location.lat.toStringAsFixed(2); + + final backgroundColor = isSelected ? context.colors.secondaryContainer : null; + final foregroundColor = isSelected ? context.colors.onSecondaryContainer : null; + + return SegmentedListTile( + isFirst: index == 0, + isLast: index == towns.length - 1, + tileColor: backgroundColor, + title: Text(location.cityTownWithLevel), + subtitle: Text( + '$code・$lng°E・$lat°N', + style: TextStyle(color: foregroundColor), + ), + trailing: Icon( + switch ((isSelected, isOverride)) { + (true, true) => Symbols.check_rounded, + (true, false) => Symbols.home_rounded, + _ => null, + }, + fill: 1, + color: foregroundColor, + ), + onTap: () => selectCode(code), + ); + }, + ); + } + + Widget _buildQuickLocations() { + final temporaryCode = widget.homeModel.temporaryCode; + + return Selector)>( + selector: (_, model) => (model.code, model.favorited), + builder: (context, data, child) { + final (currentCode, favorited) = data; + + final quickCodes = Set(); + + if (currentCode != null) quickCodes.add(currentCode); + quickCodes.addAll(favorited); + + final quickLocations = quickCodes.mapIndexed((index, code) { + final location = Global.location[code]!; + + final isCurrentCode = code == currentCode; + final isSelected = (temporaryCode == null && isCurrentCode) || code == temporaryCode; + final backgroundColor = isSelected ? context.colors.secondaryContainer : null; + final foregroundColor = isSelected ? context.colors.onSecondaryContainer : null; + + return SegmentedListTile( + isFirst: index == 0, + tileColor: backgroundColor, + leading: Icon( + isCurrentCode ? Symbols.home_rounded : Symbols.star_rounded, + fill: 1, + color: foregroundColor, + ), + title: Text( + location.cityTownWithLevel, + style: TextStyle(color: foregroundColor), + ), + trailing: Icon( + isSelected ? Symbols.check_rounded : null, + color: foregroundColor, + ), + onTap: () => selectCode(code), + ); + }); + + return SegmentedList( + label: Text('快速切換'.i18n), + children: [ + ...quickLocations, + SegmentedListTile( + isFirst: quickLocations.isEmpty, + isLast: true, + leading: const Icon(Symbols.add_circle_rounded), + title: Text('新增地點'.i18n), + onTap: () => const SettingsLocationSelectRoute().push(context), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: Icon(_selectedCity != null ? Symbols.arrow_back_rounded : Symbols.close_rounded), + onPressed: () { + if (_selectedCity != null) { + setState(() => _selectedCity = null); + _scrollController.jumpTo(0); + } else { + dismissSheet(); + } + }, + tooltip: _selectedCity != null ? '返回' : '關閉', + ), + title: Text(_selectedCity ?? '選擇地區', style: context.texts.titleMedium), + actions: [ + IconButton( + onPressed: () { + dismissSheet(); + const SettingsLocationRoute().push(context); + }, + icon: const Icon(Symbols.settings_rounded, fill: 1), + tooltip: '所在地設定', + ), + ], + actionsPadding: const .only(right: 4), + ), + body: ListView( + controller: _scrollController, + padding: .only(bottom: context.padding.bottom + 16), + children: _selectedCity == null + ? [_buildQuickLocations(), _buildCityList()] + : [_buildTownList()], + ), + ); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } +} diff --git a/lib/app/new_home/_widgets/radar.dart b/lib/app/new_home/_widgets/radar.dart new file mode 100644 index 000000000..298119c1e --- /dev/null +++ b/lib/app/new_home/_widgets/radar.dart @@ -0,0 +1,227 @@ +/// A home-page card that embeds a read-only radar map preview. +library; + +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/api/route.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/router.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/maplibre.dart'; +import 'package:dpip/utils/extensions/string.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/map/map.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +/// A card that shows the latest precipitation radar imagery on a mini map. +/// +/// Tapping the map area navigates to the full radar layer on the map page. +/// Manages the [MapLibreMapController] lifecycle via [RouteAware] and +/// [WidgetsBindingObserver]. +class Radar extends StatefulWidget { + /// Creates a [Radar] card. + const Radar({super.key}); + + @override + State createState() => _RadarState(); +} + +class _RadarState extends State with WidgetsBindingObserver, RouteAware { + MapLibreMapController? _mapController; + HomeModel? _homeModel; + + /// Resolves to the list of available radar timestamps once fetched. + late Future> _radarListFuture; + + void _onHomeModelChanged() { + final code = context.home.temporaryCode ?? GlobalProviders.location.code; + final loc = code != null ? Global.location[code] : null; + final coords = GlobalProviders.location.coordinates; + final LatLng target; + final double zoom; + + if (loc != null) { + target = LatLng(loc.lat, loc.lng); + zoom = DpipMap.kUserLocationZoom; + } else if (coords != null) { + target = coords; + zoom = DpipMap.kUserLocationZoom; + } else { + target = DpipMap.kTaiwanCenter; + zoom = DpipMap.kTaiwanZoom; + } + + _mapController?.animateCamera(CameraUpdate.newLatLngZoom(target, zoom)); + } + + Future _setupMapLayers() async { + final controller = _mapController; + if (controller == null) return; + + final sourceId = MapSourceIds.radar(); + final layerId = MapLayerIds.radar(); + + try { + final time = (await _radarListFuture).last; + final tileUrl = radarTile(time); + + if (await controller.exists(sourceId, source: true)) { + await controller.removeSource(sourceId); + } + + await controller.addSource( + sourceId, + RasterSourceProperties(tiles: [tileUrl], tileSize: 256), + ); + + if (!mounted) return; + + if (!await controller.exists(layerId, layer: true)) { + await controller.addLayer( + sourceId, + layerId, + const RasterLayerProperties(), + belowLayerId: BaseMapLayerIds.exptechCountyOutline, + ); + } + } catch (e, s) { + TalkerManager.instance.error('Radar._setupMapLayers', e, s); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _radarListFuture = ExpTech().getRadarList(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final route = ModalRoute.of(context); + if (route != null) routeObserver.subscribe(this, route); + + final model = context.home; + if (_homeModel != model) { + _homeModel?.removeListener(_onHomeModelChanged); + _homeModel = model; + model.addListener(_onHomeModelChanged); + } + } + + @override + Widget build(BuildContext context) { + final userLocation = GlobalProviders.location.coordinates; + final targetLocation = userLocation ?? DpipMap.kTaiwanCenter; + final targetZoom = userLocation != null ? DpipMap.kUserLocationZoom : DpipMap.kTaiwanZoom; + + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + clipBehavior: .antiAlias, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => const MapRoute(layers: 'radar').push(context), + child: Padding( + padding: const .all(12), + child: Column( + crossAxisAlignment: .start, + spacing: 12, + children: [ + Row( + spacing: 4, + children: [ + const Icon( + Symbols.radar_rounded, + fill: 1, + color: Colors.lightBlue, + ), + BodyText.large('雷達回波'.i18n, weight: .bold), + FutureBuilder( + future: _radarListFuture, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) return const SizedBox.shrink(); + + return Container( + padding: const .fromLTRB(6, 4, 8, 4), + decoration: BoxDecoration( + borderRadius: .circular(64), + color: context.colors.surfaceContainerHighest, + ), + child: Row( + spacing: 4, + mainAxisSize: .min, + children: [ + Icon( + Symbols.schedule_rounded, + size: 14, + color: context.colors.onSurfaceVariant, + ), + LabelText.medium( + data.last.toSimpleDateTimeString(), + color: context.colors.onSurfaceVariant, + ), + ], + ), + ); + }, + ), + const Spacer(), + const Icon( + Symbols.chevron_right_rounded, + size: 16, + ), + ], + ), + ClipRRect( + borderRadius: .circular(12), + child: IgnorePointer( + child: SizedBox( + height: 200, + child: DpipMap( + initialCameraPosition: CameraPosition( + target: targetLocation, + zoom: targetZoom, + ), + onMapCreated: (controller) => _mapController = controller, + onStyleLoadedCallback: _setupMapLayers, + dragEnabled: false, + rotateGesturesEnabled: false, + zoomGesturesEnabled: false, + focusUserLocationWhenUpdated: false, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _homeModel?.removeListener(_onHomeModelChanged); + routeObserver.unsubscribe(this); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} diff --git a/lib/app/new_home/_widgets/station_info.dart b/lib/app/new_home/_widgets/station_info.dart new file mode 100644 index 000000000..850d818c2 --- /dev/null +++ b/lib/app/new_home/_widgets/station_info.dart @@ -0,0 +1,208 @@ +/// 首頁站點觀測卡片。 +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +class StationInfo extends StatelessWidget { + /// 繼承 [HomeModel] + const StationInfo({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.weather, + builder: (context, w, _) { + final hasData = w != null; + + final pairs = <_PairData>[ + _PairData( + icon: Symbols.water_drop_rounded, + color: Colors.cyan, + label: '濕度'.i18n, + value: hasData && w.data.humidity >= 0 ? '${w.data.humidity.round()}%' : null, + ), + _PairData( + icon: Symbols.compress_rounded, + color: Colors.purple, + label: '氣壓'.i18n, + value: hasData && w.data.pressure >= 0 ? '${w.data.pressure.round()} hPa' : null, + ), + _PairData( + icon: Symbols.rainy_rounded, + color: Colors.blue, + label: '降雨'.i18n, + value: hasData && w.data.rain >= 0 ? '${w.data.rain.toStringAsFixed(1)} mm' : null, + ), + _PairData( + icon: Symbols.visibility_rounded, + color: Colors.amber, + label: '能見度'.i18n, + value: hasData && w.data.visibility >= 0 ? '${w.data.visibility.round()} km' : null, + ), + _PairData( + icon: Symbols.wind_power_rounded, + color: Colors.teal, + label: '風速'.i18n, + value: hasData && w.data.wind.speed >= 0 + ? '${w.data.wind.speed.toStringAsFixed(1)} m/s' + : null, + ), + _PairData( + icon: Symbols.air_rounded, + color: Colors.orange, + label: '陣風'.i18n, + value: hasData && w.data.gust.speed >= 0 + ? '${w.data.gust.speed.toStringAsFixed(1)} m/s' + : null, + ), + ]; + + final divider = Divider( + height: 1, + thickness: 1, + color: context.colors.outlineVariant.withValues(alpha: 0.3), + ); + + return ResponsiveContainer( + child: Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .fromLTRB(12, 12, 12, 4), + child: Column( + crossAxisAlignment: .start, + children: [ + _buildStationHeader(context, w, hasData), + const SizedBox(height: 8), + _buildPairRow(pairs[0], pairs[1]), + divider, + _buildPairRow(pairs[2], pairs[3]), + divider, + _buildPairRow(pairs[4], pairs[5]), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildStationHeader(BuildContext context, RealtimeWeather? w, bool hasData) { + String timeStr = '--:--'; + if (hasData) { + final dt = DateTime.fromMillisecondsSinceEpoch(w!.time); + final hour = dt.hour; + final hour12 = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour); + final period = hour < 12 ? '上午'.i18n : '下午'.i18n; + timeStr = + '$period ${hour12.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String stationLabel = '--'; + if (hasData) { + stationLabel = w!.station.name; + if (w.station.distance >= 0) { + stationLabel += '・${w.station.distance.toStringAsFixed(1)}km'; + } + } + + return Row( + children: [ + Icon(Symbols.pin_drop_rounded, size: 16, color: context.colors.primary), + const SizedBox(width: 4), + Expanded( + child: Text( + stationLabel, + style: context.texts.labelMedium?.copyWith( + fontWeight: .w600, + color: context.colors.onSurface, + ), + overflow: .ellipsis, + ), + ), + Text( + timeStr, + style: context.texts.labelMedium?.copyWith( + fontWeight: .w600, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ); + } + + Widget _buildPairRow(_PairData left, _PairData right) { + return Row( + children: [ + Expanded(child: _MetricPair(data: left)), + const SizedBox(width: 12), + Expanded(child: _MetricPair(data: right)), + ], + ); + } +} + +class _PairData { + final IconData icon; + final Color color; + final String label; + final String? value; + + const _PairData({ + required this.icon, + required this.color, + required this.label, + required this.value, + }); +} + +class _MetricPair extends StatelessWidget { + final _PairData data; + + const _MetricPair({required this.data}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .symmetric(vertical: 10), + child: Row( + children: [ + Icon(data.icon, size: 18, color: data.color), + const SizedBox(width: 8), + Text( + data.label, + style: context.texts.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: .w500, + ), + ), + const Spacer(), + Text( + data.value ?? '—', + style: context.texts.titleMedium?.copyWith( + color: data.value != null + ? context.colors.onSurface + : context.colors.onSurfaceVariant.withValues(alpha: 0.4), + fontWeight: .w700, + height: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/temperature.dart b/lib/app/new_home/_widgets/temperature.dart new file mode 100644 index 000000000..4394a41ce --- /dev/null +++ b/lib/app/new_home/_widgets/temperature.dart @@ -0,0 +1,51 @@ +/// Large temperature display for the home page. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Displays the current temperature in a large, thin-weight format. +/// +/// Shows "--" when weather data is unavailable. Rebuilds only when the +/// temperature value changes. +class Temperature extends StatelessWidget { + /// Creates a [Temperature] widget. + const Temperature({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.weather?.data.temperature, + builder: (context, temp, _) { + final tempStr = temp != null ? temp.toStringAsFixed(1) : '--'; + return Padding( + padding: const .symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: .start, + crossAxisAlignment: .start, + children: [ + DisplayText.large( + tempStr, + color: context.colors.onSurface, + fontFamily: 'Google Sans Flex', + fontSize: 96, + fontVariations: [const .new('ROND', 100)], + ), + DisplayText.large( + '°', + color: context.colors.onSurface, + fontFamily: 'Google Sans Flex', + weight: .w300, + fontSize: 96, + fontVariations: [const .new('ROND', 100)], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/app/new_home/_widgets/thunderstorm_alert.dart b/lib/app/new_home/_widgets/thunderstorm_alert.dart new file mode 100644 index 000000000..fa3cbe5b9 --- /dev/null +++ b/lib/app/new_home/_widgets/thunderstorm_alert.dart @@ -0,0 +1,126 @@ +/// Thunderstorm advisory card for the new home page. +library; + +import 'package:dpip/api/model/history/history.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/route/event_viewer/thunderstorm.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color_scheme.dart'; +import 'package:dpip/utils/extensions/datetime.dart'; +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_text/styled_text.dart'; + +/// Displays an active thunderstorm advisory pulled from [HomeModel]. +/// +/// Renders nothing when no thunderstorm is active near the user's location. +class ThunderstormAlert extends StatelessWidget { + /// Creates a [ThunderstormAlert]. + const ThunderstormAlert({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.thunderstorm, + builder: (context, alert, _) { + if (alert == null) return const SizedBox.shrink(); + return _Card(alert: alert); + }, + ); + } +} + +class _Card extends StatelessWidget { + final History alert; + const _Card({required this.alert}); + + @override + Widget build(BuildContext context) { + final extended = context.theme.extendedColors; + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 4), + child: Material( + color: Colors.transparent, + clipBehavior: Clip.antiAlias, + borderRadius: .circular(20), + child: InkWell( + onTap: () => context.navigator.push( + MaterialPageRoute(builder: (_) => ThunderstormPage(item: alert)), + ), + splashColor: extended.blue.withValues(alpha: 0.18), + child: Ink( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: .topLeft, + end: .bottomRight, + colors: [ + extended.blueContainer, + Color.alphaBlend( + extended.blue.withValues(alpha: 0.18), + extended.blueContainer, + ), + ], + ), + border: Border.all(color: extended.blue, width: 1.2), + borderRadius: .circular(20), + ), + padding: const .all(14), + child: Row( + spacing: 12, + children: [ + Container( + padding: const .all(10), + decoration: BoxDecoration( + color: extended.blue, + borderRadius: .circular(12), + ), + child: Icon( + Symbols.thunderstorm_rounded, + color: extended.onBlue, + size: 24, + weight: 700, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + '雷雨即時訊息'.i18n, + style: context.texts.titleMedium?.copyWith( + color: extended.onBlueContainer, + fontWeight: .w700, + ), + ), + const SizedBox(height: 2), + StyledText( + text: '持續至 {time}'.i18n.args({ + 'time': alert.time.expiresAt.toSimpleDateTimeString(), + }), + style: context.texts.bodySmall?.copyWith( + color: extended.onBlueContainer.withValues(alpha: 0.85), + ), + tags: { + 'bold': StyledTextTag( + style: const TextStyle(fontWeight: .w700), + ), + }, + ), + ], + ), + ), + Icon( + Symbols.chevron_right_rounded, + color: extended.onBlueContainer, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/weather.dart b/lib/app/new_home/_widgets/weather.dart new file mode 100644 index 000000000..a6a9f6bec --- /dev/null +++ b/lib/app/new_home/_widgets/weather.dart @@ -0,0 +1,69 @@ +/// Weather condition icon and label for the home page. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +/// Displays the current weather condition as an icon and description label. +/// +/// Shows a generic offline icon when weather data is unavailable. Rebuilds only +/// when the weather description or code changes. +class Weather extends StatelessWidget { + /// Creates a [Weather] widget. + const Weather({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return d != null ? (d.weather, d.weatherCode) : null; + }, + builder: (context, data, _) { + final icon = data != null ? _weatherIcon(data.$2) : Symbols.cloud_off_rounded; + final color = data != null ? _weatherIconColor(data.$2) : Colors.grey; + final label = data?.$1 ?? '--'; + + return Padding( + padding: const .symmetric(horizontal: 16), + child: Row( + spacing: 8, + children: [ + Icon(icon, fill: 1, color: color), + BodyText.large( + label, + fontSize: 20, + color: context.colors.onSurface, + ), + ], + ), + ); + }, + ); + } +} + +IconData _weatherIcon(int code) => switch (code) { + >= 1 && <= 3 => Symbols.clear_day_rounded, + >= 4 && <= 7 => Symbols.partly_cloudy_day_rounded, + >= 8 && <= 14 => Symbols.cloud_rounded, + >= 15 && <= 22 => Symbols.rainy_rounded, + >= 23 && <= 28 => Symbols.rainy_heavy_rounded, + >= 29 && <= 35 => Symbols.thunderstorm_rounded, + >= 36 && <= 41 => Symbols.weather_snowy_rounded, + _ => Symbols.foggy_rounded, +}; + +Color _weatherIconColor(int code) => switch (code) { + >= 1 && <= 3 => Colors.orangeAccent, + >= 4 && <= 7 => Colors.amber, + >= 8 && <= 14 => Colors.grey, + >= 15 && <= 28 => Colors.blueAccent, + >= 29 && <= 35 => Colors.yellowAccent, + >= 36 && <= 41 => Colors.lightBlue, + _ => Colors.grey, +}; diff --git a/lib/app/new_home/_widgets/weather_background.dart b/lib/app/new_home/_widgets/weather_background.dart new file mode 100644 index 000000000..df2abe177 --- /dev/null +++ b/lib/app/new_home/_widgets/weather_background.dart @@ -0,0 +1,179 @@ +/// GPU-accelerated weather sky background driven by a fragment shader. +library; + +import 'dart:ui'; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_models/weather_params.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +typedef _BgParams = ({ + int scene, + double cloud, + double rain, + double wind, + double sunPhase, +}); + +/// Full-screen procedurally-generated sky background. +/// +/// Selects a time-of-day scene and continuous cloud, rain, and wind weights +/// from [HomeModel], then feeds them to a fragment shader. The [scrollOffset] +/// drives a layered parallax effect: distant elements drift slowly while +/// foreground elements drift more. +class WeatherBackground extends StatefulWidget { + /// Current scroll offset of the home page list. + final ValueListenable scrollOffset; + + /// Creates a [WeatherBackground] driven by [scrollOffset]. + const WeatherBackground({required this.scrollOffset, super.key}); + + @override + State createState() => _WeatherBackgroundState(); +} + +class _WeatherBackgroundState extends State with SingleTickerProviderStateMixin { + FragmentShader? _shader; + late final AnimationController _ticker = AnimationController( + duration: const Duration(seconds: 60), + vsync: this, + )..repeat(); + final _epoch = DateTime.now().millisecondsSinceEpoch; + + double get _elapsed => (DateTime.now().millisecondsSinceEpoch - _epoch) / 1000.0; + + Future _loadShader() async { + try { + final program = await FragmentProgram.fromAsset('shaders/weather_sky.frag'); + if (mounted) setState(() => _shader = program.fragmentShader()); + } catch (e) { + debugPrint('Failed to load weather_sky shader: $e'); + } + } + + @override + void initState() { + super.initState(); + _loadShader(); + } + + @override + Widget build(BuildContext context) { + final light = context.theme.brightness == .light ? 1.0 : 0.0; + + return Selector( + selector: (_, m) { + final d = m.weather?.data; + final now = DateTime.now(); + + return ( + scene: resolveSkyScene(now.hour), + cloud: cloudWeight(d), + rain: rainWeight(d), + wind: windWeight(d), + sunPhase: sunPhase(now), + ); + }, + builder: (context, params, _) { + if (_shader == null) { + return ColoredBox( + color: _fallbackColor(params.scene, light), + ); + } + + return AnimatedBuilder( + animation: .merge([_ticker, widget.scrollOffset]), + builder: (context, _) { + return CustomPaint( + painter: _SkyShaderPainter( + shader: _shader!, + scene: params.scene, + cloud: params.cloud, + rain: params.rain, + wind: params.wind, + sunPhase: params.sunPhase, + light: light, + time: _elapsed, + scroll: widget.scrollOffset.value, + ), + size: .infinite, + ); + }, + ); + }, + ); + } + + @override + void dispose() { + _ticker.dispose(); + _shader?.dispose(); + super.dispose(); + } +} + +Color _fallbackColor(int scene, double light) { + if (scene == 0 && light > 0.5) return const Color(0xFFAFD4F0); + + return switch (scene) { + 1 => const Color(0xFF0A1220), + 2 => const Color(0xFF5E2455), + 3 => const Color(0xFFA0331E), + _ => const Color(0xFF1E6FC4), + }; +} + +class _SkyShaderPainter extends CustomPainter { + final double cloud; + final double light; + final double rain; + final int scene; + final double scroll; + final FragmentShader shader; + final double sunPhase; + final double time; + final double wind; + + const _SkyShaderPainter({ + required this.shader, + required this.scene, + required this.cloud, + required this.rain, + required this.wind, + required this.sunPhase, + required this.light, + required this.time, + required this.scroll, + }); + + @override + void paint(Canvas canvas, Size size) { + shader + ..setFloat(0, time) + ..setFloat(1, size.width) + ..setFloat(2, size.height) + ..setFloat(3, scene.toDouble()) + ..setFloat(4, scroll) + ..setFloat(5, cloud) + ..setFloat(6, rain) + ..setFloat(7, wind) + ..setFloat(8, sunPhase) + ..setFloat(9, light); + + canvas.drawRect(Offset.zero & size, Paint()..shader = shader); + } + + @override + bool shouldRepaint(_SkyShaderPainter old) => + old.scene != scene || + old.time != time || + old.scroll != scroll || + old.cloud != cloud || + old.rain != rain || + old.wind != wind || + old.sunPhase != sunPhase || + old.light != light; +} diff --git a/lib/app/new_home/_widgets/weather_parameters.dart b/lib/app/new_home/_widgets/weather_parameters.dart new file mode 100644 index 000000000..679c60d5d --- /dev/null +++ b/lib/app/new_home/_widgets/weather_parameters.dart @@ -0,0 +1,125 @@ +/// A grid of weather parameter cards for the home page. +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +typedef _Params = ({ + double? humidity, + RealtimeWeatherWind? wind, + double? rain, +}); + +/// A 2×2 grid of cards showing humidity, air quality, wind, and rainfall. +/// +/// Reads values from [HomeModel] and rebuilds only when the relevant weather +/// parameters change. +class WeatherParameters extends StatelessWidget { + /// Creates a [WeatherParameters] widget. + const WeatherParameters({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return ( + humidity: d?.humidity, + wind: d?.wind, + rain: d?.rain, + ); + }, + builder: (context, params, _) { + final (:humidity, :wind, :rain) = params; + + return GridView.count( + crossAxisCount: 2, + childAspectRatio: 7 / 5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + padding: const .symmetric(horizontal: 12, vertical: 4), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + children: [ + _ParameterCard( + icon: const Icon(Symbols.water_drop_rounded, fill: 1, color: Colors.blueAccent), + label: Text('相對溼度'.i18n), + value: humidity != null ? '${humidity.round()}%' : '--', + ), + _ParameterCard( + icon: const Icon(Symbols.mist_rounded, fill: 1, color: Colors.grey), + label: Text('空氣品質'.i18n), + value: '--', + ), + _ParameterCard( + icon: const Icon(Symbols.air_rounded, fill: 1, color: Colors.lightBlue), + label: Text('風向/風速'.i18n), + value: wind != null && wind.direction.isNotEmpty ? wind.direction : '--', + footer: wind != null ? Text('${wind.speed.toStringAsFixed(1)} m/s') : null, + ), + _ParameterCard( + icon: const Icon(Symbols.umbrella_rounded, fill: 1, color: Colors.indigoAccent), + label: Text('降水量'.i18n), + value: rain != null ? '${rain.toStringAsFixed(1)} mm' : '--', + ), + ], + ); + }, + ); + } +} + +class _ParameterCard extends StatelessWidget { + final Icon icon; + final Widget label; + final String value; + final Widget? footer; + + const _ParameterCard({ + required this.icon, + required this.label, + required this.value, + this.footer, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + Row( + spacing: 4, + children: [ + icon, + DefaultTextStyle( + style: context.texts.bodyMedium!.copyWith( + color: context.colors.onSurfaceVariant, + ), + child: label, + ), + ], + ), + HeadLineText.medium(value, weight: .bold), + if (footer != null) + DefaultTextStyle( + style: context.texts.bodyLarge!.copyWith( + color: context.colors.onSurfaceVariant, + ), + child: footer!, + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/weather_particles.dart b/lib/app/new_home/_widgets/weather_particles.dart new file mode 100644 index 000000000..b5149e9a6 --- /dev/null +++ b/lib/app/new_home/_widgets/weather_particles.dart @@ -0,0 +1,192 @@ +/// GPU-light particle overlay (rain, snow, lightning) for the weather background. +library; + +import 'dart:math'; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_models/weather_params.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +typedef _ParticleParams = ({ + double rain, + int weatherCode, + double wind, +}); + +/// Animated weather particles layered above the shader sky background. +/// +/// Renders nothing when rain weight is negligible. Snow takes over below ~5°C +/// when rain weight is moderate, and rare lightning flashes appear with the +/// thunderstorm weather code. +class WeatherParticles extends StatefulWidget { + /// Creates a [WeatherParticles] overlay. + const WeatherParticles({super.key}); + + @override + State createState() => _WeatherParticlesState(); +} + +class _WeatherParticlesState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ticker = AnimationController( + vsync: this, + duration: const Duration(seconds: 60), + )..repeat(); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return ( + rain: rainWeight(d), + weatherCode: d?.weatherCode ?? 0, + wind: windWeight(d), + ); + }, + builder: (context, p, _) { + if (p.rain < 0.05) return const SizedBox.shrink(); + + final temp = context.read().weather?.data.temperature ?? 20; + final isSnow = temp < 5 && p.rain > 0.2; + final isThunder = (p.weatherCode % 100) == 14 || + (p.weatherCode % 100) == 17 || + (p.weatherCode % 100) == 18 || + (p.weatherCode % 100) == 19; + + return IgnorePointer( + child: RepaintBoundary( + child: CustomPaint( + size: .infinite, + painter: _ParticlePainter( + listenable: _ticker, + isSnow: isSnow, + isThunder: isThunder, + density: p.rain.clamp(0.0, 1.0), + wind: p.wind, + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } +} + +class _ParticlePainter extends CustomPainter { + final Animation listenable; + final bool isSnow; + final bool isThunder; + final double density; + final double wind; + + /// Deterministic particle seeds so each particle keeps a stable identity + /// across frames — only its phase advances. + static final List<_Seed> _seeds = List.generate(120, (i) { + final r = Random(i * 31 + 7); + return _Seed( + x: r.nextDouble(), + speed: 0.6 + r.nextDouble() * 0.9, + scale: 0.5 + r.nextDouble() * 0.7, + phase: r.nextDouble(), + ); + }); + + _ParticlePainter({ + required this.listenable, + required this.isSnow, + required this.isThunder, + required this.density, + required this.wind, + }) : super(repaint: listenable); + + @override + void paint(Canvas canvas, Size size) { + final t = listenable.value; + final count = (_seeds.length * density).clamp(8, _seeds.length.toDouble()).toInt(); + final windOffset = wind * 80; + + if (isSnow) { + _paintSnow(canvas, size, t, count, windOffset); + } else { + _paintRain(canvas, size, t, count, windOffset); + } + if (isThunder) { + _paintLightning(canvas, size, t); + } + } + + void _paintRain(Canvas canvas, Size size, double t, int count, double wOff) { + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.45) + ..strokeCap = StrokeCap.round + ..strokeWidth = 1.4; + + for (var i = 0; i < count; i++) { + final s = _seeds[i]; + // Vertical fall cycle: phase advances by speed * 4 cycles per minute (t∈[0,1)). + final progress = (s.phase + t * s.speed * 8) % 1.0; + final y = progress * (size.height + 60) - 30; + final x = s.x * size.width + wOff * progress; + final dropLen = 12 + s.scale * 8; + canvas.drawLine( + Offset(x, y), + Offset(x - wOff * 0.04, y + dropLen), + paint..strokeWidth = 1.2 * s.scale, + ); + } + } + + void _paintSnow(Canvas canvas, Size size, double t, int count, double wOff) { + final paint = Paint()..color = Colors.white.withValues(alpha: 0.85); + for (var i = 0; i < count; i++) { + final s = _seeds[i]; + final progress = (s.phase + t * s.speed * 2) % 1.0; + final y = progress * (size.height + 30) - 15; + // Slight horizontal sway. + final sway = sin((s.phase + t * 4) * pi * 2) * 12 * s.scale; + final x = s.x * size.width + wOff * progress + sway; + final radius = 1.5 + s.scale * 2.5; + canvas.drawCircle(Offset(x, y), radius, paint); + } + } + + void _paintLightning(Canvas canvas, Size size, double t) { + // ~3 flashes per minute. Each flash lasts ~120ms. + final phase = (t * 6) % 1.0; + if (phase > 0.04) return; + final alpha = (1 - phase / 0.04) * 0.7; + canvas.drawRect( + Offset.zero & size, + Paint()..color = Colors.white.withValues(alpha: alpha.clamp(0.0, 0.7)), + ); + } + + @override + bool shouldRepaint(_ParticlePainter old) => + old.isSnow != isSnow || + old.isThunder != isThunder || + old.density != density || + old.wind != wind; +} + +class _Seed { + final double x; + final double speed; + final double scale; + final double phase; + + const _Seed({ + required this.x, + required this.speed, + required this.scale, + required this.phase, + }); +} diff --git a/lib/app/new_home/_widgets/wind.dart b/lib/app/new_home/_widgets/wind.dart new file mode 100644 index 000000000..037794e41 --- /dev/null +++ b/lib/app/new_home/_widgets/wind.dart @@ -0,0 +1,24 @@ +/// 風向卡。 +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/home/_widgets/wind_card.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Wind extends StatelessWidget { + /// 繼承 [HomeModel] + const Wind({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.weather, + builder: (context, weather, _) { + if (weather == null) return const SizedBox.shrink(); + return WindCard(weather); + }, + ); + } +} diff --git a/lib/app/new_home/events/page.dart b/lib/app/new_home/events/page.dart new file mode 100644 index 000000000..0526df0d2 --- /dev/null +++ b/lib/app/new_home/events/page.dart @@ -0,0 +1,274 @@ +/// Events tab page — full history list grouped by date. +library; + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/api/model/history/history.dart'; +import 'package:dpip/app/home/_widgets/history_timeline_item.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/datetime.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +/// Mode for the events list — regional vs national, active vs history. +enum _EventsMode { localActive, localHistory, nationalActive, nationalHistory } + +/// Dedicated events tab page with mode-switchable timeline. +class EventsPage extends StatefulWidget { + /// Creates an [EventsPage]. + const EventsPage({super.key}); + + @override + State createState() => _EventsPageState(); +} + +class _EventsPageState extends State { + _EventsMode _mode = _EventsMode.localActive; + List? _events; + Object? _error; + bool _loading = false; + + Future _fetch() async { + setState(() => _loading = true); + final code = GlobalProviders.location.code; + // Fall back to national when no location is set and a local mode is selected. + var effectiveMode = _mode; + if (code == null) { + if (_mode == _EventsMode.localActive) effectiveMode = _EventsMode.nationalActive; + if (_mode == _EventsMode.localHistory) effectiveMode = _EventsMode.nationalHistory; + } + + try { + final list = switch (effectiveMode) { + _EventsMode.localActive => await ExpTech().getRealtimeRegion(code!), + _EventsMode.localHistory => await ExpTech().getHistoryRegion(code!), + _EventsMode.nationalActive => await ExpTech().getRealtime(), + _EventsMode.nationalHistory => await ExpTech().getHistory(), + }; + if (!mounted) return; + setState(() { + _events = list..sort((a, b) => b.time.send.compareTo(a.time.send)); + _error = null; + _loading = false; + }); + } catch (e, s) { + TalkerManager.instance.error('EventsPage.fetch', e, s); + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return Scaffold( + backgroundColor: colors.surface, + body: RefreshIndicator( + onRefresh: _fetch, + child: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: Text('事件'.i18n), + pinned: true, + ), + SliverToBoxAdapter( + child: _ModeBar( + current: _mode, + hasLocation: GlobalProviders.location.code != null, + onChanged: (m) { + if (m == _mode) return; + setState(() => _mode = m); + _fetch(); + }, + ), + ), + if (_loading && _events == null) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null && _events == null) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + Symbols.error_rounded, + color: colors.error, + size: 36, + ), + const SizedBox(height: 8), + Text('載入失敗'.i18n), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: _fetch, + child: Text('再試一次'.i18n), + ), + ], + ), + ), + ) + else if (_events == null || _events!.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const .all(24), + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + Symbols.event_busy_rounded, + size: 48, + color: colors.outline, + ), + const SizedBox(height: 12), + Text( + '尚無事件'.i18n, + style: context.texts.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + _Timeline(events: _events!), + const SliverPadding(padding: EdgeInsets.only(bottom: 16)), + ], + ), + ), + ); + } +} + +class _ModeBar extends StatelessWidget { + final _EventsMode current; + final bool hasLocation; + final ValueChanged<_EventsMode> onChanged; + + const _ModeBar({ + required this.current, + required this.hasLocation, + required this.onChanged, + }); + + bool get _isLocal => + current == _EventsMode.localActive || current == _EventsMode.localHistory; + + bool get _isActive => + current == _EventsMode.localActive || current == _EventsMode.nationalActive; + + void _select({required bool local, required bool active}) { + onChanged(switch ((local, active)) { + (true, true) => _EventsMode.localActive, + (true, false) => _EventsMode.localHistory, + (false, true) => _EventsMode.nationalActive, + (false, false) => _EventsMode.nationalHistory, + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .fromLTRB(16, 4, 16, 12), + child: Column( + crossAxisAlignment: .stretch, + children: [ + // Region filter. + SegmentedButton( + segments: [ + ButtonSegment( + value: true, + label: Text('所在地'.i18n), + icon: const Icon(Symbols.location_on_rounded), + enabled: hasLocation, + ), + ButtonSegment( + value: false, + label: Text('全國'.i18n), + icon: const Icon(Symbols.public_rounded), + ), + ], + selected: {_isLocal && hasLocation}, + onSelectionChanged: (s) => _select(local: s.first, active: _isActive), + showSelectedIcon: false, + ), + const SizedBox(height: 8), + // Time filter. + SegmentedButton( + segments: [ + ButtonSegment( + value: true, + label: Text('生效中'.i18n), + icon: const Icon(Symbols.notifications_active_rounded), + ), + ButtonSegment( + value: false, + label: Text('歷史'.i18n), + icon: const Icon(Symbols.history_rounded), + ), + ], + selected: {_isActive}, + onSelectionChanged: (s) => + _select(local: _isLocal && hasLocation, active: s.first), + showSelectedIcon: false, + ), + ], + ), + ); + } +} + +class _Timeline extends StatelessWidget { + final List events; + const _Timeline({required this.events}); + + @override + Widget build(BuildContext context) { + final grouped = groupBy( + events, + (History e) => e.time.send.toLocaleFullDateString(context), + ).entries.sorted((a, b) => b.key.compareTo(a.key)).toList(); + + return SliverList.list( + children: [ + for (var i = 0; i < grouped.length; i++) ...[ + Padding( + padding: const .fromLTRB(16, 16, 16, 8), + child: Text( + grouped[i].key, + style: context.texts.titleMedium?.copyWith( + fontWeight: .w700, + ), + ), + ), + for (final item in grouped[i].value) + HistoryTimelineItem( + history: item, + expired: item.isExpired, + last: item == grouped[i].value.last, + ), + ], + ], + ); + } +} diff --git a/lib/app/new_home/layout.dart b/lib/app/new_home/layout.dart new file mode 100644 index 000000000..554bdb7b0 --- /dev/null +++ b/lib/app/new_home/layout.dart @@ -0,0 +1,192 @@ +/// Single stateful shell hosting all 5 bottom-nav tabs via [IndexedStack]. +/// +/// Tabs preserve state between switches (no rebuild) and the bottom nav is +/// fixed at the bottom of the screen. Routes can pass [initialTab] for deep +/// linking; the shell exposes [NewHomeShell.of] for descendants to switch +/// tabs programmatically. +library; + +import 'package:dpip/app/map/page.dart'; +import 'package:dpip/app/new_home/events/page.dart'; +import 'package:dpip/app/new_home/menu/page.dart'; +import 'package:dpip/app/new_home/page.dart'; +import 'package:dpip/app/new_home/weather/page.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +/// Home tab index. +const int tabHome = 0; + +/// Events timeline tab index. +const int tabEvents = 1; + +/// Map tab index. +const int tabMap = 2; + +/// Weather detail tab index. +const int tabWeather = 3; + +/// Menu tab index. +const int tabMenu = 4; + +/// Single-instance stateful shell that hosts every tab. +class NewHomeShell extends StatefulWidget { + /// The tab to display on first build. + final int initialTab; + + /// Creates a [NewHomeShell] starting on [initialTab]. + const NewHomeShell({super.key, this.initialTab = tabHome}); + + /// Returns the nearest enclosing [NewHomeShellState], or `null` when not + /// inside a shell. + static NewHomeShellState? of(BuildContext context) => + context.findAncestorStateOfType(); + + @override + State createState() => NewHomeShellState(); +} + +/// Public state allowing descendants to call [setTab]. +class NewHomeShellState extends State { + late int _tabIndex = widget.initialTab; + late final Set _visited = {_tabIndex}; + + /// Switches the active tab. + void setTab(int index) { + if (index == _tabIndex) return; + setState(() { + _visited.add(index); + _tabIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _tabIndex, + children: [ + _LazyTab(visited: _visited.contains(tabHome), child: const NewHomePage()), + _LazyTab(visited: _visited.contains(tabEvents), child: const EventsPage()), + _LazyTab( + visited: _visited.contains(tabMap), + child: const MapPage(showBackButton: false), + ), + _LazyTab( + visited: _visited.contains(tabWeather), + child: const WeatherDetailPage(), + ), + _LazyTab(visited: _visited.contains(tabMenu), child: const MenuPage()), + ], + ), + bottomNavigationBar: _BottomNav( + selectedIndex: _tabIndex, + onTap: setTab, + ), + ); + } +} + +/// Builds [child] only after its tab has been visited at least once, then +/// keeps it alive in the stack for subsequent visits. +class _LazyTab extends StatelessWidget { + final bool visited; + final Widget child; + + const _LazyTab({required this.visited, required this.child}); + + @override + Widget build(BuildContext context) { + if (!visited) return const SizedBox.shrink(); + return child; + } +} + +/// Fixed-position bottom navigation bar using the Material 3 [NavigationBar]. +class _BottomNav extends StatelessWidget { + final int selectedIndex; + final ValueChanged onTap; + + const _BottomNav({required this.selectedIndex, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return NavigationBarTheme( + data: NavigationBarThemeData( + height: 72, + backgroundColor: colors.surfaceContainer, + surfaceTintColor: Colors.transparent, + elevation: 3, + indicatorColor: colors.secondaryContainer, + indicatorShape: const StadiumBorder(), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + size: 24, + color: states.contains(WidgetState.selected) + ? colors.onSecondaryContainer + : colors.onSurfaceVariant, + ), + ), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final base = context.texts.labelMedium ?? const TextStyle(); + return base.copyWith( + fontWeight: states.contains(WidgetState.selected) ? .w700 : .w500, + color: states.contains(WidgetState.selected) + ? colors.onSurface + : colors.onSurfaceVariant, + ); + }), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onTap, + destinations: [ + NavigationDestination( + icon: const Icon(Symbols.home_rounded), + selectedIcon: const Icon(Symbols.home_rounded, fill: 1), + label: '首頁'.i18n, + ), + NavigationDestination( + icon: const Icon(Symbols.history_rounded), + selectedIcon: const Icon(Symbols.history_rounded, fill: 1), + label: '事件'.i18n, + ), + NavigationDestination( + icon: const Icon(Symbols.map_rounded), + selectedIcon: const Icon(Symbols.map_rounded, fill: 1), + label: '地圖'.i18n, + ), + NavigationDestination( + icon: const Icon(Symbols.partly_cloudy_day_rounded), + selectedIcon: const Icon(Symbols.partly_cloudy_day_rounded, fill: 1), + label: '天氣'.i18n, + ), + NavigationDestination( + icon: const Icon(Symbols.menu_rounded), + selectedIcon: const Icon(Symbols.menu_rounded, fill: 1), + label: '選單'.i18n, + ), + ], + ), + ); + } +} + +/// Backwards-compat shim — keeps the old name used elsewhere in routing. +/// +/// Routes that wrapped a page in [NewHomeLayout] now produce a [NewHomeShell]. +/// The [child] is ignored because each tab has its own dedicated widget. +class NewHomeLayout extends StatelessWidget { + /// Ignored — the shell owns its own tab widgets. Kept for source compat. + final Widget child; + + /// Creates a [NewHomeLayout]. The [child] argument is ignored. + const NewHomeLayout({required this.child, super.key}); + + @override + Widget build(BuildContext context) => const NewHomeShell(); +} diff --git a/lib/app/new_home/menu/page.dart b/lib/app/new_home/menu/page.dart new file mode 100644 index 000000000..49dd638d5 --- /dev/null +++ b/lib/app/new_home/menu/page.dart @@ -0,0 +1,215 @@ +/// Menu tab — quick links to settings sub-sections and info pages. +library; + +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/router.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/string.dart'; +import 'package:dpip/widgets/list/segmented_list.dart'; +import 'package:dpip/widgets/ui/icon_container.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:simple_icons/simple_icons.dart'; + +/// Menu tab page providing direct entry points into all settings sections +/// and external links. +class MenuPage extends StatelessWidget { + /// Creates a [MenuPage]. + const MenuPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.colors.surface, + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: Text('選單'.i18n), + pinned: true, + ), + SliverList.list( + children: [ + // 設定 + SegmentedList( + label: Text('設定'.i18n), + children: [ + SegmentedListTile( + isFirst: true, + leading: ContainedIcon( + Symbols.pin_drop_rounded, + color: Colors.deepOrangeAccent, + ), + title: Text('所在地'.i18n), + subtitle: Text('設定接收即時資訊的地區'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsLocationRoute().push(context), + ), + SegmentedListTile( + leading: ContainedIcon( + Symbols.notifications_rounded, + color: Colors.amberAccent, + ), + title: Text('通知'.i18n), + subtitle: Text('推播通知設定與測試'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsNotifyRoute().push(context), + ), + SegmentedListTile( + leading: ContainedIcon( + Symbols.palette_rounded, + color: Colors.indigoAccent, + ), + title: Text('主題'.i18n), + subtitle: Text('調整 DPIP 的外觀與顏色'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsThemeRoute().push(context), + ), + SegmentedListTile( + leading: ContainedIcon( + Symbols.translate_rounded, + color: Colors.tealAccent, + ), + title: Text('語言'.i18n), + subtitle: Text('調整顯示語言'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsLocaleRoute().push(context), + ), + SegmentedListTile( + leading: ContainedIcon( + Symbols.percent_rounded, + color: Colors.orangeAccent, + ), + title: Text('單位'.i18n), + subtitle: Text('調整顯示數值的單位'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsUnitRoute().push(context), + ), + SegmentedListTile( + isLast: true, + leading: ContainedIcon( + Symbols.tune_rounded, + color: Colors.blueGrey, + ), + title: Text('進階設定'.i18n), + subtitle: Text('版面、地圖、網路、實驗性功能'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsIndexRoute().push(context), + ), + ], + ), + + // 資訊 + SegmentedList( + label: Text('資訊'.i18n), + children: [ + SegmentedListTile( + isFirst: true, + leading: ContainedIcon( + Symbols.update_rounded, + color: Colors.cyanAccent, + ), + title: Text('更新日誌'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const ChangelogRoute().push(context), + ), + SegmentedListTile( + isLast: true, + leading: ContainedIcon( + Symbols.book_rounded, + color: Colors.brown, + ), + title: Text('第三方套件授權'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const LicenseRoute().push(context), + ), + ], + ), + + // 贊助 + SegmentedList( + children: [ + SegmentedListTile( + isFirst: true, + isLast: true, + leading: ContainedIcon( + Symbols.volunteer_activism_rounded, + color: Colors.black, + backgroundGradient: const LinearGradient( + colors: [Color(0xFFFFD700), Color(0xFFFFA500)], + begin: .topLeft, + end: .bottomRight, + ), + ), + title: Text('贊助我們'.i18n, style: .new(color: Colors.amber[600])), + subtitle: Text('幫助伺服器穩定與持續開發'.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + tileColor: Colors.amber.withValues(alpha: 0.16), + shape: RoundedRectangleBorder( + borderRadius: .circular(20), + side: BorderSide(color: Colors.amber.withValues(alpha: 0.6)), + ), + onTap: () => const SettingsDonateRoute().push(context), + ), + ], + ), + + // 社群連結 + SegmentedList( + label: Text('ExpTech Studio'), + children: [ + SegmentedListTile( + isFirst: true, + leading: ContainedIcon( + SimpleIcons.github, + color: context.theme.brightness == .dark + ? Colors.white + : Colors.black, + ), + title: const Text('GitHub'), + subtitle: const Text('ExpTechTW'), + trailing: const Icon(Symbols.arrow_outward_rounded), + onTap: () => 'https://github.com/ExpTechTW/DPIP-Pocket'.launch(), + ), + SegmentedListTile( + leading: const ContainedIcon( + SimpleIcons.discord, + color: Color(0xff5865F2), + ), + title: const Text('Discord'), + subtitle: const Text('.gg/exptech-studio'), + trailing: const Icon(Symbols.arrow_outward_rounded), + onTap: () => 'https://discord.gg/exptech-studio'.launch(), + ), + SegmentedListTile( + leading: ContainedIcon( + SimpleIcons.threads, + color: context.theme.brightness == .dark + ? Colors.white + : Colors.black, + ), + title: const Text('Threads'), + subtitle: const Text('@dpip.tw'), + trailing: const Icon(Symbols.arrow_outward_rounded), + onTap: () => 'https://www.threads.net/@dpip.tw'.launch(), + ), + SegmentedListTile( + isLast: true, + leading: const ContainedIcon( + SimpleIcons.youtube, + color: Color(0xFFFF0000), + ), + title: const Text('YouTube'), + subtitle: const Text('@exptechtw'), + trailing: const Icon(Symbols.arrow_outward_rounded), + onTap: () => 'https://www.youtube.com/@exptechtw/live'.launch(), + ), + ], + ), + SizedBox(height: context.padding.bottom + 96), + ], + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/page.dart b/lib/app/new_home/page.dart new file mode 100644 index 000000000..7c3cf408a --- /dev/null +++ b/lib/app/new_home/page.dart @@ -0,0 +1,158 @@ +/// The new home page — a concise weather + alerts + events dashboard. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_models/weather_params.dart'; +import 'package:dpip/app/new_home/_widgets/assistant_hint.dart'; +import 'package:dpip/app/new_home/_widgets/day_cycle.dart'; +import 'package:dpip/app/new_home/_widgets/eew_alert.dart'; +import 'package:dpip/app/new_home/_widgets/events_timeline.dart'; +import 'package:dpip/app/new_home/_widgets/forecast.dart'; +import 'package:dpip/app/new_home/_widgets/greeting.dart'; +import 'package:dpip/app/new_home/_widgets/location_chip.dart'; +import 'package:dpip/app/new_home/_widgets/radar.dart'; +import 'package:dpip/app/new_home/_widgets/station_info.dart'; +import 'package:dpip/app/new_home/_widgets/temperature.dart'; +import 'package:dpip/app/new_home/_widgets/thunderstorm_alert.dart'; +import 'package:dpip/app/new_home/_widgets/weather.dart'; +import 'package:dpip/app/new_home/_widgets/weather_background.dart'; +import 'package:dpip/app/new_home/_widgets/weather_particles.dart'; +import 'package:dpip/app/new_home/_widgets/wind.dart'; +import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// The main home page widget. +/// +/// Lazily creates a [HomeModel] on first dependency resolution, provides it to +/// all child widgets, supports pull-to-refresh, and automatically refreshes +/// weather data every 30 minutes. The page mirrors the original home's hero + +/// alerts + events flow with a modern shader + particle weather background. +class NewHomePage extends StatefulWidget { + /// Creates a [NewHomePage]. + const NewHomePage({super.key}); + + @override + State createState() => _NewHomePageState(); +} + +class _NewHomePageState extends State { + final _scrollOffset = ValueNotifier(0); + final _scrollController = ScrollController(); + HomeModel? _homeModel; + + void _onScroll() => _scrollOffset.value = _scrollController.offset; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _homeModel ??= HomeModel(context.read())..startAutoRefresh(); + } + + @override + Widget build(BuildContext context) { + final homeModel = _homeModel!; + + return ChangeNotifierProvider.value( + value: homeModel, + child: Selector( + selector: (_, m) { + final d = m.weather?.data; + return ( + scene: resolveSkyScene(DateTime.now().hour), + cloud: cloudWeight(d), + rain: rainWeight(d), + ); + }, + builder: (context, params, _) { + final colorScheme = ColorScheme.fromSeed( + seedColor: _seedColor(params.scene, params.cloud, params.rain), + brightness: context.theme.brightness, + ); + + return AnimatedTheme( + duration: const Duration(milliseconds: 600), + data: context.theme.copyWith( + colorScheme: colorScheme, + cardTheme: CardThemeData( + color: colorScheme.surface / 95, + ), + ), + child: Stack( + children: [ + // Layered weather background: shader sky + animated particles. + Positioned.fill(child: WeatherBackground(scrollOffset: _scrollOffset)), + const Positioned.fill(child: WeatherParticles()), + RefreshIndicator( + onRefresh: homeModel.manualRefresh, + child: ListView( + controller: _scrollController, + children: const [ + Greeting(), + LocationChip(), + SizedBox(height: 8), + // Critical alerts surface first. + EewAlerts(), + ThunderstormAlert(), + SizedBox(height: 8), + // Weather hero. + Temperature(), + Weather(), + SizedBox(height: 16), + // Detail cards. + AssistantHint(), + StationInfo(), + Forecast(), + Wind(), + DayCycle(), + SizedBox(height: 16), + // Radar preview - 1 tap to full map. + Radar(), + // Events timeline - the originally-missing list. + EventsTimeline(), + SizedBox(height: 16), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + _scrollOffset.dispose(); + _homeModel?.dispose(); + super.dispose(); + } +} + +Color _seedColor(int scene, double cloud, double rain) { + final base = switch (scene) { + 1 => const Color(0xFF1A237E), + 2 => const Color(0xFF6A1B9A), + 3 => const Color(0xFFC62828), + _ => const Color(0xFF1565C0), + }; + if (rain > 0.4) { + return Color.lerp(base, const Color(0xFF263238), ((rain - 0.4) * 1.2).clamp(0.0, 0.7))!; + } + if (cloud > 0.5) { + return Color.lerp(base, const Color(0xFF546E7A), (cloud - 0.5) * 0.5)!; + } + return base; +} diff --git a/lib/app/new_home/weather/page.dart b/lib/app/new_home/weather/page.dart new file mode 100644 index 000000000..3dc32a2ed --- /dev/null +++ b/lib/app/new_home/weather/page.dart @@ -0,0 +1,72 @@ +/// Weather detail tab — observation values, forecast, sun/moon cycle. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_widgets/all_observation_average.dart'; +import 'package:dpip/app/new_home/_widgets/assistant_hint.dart'; +import 'package:dpip/app/new_home/_widgets/day_cycle.dart'; +import 'package:dpip/app/new_home/_widgets/forecast_strip.dart'; +import 'package:dpip/app/new_home/_widgets/weather_parameters.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Detailed weather tab page. +/// +/// Provides a [HomeModel] so the weather widgets work the same way they do on +/// the main home tab, even when the user lands here directly. +class WeatherDetailPage extends StatefulWidget { + /// Creates a [WeatherDetailPage]. + const WeatherDetailPage({super.key}); + + @override + State createState() => _WeatherDetailPageState(); +} + +class _WeatherDetailPageState extends State { + HomeModel? _homeModel; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _homeModel ??= HomeModel(context.read())..startAutoRefresh(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _homeModel!, + child: Scaffold( + backgroundColor: context.colors.surface, + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + title: Text('天氣'.i18n), + pinned: true, + ), + const SliverToBoxAdapter( + child: Column( + children: [ + AssistantHint(), + ForecastStrip(), + AllObservationAverage(), + WeatherParameters(), + DayCycle(), + ], + ), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 16)), + ], + ), + ), + ); + } + + @override + void dispose() { + _homeModel?.dispose(); + super.dispose(); + } +} diff --git a/lib/app/settings/donate/page.dart b/lib/app/settings/donate/page.dart index 932344b13..bce652498 100644 --- a/lib/app/settings/donate/page.dart +++ b/lib/app/settings/donate/page.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color.dart'; import 'package:dpip/utils/extensions/product_detail.dart'; import 'package:dpip/utils/functions.dart'; import 'package:flutter/material.dart'; @@ -115,8 +116,8 @@ class _SettingsDonatePageState extends State decoration: BoxDecoration( gradient: LinearGradient( colors: [ - context.colors.primaryContainer.withOpacity(0.5), - context.colors.tertiaryContainer.withOpacity(0.5), + context.colors.primaryContainer / 0.5, + context.colors.tertiaryContainer / 0.5, ], begin: .topLeft, end: .bottomRight, @@ -205,9 +206,7 @@ class _SettingsDonatePageState extends State final isProcessing = processingProductId == product.id; final isPurchased = purchasedProductIds.contains(product.id); - final title = product.title.contains('(') - ? product.title.substring(0, product.title.indexOf('(')).trim() - : product.title; + final title = product.title.split('(').first.trim(); return AnimatedBuilder( animation: _shimmerController, @@ -399,9 +398,7 @@ class _SettingsDonatePageState extends State final isDisabled = processingProductId != null && processingProductId != product.id; final isProcessing = processingProductId == product.id; - final title = product.title.contains('(') - ? product.title.substring(0, product.title.indexOf('(')).trim() - : product.title; + final title = product.title.split('(').first.trim(); return Container( margin: const .symmetric(horizontal: 16, vertical: 6), diff --git a/lib/app/settings/experimental/page.dart b/lib/app/settings/experimental/page.dart index a9279db2f..2ddff00c4 100644 --- a/lib/app/settings/experimental/page.dart +++ b/lib/app/settings/experimental/page.dart @@ -1,133 +1,24 @@ /// Experimental features settings page. library; -import 'dart:async'; - +import 'package:dpip/app/settings/_widgets/settings_header.dart'; import 'package:dpip/core/i18n.dart'; -import 'package:dpip/core/preference.dart'; +import 'package:dpip/models/settings/experimental.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/widgets/list/segmented_list.dart'; +import 'package:dpip/widgets/ui/icon_container.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; /// A page for toggling experimental (in-development) features. /// /// Each feature shows a confirmation dialog with a countdown before it can be /// enabled. Disabled features can be turned off immediately. -class SettingsExperimentalPage extends StatefulWidget { +class SettingsExperimentalPage extends StatelessWidget { /// Creates a [SettingsExperimentalPage]. const SettingsExperimentalPage({super.key}); - @override - State createState() => _SettingsExperimentalPageState(); -} - -class _SettingsExperimentalPageState extends State { - bool _launchToMonitor = Preference.experimentalLaunchToMonitor ?? false; - bool _eewAllSource = Preference.experimentalEewAllSource ?? false; - - Future _showEnableWarningDialog({ - required String featureName, - required VoidCallback onConfirm, - }) async { - final confirmed = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => _ExperimentalWarningDialog(featureName: featureName), - ); - - if (confirmed == true) { - onConfirm(); - } - } - - void _toggleEewAllSource(bool value) { - if (value) { - _showEnableWarningDialog( - featureName: '地震速報不限制非 CWA 來源'.i18n, - onConfirm: () { - setState(() => _eewAllSource = true); - Preference.experimentalEewAllSource = true; - }, - ); - } else { - setState(() => _eewAllSource = false); - Preference.experimentalEewAllSource = false; - } - } - - void _toggleLaunchToMonitor(bool value) { - if (value) { - _showEnableWarningDialog( - featureName: '啟動時進入強震監視器'.i18n, - onConfirm: () { - setState(() => _launchToMonitor = true); - Preference.experimentalLaunchToMonitor = true; - }, - ); - } else { - setState(() => _launchToMonitor = false); - Preference.experimentalLaunchToMonitor = false; - } - } - - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const .fromLTRB(16, 8, 16, 0), - child: Row( - children: [ - Container( - padding: const .all(10), - decoration: BoxDecoration( - color: context.colors.primaryContainer, - borderRadius: .circular(12), - ), - child: Icon( - Symbols.science_rounded, - color: context.colors.onPrimaryContainer, - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text( - '實驗性功能'.i18n, - style: context.texts.titleLarge?.copyWith( - fontWeight: .bold, - ), - ), - Text( - '搶先體驗開發中的新功能'.i18n, - style: context.texts.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildIconContainer({ - required IconData icon, - required Color color, - }) { - return Container( - padding: const .all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: .circular(10), - ), - child: Icon(icon, color: color, size: 20), - ); - } - Widget _buildWarningCard(BuildContext context) { return Container( margin: const .fromLTRB(16, 16, 16, 0), @@ -139,17 +30,9 @@ class _SettingsExperimentalPageState extends State { child: Row( crossAxisAlignment: .start, children: [ - Container( - padding: const .all(10), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.2), - borderRadius: .circular(12), - ), - child: Icon( - Symbols.warning_rounded, - color: Colors.amber[700], - size: 24, - ), + const ContainedIcon( + Symbols.warning_rounded, + color: Colors.amberAccent, ), const SizedBox(width: 16), Expanded( @@ -181,165 +64,72 @@ class _SettingsExperimentalPageState extends State { @override Widget build(BuildContext context) { return ListView( - padding: .only( - top: 8, - bottom: 16 + context.padding.bottom, - ), children: [ - _buildHeader(context), - _buildWarningCard(context), - - // 啟動行為 - SegmentedList( - label: Text('啟動行為'.i18n), - children: [ - SegmentedListTile( - isFirst: true, - isLast: true, - leading: _buildIconContainer( - icon: Symbols.monitor_heart_rounded, - color: Colors.red, - ), - title: Text('啟動時進入強震監視器'.i18n), - subtitle: Text('開啟 App 時直接進入強震監視器地圖'.i18n), - trailing: Switch( - value: _launchToMonitor, - onChanged: _toggleLaunchToMonitor, - ), - onTap: () => _toggleLaunchToMonitor(!_launchToMonitor), - ), - ], + SettingsHeader( + icon: Symbols.science_rounded, + title: Text('實驗性功能'.i18n), + subtitle: Text('搶先體驗開發中的新功能'.i18n), ), - + _buildWarningCard(context), + const SizedBox(height: 16), SegmentedList( - label: Text('地震速報'.i18n), children: [ - SegmentedListTile( - isFirst: true, - isLast: true, - leading: _buildIconContainer( - icon: Symbols.earthquake_rounded, - color: Colors.orange, - ), - title: Text('不限制非 CWA 來源'.i18n), - subtitle: Text('顯示所有來源的地震速報資料'.i18n), - trailing: Switch( - value: _eewAllSource, - onChanged: _toggleEewAllSource, - ), - onTap: () => _toggleEewAllSource(!_eewAllSource), - ), - ], - ), - ], - ); - } -} - -/// A confirmation dialog with a countdown timer before the confirm button -/// becomes enabled. -class _ExperimentalWarningDialog extends StatefulWidget { - /// The display name of the experimental feature being enabled. - final String featureName; - - const _ExperimentalWarningDialog({required this.featureName}); - - @override - State<_ExperimentalWarningDialog> createState() => _ExperimentalWarningDialogState(); -} - -class _ExperimentalWarningDialogState extends State<_ExperimentalWarningDialog> { - int _countdown = 5; - Timer? _timer; - - void _startCountdown() { - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_countdown > 0) { - setState(() => _countdown--); - } else { - timer.cancel(); - } - }); - } - - @override - void initState() { - super.initState(); - _startCountdown(); - } - - @override - Widget build(BuildContext context) { - final canConfirm = _countdown == 0; - - return AlertDialog( - icon: Icon( - Symbols.warning_rounded, - color: Colors.amber[700], - size: 48, - ), - title: Text('啟用實驗性功能'.i18n), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - '你即將啟用:'.i18n, - style: context.texts.bodyMedium, - ), - const SizedBox(height: 8), - Container( - padding: const .all(12), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: .circular(8), + Selector( + selector: (_, model) => model.experimental__launchToMonitor, + builder: (context, experimental__launchToMonitor, child) { + return SegmentedListTile( + isFirst: true, + leading: const ContainedIcon( + Symbols.monitor_heart_rounded, + color: Colors.red, + ), + title: Text('啟動時進入強震監視器'.i18n), + subtitle: Text('開啟 App 時直接進入強震監視器地圖'.i18n), + trailing: Switch( + value: experimental__launchToMonitor, + onChanged: context.experimental.set_experimental__launchToMonitor, + ), + ); + }, ), - child: Row( - children: [ - Icon( - Symbols.science_rounded, - color: context.colors.primary, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.featureName, - style: context.texts.bodyMedium?.copyWith( - fontWeight: .bold, - ), + Selector( + selector: (_, model) => model.experimental__eewAllSource, + builder: (context, experimental__eewAllSource, child) { + return SegmentedListTile( + leading: const ContainedIcon( + Symbols.earthquake_rounded, + color: Colors.orange, ), - ), - ], + title: Text('不限制非 CWA 來源'.i18n), + subtitle: Text('顯示所有來源的地震速報資料'.i18n), + trailing: Switch( + value: experimental__eewAllSource, + onChanged: context.experimental.set_experimental__eewAllSource, + ), + ); + }, ), - ), - const SizedBox(height: 16), - Text( - '此功能為實驗性質,可能會造成應用程式不穩定或行為異常。如遇問題,請至設定中關閉此功能。'.i18n, - style: context.texts.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + Selector( + selector: (_, model) => model.experimental__newHomeScreen, + builder: (context, experimental__newHomeScreen, child) { + return SegmentedListTile( + isLast: true, + leading: const ContainedIcon( + Symbols.home_rounded, + color: Colors.blueAccent, + ), + title: Text('新首頁樣式'.i18n), + subtitle: Text('使用新的首頁,目前還在開發中\n需要重新啟動 DPIP 來套用設定'.i18n), + trailing: Switch( + value: experimental__newHomeScreen, + onChanged: context.experimental.set_experimental__newHomeScreen, + ), + ); + }, ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => context.pop(false), - child: Text('取消'.i18n), - ), - FilledButton( - onPressed: canConfirm ? () => context.pop(true) : null, - child: Text( - canConfirm ? '啟用'.i18n : '${_countdown}s', - ), + ], ), ], ); } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } } diff --git a/lib/app/settings/layout.dart b/lib/app/settings/layout.dart index 615792338..1404117f9 100644 --- a/lib/app/settings/layout.dart +++ b/lib/app/settings/layout.dart @@ -29,8 +29,6 @@ class SettingsLayout extends StatefulWidget { } class _SettingsLayoutState extends State { - final controller = ScrollController(); - @override Widget build(BuildContext context) { return Theme( diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index bff0eea7d..9a73fba37 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -200,7 +200,7 @@ class SettingsLayoutPage extends StatelessWidget { padding: const .symmetric(horizontal: 16), sliver: SliverReorderableList( itemCount: sections.length, - onReorderItem: (oldIndex, newIndex) { + onReorder: (oldIndex, newIndex) { context.userInterface.reorderSection(oldIndex, newIndex); }, itemBuilder: (context, index) { diff --git a/lib/app/settings/locale/select/page.dart b/lib/app/settings/locale/select/page.dart index 002346a42..2d5fad7c5 100644 --- a/lib/app/settings/locale/select/page.dart +++ b/lib/app/settings/locale/select/page.dart @@ -48,7 +48,7 @@ class _SettingsLocaleSelectPageState extends State Result, String>? progress; List localeList = I18n.supportedLocales - .where((e) => !['zh'].contains(e.toLanguageTag())) + .where((e) => e.toLanguageTag() != 'zh') .toList(); Future _refresh() async { diff --git a/lib/app/settings/location/select/page.dart b/lib/app/settings/location/select/page.dart index a25419f60..4efe6c22e 100644 --- a/lib/app/settings/location/select/page.dart +++ b/lib/app/settings/location/select/page.dart @@ -30,9 +30,6 @@ class SettingsLocationSelectPage extends StatelessWidget { .toList(); final length = locations.length; - print('length = ${locations.length}'); - print(locations); - final code = context.useLocation.code; return CustomScrollView( diff --git a/lib/app/settings/notify/_lib/utils.dart b/lib/app/settings/notify/_lib/utils.dart index df78dc459..5cd187e41 100644 --- a/lib/app/settings/notify/_lib/utils.dart +++ b/lib/app/settings/notify/_lib/utils.dart @@ -43,15 +43,13 @@ void showErrorToast(BuildContext context) { ); } -/// Calls [setter] with [value], then shows a success or error toast. -Future setEewNotifyType( +Future _setNotifyType( BuildContext context, - EewNotifyType value, - Future Function(EewNotifyType value) setter, + T value, + Future Function(T value) setter, ) async { try { await setter(value); - if (!context.mounted) return; showSuccessToast(context); } catch (e) { @@ -60,70 +58,37 @@ Future setEewNotifyType( } } +/// Calls [setter] with [value], then shows a success or error toast. +Future setEewNotifyType( + BuildContext context, + EewNotifyType value, + Future Function(EewNotifyType value) setter, +) => _setNotifyType(context, value, setter); + /// Calls [setter] with [value], then shows a success or error toast. Future setEarthquakeNotifyType( BuildContext context, EarthquakeNotifyType value, Future Function(EarthquakeNotifyType value) setter, -) async { - try { - await setter(value); - - if (!context.mounted) return; - showSuccessToast(context); - } catch (e) { - if (!context.mounted) return; - showErrorToast(context); - } -} +) => _setNotifyType(context, value, setter); /// Calls [setter] with [value], then shows a success or error toast. Future setWeatherNotifyType( BuildContext context, WeatherNotifyType value, Future Function(WeatherNotifyType value) setter, -) async { - try { - await setter(value); - - if (!context.mounted) return; - showSuccessToast(context); - } catch (e) { - if (!context.mounted) return; - showErrorToast(context); - } -} +) => _setNotifyType(context, value, setter); /// Calls [setter] with [value], then shows a success or error toast. Future setTsunamiNotifyType( BuildContext context, TsunamiNotifyType value, Future Function(TsunamiNotifyType value) setter, -) async { - try { - await setter(value); - - if (!context.mounted) return; - showSuccessToast(context); - } catch (e) { - if (!context.mounted) return; - showErrorToast(context); - } -} +) => _setNotifyType(context, value, setter); /// Calls [setter] with [value], then shows a success or error toast. Future setBasicNotifyType( BuildContext context, BasicNotifyType value, Future Function(BasicNotifyType value) setter, -) async { - try { - await setter(value); - - if (!context.mounted) return; - showSuccessToast(context); - } catch (e) { - if (!context.mounted) return; - showErrorToast(context); - } -} +) => _setNotifyType(context, value, setter); diff --git a/lib/app/settings/page.dart b/lib/app/settings/page.dart index 8c99c8763..ff2ec2673 100644 --- a/lib/app/settings/page.dart +++ b/lib/app/settings/page.dart @@ -88,8 +88,7 @@ class SettingsIndexPage extends StatelessWidget { @override Widget build(BuildContext context) { final appInfo = '${Global.packageInfo.version}(${Global.packageInfo.buildNumber})'; - final deviceInfo = - '${DeviceInfo.model}${DeviceInfo.serial != null ? '' : ''}(${DeviceInfo.version})'; + final deviceInfo = '${DeviceInfo.model}(${DeviceInfo.version})'; return ListView( padding: .only(top: 16, bottom: 16 + context.padding.bottom), diff --git a/lib/app/settings/proxy/page.dart b/lib/app/settings/proxy/page.dart index 665fc6380..bc5a9f424 100644 --- a/lib/app/settings/proxy/page.dart +++ b/lib/app/settings/proxy/page.dart @@ -27,9 +27,10 @@ class _SettingsProxyPageState extends State { late bool _enabled; void _saveSettings() { - Preference.proxyEnabled = _enabled; - Preference.proxyHost = _hostController.text.trim().isEmpty ? null : _hostController.text.trim(); + final hostText = _hostController.text.trim(); final portText = _portController.text.trim(); + Preference.proxyEnabled = _enabled; + Preference.proxyHost = hostText.isEmpty ? null : hostText; Preference.proxyPort = portText.isEmpty ? null : int.tryParse(portText); if (mounted) { diff --git a/lib/app/settings/theme/page.dart b/lib/app/settings/theme/page.dart index add31711f..f1177754a 100644 --- a/lib/app/settings/theme/page.dart +++ b/lib/app/settings/theme/page.dart @@ -23,64 +23,60 @@ class SettingsThemePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, model, child) { - return ListView( + return ListView( + children: [ + SettingsHeader( + icon: Symbols.palette_rounded, + title: Text('主題'.i18n), + subtitle: Text('調整 DPIP 整體的外觀與顏色'.i18n), + ), + const SizedBox(height: 16), + SegmentedList( children: [ - SettingsHeader( - icon: Symbols.palette_rounded, - title: Text('主題'.i18n), - subtitle: Text('調整 DPIP 整體的外觀與顏色'.i18n), + Selector( + selector: (context, model) => model.themeMode, + builder: (context, themeMode, child) { + return SegmentedListTile( + isFirst: true, + leading: ContainedIcon( + switch (context.theme.brightness) { + .light => Symbols.light_mode_rounded, + .dark => Symbols.dark_mode_rounded, + }, + color: switch (context.theme.brightness) { + .light => Colors.orange, + .dark => Colors.blue[300]!, + }, + ), + title: Text('主題模式'.i18n), + subtitle: Text(themeMode.label.i18n), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsThemeModeRoute().push(context), + ); + }, ), - const SizedBox(height: 16), - SegmentedList( - children: [ - Selector( - selector: (context, model) => model.themeMode, - builder: (context, themeMode, child) { - return SegmentedListTile( - isFirst: true, - leading: ContainedIcon( - switch (context.theme.brightness) { - .light => Symbols.light_mode_rounded, - .dark => Symbols.dark_mode_rounded, - }, - color: switch (context.theme.brightness) { - .light => Colors.orange, - .dark => Colors.blue[300]!, - }, - ), - title: Text('主題模式'.i18n), - subtitle: Text(themeMode.label.i18n), - trailing: const Icon(Symbols.chevron_right_rounded), - onTap: () => const SettingsThemeModeRoute().push(context), - ); - }, - ), - Selector( - selector: (context, model) => model.themeColor, - builder: (context, themeColor, child) { - return SegmentedListTile( - isLast: true, - leading: ContainedIcon( - Symbols.colorize_rounded, - color: themeColor ?? context.colors.primary, - ), - title: Text('主題色彩'.i18n), - subtitle: Text(switch (themeColor) { - null => '使用系統配色'.i18n, - final v => ColorTools.nameThatColor(v), - }), - trailing: const Icon(Symbols.chevron_right_rounded), - onTap: () => const SettingsThemeColorRoute().push(context), - ); - }, - ), - ], + Selector( + selector: (context, model) => model.themeColor, + builder: (context, themeColor, child) { + return SegmentedListTile( + isLast: true, + leading: ContainedIcon( + Symbols.colorize_rounded, + color: themeColor ?? context.colors.primary, + ), + title: Text('主題色彩'.i18n), + subtitle: Text(switch (themeColor) { + null => '使用系統配色'.i18n, + final v => ColorTools.nameThatColor(v), + }), + trailing: const Icon(Symbols.chevron_right_rounded), + onTap: () => const SettingsThemeColorRoute().push(context), + ); + }, ), ], - ); - }, + ), + ], ); } } diff --git a/lib/app/welcome/4-permissions/page.dart b/lib/app/welcome/4-permissions/page.dart index 320ed3052..98e0cffc2 100644 --- a/lib/app/welcome/4-permissions/page.dart +++ b/lib/app/welcome/4-permissions/page.dart @@ -31,8 +31,6 @@ class WelcomePermissionPage extends StatefulWidget { class _WelcomePermissionPageState extends State with WidgetsBindingObserver { late Future> _permissionsFuture; - late Future _autoStartPermission; - bool _autoStartStatus = false; bool _isRequestingPermission = false; bool _isNotificationPermission = false; @@ -46,43 +44,26 @@ class _WelcomePermissionPageState extends State with Widg } Future> _initializePermissions() async { - final deviceInfo = DeviceInfoPlugin(); List permissions = []; try { - final PermissionStatus status = await Permission.location.status; - if (status.isGranted) { - if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - permissions = [ - Permission.notification, - Permission.locationAlways, - if (androidInfo.version.sdkInt <= 28) Permission.storage, - Permission.ignoreBatteryOptimizations, - ]; - } else if (Platform.isIOS) { - permissions = [ - Permission.notification, - Permission.locationAlways, - Permission.photosAddOnly, - ]; - } - } else { - if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - permissions = [ - Permission.notification, - Permission.location, - if (androidInfo.version.sdkInt <= 28) Permission.storage, - Permission.ignoreBatteryOptimizations, - ]; - } else if (Platform.isIOS) { - permissions = [ - Permission.notification, - Permission.location, - Permission.photosAddOnly, - ]; - } + final status = await Permission.location.status; + final locationPermission = status.isGranted ? Permission.locationAlways : Permission.location; + + if (Platform.isAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; + permissions = [ + Permission.notification, + locationPermission, + if (androidInfo.version.sdkInt <= 28) Permission.storage, + Permission.ignoreBatteryOptimizations, + ]; + } else if (Platform.isIOS) { + permissions = [ + Permission.notification, + locationPermission, + Permission.photosAddOnly, + ]; } await _checkNotificationPermission(); diff --git a/lib/core/compass.dart b/lib/core/compass.dart index 4aa0eb7ca..3805f2eb2 100644 --- a/lib/core/compass.dart +++ b/lib/core/compass.dart @@ -11,7 +11,7 @@ class CompassService { StreamController? _controller; StreamSubscription? _sourceSubscription; - double _lastHeading = 0.0; + double _lastHeading = 0; bool _isInitialized = false; Stream? get events => _controller?.stream; @@ -23,55 +23,45 @@ class CompassService { bool get hasCompass => _isInitialized && _controller != null; Future initialize() async { + final log = TalkerManager.instance; if (_isInitialized) { - TalkerManager.instance.debug('CompassService: already initialized'); + log.debug('CompassService: already initialized'); return; } - TalkerManager.instance.debug('CompassService: initializing...'); + log.debug('CompassService: initializing...'); try { final sourceStream = FlutterCompass.events; if (sourceStream == null) { - TalkerManager.instance.debug('CompassService: compass not available'); - _isInitialized = true; + log.debug('CompassService: compass not available'); return; } _controller = StreamController.broadcast( - onListen: () { - TalkerManager.instance.debug('CompassService: first listener added'); - }, - onCancel: () { - TalkerManager.instance.debug('CompassService: last listener removed'); - }, + onListen: () => log.debug('CompassService: first listener added'), + onCancel: () => log.debug('CompassService: last listener removed'), ); _sourceSubscription = sourceStream.listen( (event) { - if (event.heading != null) { - _lastHeading = event.heading!; - } + if (event.heading != null) _lastHeading = event.heading!; _controller?.add(event); }, onError: (Object error) { - TalkerManager.instance.error('CompassService: stream error', error); + log.error('CompassService: stream error', error); _controller?.addError(error); }, onDone: () { - TalkerManager.instance.debug('CompassService: source stream done'); + log.debug('CompassService: source stream done'); _controller?.close(); }, ); - _isInitialized = true; - TalkerManager.instance.debug('CompassService: initialized successfully'); + log.debug('CompassService: initialized successfully'); } catch (e, s) { - TalkerManager.instance.error( - 'CompassService: initialization failed', - e, - s, - ); + log.error('CompassService: initialization failed', e, s); + } finally { _isInitialized = true; } } diff --git a/lib/core/device_info.dart b/lib/core/device_info.dart index 551639f53..486976b28 100644 --- a/lib/core/device_info.dart +++ b/lib/core/device_info.dart @@ -14,99 +14,139 @@ mixin DeviceInfo { model = info.model; serial = info.id; version = info.version.sdkInt.toString(); - } - if (Platform.isIOS) { + } else if (Platform.isIOS) { final info = await deviceInfo.iosInfo; - final String machineCode = info.utsname.machine; - model = _iphoneModelMap(machineCode); + model = _iphoneModelMap(info.utsname.machine); serial = info.identifierForVendor; version = info.systemVersion; } } static String _iphoneModelMap(String code) { - const groupedModels = , String>{ - ['iPhone8,1']: 'iPhone 6s', - ['iPhone8,2']: 'iPhone 6s Plus', - ['iPhone8,4']: 'iPhone SE\n(1st Gen)', - ['iPhone9,1', 'iPhone9,3']: 'iPhone 7', - ['iPhone9,2', 'iPhone9,4']: 'iPhone 7 Plus', - ['iPhone10,1', 'iPhone10,4']: 'iPhone 8', - ['iPhone10,2', 'iPhone10,5']: 'iPhone 8 Plus', - ['iPhone10,3', 'iPhone10,6']: 'iPhone X', - ['iPhone11,2']: 'iPhone XS', - ['iPhone11,4', 'iPhone11,6']: 'iPhone XS Max', - ['iPhone11,8']: 'iPhone XR', - ['iPhone12,1']: 'iPhone 11', - ['iPhone12,3']: 'iPhone 11 Pro', - ['iPhone12,5']: 'iPhone 11 Pro Max', - ['iPhone12,8']: 'iPhone SE\n(2nd Gen)', - ['iPhone13,2']: 'iPhone 12', - ['iPhone13,1']: 'iPhone 12 mini', - ['iPhone13,3']: 'iPhone 12 Pro', - ['iPhone13,4']: 'iPhone 12 Pro Max', - ['iPhone14,5']: 'iPhone 13', - ['iPhone14,4']: 'iPhone 13 mini', - ['iPhone14,2']: 'iPhone 13 Pro', - ['iPhone14,3']: 'iPhone 13 Pro Max', - ['iPhone14,6']: 'iPhone SE\n(3rd Gen)', - ['iPhone14,7']: 'iPhone 14', - ['iPhone14,8']: 'iPhone 14 Plus', - ['iPhone15,2']: 'iPhone 14 Pro', - ['iPhone15,3']: 'iPhone 14 Pro Max', - ['iPhone15,4']: 'iPhone 15', - ['iPhone15,5']: 'iPhone 15 Plus', - ['iPhone16,1']: 'iPhone 15 Pro', - ['iPhone16,2']: 'iPhone 15 Pro Max', - ['iPhone17,3']: 'iPhone 16', - ['iPhone17,4']: 'iPhone 16 Plus', - ['iPhone17,1']: 'iPhone 16 Pro', - ['iPhone17,2']: 'iPhone 16 Pro Max', - ['iPhone17,5']: 'iPhone 16e', + const models = { + 'iPhone8,1': 'iPhone 6s', + 'iPhone8,2': 'iPhone 6s Plus', + 'iPhone8,4': 'iPhone SE\n(1st Gen)', + 'iPhone9,1': 'iPhone 7', + 'iPhone9,3': 'iPhone 7', + 'iPhone9,2': 'iPhone 7 Plus', + 'iPhone9,4': 'iPhone 7 Plus', + 'iPhone10,1': 'iPhone 8', + 'iPhone10,4': 'iPhone 8', + 'iPhone10,2': 'iPhone 8 Plus', + 'iPhone10,5': 'iPhone 8 Plus', + 'iPhone10,3': 'iPhone X', + 'iPhone10,6': 'iPhone X', + 'iPhone11,2': 'iPhone XS', + 'iPhone11,4': 'iPhone XS Max', + 'iPhone11,6': 'iPhone XS Max', + 'iPhone11,8': 'iPhone XR', + 'iPhone12,1': 'iPhone 11', + 'iPhone12,3': 'iPhone 11 Pro', + 'iPhone12,5': 'iPhone 11 Pro Max', + 'iPhone12,8': 'iPhone SE\n(2nd Gen)', + 'iPhone13,1': 'iPhone 12 mini', + 'iPhone13,2': 'iPhone 12', + 'iPhone13,3': 'iPhone 12 Pro', + 'iPhone13,4': 'iPhone 12 Pro Max', + 'iPhone14,2': 'iPhone 13 Pro', + 'iPhone14,3': 'iPhone 13 Pro Max', + 'iPhone14,4': 'iPhone 13 mini', + 'iPhone14,5': 'iPhone 13', + 'iPhone14,6': 'iPhone SE\n(3rd Gen)', + 'iPhone14,7': 'iPhone 14', + 'iPhone14,8': 'iPhone 14 Plus', + 'iPhone15,2': 'iPhone 14 Pro', + 'iPhone15,3': 'iPhone 14 Pro Max', + 'iPhone15,4': 'iPhone 15', + 'iPhone15,5': 'iPhone 15 Plus', + 'iPhone16,1': 'iPhone 15 Pro', + 'iPhone16,2': 'iPhone 15 Pro Max', + 'iPhone17,1': 'iPhone 16 Pro', + 'iPhone17,2': 'iPhone 16 Pro Max', + 'iPhone17,3': 'iPhone 16', + 'iPhone17,4': 'iPhone 16 Plus', + 'iPhone17,5': 'iPhone 16e', // iPad - ['iPad6,11', 'iPad6,12']: 'iPad 5', - ['iPad7,5', 'iPad7,6']: 'iPad 6', - ['iPad7,11', 'iPad7,12']: 'iPad 7', - ['iPad11,6', 'iPad11,7']: 'iPad 8', - ['iPad12,1', 'iPad12,2']: 'iPad 9', - ['iPad13,18', 'iPad13,19']: 'iPad 10', - ['iPad15,7', 'iPad15,8']: 'iPad 11', + 'iPad6,11': 'iPad 5', + 'iPad6,12': 'iPad 5', + 'iPad7,5': 'iPad 6', + 'iPad7,6': 'iPad 6', + 'iPad7,11': 'iPad 7', + 'iPad7,12': 'iPad 7', + 'iPad11,6': 'iPad 8', + 'iPad11,7': 'iPad 8', + 'iPad12,1': 'iPad 9', + 'iPad12,2': 'iPad 9', + 'iPad13,18': 'iPad 10', + 'iPad13,19': 'iPad 10', + 'iPad15,7': 'iPad 11', + 'iPad15,8': 'iPad 11', // iPad Air - ['iPad5,3', 'iPad5,4']: 'iPad Air 2', - ['iPad11,3', 'iPad11,4']: 'iPad Air 3', - ['iPad13,1', 'iPad13,2']: 'iPad Air 4', - ['iPad13,16', 'iPad13,17']: 'iPad Air 5', - ['iPad14,8', 'iPad14,9']: 'iPad Air 11-Inch M2', - ['iPad14,10', 'iPad14,11']: 'iPad Air 13-Inch M2', - ['iPad15,3', 'iPad15,4']: 'iPad Air 11-Inch M3', - ['iPad15,5', 'iPad15,6']: 'iPad Air 13-Inch M3', + 'iPad5,3': 'iPad Air 2', + 'iPad5,4': 'iPad Air 2', + 'iPad11,3': 'iPad Air 3', + 'iPad11,4': 'iPad Air 3', + 'iPad13,1': 'iPad Air 4', + 'iPad13,2': 'iPad Air 4', + 'iPad13,16': 'iPad Air 5', + 'iPad13,17': 'iPad Air 5', + 'iPad14,8': 'iPad Air 11-Inch M2', + 'iPad14,9': 'iPad Air 11-Inch M2', + 'iPad14,10': 'iPad Air 13-Inch M2', + 'iPad14,11': 'iPad Air 13-Inch M2', + 'iPad15,3': 'iPad Air 11-Inch M3', + 'iPad15,4': 'iPad Air 11-Inch M3', + 'iPad15,5': 'iPad Air 13-Inch M3', + 'iPad15,6': 'iPad Air 13-Inch M3', // iPad Mini - ['iPad5,1', 'iPad5,2']: 'iPad Mini 4', - ['iPad11,1', 'iPad11,2']: 'iPad Mini 5', - ['iPad14,1', 'iPad14,2']: 'iPad Mini 6', - ['iPad16,1', 'iPad16,2']: 'iPad Mini 7', + 'iPad5,1': 'iPad Mini 4', + 'iPad5,2': 'iPad Mini 4', + 'iPad11,1': 'iPad Mini 5', + 'iPad11,2': 'iPad Mini 5', + 'iPad14,1': 'iPad Mini 6', + 'iPad14,2': 'iPad Mini 6', + 'iPad16,1': 'iPad Mini 7', + 'iPad16,2': 'iPad Mini 7', // iPad Pro - ['iPad6,3', 'iPad6,4']: 'iPad Pro 9-Inch', - ['iPad7,3', 'iPad7,4']: 'iPad Pro 10-Inch', - ['iPad8,1', 'iPad8,2', 'iPad8,3', 'iPad8,4']: 'iPad Pro 11-Inch', - ['iPad8,9', 'iPad8,10']: 'iPad Pro 11-Inch 2', - ['iPad13,4', 'iPad13,5', 'iPad13,6', 'iPad13,7']: 'iPad Pro 11-Inch 3', - ['iPad14,3', 'iPad14,4']: 'iPad Pro 11-Inch 4', - ['iPad16,3', 'iPad16,4']: 'iPad Pro 11-Inch (M4)', - ['iPad6,7', 'iPad6,8']: 'iPad Pro 12-Inch', - ['iPad7,1', 'iPad7,2']: 'iPad Pro 12-Inch 2', - ['iPad8,5', 'iPad8,6', 'iPad8,7', 'iPad8,8']: 'iPad Pro 12-Inch 3', - ['iPad8,11', 'iPad8,12']: 'iPad Pro 12-Inch 4', - ['iPad13,8', 'iPad13,9', 'iPad13,10', 'iPad13,11']: 'iPad Pro 12-Inch 5', - ['iPad14,5', 'iPad14,6']: 'iPad Pro 12-Inch 6', - ['iPad16,5', 'iPad16,6']: 'iPad Pro 13-Inch (M4)', + 'iPad6,3': 'iPad Pro 9-Inch', + 'iPad6,4': 'iPad Pro 9-Inch', + 'iPad7,3': 'iPad Pro 10-Inch', + 'iPad7,4': 'iPad Pro 10-Inch', + 'iPad8,1': 'iPad Pro 11-Inch', + 'iPad8,2': 'iPad Pro 11-Inch', + 'iPad8,3': 'iPad Pro 11-Inch', + 'iPad8,4': 'iPad Pro 11-Inch', + 'iPad8,9': 'iPad Pro 11-Inch 2', + 'iPad8,10': 'iPad Pro 11-Inch 2', + 'iPad13,4': 'iPad Pro 11-Inch 3', + 'iPad13,5': 'iPad Pro 11-Inch 3', + 'iPad13,6': 'iPad Pro 11-Inch 3', + 'iPad13,7': 'iPad Pro 11-Inch 3', + 'iPad14,3': 'iPad Pro 11-Inch 4', + 'iPad14,4': 'iPad Pro 11-Inch 4', + 'iPad16,3': 'iPad Pro 11-Inch (M4)', + 'iPad16,4': 'iPad Pro 11-Inch (M4)', + 'iPad6,7': 'iPad Pro 12-Inch', + 'iPad6,8': 'iPad Pro 12-Inch', + 'iPad7,1': 'iPad Pro 12-Inch 2', + 'iPad7,2': 'iPad Pro 12-Inch 2', + 'iPad8,5': 'iPad Pro 12-Inch 3', + 'iPad8,6': 'iPad Pro 12-Inch 3', + 'iPad8,7': 'iPad Pro 12-Inch 3', + 'iPad8,8': 'iPad Pro 12-Inch 3', + 'iPad8,11': 'iPad Pro 12-Inch 4', + 'iPad8,12': 'iPad Pro 12-Inch 4', + 'iPad13,8': 'iPad Pro 12-Inch 5', + 'iPad13,9': 'iPad Pro 12-Inch 5', + 'iPad13,10': 'iPad Pro 12-Inch 5', + 'iPad13,11': 'iPad Pro 12-Inch 5', + 'iPad14,5': 'iPad Pro 12-Inch 6', + 'iPad14,6': 'iPad Pro 12-Inch 6', + 'iPad16,5': 'iPad Pro 13-Inch (M4)', + 'iPad16,6': 'iPad Pro 13-Inch (M4)', }; - for (final entry in groupedModels.entries) { - if (entry.key.contains(code)) { - return entry.value; - } - } - return code; + return models[code] ?? code; } } diff --git a/lib/core/eew.dart b/lib/core/eew.dart index a37f9994d..6618282c7 100644 --- a/lib/core/eew.dart +++ b/lib/core/eew.dart @@ -9,6 +9,31 @@ import 'package:maplibre_gl/maplibre_gl.dart'; const double ln10 = 2.302585092994046; // Math.log(10) +/// Cached `(int depth, original key string)` pairs from [Global.timeTable]. +/// +/// The travel-time table keys never change at runtime, so we parse them once +/// and reuse the result for every EEW tick. +List<({int depth, String key})>? _depthKeyCache; + +/// Returns the travel-time table whose depth key is closest to [depth]. +List<({double P, double R, double S})> _closestTable(double depth) { + final cache = _depthKeyCache ??= List.unmodifiable( + Global.timeTable.keys.map((k) => (depth: int.parse(k), key: k)), + ); + + var best = cache.first; + var bestDist = (best.depth - depth).abs(); + for (var i = 1; i < cache.length; i++) { + final entry = cache[i]; + final d = (entry.depth - depth).abs(); + if (d < bestDist) { + bestDist = d; + best = entry; + } + } + return Global.timeTable[best.key]!; +} + ({double p, double s, double sT}) calcWaveRadius( double depth, int time, @@ -20,11 +45,7 @@ const double ln10 = 2.302585092994046; // Math.log(10) final double t = (now - time) / 1000.0; - final timeTable = - Global.timeTable[findClosest( - Global.timeTable.keys.map(int.parse).toList(), - depth, - ).toString()]!; + final timeTable = _closestTable(depth); ({double P, double R, double S})? prevTable; for (final table in timeTable) { @@ -57,12 +78,8 @@ const double ln10 = 2.302585092994046; // Math.log(10) prevTable = table; } - if (pDist < 0) { - pDist = 0; - } - if (sDist < 0) { - sDist = 0; - } + if (pDist < 0) pDist = 0; + if (sDist < 0) sDist = 0; return (p: pDist, s: sDist, sT: sT); } @@ -83,17 +100,21 @@ Map eewAreaPga( final Map json = {}; double eewMaxI = 0.0; + // Hoist epicenter + magnitude-dependent factors out of the per-region loop + // (region can have hundreds of entries). + final epicenter = LatLng(lat, lon); + final depthSq = depth * depth; + final pgaScale = 1.657 * exp(1.533 * mag); + region.forEach((String key, Location info) { - final double distSurface = LatLng(lat, lon).to(LatLng(info.lat, info.lng)) / 1000; - final double dist = sqrt(pow(distSurface, 2) + pow(depth, 2)); - final double pga = 1.657 * exp(1.533 * mag) * pow(dist, -1.607); + final double distSurface = epicenter.to(LatLng(info.lat, info.lng)) / 1000; + final double dist = sqrt(distSurface * distSurface + depthSq); + final double pga = pgaScale * pow(dist, -1.607); double i = pgaToFloat(pga); if (i >= 4.5) { i = eewAreaPgv([lat, lon], [info.lat, info.lng], depth, mag); } - if (i > eewMaxI) { - eewMaxI = i; - } + if (i > eewMaxI) eewMaxI = i; json[key] = {'dist': dist, 'i': i}; }); @@ -107,31 +128,26 @@ double eewAreaPgv( double depth, double magW, ) { - final double long = pow(10, 0.5 * magW - 1.85).toDouble() / 2; - final double epicenterDistance = - epicenterLocation.asLatLng.to( - pointLocation.asLatLng, - ) / - 1000; - final double hypocenterDistance = sqrt(pow(depth, 2) + pow(epicenterDistance, 2)) - long; + // Compute pow(10, 0.5*magW) once and reuse for `long` and the gpv600 term. + // long = pow(10, 0.5*magW - 1.85) / 2 = tenHalfMag * pow(10, -1.85) / 2. + final double tenHalfMag = pow(10, 0.5 * magW).toDouble(); + final double long = tenHalfMag * 0.014125375446227544 / 2; + final double epicenterDistance = epicenterLocation.asLatLng.to(pointLocation.asLatLng) / 1000; + final double hypocenterDistance = + sqrt(depth * depth + epicenterDistance * epicenterDistance) - long; final double x = max(hypocenterDistance, 3); final double gpv600 = pow( 10, - 0.58 * magW + 0.0038 * depth - 1.29 - log(x + 0.0028 * pow(10, 0.5 * magW)) / ln10 - 0.002 * x, + 0.58 * magW + 0.0038 * depth - 1.29 - log(x + 0.0028 * tenHalfMag) / ln10 - 0.002 * x, ).toDouble(); final double pgv400 = gpv600 * 1.31; - final double pgv = pgv400 * 1.0; - return 2.68 + 1.72 * log(pgv) / ln10; + return 2.68 + 1.72 * log(pgv400) / ln10; } double sWaveTimeByDistance(double depth, double sDist) { double sTime = 0.0; - final timeTable = - Global.timeTable[findClosest( - Global.timeTable.keys.map(int.parse).toList(), - depth, - ).toString()]!; + final timeTable = _closestTable(depth); ({double P, double R, double S})? prevTable; for (final table in timeTable) { @@ -157,11 +173,7 @@ double sWaveTimeByDistance(double depth, double sDist) { double pWaveTimeByDistance(double depth, double pDist) { double pTime = 0.0; - final timeTable = - Global.timeTable[findClosest( - Global.timeTable.keys.map(int.parse).toList(), - depth, - ).toString()]!; + final timeTable = _closestTable(depth); ({double P, double R, double S})? prevTable; for (final table in timeTable) { @@ -184,57 +196,41 @@ double pWaveTimeByDistance(double depth, double pDist) { return pTime * 1000; } -double pgaToFloat(double pga) { - return 2 * (log(pga) / log(10)) + 0.7; -} +double pgaToFloat(double pga) => 2 * log(pga) / ln10 + 0.7; -int pgaToIntensity(double pga) { - return intensityFloatToInt(pgaToFloat(pga)); -} +int pgaToIntensity(double pga) => intensityFloatToInt(pgaToFloat(pga)); int intensityFloatToInt(double floatValue) { - if (floatValue < 0.5) { - return 0; - } else if (floatValue < 1.5) { - return 1; - } else if (floatValue < 2.5) { - return 2; - } else if (floatValue < 3.5) { - return 3; - } else if (floatValue < 4.5) { - return 4; - } else if (floatValue < 5.0) { - return 5; // 5弱 - } else if (floatValue < 5.5) { - return 6; // 5強 - } else if (floatValue < 6.0) { - return 7; // 6弱 - } else if (floatValue < 6.5) { - return 8; // 6強 - } else { - return 9; // 7 - } + if (floatValue < 0.5) return 0; + if (floatValue < 1.5) return 1; + if (floatValue < 2.5) return 2; + if (floatValue < 3.5) return 3; + if (floatValue < 4.5) return 4; + if (floatValue < 5.0) return 5; // 5弱 + if (floatValue < 5.5) return 6; // 5強 + if (floatValue < 6.0) return 7; // 6弱 + if (floatValue < 6.5) return 8; // 6強 + return 9; // 7 } -String intensityToNumberString(int level) { - return (level == 5) - ? '5⁻' - : (level == 6) - ? '5⁺' - : (level == 7) - ? '6⁻' - : (level == 8) - ? '6⁺' - : (level == 9) - ? '7' - : level.toString(); -} +String intensityToNumberString(int level) => switch (level) { + 5 => '5⁻', + 6 => '5⁺', + 7 => '6⁻', + 8 => '6⁺', + 9 => '7', + _ => level.toString(), +}; + +/// Cached sqrt(3), used as the S/P time ratio (see derivation in [calculateWaveTime]). +final double _sqrt3 = sqrt(3); WaveTime calculateWaveTime(double depth, double distance) { - final double za = 1 * depth; - double g0; - double G; + final double za = depth; final double xb = distance; + + final double g0; + final double G; if (depth <= 40) { g0 = 5.10298; G = 0.06659; @@ -242,32 +238,24 @@ WaveTime calculateWaveTime(double depth, double distance) { g0 = 7.804799; G = 0.004573; } - final double zc = -1 * (g0 / G); - final double xc = (pow(xb, 2) - 2 * (g0 / G) * za - pow(za, 2)) / (2 * xb); + + final double g0OverG = g0 / G; + final double zc = -g0OverG; + final double xc = (xb * xb - 2 * g0OverG * za - za * za) / (2 * xb); + double thetaA = atan((za - zc) / xc); - if (thetaA < 0) { - thetaA = thetaA + pi; - } + if (thetaA < 0) thetaA += pi; thetaA = pi - thetaA; - final double thetaB = atan(-1 * zc / (xb - xc)); + final double thetaB = atan(-zc / (xb - xc)); double ptime = (1 / G) * log(tan(thetaA / 2) / tan(thetaB / 2)); - final double g0_ = g0 / sqrt(3); - final double g_ = G / sqrt(3); - final double zc_ = -1 * (g0_ / g_); - final double xc_ = (pow(xb, 2) - 2 * (g0_ / g_) * za - pow(za, 2)) / (2 * xb); - double thetaA_ = atan((za - zc_) / xc_); - if (thetaA_ < 0) { - thetaA_ = thetaA_ + pi; - } - thetaA_ = pi - thetaA_; - final double thetaB_ = atan(-1 * zc_ / (xb - xc_)); - double stime = (1 / g_) * log(tan(thetaA_ / 2) / tan(thetaB_ / 2)); - if (distance / ptime > 7) { - ptime = distance / 7; - } - if (distance / stime > 4) { - stime = distance / 4; - } + + // S-wave: g0_ = g0/sqrt(3), g_ = G/sqrt(3), so g0_/g_ == g0/G, meaning + // zc_ == zc, xc_ == xc, and both thetas are identical to the P branch. + // Therefore stime = (1/g_) * log_arg = (sqrt(3)/G) * log_arg = sqrt(3) * ptime. + double stime = ptime * _sqrt3; + + if (distance / ptime > 7) ptime = distance / 7; + if (distance / stime > 4) stime = distance / 4; return WaveTime(p: ptime, s: stime); } @@ -280,7 +268,7 @@ WaveTime calculateWaveTime(double depth, double distance) { double userLon, ) { final distSurface = LatLng(eqLat, eqLng).to(LatLng(userLat, userLon)) / 1000; - final dist = sqrt(pow(distSurface, 2) + pow(depth, 2)); + final dist = sqrt(distSurface * distSurface + depth * depth); final pga = 1.657 * exp(1.533 * mag) * pow(dist, -1.607); var intensity = pgaToFloat(pga); if (intensity >= 4.5) { diff --git a/lib/core/fcm.dart b/lib/core/fcm.dart index ee96ddd1b..07fac53ec 100644 --- a/lib/core/fcm.dart +++ b/lib/core/fcm.dart @@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart'; Future fcmInit() async { await Firebase.initializeApp(); + if (Platform.isAndroid) { await AwesomeNotificationsFcm().initialize( onFcmTokenHandle: onTokenHandle, @@ -34,13 +35,12 @@ Future onTokenHandle(String token) async { } Future onFcmSilentDataHandle(FcmSilentData silentData) async { - final Map data = silentData.data!.cast(); + final data = silentData.data!.cast(); if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) { - final channelKey = (data['channel'] as String?) ?? 'other'; data['content'] = { 'id': int.parse((data['id'] as String?) ?? '0'), - 'channelKey': channelKey, + 'channelKey': (data['channel'] as String?) ?? 'other', 'title': data['title'] as String?, 'body': data['body'] as String?, 'wakeUpScreen': true, @@ -53,12 +53,10 @@ Future onFcmSilentDataHandle(FcmSilentData silentData) async { } Future showNotify(Map data) async { - final channelKey = (data['channel'] as String?) ?? 'other'; - await AwesomeNotifications().createNotification( content: NotificationContent( id: int.parse((data['id'] as String?) ?? '0'), - channelKey: channelKey, + channelKey: (data['channel'] as String?) ?? 'other', title: data['title'] as String?, body: data['body'] as String?, wakeUpScreen: true, diff --git a/lib/core/i18n.dart b/lib/core/i18n.dart index 6621b9dc1..43f0f21e4 100644 --- a/lib/core/i18n.dart +++ b/lib/core/i18n.dart @@ -44,8 +44,7 @@ class I18nCsvLoader { final translations = values.skip(1).toList(); result[key] = { - for (final langCode in languageCodes) - langCode: translations[languageCodes.indexOf(langCode)], + for (var i = 0; i < languageCodes.length; i++) languageCodes[i]: translations[i], }; } diff --git a/lib/core/notify.dart b/lib/core/notify.dart index 36c7d89ad..6c085d24b 100644 --- a/lib/core/notify.dart +++ b/lib/core/notify.dart @@ -58,12 +58,7 @@ void _navigateBasedOnChannelKey(BuildContext context, String? channelKey) { return; } - // if (channelKey.startsWith('announcement')) { - // AnnouncementRoute().push(context); - // return; - // } - - HomeRoute().go(context); + const HomeRoute().go(context); } final List _notificationChannels = [ @@ -431,20 +426,14 @@ Future notifyInit() async { ); if (needsForceUpdate) { - for (var i = 0; i < _notificationChannels.length; i += 5) { - final batch = _notificationChannels.skip(i).take(5); - for (final channel in batch) { - try { - await AwesomeNotifications().setChannel(channel, forceUpdate: true); - } catch (e, st) { - TalkerManager.instance.error('setChannel failed: $channel', e, st); - } + for (final channel in _notificationChannels) { + try { + await AwesomeNotifications().setChannel(channel, forceUpdate: true); + } catch (e, st) { + TalkerManager.instance.error('setChannel failed: $channel', e, st); } } - await Global.preference.setInt( - _channelVersionKey, - _notificationChannelVersion, - ); + await Global.preference.setInt(_channelVersionKey, _notificationChannelVersion); } AwesomeNotifications().setListeners( diff --git a/lib/core/preference.dart b/lib/core/preference.dart index dff0b330b..d1908c05a 100644 --- a/lib/core/preference.dart +++ b/lib/core/preference.dart @@ -66,8 +66,9 @@ class PreferenceKeys { // #endregion // #region Experimental - static const experimentalLaunchToMonitor = 'experimental:launchToMonitor'; - static const experimentalEewAllSource = 'experimental:eewAllSource'; + static const experimental__launchToMonitor = 'experimental:launchToMonitor'; + static const experimental__eewAllSource = 'experimental:eewAllSource'; + static const experimental__newHomeScreen = 'experimental:newHomeScreen'; // #endregion } @@ -210,14 +211,19 @@ class Preference { // #endregion // #region Experimental - static bool? get experimentalLaunchToMonitor => - instance.getBool(PreferenceKeys.experimentalLaunchToMonitor); - static set experimentalLaunchToMonitor(bool? value) => - instance.set(PreferenceKeys.experimentalLaunchToMonitor, value); - - static bool? get experimentalEewAllSource => - instance.getBool(PreferenceKeys.experimentalEewAllSource); - static set experimentalEewAllSource(bool? value) => - instance.set(PreferenceKeys.experimentalEewAllSource, value); + static bool? get experimental__launchToMonitor => + instance.getBool(PreferenceKeys.experimental__launchToMonitor); + static set experimental__launchToMonitor(bool? value) => + instance.set(PreferenceKeys.experimental__launchToMonitor, value); + + static bool? get experimental__eewAllSource => + instance.getBool(PreferenceKeys.experimental__eewAllSource); + static set experimental__eewAllSource(bool? value) => + instance.set(PreferenceKeys.experimental__eewAllSource, value); + + static bool? get experimental__newHomeScreen => + instance.getBool(PreferenceKeys.experimental__newHomeScreen); + static set experimental__newHomeScreen(bool? value) => + instance.set(PreferenceKeys.experimental__newHomeScreen, value); // #endregion } diff --git a/lib/core/providers.dart b/lib/core/providers.dart index 29b0bc534..3cef9fa56 100644 --- a/lib/core/providers.dart +++ b/lib/core/providers.dart @@ -1,4 +1,5 @@ import 'package:dpip/models/data.dart'; +import 'package:dpip/models/settings/experimental.dart'; import 'package:dpip/models/settings/location.dart'; import 'package:dpip/models/settings/map.dart'; import 'package:dpip/models/settings/notify.dart'; @@ -8,6 +9,7 @@ class GlobalProviders { GlobalProviders._(); static late DpipDataModel data; + static late SettingsExperimentalModel experimental; static late SettingsLocationModel location; static late SettingsMapModel map; static late SettingsNotificationModel notification; @@ -15,6 +17,7 @@ class GlobalProviders { static void init() { data = DpipDataModel(); + experimental = SettingsExperimentalModel(); location = SettingsLocationModel(); map = SettingsMapModel(); notification = SettingsNotificationModel(); diff --git a/lib/core/service.dart b/lib/core/service.dart index 833e91cbb..d99d9bd11 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -17,6 +17,10 @@ import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:package_info_plus/package_info_plus.dart'; +extension on LocationPermission { + bool get isDenied => this == LocationPermission.denied || this == LocationPermission.deniedForever; +} + /// Background location service with foreground support class LocationServiceManager { LocationServiceManager._(); @@ -43,8 +47,7 @@ class LocationServiceManager { if (Preference.locationAuto != true) return; final permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) - return; + if (permission.isDenied) return; try { await stop(); @@ -193,8 +196,7 @@ class LocationService { } final permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { + if (permission.isDenied) { TalkerManager.instance.warning( '⚙️::BackgroundLocationService location permission not granted, stopping service', ); @@ -323,7 +325,7 @@ class LocationService { @pragma('vm:entry-point') static Future _$getDeviceGeographicalLocation() async { final permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + if (permission.isDenied) { TalkerManager.instance.warning( '⚙️::BackgroundLocationService location permission not granted', ); @@ -393,64 +395,36 @@ class LocationService { } } - static ({String code, Location location})? _$getLocationFromCoordinates( - LatLng target, - ) { + static bool _pointInRing(LatLng target, List> ring) { + bool inside = false; + final n = ring.length; + for (int i = 0, j = n - 1; i < n; j = i++) { + final xi = ring[i][0], yi = ring[i][1]; + final xj = ring[j][0], yj = ring[j][1]; + if ((yi > target.latitude) != (yj > target.latitude) && + target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + } + + static ({String code, Location location})? _$getLocationFromCoordinates(LatLng target) { final geoJsonData = _$geoJsonData; final locationData = _$locationData; if (geoJsonData == null || locationData == null) return null; - final features = geoJsonData.features; - - for (final feature in features) { + for (final feature in geoJsonData.features) { if (feature == null) continue; final geometry = feature.geometry; if (geometry == null) continue; - bool isInPolygon = false; - - if (geometry is GeoJSONPolygon) { - final polygon = geometry.coordinates[0]; - bool isInside = false; - int j = polygon.length - 1; - for (int i = 0; i < polygon.length; i++) { - final double xi = polygon[i][0]; - final double yi = polygon[i][1]; - final double xj = polygon[j][0]; - final double yj = polygon[j][1]; - final bool intersect = - ((yi > target.latitude) != (yj > target.latitude)) && - (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); - if (intersect) isInside = !isInside; - j = i; - } - isInPolygon = isInside; - } - - if (geometry is GeoJSONMultiPolygon) { - final multiPolygon = geometry.coordinates; - for (final polygonCoordinates in multiPolygon) { - final polygon = polygonCoordinates[0]; - bool isInside = false; - int j = polygon.length - 1; - for (int i = 0; i < polygon.length; i++) { - final double xi = polygon[i][0]; - final double yi = polygon[i][1]; - final double xj = polygon[j][0]; - final double yj = polygon[j][1]; - final bool intersect = - ((yi > target.latitude) != (yj > target.latitude)) && - (target.longitude < (xj - xi) * (target.latitude - yi) / (yj - yi) + xi); - if (intersect) isInside = !isInside; - j = i; - } - if (isInside) { - isInPolygon = true; - break; - } - } - } + final bool isInPolygon = switch (geometry) { + GeoJSONPolygon() => _pointInRing(target, geometry.coordinates[0]), + GeoJSONMultiPolygon() => geometry.coordinates.any((p) => _pointInRing(target, p[0])), + _ => false, + }; if (isInPolygon) { final code = feature.properties!['CODE']?.toString(); diff --git a/lib/core/update.dart b/lib/core/update.dart index 12e7046b9..c92cd0da5 100644 --- a/lib/core/update.dart +++ b/lib/core/update.dart @@ -5,25 +5,27 @@ import 'package:dpip/api/exptech.dart'; import 'package:dpip/core/preference.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +const int _kMinUpdateIntervalMs = 86400 * 1000; // 1 day + Future updateInfoToServer() async { final latitude = Preference.locationLatitude; final longitude = Preference.locationLongitude; + if (latitude == null || longitude == null) return; - try { - if (latitude == null || longitude == null) return; - if (Preference.notifyToken != '' && - DateTime.now().millisecondsSinceEpoch - (Preference.lastUpdateToServerTime ?? 0) > - 86400 * 1 * 1000) { - final random = Random(); - final int rand = random.nextInt(2); + final token = Preference.notifyToken; + if (token.isEmpty) return; - if (rand != 0) return; + final elapsed = DateTime.now().millisecondsSinceEpoch - (Preference.lastUpdateToServerTime ?? 0); + if (elapsed <= _kMinUpdateIntervalMs) return; - ExpTech().updateDeviceLocation( - token: Preference.notifyToken, - coordinates: LatLng(latitude, longitude), - ); - } + // 50% sampling + if (Random().nextInt(2) != 0) return; + + try { + ExpTech().updateDeviceLocation( + token: token, + coordinates: LatLng(latitude, longitude), + ); } catch (e) { print('Network info update failed: $e'); } diff --git a/lib/global.dart b/lib/global.dart index 94275d8b7..fe732572f 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -114,11 +114,11 @@ class Global { loadTimeTableData(), ]); - packageInfo = (results[0] as PackageInfo?)!; - preference = (results[1] as SharedPreferences?)!; - boxGeojson = (results[2] as GeoJSONFeatureCollection?)!; - location = (results[3] as Map?)!; - timeTable = (results[4] as TimeTable?)!; + packageInfo = results[0] as PackageInfo; + preference = results[1] as SharedPreferences; + boxGeojson = results[2] as GeoJSONFeatureCollection; + location = results[3] as Map; + timeTable = results[4] as TimeTable; townGeojson = await loadTownGeojson(); await loadNotifyTestContent(); diff --git a/lib/main.dart b/lib/main.dart index 4041b6549..f95feb201 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,6 @@ import 'package:dpip/core/service.dart'; import 'package:dpip/core/update.dart'; import 'package:dpip/global.dart'; import 'package:dpip/utils/log.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -24,12 +23,14 @@ import 'package:timezone/data/latest.dart'; final fcmReadyCompleter = Completer(); final talker = TalkerManager.instance; + +const _platform = MethodChannel('com.exptech.dpip/shortcut'); + void main() async { - final overallStartTime = DateTime.now(); + final overall = Stopwatch()..start(); talker.log('--- 冷啟動偵測開始 ---'); - talker.log('🔥 1. (main) 啟動時間: ${overallStartTime.toIso8601String()}'); WidgetsFlutterBinding.ensureInitialized(); - String? initialShortcut; + if (Platform.isIOS) { // iOS 14 以下改回用 StoreKit1 InAppPurchaseStoreKitPlatform.enableStoreKit1(); @@ -38,78 +39,39 @@ void main() async { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), ); - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.edgeToEdge, - overlays: [SystemUiOverlay.top], - ); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: [SystemUiOverlay.top]); - FlutterError.onError = (FlutterErrorDetails details) { - talker.handle(details.exception, details.stack); - if (Platform.isAndroid) { - FirebaseCrashlytics.instance.recordFlutterFatalError(details); - } - }; - - final globalInitStart = DateTime.now(); - talker.log('⏳ 2. 啟動 Global...'); - await Global.init(); - final globalInitEnd = DateTime.now(); - talker.log( - '✅ 2. Global 完成。耗時: ${globalInitEnd.difference(globalInitStart).inMilliseconds}ms', - ); + FlutterError.onError = (details) => talker.handle(details.exception, details.stack); + await _timed('Global', Global.init()); await Preference.init(); final isFirstLaunch = Preference.instance.getBool('isFirstLaunch') ?? true; GlobalProviders.init(); initializeTimeZones(); - initialShortcut = await getInitialShortcut(); - - talker.log('⏳ 3. 啟動 並行任務... (測量總耗時)'); - final futureWaitStart = DateTime.now(); - await Future.wait([ - _loggedTask('AppLocalizations.load', AppLocalizations.load()), - _loggedTask( - 'LocationNameLocalizations.load', - LocationNameLocalizations.load(), - ), - // _loggedTask( - // 'WeatherStationLocalizations.load', - // WeatherStationLocalizations.load(), - // ), - ]); - final futureWaitEnd = DateTime.now(); - talker.log( - '✅ 3.並行任務全部完成。總耗時 (取決於最慢任務): ${futureWaitEnd.difference(futureWaitStart).inMilliseconds}ms', + final initialShortcut = await _getInitialShortcut(); + + await _timed( + '並行任務', + Future.wait([ + _timed('AppLocalizations.load', AppLocalizations.load()), + _timed('LocationNameLocalizations.load', LocationNameLocalizations.load()), + ]), ); if (Platform.isIOS) { await DeviceInfo.init(); } else { - unawaited( - () async { - final start = DateTime.now(); - await DeviceInfo.init(); - talker.log( - '📱 DeviceInfo.init 完成 ${DateTime.now().difference(start).inMilliseconds}ms', - ); - }(), - ); + unawaited(_timed('📱 DeviceInfo.init', DeviceInfo.init())); } if (isFirstLaunch) { talker.log('🟣 首次啟動 → 前置初始化 FCM + 通知'); - await Future.wait([ - _loggedTask('fcmInit', fcmInit()), - _loggedTask('notifyInit', notifyInit()), - ]); - unawaited(Future(() => updateInfoToServer())); + await Future.wait([_timed('fcmInit', fcmInit()), _timed('notifyInit', notifyInit())]); + unawaited(Future(updateInfoToServer)); await Preference.instance.setBool('isFirstLaunch', false); } - final overallEndTime = DateTime.now(); - talker.log( - '🚨 總初始化耗時 (runApp 前): ${overallEndTime.difference(overallStartTime).inMilliseconds}ms', - ); + talker.log('🚨 總初始化耗時 (runApp 前): ${overall.elapsedMilliseconds}ms'); runApp( I18n( @@ -132,6 +94,7 @@ void main() async { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: GlobalProviders.data), + ChangeNotifierProvider.value(value: GlobalProviders.experimental), ChangeNotifierProvider.value(value: GlobalProviders.location), ChangeNotifierProvider.value(value: GlobalProviders.map), ChangeNotifierProvider.value(value: GlobalProviders.notification), @@ -141,6 +104,7 @@ void main() async { ), ), ); + if (!isFirstLaunch) { talker.log('🟢 非首次啟動 → FCM + 通知 為背景初始化'); unawaited( @@ -155,46 +119,28 @@ void main() async { }), ); } + unawaited(CompassService.instance.initialize()); - final locationInitStart = DateTime.now(); - talker.log('🚀 啟動 LocationServiceManager ...'); - final locationFuture = LocationServiceManager.initalize(); - - locationFuture - .whenComplete(() { - final locationInitEnd = DateTime.now(); - final locationDuration = locationInitEnd.difference(locationInitStart).inMilliseconds; - talker.log('✅ LocationServiceManager 完成。耗時: ${locationDuration}ms'); - }) - .catchError((e) { - talker.error('❌ LocationServiceManager 失敗。錯誤: $e'); - }); + unawaited(_timed('🚀 LocationServiceManager', LocationServiceManager.initalize()).catchError((_) {})); } -const platform = MethodChannel('com.exptech.dpip/shortcut'); - -Future getInitialShortcut() async { +Future _getInitialShortcut() async { try { - final result = await platform.invokeMethod('getInitialShortcut'); - return result; + return await _platform.invokeMethod('getInitialShortcut'); } on PlatformException catch (e, st) { talker.error('Failed to get initial shortcut', e, st); return null; } } -Future _loggedTask(String taskName, Future future) async { - final start = DateTime.now(); +Future _timed(String name, Future future) async { + final sw = Stopwatch()..start(); try { final result = await future; - final end = DateTime.now(); - final duration = end.difference(start).inMilliseconds; - talker.log(' [並行] 任務 "$taskName" 完成。耗時: ${duration}ms'); + talker.log('✅ $name 完成。耗時: ${sw.elapsedMilliseconds}ms'); return result; } catch (e) { - final end = DateTime.now(); - final duration = end.difference(start).inMilliseconds; - talker.error(' [並行] 任務 "$taskName" 失敗。耗時: ${duration}ms', e); + talker.error('❌ $name 失敗。耗時: ${sw.elapsedMilliseconds}ms', e); rethrow; } } diff --git a/lib/models/settings/experimental.dart b/lib/models/settings/experimental.dart new file mode 100644 index 000000000..71c45f088 --- /dev/null +++ b/lib/models/settings/experimental.dart @@ -0,0 +1,88 @@ +import 'package:dpip/core/preference.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class _SettingsExperimentalModel extends ChangeNotifier { + /// The underlying [ValueNotifier] for the "launch to monitor" experimental setting. + /// + /// Returns the stored preference value, defaulting to `false` if no preference has been set. + final $experimental__launchToMonitor = ValueNotifier( + Preference.experimental__launchToMonitor ?? false, + ); + + /// Whether the app launches directly into the monitor screen. + /// + /// Returns the current state of the "launch to monitor" experimental setting. + /// Defaults to `false` if no preference has been set. + bool get experimental__launchToMonitor => $experimental__launchToMonitor.value; + + /// Sets whether the app should launch directly into the monitor screen. + /// + /// Takes a [bool] value indicating if the app should open to the monitor screen on launch. + /// + /// Invoking this method will also update [$experimental__launchToMonitor] and notify all attached listeners. + void set_experimental__launchToMonitor(bool value) { + Preference.experimental__launchToMonitor = value; + + $experimental__launchToMonitor.value = value; + + notifyListeners(); + } + + /// The underlying [ValueNotifier] for the EEW (Early Earthquake Warning) all-source experimental setting. + /// + /// Returns the stored preference value, defaulting to `false` if no preference has been set. + final $experimental__eewAllSource = ValueNotifier(Preference.experimental__eewAllSource ?? false); + + /// Whether to enable the all-source EEW experimental feature. + /// + /// Returns the current state of the EEW all-source experimental setting. + /// Defaults to `false` if no preference has been set. + bool get experimental__eewAllSource => $experimental__eewAllSource.value; + + /// Sets whether the all-source EEW experimental feature is enabled. + /// + /// Takes a [bool] value indicating if the all-source EEW feature should be enabled. + /// + /// Invoking this method will also update [$experimental__eewAllSource] and notify all attached listeners. + void set_experimental__eewAllSource(bool value) { + Preference.experimental__eewAllSource = value; + + $experimental__eewAllSource.value = value; + + notifyListeners(); + } + + /// The underlying [ValueNotifier] for the new home screen experimental setting. + /// + /// Returns the stored preference value, defaulting to `false` if no preference has been set. + final $experimental__newHomeScreen = ValueNotifier( + Preference.experimental__newHomeScreen ?? false, + ); + + /// Whether to enable the new home screen experimental feature. + /// + /// Returns the current state of the new home screen experimental setting. + /// Defaults to `false` if no preference has been set. + bool get experimental__newHomeScreen => $experimental__newHomeScreen.value; + + /// Sets whether the new home screen experimental feature is enabled. + /// + /// Takes a [bool] value indicating if the new home screen feature should be enabled. + /// + /// Invoking this method will also update [$experimental__newHomeScreen] and notify all attached listeners. + void set_experimental__newHomeScreen(bool value) { + Preference.experimental__newHomeScreen = value; + + $experimental__newHomeScreen.value = value; + + notifyListeners(); + } +} + +class SettingsExperimentalModel extends _SettingsExperimentalModel {} + +extension SettingsExperimentalModelExtension on BuildContext { + SettingsExperimentalModel get useExperimental => watch(); + SettingsExperimentalModel get experimental => read(); +} diff --git a/lib/router.dart b/lib/router.dart index 5c2a48d83..a6c54dc94 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,8 +1,7 @@ import 'package:dpip/app/changelog/page.dart'; import 'package:dpip/app/debug/logs/page.dart'; -import 'package:dpip/app/home/layout.dart'; -import 'package:dpip/app/home/page.dart'; import 'package:dpip/app/map/page.dart'; +import 'package:dpip/app/new_home/layout.dart'; import 'package:dpip/app/settings/donate/page.dart'; import 'package:dpip/app/settings/experimental/page.dart'; import 'package:dpip/app/settings/layout.dart'; @@ -96,15 +95,30 @@ class WelcomePermissionsRoute extends GoRouteData with $WelcomePermissionsRoute } } -/// Home route - displays the main application home page. +/// Home route - displays the main shell with all bottom-nav tabs. +/// +/// Use the optional `tab` query parameter for deep-linking into a specific +/// tab: `/home?tab=events`, `/home?tab=weather`, `/home?tab=map`, or +/// `/home?tab=menu`. Defaults to the home tab. @TypedGoRoute(path: '/home') class HomeRoute extends GoRouteData with $HomeRoute { - /// Creates a [HomeRoute]. - const HomeRoute(); + /// Optional tab name for deep-linking. + final String? tab; + + /// Creates a [HomeRoute] optionally targeting a specific tab. + const HomeRoute({this.tab}); + + static int _tabIndex(String? tab) => switch (tab) { + 'events' => tabEvents, + 'map' => tabMap, + 'weather' => tabWeather, + 'menu' => tabMenu, + _ => tabHome, + }; @override Widget build(BuildContext context, GoRouterState state) { - return const HomeLayout(child: HomePage()); + return Material(child: NewHomeShell(initialTab: _tabIndex(tab))); } } @@ -538,9 +552,10 @@ final router = GoRouter( return const WelcomeRoute().location; } // Experimental: Launch to monitor if enabled - if (Preference.experimentalLaunchToMonitor == true) { + if (Preference.experimental__launchToMonitor == true) { return const MapRoute(layers: 'monitor').location; } + return const HomeRoute().location; } return null; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index b15675b73..6a0af9b6a 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -34,7 +34,7 @@ const kFadeForwardPageTransitionsTheme = PageTransitionsTheme( const kEmphasizedAnimationStyle = AnimationStyle( curve: Easing.emphasizedDecelerate, duration: Durations.medium4, - reverseCurve: Easing.emphasizedDecelerate, + reverseCurve: Easing.standardDecelerate, reverseDuration: Durations.short4, ); diff --git a/lib/utils/extensions/color.dart b/lib/utils/extensions/color.dart index 35a1bcde7..20361053f 100644 --- a/lib/utils/extensions/color.dart +++ b/lib/utils/extensions/color.dart @@ -1,5 +1,7 @@ import 'dart:ui'; +import 'package:dpip/utils/extensions/number.dart'; + /// Extension on [Color] that provides color manipulation utilities. /// /// This extension adds helpful methods and getters for transforming and manipulating colors, including color inversion @@ -26,4 +28,32 @@ extension ColorExtension on Color { green: 1 - g, blue: 1 - b, ); + + /// Returns a copy of this color with the given opacity applied. + /// + /// [value] can be a fraction in `[0, 1]` or a percentage in `(1, 100]` — both + /// map to the same `[0, 1]` alpha range. Values outside `[0, 100]` are clamped. + /// + /// Example: + /// ```dart + /// color / 0.5 // 50% opacity (fraction form) + /// color / 50 // 50% opacity (percentage form) + /// ``` + /// + /// You can also divide it by zero, which may not sound great in common math, + /// but it will just simply make the color transparent. *(Go take a screenshot + /// and confuse your best friends!)* + /// + /// ```dart + /// color / 0 // fully transparent + /// color / 0.0 // or a double if you are psychopath + /// ``` + Color operator /(num value) { + final alpha = switch (value) { + > 1 => value / 100, + _ => value, + }.clamp(0, 1).asDouble; + + return withValues(alpha: alpha); + } } diff --git a/lib/utils/extensions/iterable.dart b/lib/utils/extensions/iterable.dart index 868687bf7..7c7173e50 100644 --- a/lib/utils/extensions/iterable.dart +++ b/lib/utils/extensions/iterable.dart @@ -24,9 +24,16 @@ extension IterableExtension on Iterable { /// /// Note: Elements not present in [order] will have an index of -1 and will be sorted to the end. Iterable orderedBy(Iterable order) { - final orderList = order.toList(); + // Pre-index `order` for O(1) lookup; otherwise each comparison is O(n) + // turning the sort into O(N² log N). + final positions = {}; + var i = 0; + for (final e in order) { + positions[e] ??= i++; + } + const unranked = -1; return toList().sorted( - (a, b) => orderList.indexOf(a) - orderList.indexOf(b), + (a, b) => (positions[a] ?? unranked) - (positions[b] ?? unranked), ); } @@ -152,9 +159,15 @@ extension SetExtension on Set { /// /// Note: Elements not present in [order] will have an index of -1 and will be sorted to the end. Set orderedBy(Iterable order) { - final orderList = order.toList(); + // Pre-index `order` for O(1) lookup; otherwise each comparison is O(n). + final positions = {}; + var i = 0; + for (final e in order) { + positions[e] ??= i++; + } + const unranked = -1; return sorted( - (a, b) => orderList.indexOf(a) - orderList.indexOf(b), + (a, b) => (positions[a] ?? unranked) - (positions[b] ?? unranked), ).toSet(); } } diff --git a/lib/widgets/typography.dart b/lib/widgets/typography.dart index 9a7c7277b..f7ff60dae 100644 --- a/lib/widgets/typography.dart +++ b/lib/widgets/typography.dart @@ -31,9 +31,20 @@ class DisplayText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.displaySmall); /// Creates a medium display text widget. @@ -45,9 +56,20 @@ class DisplayText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.displayMedium); /// Creates a large display text widget. @@ -59,9 +81,20 @@ class DisplayText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.displayLarge); @override @@ -103,9 +136,20 @@ class HeadLineText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.headlineSmall); /// Creates a medium headline text widget. @@ -117,9 +161,20 @@ class HeadLineText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.headlineMedium); /// Creates a large headline text widget. @@ -131,9 +186,20 @@ class HeadLineText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, + List? shadows, TextStyle? style, this.align, - }) : style = TextStyle(color: color, fontWeight: weight).merge(style), + }) : style = TextStyle( + color: color, + fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, + shadows: shadows, + ).merge(style), _textStyleGetter = ((context) => context.texts.headlineLarge); @override @@ -176,13 +242,21 @@ class TitleText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.titleSmall); @@ -195,13 +269,21 @@ class TitleText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.titleMedium); @@ -214,13 +296,21 @@ class TitleText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.titleLarge); @@ -264,13 +354,21 @@ class BodyText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.bodySmall); @@ -283,13 +381,21 @@ class BodyText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.bodyMedium); @@ -302,13 +408,21 @@ class BodyText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.bodyLarge); @@ -352,13 +466,21 @@ class LabelText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.labelSmall); @@ -371,13 +493,21 @@ class LabelText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.labelMedium); @@ -390,13 +520,21 @@ class LabelText extends StatelessWidget { super.key, Color? color, FontWeight? weight, + String? fontFamily, + double? fontSize, + List? fontVariations, double? leading, + List? shadows, TextStyle? style, this.align, }) : style = TextStyle( color: color, fontWeight: weight, + fontFamily: fontFamily, + fontSize: fontSize, + fontVariations: fontVariations, height: leading, + shadows: shadows, ).merge(style), _textStyleGetter = ((context) => context.texts.labelLarge); @@ -409,3 +547,7 @@ class LabelText extends StatelessWidget { ); } } + +extension FontVariationExtension on FontVariation { + static roundness(double round) => FontVariation('ROND', round); +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..446a21ffe --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +flutter = "3.44.0-stable" diff --git a/pubspec.lock b/pubspec.lock index a558bc487..556bfb046 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d718c5c58904f9937290fd5dbf2d6a0e02456867706bfb6cd7b81d394e738d5" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "98.0.0" + version: "93.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "6141ad5d092d1e1d13929c0504658bbeccc1703505830d7c26e859908f5efc88" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "10.0.1" android_alarm_manager_plus: dependency: "direct main" description: @@ -262,10 +262,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.8" + version: "3.1.7" dbus: dependency: transitive description: @@ -1362,11 +1362,12 @@ packages: simple_icons: dependency: "direct main" description: - name: simple_icons - sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" - url: "https://pub.dev" - source: hosted - version: "14.6.1" + path: "." + ref: develop + resolved-ref: fa3798db2995e5c7a0464610ad2610d4c0142cc3 + url: "https://github.com/jlnrrg/simple_icons.git" + source: git + version: "16.20.0" skeletonizer: dependency: "direct main" description: @@ -1552,10 +1553,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: @@ -1760,26 +1761,26 @@ packages: dependency: "direct main" description: name: zstandard - sha256: "0332045357644eaf66ff0b92619edefc5f8d2e1ef385403d2cb46c150db5db89" + sha256: d2cae71d34dfdd014525259be2f84ebc2a738c94cc4232515df44a1415dde9d3 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.3.29" zstandard_android: - dependency: transitive + dependency: "direct main" description: name: zstandard_android - sha256: b8dc9860d2ae834744bd82d6b648fc15bdf11dd85d61e01ac563c9e1d51f4bcf + sha256: "7d85d814f147faa7a33d5cd5b5610dea451a57f95b34f98c93723bb2f2efbf98" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.3.29" zstandard_ios: - dependency: transitive + dependency: "direct main" description: name: zstandard_ios - sha256: a992ee46aef915029abce6acd54f03a4bf7076e92d9148000fc56e9e6068f6d9 + sha256: "50e6b3d05ad6ecf8def65c9fae46d4b786edd89150fd2bb207cdcef918946f0d" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.3.29" zstandard_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e53283d7b..a681b1463 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,13 +65,18 @@ dependencies: permission_handler: ^12.0.1 provider: ^6.1.5 shared_preferences: ^2.5.4 - simple_icons: ^14.6.1 + simple_icons: + git: + url: https://github.com/jlnrrg/simple_icons.git + ref: develop skeletonizer: ^2.1.2 styled_text: ^9.0.0 talker_flutter: ^5.1.9 timezone: ^0.11.0 url_launcher: ^6.3.2 - zstandard: ^1.3.29 + zstandard: 1.3.29 + zstandard_android: 1.3.29 + zstandard_ios: 1.3.29 dev_dependencies: build_runner: ^2.10.4 flutter_test: @@ -87,6 +92,7 @@ flutter: shaders: - shaders/fog.frag - shaders/thunderstorm.frag + - shaders/weather_sky.frag assets: # localizations - assets/translations/ @@ -101,3 +107,7 @@ flutter: - assets/location.json.gz - assets/notify_test.json - assets/time.json.gz + fonts: + - family: Google Sans Flex + fonts: + - asset: assets/fonts/GoogleSansFlex.ttf diff --git a/shaders/weather_sky.frag b/shaders/weather_sky.frag new file mode 100644 index 000000000..5a6eb3d1b --- /dev/null +++ b/shaders/weather_sky.frag @@ -0,0 +1,238 @@ +#include + +uniform float iTime; +uniform vec2 iResolution; +uniform float iScene; +uniform float iScroll; +uniform float iCloud; +uniform float iRain; +uniform float iWind; +uniform float iSunPhase; +uniform float iLight; + +out vec4 fragColor; + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash12(i), hash12(i + vec2(1.0, 0.0)), u.x), + mix(hash12(i + vec2(0.0, 1.0)), hash12(i + vec2(1.0, 1.0)), u.x), + u.y + ); +} + +float cloudFbm(vec2 p) { + float v = 0.0; + float a = 0.5; + mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = rot * p * 2.0; + a *= 0.5; + } + return v; +} + +float cloudShape(vec2 uv, float time, float density) { + vec2 p = uv * 1.2; + p.x += time * 0.02; + float threshold = mix(0.58, 0.40, density); + return smoothstep(threshold, threshold + 0.05, cloudFbm(p)); +} + +vec3 skyColor(float y, int scene, float light) { + if (scene == 0) { + vec3 darkTop = vec3(0.05, 0.16, 0.34); + vec3 darkBot = vec3(0.36, 0.68, 0.87); + vec3 lightTop = vec3(0.42, 0.66, 0.90); + vec3 lightBot = vec3(0.78, 0.90, 0.98); + return mix(mix(darkTop, lightTop, light), mix(darkBot, lightBot, light), y); + } + if (scene == 1) { + return mix(vec3(0.02, 0.04, 0.08), vec3(0.07, 0.11, 0.17), y); + } + if (scene == 2) { + vec3 a = vec3(0.83, 0.38, 0.16); + vec3 b = vec3(0.37, 0.14, 0.33); + vec3 c = vec3(0.09, 0.04, 0.16); + return y < 0.5 ? mix(c, b, y * 2.0) : mix(b, a, (y - 0.5) * 2.0); + } + vec3 a = vec3(0.94, 0.50, 0.19); + vec3 b = vec3(0.63, 0.20, 0.12); + vec3 c = vec3(0.09, 0.05, 0.05); + return y < 0.5 ? mix(c, b, y * 2.0) : mix(b, a, (y - 0.5) * 2.0); +} + +float starField(vec2 uv, float density, float time) { + vec2 grid = floor(uv); + vec2 gv = fract(uv) - 0.5; + vec2 r = hash22(grid); + float exists = step(density, r.x); + vec2 starPos = (r - 0.5) * 0.7; + float d = length(gv - starPos); + float core = 1.0 / (1.0 + d * d * 600.0); + float twinkle = 0.55 + 0.45 * sin(time * 2.2 + r.x * 6.28); + return core * exists * mix(0.45, 1.0, r.y) * twinkle; +} + +float crescentMoon(vec2 uv, vec2 c, float r) { + float d1 = length(uv - c) - r; + float d2 = length(uv - c - vec2(r * 0.45, -r * 0.07)) - r * 0.85; + float crescent = max(d1, -d2); + return smoothstep(0.004, -0.004, crescent); +} + +float radialGlow(vec2 uv, vec2 c, float falloff) { + float d = length(uv - c); + return exp(-d * falloff); +} + +float disc(vec2 uv, vec2 c, float r) { + float d = length(uv - c); + return smoothstep(r + 0.005, r - 0.005, d); +} + +float rainStreaks(vec2 uv, float time) { + vec2 p = uv * vec2(50.0, 8.0); + p.x += p.y * 0.5; + p.y -= time * 6.0; + vec2 cell = floor(p); + vec2 cv = fract(p); + float r = hash12(cell); + float alive = step(0.62, r); + float streak = (1.0 - cv.y * 0.7) * (1.0 - smoothstep(0.0, 0.06, abs(cv.x - 0.5))); + return streak * alive; +} + +void main() { + vec2 fc = FlutterFragCoord(); + vec2 uv = fc.xy / iResolution.xy; + float aspect = iResolution.x / iResolution.y; + vec2 puv = vec2(uv.x * aspect, uv.y); + + int scene = int(iScene + 0.5); + + float scrollN = iScroll / iResolution.y; + float p1 = scrollN * 0.10; + float p2 = scrollN * 0.22; + float p3 = scrollN * 0.40; + + vec3 col = skyColor(uv.y, scene, iLight); + + // Slightly dim sky as cloud cover increases + col = mix(col, col * mix(0.70, 0.82, iLight), iCloud * 0.30); + + // Sun position arcs east-to-west with the time-of-day phase + float phase = clamp(iSunPhase, 0.0, 1.0); + float sunX = mix(0.50, 0.95, phase) * aspect; + float sunY = 0.40 - sin(phase * 3.14159) * 0.30; + vec2 sunC = vec2(sunX, sunY - p3); + + if (scene == 0) { + float sunVis = 1.0 - iCloud * 0.7; + float dSun = length(puv - sunC); + // Soft outer bloom + col += vec3(1.0, 0.76, 0.28) * exp(-dSun * 9.0) * 0.25 * sunVis; + // Tighter glow + col += vec3(1.0, 0.86, 0.42) * exp(-dSun * 18.0) * 0.55 * sunVis; + // Disc with internal gradient: golden rim → bright yellow core, + // gradient sampled from a point slightly above-left of the sun center + float discR = 0.040; + vec2 gradC = sunC - vec2(0.003, 0.003); + float dGrad = length(puv - gradC); + vec3 rim = vec3(1.0, 0.78, 0.32); + vec3 core = vec3(1.0, 0.95, 0.60); + vec3 discCol = mix(core, rim, smoothstep(0.0, discR, dGrad)); + float discMask = smoothstep(discR + 0.004, discR - 0.004, dSun); + col = mix(col, discCol, discMask * sunVis); + } + else if (scene == 1) { + float starVis = 1.0 - iCloud * 0.85; + float bgY = uv.y + p1; + float bgStars = starField(vec2(uv.x * aspect, bgY) * 65.0, 0.78, iTime) + * smoothstep(0.78, 0.0, bgY); + col += vec3(bgStars * 0.55 * starVis); + + float midY = uv.y + p2; + float midStars = starField(vec2(uv.x * aspect, midY) * 38.0, 0.66, iTime + 1.7) + * smoothstep(0.58, 0.0, midY); + col += vec3(midStars * 0.95 * starVis); + + vec2 moonC = vec2(aspect * 0.84, 0.10 - p3); + col += vec3(0.85, 0.88, 0.97) * radialGlow(puv, moonC, 9.0) * 0.20 * starVis; + col = mix(col, vec3(0.88, 0.91, 0.99), crescentMoon(puv, moonC, 0.06) * starVis); + } + else { + // Dawn (scene 2) or sunset (scene 3): warm sun rides the same arc. + vec3 sunCol = scene == 2 ? vec3(0.93, 0.46, 0.27) : vec3(1.0, 0.55, 0.26); + float sunVis = 1.0 - iCloud * 0.5; + col += sunCol * radialGlow(puv, sunC, 5.0) * 0.40 * sunVis; + col += sunCol * radialGlow(puv, sunC, 9.0) * 0.70 * sunVis; + col = mix(col, sunCol * 1.2, disc(puv, sunC, 0.085) * sunVis); + } + + // Cloud overlay + if (iCloud > 0.01) { + vec3 cloudHi; + vec3 cloudLo; + if (scene == 1) { + cloudHi = vec3(0.20, 0.22, 0.28); + cloudLo = vec3(0.10, 0.12, 0.16); + } + else if (scene == 2) { + cloudHi = vec3(0.85, 0.55, 0.45); + cloudLo = vec3(0.45, 0.22, 0.30); + } + else if (scene == 3) { + cloudHi = vec3(1.0, 0.65, 0.40); + cloudLo = vec3(0.55, 0.25, 0.18); + } + else { + cloudHi = vec3(1.0, 0.99, 0.95); + cloudLo = vec3(0.62, 0.68, 0.76); + } + vec3 cloudCol = mix(cloudHi, cloudLo, iCloud); + + float drift = iTime * (0.3 + iWind * 1.4); + float cy1 = uv.y + p1; + vec2 cloudUV = vec2(uv.x * aspect, cy1) * vec2(1.8, 2.5); + float c1 = cloudShape(cloudUV, drift, iCloud) * smoothstep(0.55, 0.0, cy1); + float c1Up = cloudShape(cloudUV + vec2(0.0, -0.10), drift, iCloud) + * smoothstep(0.55, 0.0, cy1); + col = mix(col, cloudCol, c1); + col = mix(col, cloudLo * 0.55, c1 * c1Up * 0.45); + + if (iCloud > 0.4) { + float cy2 = uv.y + p3; + vec2 cloudUV2 = vec2(uv.x * aspect, cy2) * vec2(2.0, 2.2) + 17.3; + float c2 = cloudShape(cloudUV2, drift * 1.4 + 50.0, iCloud) + * smoothstep(0.40, 0.0, cy2); + col = mix(col, cloudLo, c2 * 0.6); + } + } + + // Rain overlay + if (iRain > 0.02) { + float ry = uv.y + p3; + float rain = rainStreaks(vec2(uv.x * aspect, ry), iTime) + * smoothstep(0.60, 0.10, ry); + vec3 rainTint = scene == 1 ? vec3(0.4, 0.5, 0.65) : vec3(0.55, 0.65, 0.78); + col += rainTint * rain * 0.45 * iRain; + } + + fragColor = vec4(col, 1.0); +}