BusTimer 是使用 Native Flutter UI 製作的香港巴士實時到站時間 App,支援 Android 及 iOS。介面採用青綠亮色、Material 3、圓角卡片、淺色玻璃感與大量留白,所有路線、車站、座標及 ETA 均來自巴士公司或香港政府公開資料。
本專案沒有 WebView、沒有內置假路線、沒有模擬 ETA,也不需要 API key。
- 九巴、龍運及城巴路線搜尋,輸入時即時進行前綴配對
- 路線方向、起點、終點及營運公司資料
- 收藏路線及最多 20 筆最近搜尋,本機持久保存
- 可從裝置相簿選擇全畫面背景,支援自由裁剪、螢幕比例、9:16、3:4 及 1:1
- 自訂背景以原色鋪滿首頁及詳情頁,不加入全頁色彩遮罩;透明效果只套用在資料卡片及必要控制元件
- 路線方向 Segmented Button 及左右滑動轉場
- 全線車站、站號、官方座標及可用車費
- 單一車站展開的手風琴列表,每站最多三班 ETA
- 選擇路線後讀取 GPS,以目前位置和官方車站座標計算最近站並自動展開
- 進入詳情頁及回到前景時立即更新,前景每 10 秒自動更新
- ETA 首次沒有有效時間時自動向官方 API 重刷新一次
- 官方座標原生繪製的路線示意圖,不使用地圖 SDK
- Loading、Empty、Error、Retry、Offline、No ETA 狀態
- App 內完整私隱政策、公開資料來源及非官方應用程式聲明
- 只提供繁體中文及淺色模式
定位只在路線詳情頁前景進行單次讀取,不會在背景持續追蹤或保存使用者座標。若拒絕權限或關閉 GPS,仍可手動展開車站。自訂背景亦只在裝置內處理及保存,不會上傳到任何伺服器。
實際使用的 API:
https://data.etabus.gov.hk/v1/transport/kmb/route/https://data.etabus.gov.hk/v1/transport/kmb/stophttps://data.etabus.gov.hk/v1/transport/kmb/route-stop/{route}/{direction}/{service_type}https://data.etabus.gov.hk/v1/transport/kmb/route-eta/{route}/{service_type}
龍運與九巴共用上述運輸署 ETA 端點。BusTimer 以運輸署路線資料中的 COMPANY_CODE=LWB 辨認龍運,不會以路線字首猜測公司。
資料集:城巴的實時抵站時間及相關資料
實際使用的 API:
https://rt.data.gov.hk/v2/transport/citybus/route/CTBhttps://rt.data.gov.hk/v2/transport/citybus/route-stop/CTB/{route}/{direction}https://rt.data.gov.hk/v2/transport/citybus/stop/{stop_id}https://rt.data.gov.hk/v2/transport/citybus/eta/CTB/{stop_id}/{route}
城巴 ETA 以官方路線、車站 ID 及方向欄位配對。城巴的路線車站與 ETA 資料集偶爾會為同一車站提供不同 seq,因此不會以跨資料集站序刪除本來有效的官方 ETA;九巴及龍運仍會按其路線 ETA 格式驗證站序。
資料集:公共交通路線及車費資料
https://static.data.gov.hk/td/routes-fares-xml/ROUTE_BUS.xml
此檔案提供營運公司及全程成人現金車費。全程票價只會顯示於方向起點;其他車站若無法從公開 API 可靠地對應分段收費,畫面顯示 車費 --,不會用全程票價或估算值冒充分段收費。
需求:
- Flutter 3.44 或相容的 stable 版本
- Dart 3.12 或以上
- Android SDK 35/36、Java 17 或以上
- iOS 開發需要完整 Xcode;Flutter 3.44 預設使用 Swift Package Manager
flutter pub get
dart run build_runner build
flutter runFreezed 及 Json Serializable 產生檔已提交到專案;修改 Model 後才需要重新執行 build_runner。
flutter doctor -v
flutter analyze
flutter test
flutter build apk --releaseAPK 輸出:
build/app/outputs/flutter-apk/app-release.apk
開發執行:
flutter run -d <android-device-id>Android package ID 為 hk.bustimer.bustimer,主 Manifest 已加入 INTERNET、ACCESS_COARSE_LOCATION 及 ACCESS_FINE_LOCATION 權限。App 不申請背景定位;定位只用於找出所選路線最近的車站。相簿選圖使用 Android 系統選取器及 Scoped Storage,不需要額外儲存權限。
正式發佈簽名可建立不納入版本控制的 android/key.properties:
storeFile=/absolute/path/to/upload-keystore.jks
storePassword=...
keyAlias=...
keyPassword=...Google Play 正式版本必須使用妥善備份的 upload key,並啟用 Play App Signing。已簽署 AAB 的輸出位置為:
build/app/outputs/bundle/release/app-release.aab
私隱政策公開網址:https://iskshadow195563.github.io/BusTimer/privacy/
Google Play 所需的商店說明、資料安全草稿、素材及提交清單位於 play_store/。upload key、android/key.properties 及密碼不得提交到版本控制。
首次使用:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -runFirstLaunch
flutter config --enable-swift-package-manager
flutter pub get模擬器:
flutter run -d ios不簽名建置檢查:
flutter build ios --no-codesign正式封存請在 Xcode 開啟 ios/Runner.xcworkspace,設定 Apple Team、Bundle Identifier 及簽名後執行 Archive。專案已包含 FlutterGeneratedPluginSwiftPackage 及 Run Prepare Flutter Framework Script pre-action;Xcode 會自動解析 Connectivity 及 SharedPreferences 的 Swift packages。
iOS 的 Info.plist 已加入 NSLocationWhenInUseUsageDescription 及 NSPhotoLibraryUsageDescription;不需要 Always、背景定位或相機權限。
lib/
├── core/
│ ├── error/ API 例外分類
│ ├── network/ Dio、timeout、retry、網絡檢查
│ ├── storage/ Hive 快取及 SharedPreferences
│ ├── theme/ Material 3 青綠亮色主題
│ └── utils/ ETA 格式化
├── data/
│ ├── models/ Freezed + Json Serializable Models
│ ├── services/ KMB、LWB、Citybus、運輸署 Services
│ └── repositories/ 巴士及外觀 Repository 實作
├── domain/
│ └── repositories/ Repository contracts
├── presentation/
│ ├── providers/ Riverpod dependency injection
│ ├── viewmodels/ MVVM 狀態及操作
│ ├── screens/ 首頁、詳情頁、背景裁剪及關於私隱頁
│ └── widgets/ 路線卡、車站卡、狀態及路線示意圖
└── routing/ GoRouter
資料流:
Screen → ViewModel → BusRepository → Route / Stop / ETA Repository
→ Operator API Services
UI 不直接呼叫 API。BusRepository 是 Presentation 層唯一使用的巴士資料入口。
- 首頁右上角的背景按鈕開啟本機背景設定。
- 系統相簿只回傳使用者選擇的一張相片;App 不提供相機或網絡上傳。
- 相片長邊先限制至 4096px,修正 EXIF 方向並轉成 JPEG,避免大圖令裁剪頁記憶體過高。
- 裁剪頁可移動、縮放及選擇螢幕比例、9:16、3:4、1:1 或自由比例。
- 裁剪結果以最高 1440px 短邊及 2560px 長邊輸出;過小圖片只作高品質插值放大,不會生成額外圖像內容。
- 最終 JPEG quality 為 88,會移除 EXIF,並保存到 Application Support 目錄。SharedPreferences 只保存檔案路徑及裁剪比例。
- 新圖片成功寫入及保存偏好後才刪除舊圖片;取消、權限拒絕、損壞檔案或保存失敗均保留原背景。
- Android 若在相簿開啟期間回收 Activity,App 會透過
retrieveLostData()恢復待裁剪圖片。
- 詳情頁取得已快取或最新的官方車站列表及座標。
- 前景單次請求高準確度 GPS,使用球面距離計算最近車站,自動展開並捲動至該卡片。
- 若定位被拒絕、GPS 關閉或逾時,顯示明確狀態並暫時展開第一站;使用者仍可手動選站或重新定位。
- 立即請求所選車站 ETA;只接受可解析且未失效的官方時間。
- 首次回應沒有任何有效 ETA 時,立即再向同一官方 API 重刷新一次。
- 第二次仍沒有有效 ETA 時,顯示「官方暫未提供到站時間」,不顯示橫杠、不估算、不偽造。
- 使用者展開另一站時,上一站收起並請求新站 ETA。
- App 保持前景時,每 10 秒更新目前展開車站。
- App 進入
inactive、paused或detached後取消 Timer。 - App 回到
resumed後立即更新並重啟 Timer。 - ETA 不寫入 Hive 或 SharedPreferences;GPS 座標亦不保存。
- 週期請求期間保留目前 ETA,不先清空卡片;新舊 ETA 相同時保留原 List。各 ETA Widget 使用 Riverpod
select及AnimatedSwitcher,只讓相關區域淡入淡出。 - 上一個 ETA 請求未完成時跳過該輪,避免 10 秒 Timer 產生併發重複請求。
ETA 顯示規則:
- 61 秒以上:向上取整後顯示
N 分鐘 - 60 秒內:
即將到站 - 官方沒有 ETA、空值或已明顯過期:自動重刷新一次,仍沒有時顯示明確的官方無資料狀態
BusTimer 每 10 秒重新向官方端點請求一次;若上游資料時間戳未改變,畫面會保留原 ETA List,不會為相同資料重繪整個車站列表。
| 資料 | 儲存方式 | 有效期 | 失敗時行為 |
|---|---|---|---|
| 路線/方向 | Hive CE + 記憶體 | 24 小時 | 使用過期快取並顯示離線狀態 |
| 路線車站/座標 | Hive CE | 24 小時 | 使用過期快取 |
| 收藏 | SharedPreferences | 永久 | 空集合 |
| 最近搜尋 | SharedPreferences | 永久,最多 20 筆 | 空列表 |
| 自訂背景路徑/裁剪比例 | SharedPreferences + Application Support 圖片 | 直到使用者移除 | 圖片不存在時回復青綠漸層 |
| ETA | 不快取 | 不適用 | 空資料自動重拉一次,再顯示官方無資料狀態 |
| 使用者 GPS | 不保存 | 不適用 | 顯示定位狀態,保留手動選站功能 |
同一路線、公司、方向、起終點若因官方 service_type 產生重複記錄,Repository 會優先保留主要服務類型;不同路線(例如 A41 與 A41P)及不同起終點仍會分開顯示。聯營路線按公司分開,避免混合不同站號與 ETA。
ApiService 設定:
- Connect timeout:10 秒
- Receive timeout:25 秒
- Send timeout:10 秒
- 只對連線錯誤、timeout、HTTP 429 及 5xx 最多重試 3 次
- 重試間隔:300ms、600ms
錯誤會分類為 offline、timeout、server、invalidResponse 或 unknown,ViewModel 再轉換成繁體中文狀態。
常見情況:
- 一直顯示 Loading:先確認裝置可連接
data.etabus.gov.hk及rt.data.gov.hk;城巴服務偶爾回應較慢。 - Offline:確認 Wi-Fi/流動數據;若有靜態快取仍可搜尋路線及查看車站。
- No ETA:App 會自動再向官方 API 讀取一次;仍沒有有效時間時顯示「官方暫未提供到站時間」,不會補造班次。
- 未能自動展開最近站:確認裝置 GPS 已開啟並允許精確定位;詳情頁的定位狀態卡可重新定位或開啟系統設定。
- 未能選擇或裁剪背景:確認 iOS 相片權限;取消選圖不會清除目前背景,損壞或不支援的圖片會顯示中文錯誤。
- 車費
--:代表公開資料無法可靠對應該站到終點的分段成人現金收費。 - Build Runner 衝突:執行
dart run build_runner build,再執行flutter analyze。 - iOS 顯示 Application not configured:確認已安裝完整 Xcode,而不是只有 Command Line Tools,然後執行
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer。
ListView.builder/Sliver lazy list,不一次建立全線所有卡片- 搜尋只操作已正規化的記憶體路線列表,無 debounce、無網絡請求
- Repository 合併重複呼叫,靜態資料使用記憶體及 Hive
- Citybus 車站資料以有限並行數取得,避免一次發出數十個請求
- 不下載遠端圖片;只有使用者主動選擇的本機背景會經壓縮後載入
- 背景按實際裝置寬度解碼,不把完整 2560px 圖片長駐記憶體
- ETA Provider 只通知選取車站與 ETA 區域
目前測試涵蓋 ETA 格式、10 秒更新及生命週期、重複請求保護、相同 ETA List identity、城巴跨資料集站序差異、九巴站序驗證、搜尋前綴、最近搜尋順序、2,500 個方向搜尋低於 50ms、背景圖片比例/放大/損壞處理,以及路線卡片必要欄位。
dart format --output=none --set-exit-if-changed lib test
flutter analyze
flutter test
flutter build apk --releaseAndroid QA 應再以實際 API 執行以下流程:開啟背景設定及系統相簿、裁剪圖片、搜尋 970、開啟兩個方向、展開不同車站、確認三班 ETA、收藏後返回首頁,以及檢查 logcat 沒有 Flutter exception。