Refactor/home page - #518
Conversation
vendored MapLibre 是 6.19.1,但 pubspec.yaml 早已寫 ^0.26.2(commit 9992448 只改 yaml、沒更新 lock 與 vendored 二進位),舊 lock 一直遮住這個矛盾;換新 Flutter SDK 重新解析後 pod install 直接衝突(0.26.2 硬性要求 MapLibre = 6.27.0)。 - 重新 vendor MapLibre 6.27.0:自官方 maplibre-native ios-v6.27.0 下載 MapLibre.dynamic.xcframework.zip,SHA256 與 maplibre-gl-native-distribution 的 checksum 逐字元吻合;framework 內版本戳記確認 6.27.0。podspec 同步更新。 - maplibre_gl ^0.25.0 → ^0.26.2。Dart API 無破壞性變更,analyze 全乾淨。 - 開啟 enable-swift-package-manager。Flutter 確認所有 iOS 外掛皆為 Swift Package;SPM 解析出 15 個套件(maplibre 6.27.0、firebase-ios-sdk 12.15.0、 iosawncore 0.12.1 等),提交 Package.resolved 以固定版本。 - Podfile 移除 pod 'MapLibre':SPM 已提供並嵌入 MapLibre,兩者並存會讓 Runner.app/Frameworks/MapLibre.framework 由兩個 build command 產出而建置失敗。 vendored 那份保留為 CocoaPods fallback(關掉 SPM 時把該行加回即可)。 驗證:三個 gate、format、241 測試全過;iOS 模擬器建置成功並啟動,地圖(向量圖磚 + 雷達回波 + 行政區界)渲染正常。pbxproj 與 Podfile.lock 依慣例未納入提交 (Flutter 會自行移除已無作用的 [CP] Embed/Copy Pods 階段)。
移除 Podfile 後建置仍失敗: Error (Xcode): Framework 'Pods_Runner' not found Linker command failed with exit code 1 CocoaPods 會產生 Pods_<Target>.framework 傘型產物並連進 target,SPM 下不存在 這個產物,連結就斷。清掉 Runner 與 RunnerTests 的 Pods_*.framework 連結、其 PBXFileReference,以及只裝這兩者、清空後的 Frameworks 群組(以 xcodeproj gem 程式化處理,非手改文字)。 至此 pbxproj 對 Pods_ / Pods- / [CP] / PODS_ROOT / Manifest.lock / CocoaPods 全拼法皆為 0 匹配,FlutterGeneratedPluginSwiftPackage 接線完好。 驗證:模擬器建置成功並啟動,地圖(向量圖磚 + 雷達回波)渲染正常、Firebase initialized、零執行期錯誤;三個 gate、format、analyze、241 測試全過。
|
🔍 OpenCodeReview found 22 issue(s) in this PR.
|
CocoaPods 移除後 Runner.xcworkspace/contents.xcworkspacedata 仍留著 group:Pods/Pods.xcodeproj 的 FileRef。不會擋建置(Xcode 容忍缺檔的 FileRef), 但在 Xcode 專案導覽器會顯示為缺失群組,且與 Flutter 3.44 的 create 模板不符 (該模板只含 Runner.xcodeproj 一個 FileRef)。 驗證:flutter build ios --simulator --debug 成功(✓ Built Runner.app)。
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!mounted) return; | ||
| _appliedCode = null; | ||
| _applySelection(); | ||
| }); |
There was a problem hiding this comment.
[bug · high]
當目前的選擇是全台灣 (code 為 null) 時,將 _appliedCode 設為 null 會導致 code == _appliedCode 成立。如果 styleEpoch 未變,_applySelection 會直接 return,導致地圖尺寸改變時無法重新對焦。建議同時將 _appliedCodeEpoch 設為 -1。
Suggestion:
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (!mounted) return; | |
| _appliedCode = null; | |
| _applySelection(); | |
| }); | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (!mounted) return; | |
| _appliedCode = null; | |
| _appliedCodeEpoch = -1; | |
| _applySelection(); | |
| }); |
| // The map's own size, not MediaQuery's screen size: this surface sits inside | ||
| // the shell (below the status bar, above the nav bar), so the two differ by a | ||
| // device-dependent amount and a zoom derived from the screen mis-frames. | ||
| final size = _mapViewSize ?? MediaQuery.sizeOf(context); |
There was a problem hiding this comment.
[maintainability · medium]
_mapViewSize 的更新與使用可能存在不一致性。_mapViewSize 是在 build 階段透過 LayoutBuilder 更新的,而 _frameBounds 可能在生命週期中的其他時機點被呼叫(例如 _onStyleLoaded)。若在佈局尚未完成或正在變動時觸發 _frameBounds,可能會導致使用到舊的(或 fallback 的 MediaQuery)視口尺寸,進而影響地圖瓦片加載的精確度。
全國框景取自 BaseMap.taiwanBounds,而該框的座標本身是錯的: southwest (22.2, 119) 西緣深入海峽約 110 km northeast (25.35, 121.05) 東緣 121.05 落在島內,宜蘭/花蓮/臺東全在框外 於是框的中心經度是 120.02(島的真實中心約 121.0),fit 之後臺灣被推到畫面右側、 東海岸被切掉,左半則是空海——與註解宣稱的「不被開闊海面主宰」正好相反。換不同 長寬比的手機只是把這個既有偏斜放得更明顯。 改為本島實際極點加少量邊界:富貴角 25.30N、鵝鑾鼻 21.90N、國聖港 120.04E、 三貂角 122.01E → (21.87, 119.99) ~ (25.32, 122.05),中心經度 121.02。 同時改成 getter:lazily-initialised 的 static final 在熱重載後會保留舊值,改這些 數字會看起來毫無作用。 新增 test/shared/map/base_map_test.dart 釘住四個極點都在框內、中心不偏離島嶼、 且不灌入過多海面,避免再度悄悄劣化。245 測試通過。
兩個問題: 1. 地圖是 Positioned.fill 全螢幕,RegionBar 疊在上方、拖盤疊在下方(見 home_page.dart 的 Stack)。boundsFitCamera 只有 bottomInset、沒有 topInset, 所以上緣被 RegionBar 遮住的部分從未扣除,框出來的內容偏上且被切。 加入 topInset:可用高度扣掉上下兩端,相機中心改位移 (bottomInset - topInset)/2 —— 拖盤把框往上抬、頂部列把框往下推。首頁傳入 status bar + RegionBar.height。 RegionBar 的 44 改為公開常數,不再是散落的魔術數字。 2. 全國需含金門。舊框西緣 119.99 只到本島西岸,金門(烈嶼約 118.24E)在框外。 西緣改為 118.15;澎湖落在臺灣—金門的跨距內,順帶涵蓋。馬祖(約 26.15N)仍在 框外,需要的話再往北擴。 實測數值(iPhone 17 Pro,402x874):可視帶 354x432,fit zoom 6.00(寬為限制軸), 框內容渲染 354x342,完整落在可視帶內。 測試:base_map_test 改為驗證金門/烈嶼/澎湖與本島極點皆在框內、西緣不過度外擴; camera_fit_test 新增 topInset 四項(往下推、上下相等則置中但縮小、兩端都縮減、 兩端相加超過高度回傳 null)。248 測試通過。
三個問題同根:地圖只在首次載入框一次(_framed),之後換圖層既不重算遮擋高度、 也不重新對焦目標。 - 新增 MapLayer.bottomChromeFraction:每個圖層宣告自己「常駐」chrome 佔地圖高度的 比例。測站層回傳 StationSheet.peekExtent、颱風層回傳 TyphoonPanel.peekExtent、 RTS 回傳 RtsMonitorPanel.bottomStripFraction;雷達回傳 0,因為時間軸是 scaffold 自己擁有的元件,改以 GlobalKey 量測真實高度(內容會變高度,宣告不準)。 三個面板的高度常數一併公開,消除散落的魔術數字。 - scaffold 保留 _target(框景目標)獨立於相機。_applyFraming() 以「目標 + 當前 可視帶」重算,觸發點:樣式載入、handoff、換圖層、時間軸量到新高度。 於是選了鄉鎮再切雷達回波,鄉鎮仍是主體;而各圖層遮擋高度不同也各自正確。 - 拖盤只回報「靜止」高度:使用者自己拖開拖盤不該移動相機。 - 上緣改為扣除 status bar。 - 移除 MapCameraHandoff.bottomInsetFraction:地圖現在自行推導遮擋,不需要首頁把 比例傳過來(首頁與地圖的 chrome 本來就不同,強制同一相機反而是錯的)。 handoff 現在只交接「地理範圍」。 功能不變,行為更正確。三個 gate、analyze、248 測試全過。
setCurrentCode 全專案只有兩個呼叫點:app.dart 啟動時一次,以及 LocationMonitor 在「不可用→可用」轉換時。那條 250m 的前景定位串流只餵給伺服器上傳,沒有任何人 用它更新所在地。結果:在臺南開 app、移動到臺北,所在地仍是臺南,直到重開 app 或 把定位服務關掉再開。對防災 app 而言等於對著錯誤的行政區顯示與示警。 第一次定位若逾時(冷啟動、室內很常見)也沒有重試,整個 session 所在地都是空的。 - DeviceLocationReporter 新增 fixes 廣播串流,發布每個被接受的定位。刻意在上傳 「之前」發布:token 還沒註冊或上傳失敗時,所在地仍須跟上。 共用同一條串流而非各開一條——每次 getPositionStream 都會啟動獨立的原生定位 session,兩個訂閱等於兩份 GPS 耗電算同一個座標。 - LocationMonitor 訂閱該串流,解析行政區並在「有變化時」發布,於是所在地持續跟隨 使用者;串流本身也讓首次定位失敗能自行痊癒(不再需要重開 app)。 - LocationService.townAt 公開:已持有定位的呼叫端不必再抓一次。 - getLastKnownPosition 加上 10 分鐘時效上限。它原本無條件採用,可能是數小時前、 其他縣市的定位——那會直接標錯所在地。逾時則走 getCurrentPosition。 測試:新增 location_monitor_test(跨行政區移動會更新、同行政區內移動不擾動監聽者、 dispose 後停止追蹤),reporter 新增兩項(fixes 廣播、上傳丟例外仍發布)。 三個 gate、analyze、253 測試全過。
| final size = _mapViewSize ?? MediaQuery.sizeOf(context); | ||
| // The map fills the page, but the region bar is layered over its top and the | ||
| // sheet over its bottom, so the band actually visible is shorter at both | ||
| // ends. Frame into that band, not the whole map. | ||
| final topInset = MediaQuery.paddingOf(context).top + RegionBar.height; |
There was a problem hiding this comment.
[other · low]
目前的 topInset 計算方式為 MediaQuery.paddingOf(context).top + RegionBar.height。這假設了 RegionBar 的高度是靜態且始終位於地圖頂部的。如果 RegionBar 的顯示狀態或高度會動態變化(例如透過動畫縮放),則 topInset 可能無法準確反映實際的視覺遮擋,進而導致地圖對焦偏移。建議確認 RegionBar.height 是否能反映實時的視覺高度。
| final measured = constraints.biggest; | ||
| if (measured != _mapViewSize && measured.isFinite) { | ||
| _mapViewSize = measured; | ||
| // Re-frame once the new size is laid out. Bypasses the | ||
| // unchanged-selection guard, which only tracks the code, not the size. | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!mounted) return; | ||
| _appliedCode = null; | ||
| _applySelection(); | ||
| }); | ||
| } | ||
| return BaseMap( |
There was a problem hiding this comment.
[maintainability · low]
在 LayoutBuilder 的 builder 方法中直接修改 _mapViewSize 屬於副作用操作。雖然您使用了 WidgetsBinding.instance.addPostFrameCallback 來避開在 build 階段呼叫 setState 的錯誤,但直接在 builder 中修改狀態變數仍不是最佳實踐。建議考慮將此邏輯移至 didChangeDependencies 或透過其他方式監控佈局變化,以符合 Flutter 的單向數據流原則。
| expect( | ||
| point.latitude, | ||
| inInclusiveRange( | ||
| bounds.southwest.latitude, | ||
| bounds.northeast.latitude, | ||
| ), | ||
| reason: '$name latitude falls outside taiwanBounds', | ||
| ); |
There was a problem hiding this comment.
[bug · medium]
測試程式碼中使用了 inInclusiveRange 這個 matcher,但在目前的 import 列表中並未發現其定義或來源。如果這不是專案中自定義的 extension,將導致編譯錯誤。建議改用 flutter_test 原生的 greaterThanOrEqual 與 lessThanOrEqual。
Suggestion:
| expect( | |
| point.latitude, | |
| inInclusiveRange( | |
| bounds.southwest.latitude, | |
| bounds.northeast.latitude, | |
| ), | |
| reason: '$name latitude falls outside taiwanBounds', | |
| ); | |
| expect( | |
| point.latitude, | |
| greaterThanOrEqual(bounds.southwest.latitude), | |
| reason: '$name latitude falls below taiwanBounds', | |
| ); | |
| expect( | |
| point.latitude, | |
| lessThanOrEqual(bounds.northeast.latitude), | |
| reason: '$name latitude falls above taiwanBounds', | |
| ); |
訊息其實早就存在(regionCurrentUnavailable),但被放在 home_content 的內容區、 落在拖盤靜止高度的視線之外——使用者看到的是標頭寫著「所在地」配一排讀數,讀起來像 「我們在所在地,天氣不明」,而不是「我們定位不到你」。 - 改在 HomeSheetHeader 呈現:所在地無定位時,以定位關閉圖示 + 訊息「取代」整排 讀數。沒有位置就沒有該位置的天氣,列出一排破折號等於暗示我們知道你在哪。 - 移除 home_content 內重複的同一段文字(含已無用的 _CurrentUnavailableNotice), 避免拖開拖盤後同一句話出現兩次。 - 修掉捏造值:降雨原本是 rain?.toStringAsFixed(1) ?? '0.0',無資料時顯示 「0.0 mm」——與真實的「沒有降雨」完全無法區分。改為 '—',與濕度一致。 - 中文語系措辭改為「無法取得所在地位置資訊」(zh / zh_Hant_HK),簡體對應調整; 其餘 7 個語系語意相同,維持原譯。 測試:新增三項(無定位顯示訊息與圖示、無定位不出現任何讀數、有定位則顯示讀數 而非訊息)。三個 gate、analyze、256 測試全過。
- data/event_api.dart:getHistoryList(全國)與 getHistoryRegion({region})。
兩者不是同一份資料的前端過濾——事件是否涵蓋某鄉鎮由伺服器依 area/polygon 判定,
前端無從計算,所以全國與鄉鎮是不同請求。走 ApiTier.legacyApi(api-1)。
- domain/event_repository.dart 回傳 Result<List<Event>>:空時間軸不能有歧義,
「這裡沒發生事」與「連不上伺服器」在畫面上長一樣是防災 app 不能接受的。
- Event.fromJson 依「所檢視的鄉鎮」挑選文字。這裡有個真實陷阱:wire 的
text.description 是以鄉鎮代碼為鍵的 map,而 "all" 並非泛用備援——實測同一則
大雨特報裡 all 放的是「新北市三峽區」的句子,同時另有 112 個鄉鎮各自的句子。
若拿 all 當備援,永康區使用者會讀到三峽區的災害描述。因此 all 只在它是 map
唯一鍵時才採用。
- 映射全面防禦性:欄位皆可選,id 空則用 key、再退回送出時間;無時間戳的項目直接
捨棄(無法放上時間軸);未知 type 仍以一般警報呈現而非消失;單筆壞資料只跳過
該筆,不讓整份危害清單變空白。
- 時間軸改由 AsyncView 驅動(loading/empty/error+重試),頁面依各分頁的區域取碼;
所在地無定位時退回全國,而非顯示空清單。移除假資料與其 l10n-ignore-file 豁免。
驗證:對線上 API 實測,全國 5 筆、永康區 1 筆且描述正確為「臺南市永康區」;
新增 10 項映射測試(含 all 陷阱三種情境)。三個 gate、analyze、266 測試全過。
Local path overrides leaked into the lockfile and tripped the absolute-path gate; drop an unnecessary cast warning in the typhoon overlay test. Co-authored-by: Cursor <cursoragent@cursor.com>
No description provided.