Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)——
Expand Down Expand Up @@ -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) 與後端
Expand Down
103 changes: 96 additions & 7 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-完成)
Expand All @@ -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 點)。
Expand Down Expand Up @@ -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 這一輪做完了什麼(開工先看這段)**
Expand Down Expand Up @@ -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、
Expand Down Expand Up @@ -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 -- <file>`,
Expand Down
36 changes: 36 additions & 0 deletions lib/core/location/customer_locator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:geolocator/geolocator.dart';

/// 乘客端叫車路徑用到的定位相依,抽成介面**只為了能測**。
///
/// geolocator 是靜態方法,單元測試環境沒有 platform channel,一碰就
/// `MissingPluginException`——於是「權限被永久拒絕」「定位服務被關閉」這幾條出口
/// 在測試裡完全碰不到,壞掉也不會有人知道(第二十二輪就是這樣壞的)。
///
/// 接縫刻意開在**最外層的 geolocator 呼叫**,而不是「直接餵一個錯誤給錯誤處理函式」:
/// 後者測不到「哪一種例外會從哪一支呼叫冒出來」,把 `on ...` 分支拔掉照樣會綠。
abstract class CustomerLocator {
Future<LocationPermission> checkPermission();
Future<LocationPermission> requestPermission();
Future<Position> getCurrentPosition(LocationSettings settings);
Future<Position?> getLastKnownPosition();
}

/// 正式實作:原樣轉給 geolocator。
class GeolocatorCustomerLocator implements CustomerLocator {
const GeolocatorCustomerLocator();

@override
Future<LocationPermission> checkPermission() => Geolocator.checkPermission();

@override
Future<LocationPermission> requestPermission() =>
Geolocator.requestPermission();

@override
Future<Position> getCurrentPosition(LocationSettings settings) =>
Geolocator.getCurrentPosition(locationSettings: settings);

@override
Future<Position?> getLastKnownPosition() =>
Geolocator.getLastKnownPosition();
}
Loading
Loading