系列:30 天用 Google AI 打造臺灣防災速報 App(Day 19/30)
今天做 App 的首頁。要做到三件事:資料一變,畫面自己更新;最嚴重的示警排在最上面;網路斷了,要讓人知道眼前的資料可能不是最新的。

Firestore 的 Flutter 套件可以「監聽」一個集合。集合裡有文件新增、修改或刪除,App 就會收到新資料,畫面跟著重畫,不用自己定時重新下載。
Stream<AlertFeed> watchActive() => _db
.collection('alerts')
.snapshots(includeMetadataChanges: true)
.map((snap) => _feed(
[for (final d in snap.docs) Alert.fromMap(d.data())],
fromCache: snap.metadata.isFromCache,
));
Stream 是 Dart 裡「會持續送來新資料」的型別。首頁訂閱這個 Stream,每收到一次就更新畫面。
要注意 isFromCache。Firestore 會把讀過的資料存在手機裡,還沒拿到伺服器的資料時(例如剛打開 App,或沒網路),先拿這份本機快取給 App。快取可能是幾小時前的,也可能是空的。includeMetadataChanges: true 不能省:資料內容沒變、只是從「快取」變成「伺服器確認過」時,加了它 App 才會收到通知。下面會用到這個分別。
先依等級排,同等級再依更新時間由新到舊。等級依序是紅、橙、黃、藍。
過了有效期限的示警不會馬上消失。卡片變灰、標「已過期」、移到最後,等後端下一輪同步再移走。使用者看到的是「已過期」,不會覺得示警突然不見了。
這是首頁最需要小心的地方。不能只看畫面上有沒有示警,下面四種狀態意思完全不同:
| 情況 | 畫面顯示 |
|---|---|
| 伺服器確認目前沒有示警 | 目前沒有生效中的示警 |
| 還沒拿到伺服器的資料,本機快取是空的 | 尚未取得最新資料,請確認網路後重試。 |
| 還沒拿到伺服器的資料,只有舊的快取 | 照樣顯示清單,上方提示「目前顯示的是離線資料,可能不是最新狀態。」 |
| 有先前的資料,但監聽回報錯誤 | 照樣顯示清單,上方提示「更新失敗,目前顯示的是先前的資料。」 |
如果離線時也顯示「目前沒有生效中的示警」,使用者會以為平安無事。所以畫面要先判斷資料是不是快取、上次更新有沒有失敗,再決定畫面要顯示什麼內容和提示。
還有一種情況只看示警清單看不出來:後端的同步排程停了。手機有網路,監聽也正常,但示警已經好幾個小時沒更新。所以後端每輪同步都會在 meta/sync_status 記下三件事:這輪什麼時候跑、成功沒有、上次成功是什麼時候。App 也監聽這份文件。超過 25 分鐘沒有新的一輪,首頁就提醒「資料可能不是最新」;這輪失敗,就顯示上次成功的時間,提醒從那之後同步都沒有成功。
Day 16 決定同一個 id 的示警全部保留,結果首頁會出現很多張幾乎一樣的卡,例如一份電文裡有 19 家醫院的門診異動。資料庫照樣保留每一則,只在畫面上合成一張卡,點開再列出每一則。

上方可以依分類和縣市篩選,選項旁邊直接寫生效中的示警則數(合併成一張卡的,照則數算),數量是 0 的不列出來。
如果同時選了分類和縣市,結果可能是空的。這時要顯示「沒有符合目前篩選的示警。」,不能說「目前沒有生效中的示警」。
首頁最下面有一個收合區塊,展開時才去讀已結束的示警。被新版取代的不顯示,免得同一件事出現兩次。
這裡有一個容易踩到的地方。同時用「狀態」和「結束時間」查詢,Firestore 要另建一個複合索引(同時涵蓋兩個欄位的索引)。我們不想另建,所以查詢只用結束時間,狀態在 App 裡過濾。如果先「只讀 50 筆」再過濾,前 50 筆剛好都是被取代的,就會變成「沒有已結束的示警」。所以改成每次讀 200 筆、在手機裡過濾,不夠就再往下讀,直到收滿 50 則或讀完 24 小時。
明天 Day 20:使用者說「我很害怕」時,問答該怎麼回。