From a6b66c7a0148823b91dfe3104744f7324e4f393b Mon Sep 17 00:00:00 2001 From: awei Date: Thu, 30 Jul 2026 18:35:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E4=B9=98=E5=AE=A2=E7=AB=AF?= =?UTF-8?q?=E5=8F=AB=E8=BB=8A=E7=9A=84=E5=AE=9A=E4=BD=8D=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=AE=9A=E4=BD=8D=E6=9C=8D=E5=8B=99=E9=97=9C?= =?UTF-8?q?=E8=91=97=E6=99=82=E6=95=B4=E6=A2=9D=E6=98=AF=E9=9D=9C=E9=BB=98?= =?UTF-8?q?=E7=9A=84=EF=BC=88=E7=AC=AC=E4=BA=8C=E5=8D=81=E4=BA=8C=E8=BC=AA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二十一輪換族到「定位健康度」,這一輪把同族的乘客端清完。三個病都在 placeOrder: 1. 靜默:_acquirePosition 只攔 TimeoutException,系統定位服務被關時 getCurrentPosition 丟的是 LocationServiceDisabledException,而 placeOrder 的 catch 只收 ApiException——例外整個穿出去變成未處理的非同步錯誤,_error 沒設、 busy 被 finally 清掉,乘客按下叫車只看到按鈕轉一下就回到原狀。 2. deniedForever 之後 requestPermission 不會再彈窗,文案卻只說「需要定位權限」, 乘客在 App 裡按到死都按不出結果——現在會說要去系統設定。 3. 多停靠點行程的 pickup/dropoff 由 stops 推導(後端 prepareStops 覆蓋座標欄位, 根本不看 App 送的那組),卻照樣先要權限再等 GPS fix。程式碼裡的註解本來就寫著 「不需要定位」,是實作與它矛盾。現在改用第一個上車點的座標,不再碰 GPS。 順手第四個:司機端 goOffline() 清了 _locationStreamFailed 卻沒清那句紅字, 離線後畫面仍寫「請開啟才能接單」。只清這一類,業務錯誤不動。 測試接縫 CustomerLocator 刻意開在最外層的 geolocator 呼叫,不是餵錯誤給 錯誤處理函式——後者測不到「哪一種例外從哪一支呼叫冒出來」,把 on 分支拔掉照樣綠。 驗收:flutter analyze 無 issue、flutter test 377 passed(367+乘客 8+司機 2)。 反向驗證三半各跑一次,各 2 案 FAIL。模擬器實跑(m6_pixel,location_mode=0): 單點叫車出現「裝置定位服務已關閉,請開啟後再叫車」;多停靠點在定位關著時建單成功 (ride #36,pickup_point = 第一個上車點,ride_stops 兩筆)。 同場補完第二十一輪留的實跑尾巴:hero 真的會降級、恢復也成立,但這台模擬器 造不出「串流永久靜止」,定期重評那一半只驗到局部——文件已寫明,不宣稱兩半都驗過。 Co-Authored-By: Claude Opus 5 --- README.md | 11 +- docs/TODO.md | 103 ++++++++- lib/core/location/customer_locator.dart | 36 ++++ lib/customer/customer_controller.dart | 99 ++++++--- lib/driver/driver_controller.dart | 5 + test/customer_location_exits_test.dart | 272 ++++++++++++++++++++++++ test/driver_location_health_test.dart | 43 +++- 7 files changed, 535 insertions(+), 34 deletions(-) create mode 100644 lib/core/location/customer_locator.dart create mode 100644 test/customer_location_exits_test.dart diff --git a/README.md b/README.md index 6548033..cfc39ee 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **361 passed**(48 個測試檔,2026-07-30 實跑)。 -~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **377 passed**(50 個測試檔,2026-07-30 實跑)。 +~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 **2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪): 先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)—— @@ -287,6 +287,13 @@ SnackBar;FCM token 輪替失敗會冒出司機看不懂也無事可做的紅 **production 首頁沒有遺失物協尋入口**(banner 只寫在非 production 的卡片版首頁,乘客付不了處理費就拿不回東西)。 三個「畫面沒人讀」類的修正已於同日在 `m6_pixel` 模擬器上**逐一實機閉環驗證**。 +**2026-07-30 定位出口(第二十一~二十二輪,詳見 [`docs/TODO.md`](docs/TODO.md))**: +司機端定位串流死掉時 hero 會降級成「位置回報失敗,暫時收不到派單」(`onError` 不再吞, +權限被撤與定位服務被關**分開講**);乘客端叫車的三種定位失敗也各自有話講—— +其中「系統定位服務被關」原本是**完全靜默**的(例外穿出 `placeOrder`,畫面一句話都沒有)。 +另外,**多停靠點行程不再需要裝置定位**:pickup/dropoff 由 stops 推導,後端本來就不看那組座標。 +兩端都在 `m6_pixel` 上以 `settings put secure location_mode 0` 實跑驗過。 + ## 規劃中(尚未實作) > 完整規格與待拍板事項見 [`docs/TODO.md`](docs/TODO.md) 與後端 diff --git a/docs/TODO.md b/docs/TODO.md index 19cb5cb..a896f39 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -67,6 +67,7 @@ - [🧪 第十四輪:把第十輪那兩項搬到模擬器實跑(抓到 1 個 bug)](#-2026-07-30-第十四輪把第十輪那兩項搬到模擬器上實跑本批抓到-1-個-bug) - [🔦 第十九輪:司機端協尋畫面的實跑證據(沒抓到 bug,補了負向對照)](#-2026-07-30-第十九輪司機端協尋畫面的實跑證據沒抓到-bug補了一個負向對照) - [📍 第二十一輪:定位串流死掉時,畫面永遠不會知道](#-2026-07-30-第二十一輪定位串流死掉時畫面永遠不會知道本批修掉) +- [📍 第二十二輪:乘客端的定位出口(+第二十一輪的實跑尾巴)](#-2026-07-30-第二十二輪乘客端的定位出口本批修掉-31-個並補完第二十一輪的實跑尾巴) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -83,7 +84,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**49 個測試檔、`flutter test` 367 passed**(2026-07-30 第二十一輪實跑;`flutter analyze` 無 issue)。 +- 單元測試:**50 個測試檔、`flutter test` 377 passed**(2026-07-30 第二十二輪實跑;`flutter analyze` 無 issue)。 ~~(54 項)~~ 是 2026-07-10 的數字,長期沒更新,已更正——**本節的數字請跟著最後盤點日一起改**。 - 遠端:`github.com/thothawei/fleet-app`。**2026-07-29 實查**:`git ls-remote --heads origin` 只有 `main`、 `gh pr list` 三個 repo 的 open PR 皆為 0(開工前請自己再跑一次,見「下次任務」第 1 點)。 @@ -2002,6 +2003,87 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log --- +## 📍 2026-07-30 第二十二輪:乘客端的定位出口(本批修掉 3+1 個,並補完第二十一輪的實跑尾巴) + +> 第二十一輪換族到「定位健康度」,這一輪把同族的**乘客端**清完 +> (該輪列的「同族還沒碰的角落 1」)。三個病都在 `placeOrder` 這一條路上。 + +### 根因三句話 + +1. **靜默**:`_acquirePosition` 只攔 `TimeoutException`,而系統定位服務被關掉時 + `getCurrentPosition` 丟的是 `LocationServiceDisabledException`;`placeOrder` 的 + `catch` 只收 `ApiException`,於是例外整個穿出去變成未處理的非同步錯誤—— + `_error` 沒被設、`busy` 被 `finally` 清掉,**乘客按下叫車只看到按鈕轉一下就回到原狀**。 +2. **文案沒有出路**:`deniedForever` 之後 `requestPermission` 不會再彈任何視窗, + 但訊息只說「需要定位權限才能叫車」——乘客在 App 裡按到死都按不出結果, + 唯一的路是系統設定,而畫面沒說。 +3. **不必要的阻擋**:多停靠點行程的 pickup/dropoff 由 stops 推導 + (後端 `prepareStops` 會覆蓋座標欄位,**根本不看** App 送的 pickup), + `placeOrder` 卻照樣先要權限再等 GPS fix。程式碼裡那句 + 「N3:多乘客模式下…故不需要定位」與它下面的實作**互相矛盾**,註解是對的、碼是錯的。 + +### 修法 + +- 新增 `lib/core/location/customer_locator.dart`:把 geolocator 的四個呼叫抽成介面。 + **接縫刻意開在最外層呼叫**,不是「餵一個錯誤給錯誤處理函式」——後者測不到 + 「哪一種例外會從哪一支呼叫冒出來」,把 `on ...` 分支拔掉照樣會綠。 +- `_resolvePickupPosition()`:三種失敗**分開講**(比照第二十一輪的司機端)—— + 定位服務關閉/權限被永久拒絕(含「去系統設定」)/拿不到 fix。 +- 多停靠點:跳過定位,`pickupLat/Lng` 直接用第一個上車點 + (=後端推導出來的同一組;送 `(0,0)` 會被 `validatePickupCoords` 判成無效座標)。 +- 順手:司機端 `goOffline()` 收掉定位串流那句紅字(見下方「順手修的第四個」)。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **377 passed**(367 +乘客 8 +司機 2)。 +- **反向驗證三半各跑一次**:拿掉例外分支 → 2 案 FAIL;拿掉 `deniedForever` 分岔 → + 2 案 FAIL;讓多停靠點回去走定位路徑 → 2 案 FAIL。三半各自釘不同的洞。 +- **模擬器實跑**(`m6_pixel` +本機後端,customer flavor,`settings put secure location_mode 0`): + +| 情境 | 結果 | +|---|---| +| 正向對照:定位開著按叫車 | ride #35 建單成功,`pickup_address=目前位置 (25.03300, 121.56540)` | +| 定位服務關閉+單點叫車 | SnackBar **「裝置定位服務已關閉,請開啟後再叫車」**(畫面實拍) | +| 同上、另一次嘗試 | 走到「目前無法取得定位,請確認 GPS 已開啟後再試」——geolocator 在同一情境下**有時丟 `TimeoutException`**(`getLastKnownPosition` 又回 null),兩條出口都有話講 | +| 定位服務關閉+多停靠點叫車 | **建單成功** ride #36,`pickup_point=POINT(121.517 25.05285…)` =第一個上車點,`ride_stops` 兩筆(A pickup/dropoff);全程沒有碰過 GPS | + +- **Android 會先插一個系統對話框**("For a better experience, turn on device location"): + 按 **No thanks** 之後才會走到 App 的錯誤出口。要驗這條就得記得先關掉它, + 否則會誤以為 App 沒反應。 + +### 順手修的第四個:離線之後紅字還掛著 + +實跑時看到司機端按了離線,畫面仍寫「裝置定位服務已關閉,請開啟才能接單」。 +`goOffline()` 清了 `_locationStreamFailed` 卻沒清那句話——第二十一輪只想到旗標。 +修法**只清這一類**(`_locationStreamFailed` 為真才清),後端明確拒絕的業務錯誤 +不能一起抹掉(那是司機唯一的失敗回饋,第二十一輪的教訓);兩個方向各有一個測試。 + +### 第二十一輪留下的實跑尾巴:跑完了,而且比文件寫的更細 + +`adb shell settings put secure location_mode 0`(撤權限會直接殺掉 App 行程,驗不到): + +- **hero 真的會降級**:紅底「上線中/位置回報失敗,暫時收不到派單」,實拍。 +- **時機分兩種**,取決於串流有沒有丟錯誤:丟了(`onError` 那一半)→ **約 2 秒**內降級; + 沒丟、只是靜靜停止吐位置 → 等鮮度窗那一半,實測**約 12~24 秒**之間翻面。 +- **恢復也成立**:把定位服務開回來 → 位置又送得出去 → 自動回綠,不會掛著假紅字。 +- **這台模擬器造不出「串流永久靜止」**:`location_mode=0` 之後 geolocator 有時仍持續吐位置 + (後端 log 在關閉後仍每 8 秒收到 `POST /api/driver/location` 200), + 此時 hero 回綠是**正確的**——位置真的送出去了,他仍在派單池裡。 + 也就是說「定期重評」那一半在真裝置上**只驗到局部**(翻面看得到,永久靜止情境沒造出來)。 + 要完整驗它得用真機關 GPS。**不要宣稱兩半都實跑過。** + +### 這一輪學到的兩條 + +1. **同一個 API 在同一個情境下不保證丟同一種例外**:`getCurrentPosition` 在 + 「定位服務關閉」下丟過 `LocationServiceDisabledException`,也丟過 `TimeoutException`。 + 出口要按「使用者的下一步」分類,而不是賭它一定丟哪一種。 +2. **第二十輪那條負向斷言的教訓要反過來用**:我這一輪一開始在裝置上「看不到 SnackBar」, + 差點下結論說錯誤沒有顯示——其實是**截圖落在它出現之前**(widget tree dump 顯示 + SnackBar 就在 Scaffold 的 snackBar slot、offset y=842.3、高 72)。 + 短命 UI 要用**連拍**取樣(我最後用 12 連拍+底部條帶亮度自動判定才定案)。 + +--- + ## 下次任務 > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** @@ -2079,6 +2161,9 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log > **2026-07-30 第十四輪開工前第四次實查:仍然都不在**(同三條指令)。 > **2026-07-30 第十九輪開工前第五次實查:仍然都不在**;同時 `gh pr list --repo` 三個 repo > 的 open PR 皆為 0(沒有撞題風險)。 +> **2026-07-30 第二十二輪開工前第六次實查:三者依然都不在** +> (`android/app/google-services.json`、`ios/Runner/GoogleService-Info.plist` 不存在、 +> `xcrun devicectl list devices` → No devices found);三個 repo 的 open PR 同樣皆為 0。 > > **2026-07-28 收尾**:清單清空後改做 debug——修掉 4 個 bug、做完一輪跨端契約對帳, > 見上方「🐞 2026-07-28 debug」。**六份清單(本檔、admin TODO、IOS_PLAN、gap-analysis-plan、 @@ -2151,15 +2236,19 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log > **下一輪請換一個還沒碰過的族**,別再往這條線挖。 > > ✅ **第二十一輪就是換族的第一輪**:司機端定位健康度(見上方專段), -> 修掉「定位串流死掉時畫面永遠不會知道」。**它留了一條實跑尾巴**—— -> 下次起模擬器時跑「關掉系統定位服務」那條 -> (`adb shell settings put secure location_mode 0`,撤權限會直接殺掉 App 行程,驗不到)。 +> 修掉「定位串流死掉時畫面永遠不會知道」。~~**它留了一條實跑尾巴**~~ +> ✅ **已於第二十二輪跑完**(`location_mode=0`):hero 真的會降級、恢復也成立; +> 但**「定期重評」那一半只驗到局部**——這台模擬器造不出「串流永久靜止」, +> 詳見第二十二輪專段那張表,別把它當成兩半都驗過。 > -> **同族還沒碰的角落**(讀碼可查,尚未取證): -> 1. **乘客端有沒有同一種病**——它有 15 秒輪詢,所以重畫來源比較穩, -> 但「定位權限被拒」在叫車以外的路徑(例如地圖跟隨)有沒有出口沒查過。 +> **同族還沒碰的角落**: +> 1. ~~**乘客端有沒有同一種病**~~ ✅ **已於第二十二輪做掉**:有,而且是三個 +> (靜默、`deniedForever` 文案沒出路、多停靠點被不必要地擋住)。 +> 順帶查清一件事:**地圖跟隨完全不吃裝置定位**(司機 marker 來自 WS, +> `lastPosition` 為 null 時退回台北市中心),所以那條路徑沒有出口要補。 > 2. **前景服務被系統收走**(Android 省電模式殺掉常駐通知): > 現在只有定位串流的 onError 會知道,服務本身消失時 App 端沒有任何偵測。 +> **這是本族目前唯一剩下的角落**,也是下一輪的首選。 > > **第二十輪順帶學到的一條**(已寫進坑卡):反向驗證要還原檔案時, > **先 `cp` 一份再改、改完從備份還原**——這一輪順手打了 `git checkout -- `, diff --git a/lib/core/location/customer_locator.dart b/lib/core/location/customer_locator.dart new file mode 100644 index 0000000..90bd8a6 --- /dev/null +++ b/lib/core/location/customer_locator.dart @@ -0,0 +1,36 @@ +import 'package:geolocator/geolocator.dart'; + +/// 乘客端叫車路徑用到的定位相依,抽成介面**只為了能測**。 +/// +/// geolocator 是靜態方法,單元測試環境沒有 platform channel,一碰就 +/// `MissingPluginException`——於是「權限被永久拒絕」「定位服務被關閉」這幾條出口 +/// 在測試裡完全碰不到,壞掉也不會有人知道(第二十二輪就是這樣壞的)。 +/// +/// 接縫刻意開在**最外層的 geolocator 呼叫**,而不是「直接餵一個錯誤給錯誤處理函式」: +/// 後者測不到「哪一種例外會從哪一支呼叫冒出來」,把 `on ...` 分支拔掉照樣會綠。 +abstract class CustomerLocator { + Future checkPermission(); + Future requestPermission(); + Future getCurrentPosition(LocationSettings settings); + Future getLastKnownPosition(); +} + +/// 正式實作:原樣轉給 geolocator。 +class GeolocatorCustomerLocator implements CustomerLocator { + const GeolocatorCustomerLocator(); + + @override + Future checkPermission() => Geolocator.checkPermission(); + + @override + Future requestPermission() => + Geolocator.requestPermission(); + + @override + Future getCurrentPosition(LocationSettings settings) => + Geolocator.getCurrentPosition(locationSettings: settings); + + @override + Future getLastKnownPosition() => + Geolocator.getLastKnownPosition(); +} diff --git a/lib/customer/customer_controller.dart b/lib/customer/customer_controller.dart index ca99ad0..94b956b 100644 --- a/lib/customer/customer_controller.dart +++ b/lib/customer/customer_controller.dart @@ -7,6 +7,7 @@ import '../core/api/api_error.dart' show sessionExpiredMessage; import '../core/api/customer_api_client.dart'; import '../core/api/fleet_api_client.dart' show ApiException; import '../core/config/app_config.dart'; +import '../core/location/customer_locator.dart'; import '../core/models/models.dart'; import '../core/push/fleet_push_service.dart'; import '../core/push/push_payload.dart'; @@ -20,10 +21,12 @@ class CustomerController extends ChangeNotifier { CustomerApiClient? api, FleetWsClientFactory? wsFactory, FleetPushService? push, + CustomerLocator? locator, }) : _storage = storage ?? CustomerTokenStorage(), _api = api ?? CustomerApiClient(), _wsFactory = wsFactory ?? FleetWsClient.new, _push = push ?? NoOpFleetPushService(), + _locator = locator ?? const GeolocatorCustomerLocator(), _ws = FleetWsClient(onEvent: (_) {}) { // token 過期/失效時把乘客送回登入頁(見 _handleUnauthorized)。 _api.onUnauthorized = _handleUnauthorized; @@ -33,6 +36,7 @@ class CustomerController extends ChangeNotifier { final CustomerApiClient _api; final FleetWsClientFactory _wsFactory; final FleetPushService _push; + final CustomerLocator _locator; FleetWsClient _ws; /// 已向後端註冊的推播 token;登出時要拿它去註銷。 @@ -995,24 +999,35 @@ class CustomerController extends ChangeNotifier { } _setBusy(true); try { - final ok = await _ensureLocationPermission(); - if (!ok) { - _error = '需要定位權限才能叫車'; - return; - } - final pos = await _acquirePosition(); - if (pos == null) { - _error = '目前無法取得定位,請確認 GPS 已開啟後再試'; - return; + final double pickupLat; + final double pickupLng; + final String pickup; + if (stops.isNotEmpty) { + // N3:多停靠點行程的上車點就是第一個 pickup(後端 prepareStops 也是這樣推導, + // 並且會忽略下面這幾個欄位)——**這條路徑不需要裝置定位**。 + // 原本卻照樣先要權限再等 GPS fix,於是定位服務關著、或室內拿不到 fix 時, + // 連根本不看座標的多停靠點行程都叫不了車。 + // `buildStops` 保證 pickup 全排在 dropoff 之前,故 first 就是第一個上車點。 + final firstPickup = stops.first; + pickupLat = firstPickup.lat; + pickupLng = firstPickup.lng; + pickup = firstPickup.address.isNotEmpty + ? firstPickup.address + : pickupAddress.trim(); + } else { + final pos = await _resolvePickupPosition(); + if (pos == null) return; // 失敗原因已寫進 _error + _lastPosition = pos; + pickupLat = pos.latitude; + pickupLng = pos.longitude; + pickup = pickupAddress.trim().isNotEmpty + ? pickupAddress.trim() + : '目前位置 (${pos.latitude.toStringAsFixed(5)}, ' + '${pos.longitude.toStringAsFixed(5)})'; } - _lastPosition = pos; - final pickup = pickupAddress.trim().isNotEmpty - ? pickupAddress.trim() - : '目前位置 (${pos.latitude.toStringAsFixed(5)}, ' - '${pos.longitude.toStringAsFixed(5)})'; final ride = await _api.createRide( - pickupLat: pos.latitude, - pickupLng: pos.longitude, + pickupLat: pickupLat, + pickupLng: pickupLng, pickupAddress: pickup, dropoffAddress: dropoffAddress.trim(), dropoffLat: dropoffLat, @@ -1217,26 +1232,62 @@ class CustomerController extends ChangeNotifier { /// 取得目前位置:高精度定位在模擬器/室內可能長時間拿不到 fix, /// 故設 8 秒逾時;逾時後退回最後已知位置,避免叫車一直卡在載入轉圈。 + /// + /// **只攔逾時**:定位服務被關、權限被撤都要往上丟,由 `_resolvePickupPosition` + /// 翻成乘客看得懂的下一步(原本沒人攔,整個例外穿出 `placeOrder`, + /// 畫面一句話都沒有)。 Future _acquirePosition() async { try { - return await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( + return await _locator.getCurrentPosition( + const LocationSettings( accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 8), ), ); } on TimeoutException { - return Geolocator.getLastKnownPosition(); + return _locator.getLastKnownPosition(); + } + } + + /// 單點模式的上車座標。回傳 null = 已設好 `_error`,呼叫端放棄這次建單。 + /// + /// **三種失敗要分開講**(比照司機端第二十一輪):乘客的下一步完全不同—— + /// 去系統設定給權限/把定位服務打開/換個位置再試。含糊的「定位失敗」等於沒說。 + Future _resolvePickupPosition() async { + final perm = await _ensureLocationPermission(); + if (perm != LocationPermission.always && + perm != LocationPermission.whileInUse) { + // deniedForever 之後 `requestPermission` 不會再彈任何視窗—— + // 只說「需要定位權限」的話,乘客會一直重按叫車,App 裡永遠按不出結果。 + _error = perm == LocationPermission.deniedForever + ? '定位權限已被永久拒絕,請到系統設定開啟才能叫車' + : '需要定位權限才能叫車'; + return null; + } + try { + final pos = await _acquirePosition(); + if (pos == null) { + _error = '目前無法取得定位,請確認 GPS 已開啟後再試'; + } + return pos; + } on LocationServiceDisabledException { + // 權限給了、系統定位服務關著(Android 快捷設定一鍵就關掉)。 + _error = '裝置定位服務已關閉,請開啟後再叫車'; + return null; + } on PermissionDeniedException { + // 檢查通過到取座標之間權限被撤(例如在通知欄操作)。 + _error = '定位權限已被關閉,請到系統設定開啟才能叫車'; + return null; } } - Future _ensureLocationPermission() async { - var perm = await Geolocator.checkPermission(); + /// 回傳**目前的權限狀態**而不是 bool:deniedForever 與 denied 的出路不一樣。 + Future _ensureLocationPermission() async { + var perm = await _locator.checkPermission(); if (perm == LocationPermission.denied) { - perm = await Geolocator.requestPermission(); + perm = await _locator.requestPermission(); } - return perm == LocationPermission.always || - perm == LocationPermission.whileInUse; + return perm; } void _startPolling() { diff --git a/lib/driver/driver_controller.dart b/lib/driver/driver_controller.dart index 1d6b59c..c42a330 100644 --- a/lib/driver/driver_controller.dart +++ b/lib/driver/driver_controller.dart @@ -578,6 +578,11 @@ class DriverController extends ChangeNotifier { _online = false; _onlineSince = null; _lastLocationOkAt = null; + // 定位串流的錯誤跟著離線一起收掉:那句話講的是「還想接單就得去開定位」, + // 司機自己按了離線之後它就不再成立,留著會讓已離線的畫面繼續喊 + // 「請開啟才能接單」(2026-07-30 模擬器實跑看到)。 + // **只清這一類**——後端明確拒絕的業務錯誤是司機唯一的失敗回饋,不可一起抹掉。 + if (_locationStreamFailed) _setError(null); _locationStreamFailed = false; await _stopLocationStream(); notifyListeners(); diff --git a/test/customer_location_exits_test.dart b/test/customer_location_exits_test.dart new file mode 100644 index 0000000..e489786 --- /dev/null +++ b/test/customer_location_exits_test.dart @@ -0,0 +1,272 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:line_fleet_app/core/api/customer_api_client.dart'; +import 'package:line_fleet_app/core/location/customer_locator.dart'; +import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/core/storage/customer_token_storage.dart'; +import 'package:line_fleet_app/core/ws/fleet_ws_client.dart'; +import 'package:line_fleet_app/customer/customer_controller.dart'; + +/// 乘客端叫車時「定位拿不到」的每一條出口。 +/// +/// 這是第二十一輪(司機端定位健康度)的同族角落:定位失敗的路徑上, +/// **狀態算得對不代表乘客看得到,看得到也不代表他知道下一步該做什麼**。 +/// 原本三條都壞: +/// 1. 系統定位服務關著時 `getCurrentPosition` 丟 `LocationServiceDisabledException`, +/// `_acquirePosition` 只攔 `TimeoutException`、`placeOrder` 只攔 `ApiException`, +/// 例外整個穿出去變成未處理的非同步錯誤——`_error` 沒設、busy 被 finally 清掉, +/// 乘客按「叫車」轉一下就回到原狀,**畫面上一句話都沒有**。 +/// 2. 權限被「永久拒絕」後 `requestPermission` 不會再彈窗, +/// 但文案只說「需要定位權限才能叫車」——乘客在 App 裡按到死都按不出結果。 +/// 3. 多停靠點行程的 pickup/dropoff 由 stops 推導(後端 prepareStops 會覆蓋座標欄位), +/// 根本不需要裝置定位,卻照樣被權限/GPS fix 擋在門外。 +void main() { + StopPoint p(double v) => StopPoint(lat: v, lng: v, address: '地點$v'); + + group('單點叫車:定位失敗要說得出下一步', () { + test('系統定位服務關著 → 明確提示,不可靜默什麼都沒發生', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator()..currentError = const LocationServiceDisabledException(), + ); + addTearDown(ctrl.dispose); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(ctrl.error, isNotNull, + reason: '例外穿出去、畫面沒訊息=乘客只看到按鈕轉一下就沒事了'); + expect(ctrl.error, contains('定位服務')); + expect(ctrl.busy, isFalse); + expect(api.createCalls, 0); + }); + + test('權限被永久拒絕 → 要說「到系統設定」(App 裡再按也不會彈窗)', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator()..permission = LocationPermission.deniedForever, + ); + addTearDown(ctrl.dispose); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(ctrl.error, contains('設定')); + expect(api.createCalls, 0); + }); + + test('這次被拒(還能再問)與永久拒絕要分開講', () async { + final denyOnce = _FakeLocator() + ..permission = LocationPermission.denied + ..requestResult = LocationPermission.denied; + final ctrlDenied = await _customer(_FakeApi(), denyOnce); + addTearDown(ctrlDenied.dispose); + await ctrlDenied.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + final forever = _FakeLocator() + ..permission = LocationPermission.deniedForever; + final ctrlForever = await _customer(_FakeApi(), forever); + addTearDown(ctrlForever.dispose); + await ctrlForever.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(denyOnce.requestCalls, 1, reason: 'denied 還有機會,要問一次'); + expect(forever.requestCalls, 0, + reason: 'deniedForever 再問也不會彈窗,問了只是讓乘客以為在等系統對話框'); + expect(ctrlDenied.error, isNot(ctrlForever.error)); + }); + + test('檢查通過後權限才被撤 → 也要提示,不是靜默失敗', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator()..currentError = const PermissionDeniedException(null), + ); + addTearDown(ctrl.dispose); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(ctrl.error, contains('權限')); + expect(api.createCalls, 0); + }); + + test('拿 fix 逾時但有最後已知位置 → 照樣叫得到車(不要因為室內就不能叫車)', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator() + ..currentError = TimeoutException('no fix') + ..lastKnown = _pos(25.03, 121.56), + ); + addTearDown(ctrl.dispose); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(api.createCalls, 1); + expect(api.lastPickupLat, 25.03); + expect(ctrl.error, isNull); + }); + + test('逾時又沒有最後已知位置 → 維持原本可行動的提示', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator()..currentError = TimeoutException('no fix'), + ); + addTearDown(ctrl.dispose); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: '信義路'); + + expect(ctrl.error, contains('無法取得定位')); + expect(api.createCalls, 0); + }); + }); + + group('多停靠點行程:不需要裝置定位', () { + test('定位服務關著也照樣建單(後端只看 stops)', () async { + final api = _FakeApi(); + final locator = _FakeLocator() + ..permission = LocationPermission.deniedForever + ..currentError = const LocationServiceDisabledException(); + final ctrl = await _customer(api, locator); + addTearDown(ctrl.dispose); + ctrl.enableMultiStop(); + ctrl.setPassengerPoint(0, pickup: p(25.05), dropoff: p(25.07)); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: ''); + + expect(api.createCalls, 1, + reason: 'pickup/dropoff 由 stops 推導,被 GPS 擋住等於白擋'); + expect(ctrl.error, isNull); + expect(locator.currentCalls, 0, reason: '這條路徑根本不該去問 GPS'); + }); + + test('帶出去的 pickup 座標=第一個上車點(與後端推導的同一組)', () async { + final api = _FakeApi(); + final ctrl = await _customer( + api, + _FakeLocator()..currentError = const LocationServiceDisabledException(), + ); + addTearDown(ctrl.dispose); + ctrl.enableMultiStop(); + ctrl.setPassengerPoint(0, pickup: p(25.05), dropoff: p(25.07)); + ctrl.addPassenger(); + ctrl.setPassengerPoint(1, pickup: p(25.06), dropoff: p(25.08)); + + await ctrl.placeOrder(pickupAddress: '', dropoffAddress: ''); + + expect(api.lastPickupLat, 25.05); + expect(api.lastPickupLng, 25.05); + expect(api.lastPickupAddress, '地點25.05', + reason: '送 (0,0) 會被後端 validatePickupCoords 判成無效座標'); + expect(api.lastStops.length, 4); + }); + }); +} + +Position _pos(double lat, double lng) => Position( + latitude: lat, + longitude: lng, + timestamp: DateTime.now(), + accuracy: 5, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +Future _customer(_FakeApi api, _FakeLocator locator) async { + final ctrl = CustomerController( + storage: _MemoryCustomerStorage() + ..save(const CustomerSession(customerId: 3, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + locator: locator, + ); + await ctrl.init(); + return ctrl; +} + +/// 假定位:測試自己決定權限狀態、以及取座標時丟哪一種例外。 +class _FakeLocator implements CustomerLocator { + LocationPermission permission = LocationPermission.whileInUse; + LocationPermission requestResult = LocationPermission.whileInUse; + Object? currentError; + Position? lastKnown; + int requestCalls = 0; + int currentCalls = 0; + + @override + Future checkPermission() async => permission; + + @override + Future requestPermission() async { + requestCalls++; + return requestResult; + } + + @override + Future getCurrentPosition(LocationSettings settings) async { + currentCalls++; + if (currentError != null) throw currentError!; + return _pos(25.0, 121.5); + } + + @override + Future getLastKnownPosition() async => lastKnown; +} + +class _FakeApi extends CustomerApiClient { + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + int createCalls = 0; + double? lastPickupLat; + double? lastPickupLng; + String? lastPickupAddress; + List lastStops = const []; + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => null; + + @override + Future> fetchLostItems() async => const []; + + @override + Future createRide({ + required double pickupLat, + required double pickupLng, + required String pickupAddress, + String? dropoffAddress, + double? dropoffLat, + double? dropoffLng, + String? requiredVehicleType, + List stops = const [], + }) async { + createCalls++; + lastPickupLat = pickupLat; + lastPickupLng = pickupLng; + lastPickupAddress = pickupAddress; + lastStops = stops; + return CustomerRide.fromJson({'ride_id': 1, 'status': 0}); + } +} + +class _MemoryCustomerStorage extends CustomerTokenStorage { + CustomerSession? _saved; + + @override + Future read() async => _saved; + + @override + Future save(CustomerSession session) async => _saved = session; + + @override + Future clear() async => _saved = null; +} diff --git a/test/driver_location_health_test.dart b/test/driver_location_health_test.dart index 3e9b14c..51652ae 100644 --- a/test/driver_location_health_test.dart +++ b/test/driver_location_health_test.dart @@ -93,6 +93,43 @@ void main() { expect(ctrl.locationStale, isFalse, reason: '一筆成功回報就代表串流活過來了'); ctrl.setOnlineForTest(false); }); + + test('司機自己按離線 → 定位那句話要收掉(已離線還喊「才能接單」沒有意義)', () async { + final (ctrl, gps) = await _driverWithGps(); + addTearDown(ctrl.dispose); + ctrl.setOnlineForTest(true); + await ctrl.startLocationStreamForTest(); + + gps.addError(const LocationServiceDisabledException()); + await Future.delayed(Duration.zero); + expect(ctrl.error, isNotNull, reason: '前置:紅字已經掛上去了'); + + await ctrl.goOffline(); + + expect(ctrl.error, isNull, + reason: '2026-07-30 模擬器實跑:離線後畫面仍寫「請開啟才能接單」'); + }); + + test('離線不可以順手抹掉後端明確拒絕的錯誤(那是司機唯一的失敗回饋)', () async { + final api = _SilentApi() + ..reportError = ApiException('此帳號已被停權', statusCode: 403); + final ctrl = DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + positionStream: (_) => const Stream.empty(), + ); + await ctrl.init(); + addTearDown(ctrl.dispose); + ctrl.setOnlineForTest(true); + await ctrl.reportPositionForTest(_pos(25.03)); + expect(ctrl.error, '此帳號已被停權'); + + await ctrl.goOffline(); + + expect(ctrl.error, '此帳號已被停權'); + }); }); group('沒有 tick 的時候誰來把畫面叫醒', () { @@ -207,8 +244,12 @@ class _SilentApi extends FleetApiClient { @override void setToken(String? token) {} + ApiException? reportError; + @override - Future reportLocation({required double lat, required double lng}) async {} + Future reportLocation({required double lat, required double lng}) async { + if (reportError != null) throw reportError!; + } @override Future activeRide() async => null;