From ad3c9f7ccdfc5154451a4ed4a8b6b415b5c79ef5 Mon Sep 17 00:00:00 2001 From: awei Date: Fri, 31 Jul 2026 07:54:10 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(app):=20=E3=80=8C=E6=88=91=E7=9A=84?= =?UTF-8?q?=E8=A1=8C=E7=A8=8B=E3=80=8D=E5=8F=AA=E7=9C=8B=E5=BE=97=E5=88=B0?= =?UTF-8?q?=E6=9C=80=E8=BF=91=2020=20=E8=B6=9F=E2=80=94=E2=80=94=E8=88=8A?= =?UTF-8?q?=E8=A1=8C=E7=A8=8B=E7=9A=84=E5=8D=94=E5=B0=8B=E8=88=87=E8=A3=9C?= =?UTF-8?q?=E8=A9=95=E5=88=86=E5=85=A5=E5=8F=A3=E6=95=B4=E5=80=8B=E6=B6=88?= =?UTF-8?q?=E5=A4=B1=EF=BC=88=E7=AC=AC=E4=BA=8C=E5=8D=81=E5=9B=9B=E8=BC=AA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchRideHistory()` 固定要 20 筆、畫面沒有載入更多,而這個畫面是事後聯絡司機、 申報遺失物、補評分的唯一入口——第 21 趟以前的行程在乘客端永遠打不開。 後端 `limit` 照單全收(上限 5000),所以本輪不動 dispatch:改成捲到底自動再要一頁 (沒有 cursor,就是把 limit 加大重讀)。載入更多的錯誤與整頁錯誤分開、失敗時視窗 不推進;下拉刷新保持已展開的筆數,登出才收回一頁。 驗收:flutter analyze 無 issue、flutter test 386 passed;反向驗證兩半各紅一次; 模擬器實跑 25 筆行程,修好的版本捲到底發出 LIMIT 40 並撈到最舊那筆, 修改前的版本只發過一次 LIMIT 20、停在第 20 筆。 Co-Authored-By: Claude Opus 5 --- README.md | 4 + docs/TODO.md | 92 +++++++- lib/customer/customer_controller.dart | 64 +++++- lib/customer/screens/ride_history_screen.dart | 65 +++++- test/customer_ride_history_test.dart | 215 +++++++++++++++++- 5 files changed, 433 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cfc39ee..74ae257 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 - **我的行程歷史(2026-07-19)**:乘客首頁右上「我的行程」→ 列出過去行程 (狀態/路線/時間/車資);**有司機的行程可事後「聯絡司機」**開對話 (沿用 `RideChatScreen`)。後端 `GET /customer/rides`(只回本人,LEFT JOIN 司機名)。 + **分頁(2026-07-31)**:一次 20 筆,捲到底自動再要一頁——這個畫面是事後聯絡司機/ + 申報遺失物/補評分的唯一入口,固定 20 筆等於第 21 趟以前的行程再也打不開。 + 後端沒有 offset/cursor,所以「載入更多」是**把 `limit` 加大重讀**(上限 5000); + 下拉刷新保持已展開的筆數,登出才收回一頁。 - **乘客端多停靠點行程進度(2026-07-21)**:多乘客訂單在地圖上依序畫出全程停靠點 (乘客標籤 A/B…)+「司機→下一站→之後待處理站」折線,sheet 內「行程進度 N/M 站」 與全程清單。司機每標記一站,WS **`ride.stop_updated`**(payload 帶整趟 stops)即時更新, diff --git a/docs/TODO.md b/docs/TODO.md index a896f39..3042fcb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -68,6 +68,7 @@ - [🔦 第十九輪:司機端協尋畫面的實跑證據(沒抓到 bug,補了負向對照)](#-2026-07-30-第十九輪司機端協尋畫面的實跑證據沒抓到-bug補了一個負向對照) - [📍 第二十一輪:定位串流死掉時,畫面永遠不會知道](#-2026-07-30-第二十一輪定位串流死掉時畫面永遠不會知道本批修掉) - [📍 第二十二輪:乘客端的定位出口(+第二十一輪的實跑尾巴)](#-2026-07-30-第二十二輪乘客端的定位出口本批修掉-31-個並補完第二十一輪的實跑尾巴) +- [📜 第二十四輪:「我的行程」只看得到最近 20 趟](#-2026-07-31-第二十四輪我的行程只看得到最近-20-趟本批修掉) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -84,7 +85,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**50 個測試檔、`flutter test` 377 passed**(2026-07-30 第二十二輪實跑;`flutter analyze` 無 issue)。 +- 單元測試:**50 個測試檔、`flutter test` 386 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 點)。 @@ -2084,8 +2085,97 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log --- +## 📜 2026-07-31 第二十四輪:「我的行程」只看得到最近 20 趟(本批修掉) + +> **開工先撞到的事**:TODO 寫的下一輪首選(前景服務被系統收走)**已經被別的 session 認領** +> ([app #94](https://github.com/thothawei/fleet-app/pull/94),open 中),另一支 +> [#95](https://github.com/thothawei/fleet-app/pull/95) 在做預約司機+常用地點。 +> 這正是 2026-07-29 那條「開工第一件事是 `gh pr list`」救到的情況——所以本輪換族, +> 盤點「**清單的資料量上限**」這個從沒碰過的角。 + +### 根因一句話 + +`fetchRideHistory()` 固定要 20 筆、畫面沒有任何「載入更多」, +而「我的行程」是**事後聯絡司機/申報遺失物/補評分的唯一入口**—— +第 21 趟以前的行程在乘客端永遠打不開。 + +### 盤點:這一族只有一個真洞 + +| 清單 | 上限 | 判定 | +|---|---|---| +| 乘客「我的行程」 | App 寫死 `limit=20`、無載入更多 | ❌ **真洞** | +| 行程內對話 | App 不帶 limit → 後端 `MaxListRows`=5000 | ✅ | +| 協尋單(兩端) | 同上 5000 | ✅ | +| 司機收入頁 | 依月聚合,可無限往回切 | ✅ | + +後端 `GET /api/customer/rides` 的 `limit` **照單全收**(`ListRecentByCustomer`: +`limit<=0` 用預設、超過 5000 才收斂),所以這輪**不用動 dispatch**。 + +### 修法 + +- `CustomerController` 加 `_historyWindow`(目前要幾筆)/`historyHasMore`/ + `historyLoadingMore`/`historyMoreError`,新增 `loadMoreRideHistory()`。 +- **沒有 cursor 就別假裝有**:後端只有 `limit`、沒有 offset,所以「載入更多」是 + **把 limit 加大重讀**,不是接續抓下一段。代價是剛好整除時會多要一次; + 好處是順帶更新已顯示那幾筆的評分/協尋狀態。筆數上百再改 cursor 才划算。 +- **失敗訊息分兩條**:`historyMoreError` 與 `historyError` 分開——已經載進來的行程 + 還在畫面上,錯誤只該長在清單尾巴(整頁換成錯誤畫面等於把使用者已經拿到的東西收走)。 + 失敗時**視窗不推進**,重試要的是同一段。 +- **下拉刷新保持已展開的筆數**:已經捲到第 60 筆的人刷新後不該被縮回 20 筆; + 登出才收回一頁(換人登入不該替他要 60 筆)。 +- 尾巴**不是按鈕是自動載入**:舊行程是「想起有東西掉在車上」時才會去翻的, + 多一次點擊就多一個放棄點;只有失敗時才退化成看得懂的重試按鈕。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **386 passed**(377 +新 9)。 +- **反向驗證兩半各一次**:拿掉 `loadMoreRideHistory` 的補讀 → 6 案 FAIL; + 拿掉畫面尾巴那一格(觸發點) → 2 案 FAIL。兩半各自釘不同的洞。 +- **模擬器實跑**(`m6_pixel` +本機 dispatch,customer flavor,乘客 `customer_id=17`, + 以 API 造 25 筆行程 #41–#65/`pickup point 1`–`25`): + +| 情境 | 後端實際收到的查詢 | 畫面 | +|---|---|---| +| 修好的版本:開畫面、**不捲動** | 只有 `LIMIT 20` | 最新 #65 起 20 筆 | +| 修好的版本:捲到底 | `LIMIT 20` → `LIMIT 40`(rows 20 → 25) | **最舊 #41/`pickup point 1` 進得來**,尾巴自動收掉 | +| **修改前的版本**:同樣捲到底 14 次 | **只有一次 `LIMIT 20`** | 停在 #46/`pickup point 6`,**第 21~25 筆沒有任何入口** | + +- 證據來自後端 GORM 的 SQL log(`... WHERE rides.customer_id = 17 ORDER BY rides.id DESC LIMIT 40`), + 不是只看畫面。 +- dev DB:25 筆全部是 `status=9`(已取消)終態。 + +### 這一輪踩到的兩條 + +1. **本機有兩個 redis 在搶 6379**:主機的 `redis-server` 綁 `127.0.0.1`、docker 綁 `*`, + `REDIS_ADDR=127.0.0.1:6379` 會連到**主機那個**。在 docker 容器裡 `redis-cli KEYS` 查不到 + 任何鍵、以為限流沒生效,其實只是查錯了那台。 +2. **造測試資料會撞到叫車限流**(`AllowRateLimit`,預設 5 次/分鐘、key 帶 `line_user_id`): + 25 筆要配速(本輪用 13 秒一筆);而且每筆建完要**立刻取消**, + 否則 `FindActiveByCustomer` 會擋掉下一筆。 + +### 同族還沒碰的角落 + +- **司機端沒有行程歷史畫面**:司機事後想聯絡乘客/查過去趟次沒有入口 + (收入頁只有聚合數字)。這是**功能缺口**不是 bug,要不要做是產品決定。 + +--- + ## 下次任務 +> **🎯 2026-07-31 第二十四輪之後的狀態(開工先看這三行)** +> +> 1. **開工第一件事仍然是 `gh pr list`(三個 repo)**——這一輪就是靠它才發現 +> 「前景服務被系統收走」已被 [#94](https://github.com/thothawei/fleet-app/pull/94) 認領, +> 另有 [#95](https://github.com/thothawei/fleet-app/pull/95)(預約司機+常用地點) +> + dispatch [#70](https://github.com/thothawei/fleet-dispatch/pull/70) 在飛。 +> **要認領題目就在本檔當場劃掉**,不然平行 session 一定撞。 +> 2. **第二十四輪開工前第七次實查三個外部卡點:依然都不在** +> (`android/app/google-services.json`、`ios/Runner/GoogleService-Info.plist` 不存在、 +> `xcrun devicectl list devices` → No devices found)。 +> 3. **下一輪的候選**:定位族最後一角在 #94 手上;本輪盤點的「清單資料量」族只有 +> 一個真洞、已修完,剩下的是**司機端沒有行程歷史畫面**(功能缺口,需產品決定)。 +> 真的要再挖 debug,建議先重新盤點一個新族,別回頭挖逾時對帳那條線(三端已清完)。 +> > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** > > 主題是「**推播這條通道從頭到尾打通**」。本 repo 合併了 6 支 PR: diff --git a/lib/customer/customer_controller.dart b/lib/customer/customer_controller.dart index 94b956b..f60d8da 100644 --- a/lib/customer/customer_controller.dart +++ b/lib/customer/customer_controller.dart @@ -47,6 +47,9 @@ class CustomerController extends ChangeNotifier { // WS 即時到手後只做保底對帳,輪詢間隔放寬。 static const _pollInterval = Duration(seconds: 15); + /// 「我的行程」一次要幾筆;捲到底再多要一頁。 + static const historyPageSize = 20; + CustomerSession? _session; bool _loading = false; String? _error; @@ -110,6 +113,13 @@ class CustomerController extends ChangeNotifier { List _rideHistory = []; bool _historyLoading = false; String? _historyError; + // 分頁:目前跟後端要幾筆、還有沒有更舊的、載入更多的進行中/失敗狀態。 + // 沒有這一組的話清單就只有最近 20 筆——而「我的行程」是事後聯絡司機、 + // 申報遺失物、補評分的**唯一入口**,第 21 趟以前的行程等於再也打不開。 + int _historyWindow = historyPageSize; + bool _historyHasMore = false; + bool _historyLoadingMore = false; + String? _historyMoreError; // session 失效清理中;並發的 401(輪詢+使用者操作同時)不重入清理。 bool _sessionExpiring = false; @@ -366,14 +376,31 @@ class CustomerController extends ChangeNotifier { bool get historyLoading => _historyLoading; String? get historyError => _historyError; - /// 載入歷史行程(進「我的行程」畫面時呼叫)。 + /// 是否還有更舊的行程沒載進來(捲到底時要不要繼續要)。 + bool get historyHasMore => _historyHasMore; + + /// 正在載入更舊的行程(首載/下拉刷新走 `historyLoading`,兩者不共用)。 + bool get historyLoadingMore => _historyLoadingMore; + + /// 「載入更多」那一次失敗的訊息。**與 `historyError` 分開**:已經載進來的 + /// 行程還在畫面上,錯誤只該長在清單尾巴,不能讓整頁變成錯誤畫面。 + String? get historyMoreError => _historyMoreError; + + /// 載入歷史行程(進「我的行程」畫面時呼叫;下拉刷新也是這支)。 + /// + /// **刷新時要求的是目前已展開的筆數**(`_historyWindow`),不是固定第一頁—— + /// 已經捲到第 60 筆的人下拉刷新,清單不該縮回 20 筆。 Future loadRideHistory() async { if (_session == null) return; _historyLoading = true; _historyError = null; + _historyMoreError = null; notifyListeners(); + final want = _historyWindow; try { - _rideHistory = await _api.fetchRideHistory(); + final rows = await _api.fetchRideHistory(limit: want); + _rideHistory = rows; + _historyHasMore = rows.length >= want; } on ApiException catch (e) { _historyError = e.message; } finally { @@ -382,6 +409,36 @@ class CustomerController extends ChangeNotifier { } } + /// 再往下要一頁更舊的行程(捲到清單底部時觸發)。 + /// + /// 後端 `GET /api/customer/rides` **只有 `limit`、沒有 offset/cursor**, + /// 所以這裡是「把 limit 加大重讀一次」而不是接續抓下一段:回來的永遠是 + /// 完整的前 N 筆,順帶把已顯示那幾筆的評分/協尋狀態一起更新。 + /// 筆數量級到上百筆(後端上限 `MaxListRows`=5000)再改 cursor 才划算。 + Future loadMoreRideHistory() async { + if (_session == null) return; + // 沒有更多、或已經有一個請求在飛,就不要再發(捲動會連續觸發很多次)。 + if (!_historyHasMore || _historyLoading || _historyLoadingMore) return; + _historyLoadingMore = true; + _historyMoreError = null; + notifyListeners(); + final want = _historyWindow + historyPageSize; + try { + final rows = await _api.fetchRideHistory(limit: want); + _rideHistory = rows; + _historyWindow = want; + // 回滿 = 後面可能還有。剛好整除時會多要一次、下一次才收掉尾巴, + // 這是沒有 cursor 的必然代價,比「少一頁永遠看不到」好。 + _historyHasMore = rows.length >= want; + } on ApiException catch (e) { + // 視窗**不推進**:重試時才會重新要同一段,已載入的清單原樣留著。 + _historyMoreError = e.message; + } finally { + _historyLoadingMore = false; + notifyListeners(); + } + } + /// 對已完成行程評分司機(B5)。成功回 null,失敗回可直接顯示的中文訊息。 /// /// **不寫進 `_error`**:評分是使用者當下在對話框裡做的動作,錯誤要留在對話框上, @@ -666,6 +723,9 @@ class CustomerController extends ChangeNotifier { // 就會在自己的資料載入前先看到前一位乘客的行程與車資。 _rideHistory = []; _historyError = null; + _historyMoreError = null; + _historyWindow = historyPageSize; + _historyHasMore = false; _completedRatingScore = null; _completedRatingRideId = null; _fcmToken = null; diff --git a/lib/customer/screens/ride_history_screen.dart b/lib/customer/screens/ride_history_screen.dart index 0d34815..ce690d6 100644 --- a/lib/customer/screens/ride_history_screen.dart +++ b/lib/customer/screens/ride_history_screen.dart @@ -67,11 +67,70 @@ class _CustomerRideHistoryScreenState extends State { ], ); } + // 尾巴那一格:還有更舊的行程時放「載入中」,載入更多失敗時放重試。 + final hasFooter = ctrl.historyHasMore || ctrl.historyMoreError != null; return ListView.builder( padding: const EdgeInsets.all(12), - itemCount: ctrl.rideHistory.length, - itemBuilder: (context, i) => - _RideHistoryCard(ctrl: ctrl, ride: ctrl.rideHistory[i]), + itemCount: ctrl.rideHistory.length + (hasFooter ? 1 : 0), + itemBuilder: (context, i) { + if (i == ctrl.rideHistory.length) return _HistoryFooter(ctrl: ctrl); + return _RideHistoryCard(ctrl: ctrl, ride: ctrl.rideHistory[i]); + }, + ); + } +} + +/// 清單尾巴:被建出來就代表使用者已經捲到底,直接去要下一頁。 +/// +/// **不是按鈕**——舊行程是「想起有東西掉在車上」時才會去翻的, +/// 多一次點擊就多一個放棄點;失敗時才退化成看得懂的重試按鈕。 +class _HistoryFooter extends StatefulWidget { + const _HistoryFooter({required this.ctrl}); + + final CustomerController ctrl; + + @override + State<_HistoryFooter> createState() => _HistoryFooterState(); +} + +class _HistoryFooterState extends State<_HistoryFooter> { + @override + void initState() { + super.initState(); + // build 期間不可改 provider 狀態,排到下一影格(同畫面 initState 的作法)。 + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + void _load() { + if (!mounted) return; + // 重入由 controller 擋(有請求在飛就直接 return)。 + widget.ctrl.loadMoreRideHistory(); + } + + @override + Widget build(BuildContext context) { + final err = widget.ctrl.historyMoreError; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: err == null + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), + ) + : Column( + children: [ + Text(err, textAlign: TextAlign.center), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('載入更多'), + ), + ], + ), + ), ); } } diff --git a/test/customer_ride_history_test.dart b/test/customer_ride_history_test.dart index 0f42a0c..ca3517f 100644 --- a/test/customer_ride_history_test.dart +++ b/test/customer_ride_history_test.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:line_fleet_app/core/api/customer_api_client.dart'; import 'package:line_fleet_app/core/api/fleet_api_client.dart' show ApiException; 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'; import 'package:line_fleet_app/customer/screens/ride_history_screen.dart'; import 'package:provider/provider.dart'; @@ -14,15 +18,34 @@ class _FakeApi extends CustomerApiClient { List history = const []; ApiException? historyError; int fetchCount = 0; + /// 每一次請求要的筆數(驗「視窗有沒有推進」用)。 + final List requestedLimits = []; + /// 非 null 時請求會卡在這裡,讓測試造出「請求還在飛」的狀態。 + Completer? gate; @override Future> fetchRideHistory({int limit = 20}) async { fetchCount++; + requestedLimits.add(limit); + if (gate != null) await gate!.future; if (historyError != null) throw historyError!; - return history; + // 後端行為:新到舊排序後回前 limit 筆。 + return history.take(limit).toList(); } } +/// 造 n 筆歷史行程(#n 最新排最前,全部有司機以便驗「聯絡司機」入口)。 +List _rides(int n) => [ + for (var i = n; i >= 1; i--) + CustomerRideSummary( + rideId: i, + status: 4, + pickupAddress: '上車點 $i', + driverId: 7, + driverName: '阿明', + ), + ]; + CustomerController _loggedIn(_FakeApi api) { final ctrl = CustomerController(api: api); ctrl.setSessionForTest( @@ -94,6 +117,138 @@ void main() { }); }); + // 「我的行程」是事後聯絡司機/申報遺失物/補評分的唯一入口。 + // 沒有這一組,第 21 趟以前的行程在乘客端永遠打不開。 + group('歷史行程分頁', () { + test('回滿一頁 → 還有更多;不滿一頁 → 沒有了', () async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await ctrl.loadRideHistory(); + expect(api.requestedLimits, [20]); + expect(ctrl.rideHistory, hasLength(20)); + expect(ctrl.historyHasMore, isTrue); + + final api2 = _FakeApi()..history = _rides(12); + final ctrl2 = _loggedIn(api2); + addTearDown(ctrl2.dispose); + await ctrl2.loadRideHistory(); + expect(ctrl2.historyHasMore, isFalse); + }); + + test('載入更多把視窗推到 40,第 21 筆之後的行程進得來', () async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await ctrl.loadRideHistory(); + expect(ctrl.rideHistory.map((r) => r.rideId), isNot(contains(5))); + + await ctrl.loadMoreRideHistory(); + expect(api.requestedLimits, [20, 40]); + expect(ctrl.rideHistory, hasLength(25)); + expect(ctrl.rideHistory.map((r) => r.rideId), contains(5)); + // 25 < 40:後面沒有了。 + expect(ctrl.historyHasMore, isFalse); + expect(ctrl.historyMoreError, isNull); + }); + + test('沒有更多時不再打後端', () async { + final api = _FakeApi()..history = _rides(3); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await ctrl.loadRideHistory(); + await ctrl.loadMoreRideHistory(); + expect(api.fetchCount, 1); + }); + + test('載入更多失敗:清單原樣留著、錯誤不污染整頁、視窗不推進', () async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await ctrl.loadRideHistory(); + api.historyError = ApiException('無法連線到伺服器,請檢查網路'); + await ctrl.loadMoreRideHistory(); + + expect(ctrl.historyMoreError, '無法連線到伺服器,請檢查網路'); + expect(ctrl.historyError, isNull, reason: '整頁不能變成錯誤畫面'); + expect(ctrl.rideHistory, hasLength(20), reason: '已載入的行程不能消失'); + expect(ctrl.historyHasMore, isTrue, reason: '還要留著重試的入口'); + + // 重試:要的仍是同一段 40,不是被失敗那次推到 60。 + api.historyError = null; + await ctrl.loadMoreRideHistory(); + expect(api.requestedLimits, [20, 40, 40]); + expect(ctrl.rideHistory, hasLength(25)); + expect(ctrl.historyMoreError, isNull); + }); + + test('請求還在飛時重複觸發只會發一次(捲動會連續呼叫)', () async { + final api = _FakeApi() + ..history = _rides(60) + ..gate = Completer(); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + final first = ctrl.loadRideHistory(); + api.gate!.complete(); + await first; + expect(api.fetchCount, 1); + + api.gate = Completer(); + final a = ctrl.loadMoreRideHistory(); + final b = ctrl.loadMoreRideHistory(); + final c = ctrl.loadMoreRideHistory(); + api.gate!.complete(); + await Future.wait([a, b, c]); + + expect(api.fetchCount, 2); + expect(api.requestedLimits, [20, 40]); + }); + + test('下拉刷新保持已展開的筆數,不會縮回第一頁', () async { + final api = _FakeApi()..history = _rides(60); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await ctrl.loadRideHistory(); + await ctrl.loadMoreRideHistory(); + expect(ctrl.rideHistory, hasLength(40)); + + await ctrl.loadRideHistory(); // 下拉刷新 + expect(api.requestedLimits, [20, 40, 40]); + expect(ctrl.rideHistory, hasLength(40)); + }); + + test('登出把視窗收回一頁(換人登入不該替他要 60 筆)', () async { + final api = _FakeApi()..history = _rides(60); + final ctrl = CustomerController( + storage: _MemoryCustomerStorage(), + api: api, + wsFactory: FleetWsClient.silent, + ); + addTearDown(ctrl.dispose); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + + await ctrl.loadRideHistory(); + await ctrl.loadMoreRideHistory(); + await ctrl.logout(); + expect(ctrl.rideHistory, isEmpty); + expect(ctrl.historyHasMore, isFalse); + + ctrl.setSessionForTest( + const CustomerSession(customerId: 2, token: 'tok2', name: '阿華'), + ); + await ctrl.loadRideHistory(); + expect(api.requestedLimits.last, 20); + }); + }); + group('歷史畫面', () { Future pump(WidgetTester tester, CustomerController ctrl) { return tester.pumpWidget( @@ -133,5 +288,63 @@ void main() { await tester.pumpAndSettle(); expect(find.text('還沒有行程紀錄'), findsOneWidget); }); + + testWidgets('捲到底自動補上更舊的行程(第 21 筆之後)', (tester) async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await pump(tester, ctrl); + await tester.pumpAndSettle(); + expect(find.text('行程 #5'), findsNothing); + + await tester.scrollUntilVisible( + find.text('行程 #5'), + 400, + maxScrolls: 60, + ); + await tester.pumpAndSettle(); + + expect(find.text('行程 #5'), findsOneWidget); + expect(api.requestedLimits, [20, 40]); + }); + + testWidgets('載入更多失敗時清單還在,尾巴給重試', (tester) async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await pump(tester, ctrl); + await tester.pumpAndSettle(); + api.historyError = ApiException('無法連線到伺服器,請檢查網路'); + + await tester.scrollUntilVisible( + find.text('無法連線到伺服器,請檢查網路'), + 400, + maxScrolls: 60, + ); + await tester.pumpAndSettle(); + + expect(find.text('行程 #25'), findsNothing, reason: '已捲到底,最新那筆在畫面外'); + expect(find.text('載入更多'), findsOneWidget); + + api.historyError = null; + await tester.tap(find.text('載入更多')); + await tester.pumpAndSettle(); + expect(find.text('行程 #5'), findsOneWidget); + }); }); } + +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; +} From 6eebe079609edd13fc2b48292e557533ee08a827 Mon Sep 17 00:00:00 2001 From: awei Date: Fri, 31 Jul 2026 08:20:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore(app):=20=E6=8A=8A=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E5=8D=81=E5=9B=9B=E8=BC=AA=E8=B8=A9=E5=88=B0=E7=9A=84=E5=85=A9?= =?UTF-8?q?=E5=80=8B=E7=92=B0=E5=A2=83=E5=9D=91=E4=BF=AE=E6=8E=89=EF=BC=88?= =?UTF-8?q?=E9=80=A0=E8=B3=87=E6=96=99=E8=85=B3=E6=9C=AC=EF=BC=8Bredis=20?= =?UTF-8?q?=E5=9F=A0=E6=9B=B4=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 造大量測試行程改用 tool/seed_customer_rides.sh:叫車限流(5 次/分)、 「已有進行中訂單」、建單回應是 ride_id 不是 id——三個都不會讓迴圈停下來, 自己寫 for 迴圈會整批靜默失敗還印得像成功。腳本建單失敗直接 exit 1 並印回應。 已用 seed_customer_rides.sh 3 實跑驗過(ride #66-#68,全部已取消)。 2. 本機後端跑法更正為 REDIS_ADDR=127.0.0.1:6380:6379 會靜默連到 Mac 本機的 redis-server(已用 raw socket 對兩台寫 whoami 鍵驗死)。容器改發布 6380 見 dispatch PR #72。 Co-Authored-By: Claude Opus 5 --- docs/TODO.md | 36 ++++++++++++++++++------ tool/seed_customer_rides.sh | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) create mode 100755 tool/seed_customer_rides.sh diff --git a/docs/TODO.md b/docs/TODO.md index 3042fcb..b08516b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2144,14 +2144,28 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log 不是只看畫面。 - dev DB:25 筆全部是 `status=9`(已取消)終態。 -### 這一輪踩到的兩條 - -1. **本機有兩個 redis 在搶 6379**:主機的 `redis-server` 綁 `127.0.0.1`、docker 綁 `*`, - `REDIS_ADDR=127.0.0.1:6379` 會連到**主機那個**。在 docker 容器裡 `redis-cli KEYS` 查不到 - 任何鍵、以為限流沒生效,其實只是查錯了那台。 -2. **造測試資料會撞到叫車限流**(`AllowRateLimit`,預設 5 次/分鐘、key 帶 `line_user_id`): - 25 筆要配速(本輪用 13 秒一筆);而且每筆建完要**立刻取消**, - 否則 `FindActiveByCustomer` 會擋掉下一筆。 +### 這一輪踩到的兩條(**都已修掉,不只是記下來**) + +1. **本機有兩個 redis 在搶 6379** → 已在 dispatch 讓開埠號。 + 主機的 `redis-server` 綁 `127.0.0.1`、docker 綁 `*`,**localhost 一律由前者接手**, + 所以服務跑在主機時吃的是本機那台;我在容器裡 `redis-cli KEYS` 查不到任何鍵, + 一度以為限流沒生效——其實只是查錯了那台。 + **當時只是推論,事後用 raw socket 對兩台各寫一個 `whoami` 鍵驗死**: + `127.0.0.1:6379` 回 `host_redis`。 + **修法**(dispatch [PR #72](https://github.com/thothawei/fleet-dispatch/pull/72)): + compose 把 redis 發布到 `${REDIS_HOST_PORT:-6380}`,與 postgis 讓開 5432 同一個做法; + README 補「只起相依服務、server 跑在主機」的正式段落。 + 改完實測:`6380` → `docker_redis_6380`、`6379` → `host_redis`; + 用新埠跑一輪後 `ratelimit:*` **出現在容器那台**、主機那台是空的。 + **主機跑法從此是 `REDIS_ADDR=127.0.0.1:6380`**(本檔下面那段指令已更正)。 +2. **造測試資料會撞到叫車限流** → 已寫成 + [`tool/seed_customer_rides.sh`](../tool/seed_customer_rides.sh)。 + 三個東西缺一不可地擋在路上:`AllowRateLimit`(預設 5 次/分鐘、key 帶 `line_user_id`, + 值由 admin 派單設定的 rate 決定)、`FindActiveByCustomer`(上一筆沒進終態就建不了下一筆)、 + 建單回應是 `{"ride_id":42}` **不是** `{"id":...}`。 + **最糟的是這三個都不會讓迴圈停下來**——我第一版 25 次全跑空還印得像成功, + 所以腳本在建單失敗時**直接 exit 1 並印出回應**。 + 已用 `tool/seed_customer_rides.sh 3` 實跑驗過(ride #66–#68,全部已取消)。 ### 同族還沒碰的角落 @@ -2204,9 +2218,13 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log > 沒有任何管道能把一則推播送進 App。 > **本機後端怎麼起**(實測有效,`.env` 是給 docker 內用的,跑在主機要覆寫三個變數): > `docker compose up -d postgis redis` + -> `DB_HOST=127.0.0.1 DB_PORT=5433 REDIS_ADDR=127.0.0.1:6379 go run ./cmd/server` +> `DB_HOST=127.0.0.1 DB_PORT=5433 REDIS_ADDR=127.0.0.1:6380 go run ./cmd/server` > (dispatch repo;Docker registry 拉不到映像,別用 `--build`)。 +> **redis 是 6380 不是 6379**(2026-07-31 更正):舊的 6379 會靜默連到 Mac 本機那台 +> `redis-server`,見第二十四輪「踩到的兩條」第 1 點;dispatch 已把容器改發布 6380。 > **弱網/回應遺失要用 [`tool/lossy_proxy.py`](../tool/lossy_proxy.py)**,不要再試 `docker pause`。 +> **要造大量歷史行程用 [`tool/seed_customer_rides.sh`](../tool/seed_customer_rides.sh)**, +> 別自己寫 for 迴圈打 API(會被限流與「已有進行中訂單」擋掉,而且不會報錯)。 > > **這一輪學到的一條**:`StreamController.broadcast()` 在沒有訂閱者時 `add` 的事件會**靜默消失**。 > 只要「發送點」與「訂閱點」不在同一個生命週期階段,就要問「**發送比訂閱早的那一次去哪了**」。 diff --git a/tool/seed_customer_rides.sh b/tool/seed_customer_rides.sh new file mode 100755 index 0000000..ee2547c --- /dev/null +++ b/tool/seed_customer_rides.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# 造一位乘客 + N 筆已取消的歷史行程——驗「我的行程」分頁、清單容量之類的題目用。 +# +# 背景(第二十四輪踩到才寫這支):直接寫個 for 迴圈打 API 會**整批靜默失敗**, +# 三個原因缺一不可地擋在路上: +# 1. 叫車限流 `AllowRateLimit`:預設 5 次/分鐘,key 是 `ratelimit:` +# (值由 admin 派單設定的 rate 決定,不是常數)→ 要配速。 +# 2. `FindActiveByCustomer`:上一筆沒進終態就建不了下一筆 → 每筆建完要立刻取消。 +# 3. 建單回應是 `{"ride_id":42,"status":0}`,**不是** `{"id":...}` → 解析錯會讓 +# 後面每一筆都失敗,而迴圈還一路跑完,看起來像成功。 +# +# 用法: +# tool/seed_customer_rides.sh [筆數] [API base] [每筆間隔秒] +# tool/seed_customer_rides.sh 25 http://127.0.0.1:8080/api 13 +# +# 跑完會印出帳密,直接拿去 App 登入。造出來的行程一律是 status=9(已取消)終態, +# 不會卡住司機或派單池。 +set -euo pipefail + +COUNT="${1:-25}" +API="${2:-http://127.0.0.1:8080/api}" +INTERVAL="${3:-13}" +LINE_USER_ID="U_seed_$(date +%s)" +PASSWORD="pass1234" + +json_field() { python3 -c "import sys,json;print(json.load(sys.stdin).get('$1',''))"; } + +TOKEN=$(curl -sS -X POST "$API/customer/register" -H 'Content-Type: application/json' \ + -d "{\"line_user_id\":\"$LINE_USER_ID\",\"name\":\"造數據乘客\",\"password\":\"$PASSWORD\"}" \ + | json_field token) +if [ -z "$TOKEN" ]; then + echo "註冊失敗(後端起來了嗎?$API)" >&2 + exit 1 +fi +echo "乘客 $LINE_USER_ID 建好,開始造 $COUNT 筆(每 ${INTERVAL}s 一筆,避開 5 次/分鐘的叫車限流)" + +for i in $(seq 1 "$COUNT"); do + [ "$i" -gt 1 ] && sleep "$INTERVAL" + RESP=$(curl -sS -X POST "$API/rides" -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"pickup_lat\":25.033,\"pickup_lng\":121.5654,\"pickup_address\":\"pickup point $i\",\"dropoff_address\":\"dropoff $i\",\"dropoff_lat\":25.0636,\"dropoff_lng\":121.5525}") + RIDE_ID=$(echo "$RESP" | json_field ride_id) + if [ -z "$RIDE_ID" ]; then + # 不要吞掉——第一版就是這樣把 25 次全跑成空的。 + echo "第 $i 筆建單失敗:$RESP" >&2 + echo "(若是「叫車太頻繁」,把間隔調大或請 admin 調高派單設定的 rate)" >&2 + exit 1 + fi + curl -sS -X POST "$API/rides/$RIDE_ID/cancel-by-customer" -H "Authorization: Bearer $TOKEN" > /dev/null + echo " 第 $i 筆 → ride $RIDE_ID(已取消)" +done + +echo +echo "完成。App 登入用:" +echo " LINE User ID: $LINE_USER_ID" +echo " 密碼: $PASSWORD" From 2304f611baadaff48b3b1c44eb62cdc64147cc71 Mon Sep 17 00:00:00 2001 From: Aawei <79500596+thothawei@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:21:25 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(app):=20App=20=E8=A2=AB=E6=94=B6?= =?UTF-8?q?=E6=8E=89=E5=86=8D=E9=96=8B=EF=BC=8C=E8=A1=8C=E7=A8=8B=E9=82=84?= =?UTF-8?q?=E5=9C=A8=E4=BD=86=E4=BD=8D=E7=BD=AE=E5=9B=9E=E5=A0=B1=E6=95=B4?= =?UTF-8?q?=E6=AE=B5=E6=B6=88=E5=A4=B1=EF=BC=88=E7=AC=AC=E4=BA=8C=E5=8D=81?= =?UTF-8?q?=E4=B8=89=E8=BC=AA=EF=BC=89=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「定位健康度」這一族的最後一個角落。TODO 原本寫「前景服務被系統收走時 App 端沒有 任何偵測」——查下去發現那句要更正:服務被收走而 App 還活著時串流會停,第二十一輪的 定期重評已經接住(第二十二輪實測 12~24 秒降級)。真正沒有網的是冷啟動: init() 會用 _restoreActiveRide() 還原進行中行程(行程卡照樣顯示導航/已上車/完成), 但 _online 一律從 false 起、定位串流也沒起來 → 整趟零位置回報。後果不在司機端: 乘客端司機 marker 定格、後端抵達圍籬不觸發、F3 里程軌跡缺一整段。而 hero 只寫 「離線/目前不會收到派單」——在載客途中,那句話講的是接不接新單。 修法兩個: 1. 冷啟動還原到進行中行程時接回定位回報。三個限制寫在碼裡:只在冷啟動做(回前景也 自動上線的話離線鈕按不掉)、只在已有權限時做(冷啟動彈權限視窗太侵入,沒權限就留 一則「需要定位權限才能把位置回報給乘客」而不是「才能上線」)、沒有行程不自動上線。 2. refreshVehicle() 不再無條件 _setError(null)——它會把排在前面設好的訊息全洗掉, 上面那句權限提示就是這樣消失的。改成只清自己造成的那則。 驗收:flutter analyze 無 issue、flutter test 383 passed(377+新 6)。 反向驗證兩半各一次,各 2 案 FAIL。模擬器實跑同一台裝置、同一張行程 #37 跑兩個版本: force-stop 後修好的版本重開 8 秒內恢復回報(hero「上線中」),修改前 40 秒零筆 (行程卡完整、hero 卻寫「離線」)。負向對照:沒有行程時冷啟動零回報。 過程中踩到一條記進 TODO:接縫的預設實作會跑進測試環境——冷啟動直接 await 平台權限 確認,讓 driver_home_widget_test 從 4.8 秒變成 7 分鐘以上(測試環境沒有 platform channel,那個 Future 永遠不會完成)。預設探針現在先看 FLUTTER_TEST 回「查不到」, 並用三態(null/denied/granted)區分「查不到」與「被拒絕」。 Co-authored-by: Claude Opus 5 --- README.md | 9 +- docs/TODO.md | 77 +++++++++++- lib/driver/driver_controller.dart | 76 ++++++++++- test/driver_resume_reporting_test.dart | 168 +++++++++++++++++++++++++ test/driver_vehicle_test.dart | 21 ++++ 5 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 test/driver_resume_reporting_test.dart diff --git a/README.md b/README.md index 4fdbd3e..fe79acc 100644 --- a/README.md +++ b/README.md @@ -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)—— @@ -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)**:乘客可預約**未來**的用車,以及把住家/公司等 常去的地點存起來,叫車與預約時一鍵帶入。 diff --git a/docs/TODO.md b/docs/TODO.md index c1eac41..81cc002 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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跨端新功能) **四、維護、決策與待辦** @@ -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 點)。 @@ -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,跨端新功能) > 需求:乘客可以**預約未來的用車**(不是現在叫車),以及把**住家/公司等常去的地點存起來**, @@ -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 -- `, diff --git a/lib/driver/driver_controller.dart b/lib/driver/driver_controller.dart index c42a330..47031ea 100644 --- a/lib/driver/driver_controller.dart +++ b/lib/driver/driver_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; @@ -17,9 +18,29 @@ import '../core/ws/fleet_ws_client.dart'; /// 定位串流的來源;測試以此換掉真 GPS(比照 `FleetWsClientFactory`)。 typedef DriverPositionStreamFactory = Stream Function(LocationSettings); +/// 上線前的權限確認(可能會彈系統視窗);測試以此換掉平台對話框。 +typedef DriverLocationPermissionCheck = Future Function(); + Stream _geolocatorPositionStream(LocationSettings settings) => Geolocator.getPositionStream(locationSettings: settings); +/// 「現在的定位權限」——**只查不請求**,冷啟動自動恢復回報時用。 +/// `null` = 查不到(平台不可用,例如單元測試沒有 platform channel): +/// **查不到與被拒絕是兩件事**,前者什麼都不該做,後者要跟司機說一聲。 +typedef DriverLocationPermissionProbe = Future Function(); + +Future _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({ @@ -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; @@ -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; @@ -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; @@ -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(); @@ -374,6 +407,39 @@ class DriverController extends ChangeNotifier { } } + /// 冷啟動還原到進行中行程時,把定位回報接回來。 + /// + /// **這是行程中位置回報唯一會整段消失的路徑**:App 被系統收掉(省電模式殺前景服務、 + /// 廠商清背景),或司機自己把它從最近工作清單滑掉,再打開——`_restoreActiveRide` + /// 會把行程還原、行程卡照樣顯示「導航/已上車/完成」,但 `_online` 一律從 false 起、 + /// 定位串流也沒起來,於是**整趟零位置回報**:乘客端的司機 marker 定格、ETA 不再更新, + /// 後端的抵達圍籬不會觸發,F3 里程的軌跡缺一整段(車資會偏低)。 + /// 而 hero 只寫「離線/目前不會收到派單」——在載客途中,那句話講的是接不接新單, + /// 司機不會從它聯想到「乘客看不到我在動」。 + /// + /// **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的, + /// 回前景時再自動上線等於那顆離線鈕按不掉。 + /// + /// **而且只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史), + /// 沒權限時留一則可行動的訊息,等他自己按上線再彈。 + Future _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` @@ -557,11 +623,13 @@ class DriverController extends ChangeNotifier { } } - Future goOnline() async { + Future goOnline() => _goOnline(deniedMessage: '需要定位權限才能上線'); + + Future _goOnline({required String deniedMessage}) async { if (_session == null) return; - final ok = await ensureDriverLocationPermissions(); + final ok = await _ensurePermissions(); if (!ok) { - _setError('需要定位權限才能上線'); + _setError(deniedMessage); notifyListeners(); return; } diff --git a/test/driver_resume_reporting_test.dart b/test/driver_resume_reporting_test.dart new file mode 100644 index 0000000..3f0be7e --- /dev/null +++ b/test/driver_resume_reporting_test.dart @@ -0,0 +1,168 @@ +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/fleet_api_client.dart'; +import 'package:line_fleet_app/core/config/app_config.dart'; +import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/core/storage/token_storage.dart'; +import 'package:line_fleet_app/core/ws/fleet_ws_client.dart'; +import 'package:line_fleet_app/driver/driver_controller.dart'; + +/// App 被收掉後重開,行程還在——但位置回報整段消失。 +/// +/// 這是「定位健康度」這一族最後一個沒有網的角落:前景服務被系統收走、或司機自己 +/// 把 App 從最近工作清單滑掉,`_restoreActiveRide` 會把行程還原、行程卡照樣顯示 +/// 「導航/已上車/完成」,但 `_online` 一律從 false 起、定位串流也沒起來。 +/// 後果不在司機這一端:**乘客端的司機 marker 定格、後端的抵達圍籬不會觸發、 +/// F3 里程的軌跡缺一整段**,而司機看到的只是 hero 上那句「離線/目前不會收到派單」。 +void main() { + group('冷啟動還原到行程中', () { + test('要把定位回報接回來(不然整趟乘客都看不到司機在動)', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.activeRide?.rideId, 9, reason: '前置:行程有還原'); + expect(ctrl.online, isTrue, reason: '載客途中卻不回報位置=乘客端的司機定格'); + + gps.add(_pos(25.03)); + await Future.delayed(Duration.zero); + expect(api.reportCalls, greaterThan(0), + reason: '旗標打開卻沒接上串流,位置一樣送不出去'); + + await ctrl.goOffline(); + }); + + test('沒有行程就不自動上線(接不接單是司機的決定)', () async { + final api = _RideApi()..active = null; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(api.reportCalls, 0); + }); + + test('查不到權限狀態(平台不可用)→ 什麼都不做,也不編故事', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + positionStream: (_) => gps.stream, + locationPermissionProbe: () async => null, + ); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(ctrl.error, isNull, reason: '「查不到」與「被拒絕」是兩件事'); + }); + + test('權限被拒 → 不上線、不彈視窗,但要講「回報給乘客」不是「上線」', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: false); + addTearDown(ctrl.dispose); + + await ctrl.init(); + + expect(ctrl.online, isFalse); + expect(ctrl.error, contains('乘客'), + reason: '行程中的司機看到「才能上線」會以為那只影響接新單'); + }); + + test('司機自己按了離線就不再自動接回來(那顆鈕要按得掉)', () async { + final api = _RideApi()..active = _ride; + final gps = StreamController(); + final ctrl = _driver(api, gps, granted: true); + addTearDown(ctrl.dispose); + await ctrl.init(); + await ctrl.goOffline(); + expect(ctrl.online, isFalse, reason: '前置:司機按下離線'); + + // 回前景會再對帳一次行程——這條路徑**不可以**順手把他重新上線。 + await ctrl.onAppResumed(); + + expect(ctrl.online, isFalse); + }); + }); +} + +final _ride = const ActiveRide( + rideId: 9, + address: '台北車站', + phase: DriverRidePhase.onTrip, +); + +Position _pos(double lat) => Position( + latitude: lat, + longitude: 121.56, + timestamp: DateTime.now(), + accuracy: 5, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +DriverController _driver( + _RideApi api, + StreamController gps, { + required bool granted, + LocationPermission? probe, +}) => + DriverController( + storage: MemoryDriverAuthStore() + ..save(const AuthSession(driverId: 7, token: 'tok')), + api: api, + wsFactory: FleetWsClient.silent, + positionStream: (_) => gps.stream, + locationPermissions: () async => granted, + locationPermissionProbe: () async => + probe ?? + (granted + ? LocationPermission.whileInUse + : LocationPermission.denied), + ); + +class _RideApi extends FleetApiClient { + _RideApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + ActiveRide? active; + int reportCalls = 0; + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => active; + + @override + Future reportLocation({required double lat, required double lng}) async { + reportCalls++; + } + + @override + Future> fetchLostItems() async => []; + + @override + Future fetchVehicle() async => const DriverVehicle( + vehicleType: 'sedan', + plateNumber: 'TEST-01', + hasVehicle: true, + canAccept: true, + ); +} diff --git a/test/driver_vehicle_test.dart b/test/driver_vehicle_test.dart index 11698de..3bc6f75 100644 --- a/test/driver_vehicle_test.dart +++ b/test/driver_vehicle_test.dart @@ -173,6 +173,27 @@ void main() { expect(ctrl.error, isNull); }); + test('查車輛成功不可以順手清掉別人設的錯誤', () async { + // 這裡原本是無條件 `_setError(null)`:`init()` 裡排在 refreshVehicle 前面 + // 設好的「需要定位權限才能把位置回報給乘客」就這樣被抹掉——設了、也通知了, + // 下一行清空,畫面上什麼都沒有。清錯誤要指名清哪一則。 + final denied = DriverController( + storage: MemoryDriverAuthStore(), + api: api, + wsFactory: FleetWsClient.silent, + locationPermissions: () async => false, + ); + addTearDown(denied.dispose); + await denied.init(); + await denied.login(lineUserId: 'U', password: 'pw'); + await denied.goOnline(); + expect(denied.error, isNotNull, reason: '前置:權限被拒的訊息掛上去了'); + + await denied.refreshVehicle(); + + expect(denied.error, isNotNull); + }); + test('儲存後以後端回傳值為準(車牌已正規化)', () async { await ctrl.init(); await ctrl.login(lineUserId: 'U', password: 'pw'); From fe9b9d3fdf1c6bc882891c82ced08e401bb9aec5 Mon Sep 17 00:00:00 2001 From: awei Date: Sat, 1 Aug 2026 18:05:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(app):=20=E5=88=86=E9=A0=81=E7=9A=84?= =?UTF-8?q?=E5=B0=BE=E5=B7=B4=E6=9C=83=E5=8D=A1=E6=AD=BB=E5=9C=A8=E8=BD=89?= =?UTF-8?q?=E5=9C=88=E2=80=94=E2=80=94=E7=AC=AC=2021=20=E7=AD=86=E4=B9=8B?= =?UTF-8?q?=E5=BE=8C=E5=86=8D=E4=B9=9F=E9=80=B2=E4=B8=8D=E4=BE=86=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E5=8D=81=E4=BA=94=E8=BC=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回頭 debug 第二十四輪自己。`_HistoryFooter` 只在 initState 觸發一次補讀,而 `loadMoreRideHistory()` 有「已經有請求在飛就 return」的重入防護:尾巴被建出來的 那一刻若剛好有下拉刷新在飛,這唯一的一次觸發就被吞掉,之後沒有任何人再試一次 ——尾巴永遠停在轉圈,舊行程的協尋與補評分入口又回到打不開的狀態。 原本 9 個案子測的是補讀本身對不對(視窗推進、錯誤分兩條、登出收回),沒有一個 案子讓兩個請求在時間上重疊,所以測不到。 修法兩個半邊,缺一不可: - 尾巴加 didUpdateWidget,每次 rebuild 都補問一次(rebuild 由 notifyListeners 帶動,刷新一結束就會走到);重入仍由 controller 擋,多問不會多打。 - 自動補讀失敗過就停手交給重試按鈕——didUpdateWidget 跟著每次 notifyListeners 觸發,少了這道 guard 會在後端還沒恢復時變成連續打點。 順手補跨年行程的年份:`_dateLabel()` 只印「1月5日 08:00」。跨年的行程是分頁上線 後才翻得到的,而這頁是事後申報遺失物、補評分的入口,找錯年份就是找錯那一趟。 驗收:flutter analyze 無 issue、flutter test 425 passed(423+新 2)。 新測試在修改前就是紅的,紅的方式本身就是證據:pumpAndSettle timed out,因為那顆 CircularProgressIndicator 永遠不會停。反向驗證兩個半邊各一次——拿掉 didUpdateWidget 新案 FAIL;拿掉「失敗就停手」guard,既有的「載入更多失敗時尾巴 給重試」那案 FAIL(27 秒逾時,正是自動重試打成迴圈的樣子)。 flutter build apk --debug --flavor customer 成功(analyze 綠不代表包得出來)。 TODO 補上第二十五輪專章,含「掃過但沒有發現問題」的七項對照表(時區、預約排序、 載入路徑、error 顯示點、.first 空集合、dispose、其他一次性觸發),免得下輪重掃。 Co-Authored-By: Claude Opus 5 --- README.md | 4 +- docs/TODO.md | 85 ++++++++++++++++++- lib/customer/screens/ride_history_screen.dart | 25 +++++- test/customer_ride_history_test.dart | 58 +++++++++++++ 4 files changed, 167 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1be95be..32d8349 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **414 passed**(54 個測試檔,2026-07-31 實跑)。 -~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **425 passed**(54 個測試檔,2026-08-01 實跑)。 +~~414 passed~~/~~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)—— diff --git a/docs/TODO.md b/docs/TODO.md index 8132d71..4ca1875 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -76,6 +76,7 @@ - [🚗 第二十三輪:App 被收掉再開,行程還在但位置回報整段消失](#-2026-07-30-第二十三輪app-被收掉再開行程還在但位置回報整段消失本批修掉-2-個) - [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能) - [📜 第二十四輪:「我的行程」只看得到最近 20 趟](#-2026-07-31-第二十四輪我的行程只看得到最近-20-趟本批修掉) +- [🔁 第二十五輪:分頁尾巴會卡死在轉圈(回頭 debug 第二十四輪自己)](#-2026-08-01-第二十五輪分頁的尾巴會卡死在轉圈回頭-debug-第二十四輪自己本批修掉-2-個) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -92,7 +93,8 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**54 個測試檔、`flutter test` 414 passed**(2026-07-31 第二十三輪與預約司機那批合併後實測;`flutter analyze` 無 issue)。 +- 單元測試:**54 個測試檔、`flutter test` 425 passed**(2026-08-01 第二十四輪併回 main 並修完尾巴那兩個洞後實測; + `flutter analyze` 無 issue、`flutter build apk --debug --flavor customer` 成功)。 ~~(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 點)。 @@ -2390,11 +2392,92 @@ PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \ - **司機端沒有行程歷史畫面**:司機事後想聯絡乘客/查過去趟次沒有入口 (收入頁只有聚合數字)。這是**功能缺口**不是 bug,要不要做是產品決定。 + **2026-08-01 補查**:後端也還沒有這支端點——`cmd/server/main.go` 的 driver 路由只有 + `/driver/rides/active`,沒有列表。要做的話是跨端,不是 App 單邊。 + +--- + +## 🔁 2026-08-01 第二十五輪:分頁的尾巴會卡死在轉圈(回頭 debug 第二十四輪自己)(本批修掉 2 個) + +> 這一輪的題目是**上一輪自己**。第二十四輪的 PR #97 與 main 分岔了三處, +> 解完衝突之後沒有直接收工,而是把那段新程式碼當成沒驗過的東西重查一遍—— +> 結果它有一個會**永久卡死**的狀態。 + +### 根因一句話 + +`_HistoryFooter` **只在 `initState` 觸發一次**補讀,而 `loadMoreRideHistory()` +有「已經有請求在飛就直接 return」的重入防護:尾巴被建出來的那一刻若剛好有 +下拉刷新在飛,這唯一的一次觸發就被吞掉了,**之後沒有任何人再試一次**—— +尾巴永遠停在轉圈,第 21 筆之後再也進不來。 + +### 為什麼原本的測試抓不到 + +原本 9 個案子測的是「補讀本身對不對」(視窗推進、錯誤分兩條、登出收回), +**沒有一個案子讓兩個請求在時間上重疊**。這與預約那輪的教訓是同一條: +沒有真的重疊的併發測試,測到的只是循序重跑。 + +### 修法 + +- `_HistoryFooter` 加 `didUpdateWidget`:尾巴還在畫面上就代表使用者仍停在底部, + 所以**每一次 rebuild 都補問一次**(rebuild 由 controller 的 `notifyListeners` 帶動, + 刷新一結束就會走到)。重入仍由 controller 擋,多問不會多打。 +- 但自動補讀**失敗過就停手**,交給既有的重試按鈕:`didUpdateWidget` 跟著每一次 + `notifyListeners` 觸發,少了這道 guard 會在後端還沒恢復時變成**連續打點**。 + 兩個半邊都是防線,見下面的反向驗證。 +- 順手補上**跨年行程的年份**:`_dateLabel()` 只印「1月5日 08:00」。 + 跨年的行程是**分頁上線後才翻得到的**(在那之前只看得到最近 20 趟), + 而這一頁是事後申報遺失物、補評分的入口——找錯年份就是找錯那一趟。 + 今年的不加年份,免得每張卡都變長。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **425 passed**(423 +新 2)。 +- **抓 bug 的順序是先紅再修**:新測試在修改前就是紅的,而且紅的方式本身就是證據—— + `pumpAndSettle timed out`,因為尾巴那顆 `CircularProgressIndicator` **永遠不會停**。 +- **反向驗證兩個半邊各一次**: + - 拿掉 `didUpdateWidget` → 新測試 FAIL(`pumpAndSettle` 逾時)。 + - 拿掉「失敗就停手」那道 guard → **既有**的「載入更多失敗時清單還在,尾巴給重試」 + 那案 FAIL,而且是 27 秒才逾時——正是自動重試打成迴圈的樣子。 +- `flutter build apk --debug --flavor customer -t lib/main_customer.dart` 成功 + (`app-customer-debug.apk`)——`analyze` 綠不代表打得包出來。 + +### 這一輪掃過但**沒有**發現問題的(寫下來,免得下一輪重掃) + +| 掃的東西 | 結論 | +|---|---| +| 時區:全 repo 的 `DateTime.parse`/`tryParse` 與顯示點 | ✅ 送出 `.toUtc()`、顯示 `.toLocal()`,兩個方向都對 | +| 預約清單排序 | ✅ 後端 `scheduled_at ASC, id ASC`、App 的 `_mergeScheduledRide` 也 sort,兩邊一致 | +| 常用地點/預約的載入路徑 | ✅ `init()` 與 `_authenticate()` 都載(#95 補過),登出也清 | +| 每個 `*Error` 欄位有沒有顯示點 | ✅ 6 個 getter 全部有對應畫面 | +| `.first`/`.last` 有沒有空集合保護 | ✅ 7 處全部有 `isNotEmpty` 或三元退路 | +| `Timer`/`StreamSubscription`/`TextEditingController` 的 dispose | ✅ 有建就有 dispose | +| 其他「一次性 initState 觸發+重入防護」的組合 | ✅ 只有尾巴這一處,其餘被觸發的方法都沒有重入防護 | + +### 同族還沒碰的角落 + +- **刷新與補讀真正同時回來時,清單會先縮回 20 筆再長回去**(畫面閃一下,資料不會錯)。 + 要修得在 controller 記一個請求序號,把晚出發卻早回來的那次丟掉。 + **條件**:等有人回報看到清單跳動再做——現在改要多一組狀態,代價大於症狀。 --- ## 下次任務 +> **🎯 2026-08-01 第二十五輪之後的狀態(開工先看這段)** +> +> 1. **`gh pr list`(三個 repo)照樣是第一件事。** 本輪開工時:app 只有 #97(就是本輪在收的那支)、 +> dispatch #72(redis 讓開埠號)、admin 0。 +> 2. **第八次實查三個外部卡點:依然都不在**(`android/app/google-services.json`、 +> `ios/Runner/GoogleService-Info.plist` 不存在、`xcrun devicectl list devices` → No devices found)。 +> 3. **功能清單目前沒有「不需前置條件就能做」的項目**——實查過了: +> 唯一還沒勾的 `[ ]`(車種供給為零)等產品拍板+後端查詢、 +> A2 等 Firebase、A5 階段 5-6 等實機/付費帳號、B5 付款等金流、 +> 司機端行程歷史後端也還沒有端點。**所以本輪照 2026-07-28 立的規矩改做 debug。** +> 4. **下一輪建議**:本輪示範了一個便宜又有效的取材法—— +> **把上一輪剛寫完的程式碼當成沒驗過的東西重查一遍**。 +> 新程式碼是全 repo 最沒被實跑過的部分,而寫它的人剛好最不會懷疑它。 + + > **🎯 2026-07-31 第二十四輪之後的狀態(開工先看這三行)** > > 1. **開工第一件事仍然是 `gh pr list`(三個 repo)**——這一輪就是靠它才發現 diff --git a/lib/customer/screens/ride_history_screen.dart b/lib/customer/screens/ride_history_screen.dart index ce690d6..2fc506f 100644 --- a/lib/customer/screens/ride_history_screen.dart +++ b/lib/customer/screens/ride_history_screen.dart @@ -98,7 +98,24 @@ class _HistoryFooterState extends State<_HistoryFooter> { void initState() { super.initState(); // build 期間不可改 provider 狀態,排到下一影格(同畫面 initState 的作法)。 - WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + WidgetsBinding.instance.addPostFrameCallback((_) => _autoLoad()); + } + + @override + void didUpdateWidget(covariant _HistoryFooter oldWidget) { + super.didUpdateWidget(oldWidget); + // **只靠 initState 會卡死**:尾巴被建出來的那一刻若剛好有別的請求在飛 + //(下拉刷新、或首載還沒回來),controller 的重入防護會把這次補讀擋掉, + // 而擋掉之後沒有任何人再試一次——尾巴就永遠停在轉圈,第 21 筆之後再也進不來。 + // 尾巴還在畫面上代表使用者仍停在底部,所以每次重建都補問一次。 + WidgetsBinding.instance.addPostFrameCallback((_) => _autoLoad()); + } + + /// 自動補讀。**失敗過就停手**交給重試按鈕:`didUpdateWidget` 會跟著每一次 + /// `notifyListeners` 觸發,自動重試在後端還沒恢復時會變成連續打點。 + void _autoLoad() { + if (!mounted || widget.ctrl.historyMoreError != null) return; + _load(); } void _load() { @@ -152,7 +169,11 @@ class _RideHistoryCard extends StatelessWidget { final local = t.toLocal(); final hh = local.hour.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0'); - return '${_months[local.month]}${local.day}日 $hh:$mm'; + // **跨年的行程從分頁上線後才翻得到**(在那之前只看得到最近 20 趟)。 + // 「1月5日」看不出是今年還是去年,而這頁是事後申報遺失物、補評分的入口—— + // 找錯年份就等於找錯那一趟。今年的不加年份,免得每張卡都變長。 + final year = local.year == DateTime.now().year ? '' : '${local.year}年'; + return '$year${_months[local.month]}${local.day}日 $hh:$mm'; } void _openChat(BuildContext context) { diff --git a/test/customer_ride_history_test.dart b/test/customer_ride_history_test.dart index 7dab56a..f2d0820 100644 --- a/test/customer_ride_history_test.dart +++ b/test/customer_ride_history_test.dart @@ -343,6 +343,64 @@ void main() { await tester.pumpAndSettle(); expect(find.text('行程 #5'), findsOneWidget); }); + + testWidgets('下拉刷新還在飛的時候捲到底,刷新結束後尾巴仍會補上更舊的行程', (tester) async { + final api = _FakeApi()..history = _rides(25); + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await pump(tester, ctrl); + await tester.pumpAndSettle(); + expect(api.requestedLimits, [20]); + + // 刷新卡在網路上時捲到底:尾巴的自動補讀會被 controller 的 + // 「已經有請求在飛就 return」擋掉。擋掉之後沒有人再試一次的話, + // 尾巴就永遠停在轉圈,第 21 筆之後再也進不來。 + api.gate = Completer(); + unawaited(ctrl.loadRideHistory()); + await tester.pump(); + + await tester.scrollUntilVisible(find.text('行程 #6'), 400, maxScrolls: 60); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -400)); + await tester.pump(); + + api.gate!.complete(); + api.gate = null; + await tester.pumpAndSettle(); + + expect(api.requestedLimits, [20, 20, 40], + reason: '刷新結束後尾巴要自己再要一次,不能停在轉圈'); + expect(ctrl.rideHistory.length, 25); + expect(ctrl.historyHasMore, isFalse); + }); + + testWidgets('往年的行程要帶年份,今年的不帶(分頁上線後才翻得到跨年行程)', (tester) async { + final now = DateTime.now(); + final api = _FakeApi() + ..history = [ + CustomerRideSummary( + rideId: 2, + status: 4, + pickupAddress: '今年那趟', + completedAt: DateTime(now.year, 3, 4, 9, 5), + ), + CustomerRideSummary( + rideId: 1, + status: 4, + pickupAddress: '去年那趟', + completedAt: DateTime(now.year - 1, 1, 5, 8, 0), + ), + ]; + final ctrl = _loggedIn(api); + addTearDown(ctrl.dispose); + + await pump(tester, ctrl); + await tester.pumpAndSettle(); + + expect(find.text('${now.year - 1}年1月5日 08:00'), findsOneWidget); + expect(find.text('3月4日 09:05'), findsOneWidget, + reason: '今年的不加年份,免得每張卡都變長'); + }); }); }