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
9 changes: 7 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` **377 passed**(50 個測試檔,2026-07-30 實跑)。
~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。
**目前**:`flutter analyze` 無 issue、`flutter test` **414 passed**(54 個測試檔,2026-07-31 實跑)。
~~383 passed~~/~~377 passed~~/~~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 @@ -293,6 +293,11 @@ SnackBar;FCM token 輪替失敗會冒出司機看不懂也無事可做的紅
其中「系統定位服務被關」原本是**完全靜默**的(例外穿出 `placeOrder`,畫面一句話都沒有)。
另外,**多停靠點行程不再需要裝置定位**:pickup/dropoff 由 stops 推導,後端本來就不看那組座標。
兩端都在 `m6_pixel` 上以 `settings put secure location_mode 0` 實跑驗過。
**第二十三輪**再補一個真裝置才會發生的洞:**App 被系統收掉(或司機自己滑掉)再開時,
行程會被還原,但定位回報整段消失**——乘客端的司機 marker 定格、抵達圍籬不觸發、
里程軌跡缺一段,而司機看到的只是 hero 上那句「離線」。冷啟動還原到進行中行程時會接回回報
(只在已有權限時、且不在回前景時做,那顆離線鈕要按得掉)。
同一台裝置、同一張行程跑過修改前後兩個版本:修好的版本重開 8 秒後恢復回報,修改前 40 秒零筆。

- **預約司機+常用地點(2026-07-31)**:乘客可預約**未來**的用車,以及把住家/公司等
常去的地點存起來,叫車與預約時一鍵帶入。
Expand Down
77 changes: 73 additions & 4 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
- [🔦 第十九輪:司機端協尋畫面的實跑證據(沒抓到 bug,補了負向對照)](#-2026-07-30-第十九輪司機端協尋畫面的實跑證據沒抓到-bug補了一個負向對照)
- [📍 第二十一輪:定位串流死掉時,畫面永遠不會知道](#-2026-07-30-第二十一輪定位串流死掉時畫面永遠不會知道本批修掉)
- [📍 第二十二輪:乘客端的定位出口(+第二十一輪的實跑尾巴)](#-2026-07-30-第二十二輪乘客端的定位出口本批修掉-31-個並補完第二十一輪的實跑尾巴)
- [🚗 第二十三輪:App 被收掉再開,行程還在但位置回報整段消失](#-2026-07-30-第二十三輪app-被收掉再開行程還在但位置回報整段消失本批修掉-2-個)
- [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能)

**四、維護、決策與待辦**
Expand All @@ -90,7 +91,7 @@
- **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。
**登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。
- **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。
- 單元測試:**53 個測試檔、`flutter test` 408 passed**(2026-07-31 預約司機那批實跑;`flutter analyze` 無 issue)。
- 單元測試:**54 個測試檔、`flutter test` 414 passed**(2026-07-31 第二十三輪與預約司機那批合併後實測;`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 @@ -2090,6 +2091,65 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log

---

## 🚗 2026-07-30 第二十三輪:App 被收掉再開,行程還在但位置回報整段消失(本批修掉 2 個)

> 「定位健康度」這一族的最後一個角落。原本 TODO 寫的是「前景服務被系統收走時 App 端
> 沒有任何偵測」——**查下去發現那句話要修正**:前景服務被收走而 App 還活著時,
> 串流會停,第二十一輪的定期重評已經把它接住了(第二十二輪實測 12~24 秒降級)。
> 真正沒有網的是**行程被還原、但沒有人把定位回報接回來**。

### 根因一句話

`init()` 會用 `_restoreActiveRide()` 把進行中行程還原(行程卡照樣顯示「導航/已上車/完成」),
但 `_online` 一律從 false 起、定位串流也沒起來——**整趟零位置回報**。
而 hero 只寫「離線/目前不會收到派單」:在載客途中,那句話講的是接不接**新單**,
司機不會從它聯想到「乘客看不到我在動」。

**後果不在司機這一端**:乘客端的司機 marker 定格、ETA 不再更新;
後端的抵達圍籬(靠司機座標自動觸發)不會觸發;F3 里程計價的「軌跡 vs 路線取大者」
缺一整段軌跡。觸發條件很日常:省電模式或廠商清背景把 App 收掉、司機自己從最近工作清單滑掉。

### 修法(兩個)

1. **冷啟動恢復回報**:`init()` 還原到進行中行程時接回定位回報。三個限制條件都寫在碼裡——
- **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的,
回前景也自動上線的話那顆離線鈕就按不掉了(`onAppResumed` 刻意不做)。
- **只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史)。
沒權限時留一則可行動的訊息(「需要定位權限才能把位置回報給乘客」,
**不是**「才能上線」——行程中的司機看到後者會以為只影響接新單)。
- **沒有行程就不自動上線**:接不接單是司機的決定。
2. **`refreshVehicle()` 不再無條件清錯誤**:它原本成功時就 `_setError(null)`,
於是排在它前面設好的訊息全被洗掉——上面那句權限提示就是這樣消失的(設了、也通知了,
下一行清空)。改成**只清自己造成的那則**(`_vehicleLoadFailed` 為真才清)。
同一種病第二十一輪在位置回報探針上修過一次:**清錯誤要指名清哪一則。**

### 驗收

- `flutter analyze` 無 issue、`flutter test` **383 passed**(377 +新 6)。
**與預約司機那批合併後重測:414 passed、54 個測試檔**(兩批的測試互不干擾)。
- **反向驗證兩半各一次**:拿掉冷啟動恢復 → 2 案 FAIL;讓 `refreshVehicle` 回去無條件清錯誤 → 2 案 FAIL。
- **模擬器實跑,同一台裝置、同一張行程 #37,兩個版本各跑一次**(這是本輪最強的證據):

| 時刻 | 事件 | 後端收到的位置回報 |
|---|---|---|
| 23:21:29–23:22:22 | 上線+接單 #37 | 每 8 秒一筆 |
| 23:22:25 | `am force-stop`(模擬系統收掉 App) | 停止 |
| 23:23:15 | **修好的版本**冷啟動 | **23:23:23 起恢復**,畫面 hero「上線中」+行程卡 |
| 23:24:41 | **修改前的版本**冷啟動(同一張單) | **40 秒零筆**,畫面行程卡完整、hero 卻寫「離線」 |

- **負向對照**:沒有進行中行程時冷啟動 → 後端零位置回報(沒有偷偷幫司機上線)。

### 這一輪學到的兩條

1. **接縫的預設實作會跑進測試環境**:我一開始讓冷啟動直接呼叫權限確認,
結果 `driver_home_widget_test` 從 4.8 秒變成 **7 分鐘以上**——測試環境沒有 platform channel,
那支呼叫的 Future **永遠不會完成**,`init()` await 下去就把測試卡到 timeout。
預設實作現在會先看 `FLUTTER_TEST` 直接回「查不到」。
**判準**:新增任何「`init()` 期間 await 平台能力」的程式碼,先問它在測試環境會回什麼。
2. **「查不到」與「被拒絕」要分開**:權限探針回三態(`LocationPermission?`),
`null` 什麼都不做、`denied` 才留訊息。混成 bool 的話,測試環境會冒出一則假的權限錯誤。

---
## 🗓️ 預約司機+常用地點(2026-07-31,跨端新功能)

> 需求:乘客可以**預約未來的用車**(不是現在叫車),以及把**住家/公司等常去的地點存起來**,
Expand Down Expand Up @@ -2435,9 +2495,18 @@ PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \
> (靜默、`deniedForever` 文案沒出路、多停靠點被不必要地擋住)。
> 順帶查清一件事:**地圖跟隨完全不吃裝置定位**(司機 marker 來自 WS,
> `lastPosition` 為 null 時退回台北市中心),所以那條路徑沒有出口要補。
> 2. **前景服務被系統收走**(Android 省電模式殺掉常駐通知):
> 現在只有定位串流的 onError 會知道,服務本身消失時 App 端沒有任何偵測。
> **這是本族目前唯一剩下的角落**,也是下一輪的首選。
> 2. ~~**前景服務被系統收走**(Android 省電模式殺掉常駐通知)~~
> ✅ **已於第二十三輪查清並修掉**,但**原本的描述要更正**:
> 前景服務被收走而 App 還活著時,串流會停,第二十一輪的定期重評已經接住了
> (第二十二輪實測 12~24 秒降級)。真正沒有網的是**App 被收掉再開**——
> 行程會被還原,但沒有人把定位回報接回來,整趟零回報。詳見第二十三輪專段。
>
> **➡️ 定位健康度這一族到此清完**(司機端串流健康度、乘客端叫車出口、冷啟動恢復回報)。
> 下一輪請換族。讀碼時順手記下、還沒查的候選:
> - **hero 在行程中仍寫「等待派單中」**(第二十三輪實跑畫面看到):司機已經在載客,
> 那句話講的是接新單,語意不精確。屬文案/狀態顯示,不影響功能。
> - **司機端沒有任何輪詢**:WS 漏事件時只有回前景與操作被 409 打回兩條補救路徑。
> 第四輪修過回前景對帳,但**長時間停留在前景**的漏事件情境沒有網。
>
> **第二十輪順帶學到的一條**(已寫進坑卡):反向驗證要還原檔案時,
> **先 `cp` 一份再改、改完從備份還原**——這一輪順手打了 `git checkout -- <file>`,
Expand Down
76 changes: 72 additions & 4 deletions lib/driver/driver_controller.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
Expand All @@ -17,9 +18,29 @@ import '../core/ws/fleet_ws_client.dart';
/// 定位串流的來源;測試以此換掉真 GPS(比照 `FleetWsClientFactory`)。
typedef DriverPositionStreamFactory = Stream<Position> Function(LocationSettings);

/// 上線前的權限確認(可能會彈系統視窗);測試以此換掉平台對話框。
typedef DriverLocationPermissionCheck = Future<bool> Function();

Stream<Position> _geolocatorPositionStream(LocationSettings settings) =>
Geolocator.getPositionStream(locationSettings: settings);

/// 「現在的定位權限」——**只查不請求**,冷啟動自動恢復回報時用。
/// `null` = 查不到(平台不可用,例如單元測試沒有 platform channel):
/// **查不到與被拒絕是兩件事**,前者什麼都不該做,後者要跟司機說一聲。
typedef DriverLocationPermissionProbe = Future<LocationPermission?> Function();

Future<LocationPermission?> _geolocatorPermissionProbe() async {
// 測試環境沒有 platform channel,這支呼叫的 Future **永遠不會完成**——
// `init()` await 下去就把整個測試卡到 timeout(實測 driver_home_widget_test
// 從 4.8 秒變成 7 分鐘以上)。「查不到」正是這裡該有的答案。
if (Platform.environment.containsKey('FLUTTER_TEST')) return null;
try {
return await Geolocator.checkPermission();
} catch (_) {
return null;
}
}

/// 司機端狀態:登入、上線、WS 派單、行程操作。
class DriverController extends ChangeNotifier {
DriverController({
Expand All @@ -28,11 +49,16 @@ class DriverController extends ChangeNotifier {
FleetWsClientFactory? wsFactory,
FleetPushService? push,
DriverPositionStreamFactory? positionStream,
DriverLocationPermissionCheck? locationPermissions,
DriverLocationPermissionProbe? locationPermissionProbe,
}) : _storage = storage ?? TokenStorage(),
_api = api ?? FleetApiClient(),
_wsFactory = wsFactory ?? FleetWsClient.new,
_push = push ?? NoOpFleetPushService(),
_positionStream = positionStream ?? _geolocatorPositionStream,
_ensurePermissions =
locationPermissions ?? ensureDriverLocationPermissions,
_permissionProbe = locationPermissionProbe ?? _geolocatorPermissionProbe,
_ws = FleetWsClient(onEvent: (_) {}) {
// token 過期/失效時把司機送回登入頁(見 _handleUnauthorized)。
_api.onUnauthorized = _handleUnauthorized;
Expand All @@ -43,6 +69,8 @@ class DriverController extends ChangeNotifier {
final FleetWsClientFactory _wsFactory;
final FleetPushService _push;
final DriverPositionStreamFactory _positionStream;
final DriverLocationPermissionCheck _ensurePermissions;
final DriverLocationPermissionProbe _permissionProbe;
FleetWsClient _ws;

AuthSession? _session;
Expand Down Expand Up @@ -222,8 +250,12 @@ class DriverController extends ChangeNotifier {
if (_session == null) return;
try {
_vehicle = await _api.fetchVehicle();
// **只清自己造成的那則**:這裡原本無條件 `_setError(null)`,於是任何排在
// 它前面設好的訊息都會被洗掉——`init()` 裡「需要定位權限才能把位置回報給乘客」
// 就是這樣消失的(設了、也通知了,下一行就被抹掉,畫面上什麼都沒有)。
// 同一種病第二十一輪在位置回報探針上修過一次:清錯誤要指名清哪一則。
if (_vehicleLoadFailed) _setError(null);
_vehicleLoadFailed = false;
_setError(null);
} on ApiException catch (e) {
_setApiError(e);
_vehicleLoadFailed = true;
Expand Down Expand Up @@ -306,6 +338,7 @@ class DriverController extends ChangeNotifier {
if (saved != null) {
await _applySession(saved);
await _restoreActiveRide();
await _resumeReportingForRestoredRide();
await refreshLostItems();
// O3 gate 的 App 端引導:一還原 session 就查車輛,_DriverRoot 才知道要不要跳設定頁。
await refreshVehicle();
Expand Down Expand Up @@ -374,6 +407,39 @@ class DriverController extends ChangeNotifier {
}
}

/// 冷啟動還原到進行中行程時,把定位回報接回來。
///
/// **這是行程中位置回報唯一會整段消失的路徑**:App 被系統收掉(省電模式殺前景服務、
/// 廠商清背景),或司機自己把它從最近工作清單滑掉,再打開——`_restoreActiveRide`
/// 會把行程還原、行程卡照樣顯示「導航/已上車/完成」,但 `_online` 一律從 false 起、
/// 定位串流也沒起來,於是**整趟零位置回報**:乘客端的司機 marker 定格、ETA 不再更新,
/// 後端的抵達圍籬不會觸發,F3 里程的軌跡缺一整段(車資會偏低)。
/// 而 hero 只寫「離線/目前不會收到派單」——在載客途中,那句話講的是接不接新單,
/// 司機不會從它聯想到「乘客看不到我在動」。
///
/// **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的,
/// 回前景時再自動上線等於那顆離線鈕按不掉。
///
/// **而且只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史),
/// 沒權限時留一則可行動的訊息,等他自己按上線再彈。
Future<void> _resumeReportingForRestoredRide() async {
if (_session == null || _online || _activeRide == null) return;
const denied = '需要定位權限才能把位置回報給乘客';
switch (await _permissionProbe()) {
case LocationPermission.always:
case LocationPermission.whileInUse:
await _goOnline(deniedMessage: denied);
case LocationPermission.denied:
case LocationPermission.deniedForever:
case LocationPermission.unableToDetermine:
// 不彈視窗,只說一聲——司機按上線時才會走到請求那條。
_setError(denied);
notifyListeners();
case null:
break; // 查不到權限狀態:不自動恢復,也不編故事。
}
}

/// App 從背景回到前景(由 `AppLifecycleReactor` 呼叫)。
///
/// 司機端**沒有任何輪詢**:`ride.assigned`/`ride.cancelled`/`ride.completed`
Expand Down Expand Up @@ -557,11 +623,13 @@ class DriverController extends ChangeNotifier {
}
}

Future<void> goOnline() async {
Future<void> goOnline() => _goOnline(deniedMessage: '需要定位權限才能上線');

Future<void> _goOnline({required String deniedMessage}) async {
if (_session == null) return;
final ok = await ensureDriverLocationPermissions();
final ok = await _ensurePermissions();
if (!ok) {
_setError('需要定位權限才能上線');
_setError(deniedMessage);
notifyListeners();
return;
}
Expand Down
Loading
Loading