From 104aba290b291c7700f1d923b221e56ebdb8c55b Mon Sep 17 00:00:00 2001 From: awei Date: Sat, 1 Aug 2026 19:50:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E5=BE=8C=E7=AB=AF=E6=AF=94=20App?= =?UTF-8?q?=20=E6=96=B0=E6=99=82=EF=BC=8C=E9=80=B2=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E8=A8=82=E5=96=AE=E6=9C=83=E5=BE=9E=E7=95=AB=E9=9D=A2?= =?UTF-8?q?=E4=B8=8A=E6=B6=88=E5=A4=B1=EF=BC=88=E7=AC=AC=E4=BA=8C=E5=8D=81?= =?UTF-8?q?=E4=B8=83=E8=BC=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本輪掃兩族。第一族(金額與數值格式)**沒有找到任何問題**,五項查證結果寫進 TODO——不寫的話下一輪會有人再查一次同樣的東西(第十九輪立的規矩)。 重點結論:畫面上「車資+清潔費=合計」不會加不起來,因為後端 roundNtd/floorNtd 把每筆都收斂到整數元;司機收入頁四個數也全部由後端算好帶回。 第二族修掉一個:RideStatus.isTerminal 是白名單,未知狀態碼不算終態,所以 controller 會繼續把它當進行中的訂單輪詢;但畫面的 switch 走 default 回到 **叫車表單**。三件事同時發生——那一按必定被後端「已有進行中訂單」擋掉、整張 進行中的訂單在畫面上完全消失、controller 還在背景輪詢它。 這是前瞻性修補(現行後端只有 0/1/2/3/4/9,未知碼只會來自後端比 App 新), 但行為本身是確定的、測試可重現,不是猜測。 修法:UnknownPhaseContent——據實說出狀態碼(客服問「畫面上寫什麼」時這是唯一 能對帳的線索)、保留「取消行程」這條唯一脫身出口、不提供叫車。卡片版首頁的 default 是 SizedBox.shrink()(更徹底的隱形),一併對齊——那支目前沒被 app.dart 使用,但哪天換回來這個洞會安靜地回來。 順手補完第二十六輪留的缺口:遺失物描述/預約備註/評分評論三個畫面的「真畫面 接上去了」測試。補的過程確認了一件事——預約表單是 push 出去的新 route,provider 要放在 MaterialApp 之上才看得到,而 production 的 CustomerApp 確實是這樣放的 (所以那是測試結構問題,不是 App 會崩)。 驗收:flutter analyze 無 issue、flutter test 440 passed(434+新 6)、56 個測試檔、 flutter build apk --debug --flavor customer 成功。 反向驗證:把 default 改回 OrderFormContent → 該案 FAIL;另有一案守「沒有把正常 流程一起改壞」(status=0 仍是 SearchingContent)。 Co-Authored-By: Claude Opus 5 --- README.md | 4 +- docs/TODO.md | 90 +++++++++- .../screens/customer_home_screen.dart | 29 +-- .../screens/customer_map_home_screen.dart | 62 +++---- lib/customer/widgets/ride_phase_content.dart | 167 ++++++++++++------ test/rune_limit_test.dart | 113 ++++++++++++ test/unknown_ride_status_test.dart | 106 +++++++++++ 7 files changed, 470 insertions(+), 101 deletions(-) create mode 100644 test/unknown_ride_status_test.dart diff --git a/README.md b/README.md index b47344c..750d00c 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **434 passed**(55 個測試檔,2026-08-01 實跑)。 -~~425 passed~~/~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **440 passed**(56 個測試檔,2026-08-01 實跑)。 +~~434 passed~~/~~425 passed~~/~~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 6ba0c98..a9d0085 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -78,6 +78,7 @@ - [📜 第二十四輪:「我的行程」只看得到最近 20 趟](#-2026-07-31-第二十四輪我的行程只看得到最近-20-趟本批修掉) - [🔁 第二十五輪:分頁尾巴會卡死在轉圈(回頭 debug 第二十四輪自己)](#-2026-08-01-第二十五輪分頁的尾巴會卡死在轉圈回頭-debug-第二十四輪自己本批修掉-2-個) - [🔤 第二十六輪:App 的字數上限與後端數的不是同一種單位](#-2026-08-01-第二十六輪app-的字數上限與後端數的不是同一種單位本批修掉-6-個欄位) +- [🚦 第二十七輪:後端比 App 新時,進行中的訂單會從畫面上消失](#-2026-08-01-第二十七輪後端比-app-新時進行中的訂單會從畫面上消失本批修掉-1-個補完上輪缺口) **四、維護、決策與待辦** - [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成) @@ -94,7 +95,7 @@ - **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。 **登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。 - **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。 -- 單元測試:**55 個測試檔、`flutter test` 434 passed**(2026-08-01 第二十六輪後實測; +- 單元測試:**56 個測試檔、`flutter test` 440 passed**(2026-08-01 第二十七輪後實測; `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`、 @@ -2522,16 +2523,95 @@ TextField(maxLength: 5) 擋完後 → characters=5 runes=35 ### 同族還沒碰的角落 -- **遺失物描述/預約備註/評分評論這三個畫面沒有各自的「真畫面接上去了」測試** - (只有常用地點與聊天室有)。三個接法完全相同、`analyze` 也綠, - 但嚴格說它們的接線目前靠 review 不靠測試。**條件**:下次動到這三個畫面時順手補。 +- ~~**遺失物描述/預約備註/評分評論這三個畫面沒有各自的「真畫面接上去了」測試**~~ + ✅ **已於第二十七輪補完**(見下一段)。 - **司機端沒有任何自由輸入欄位**(車牌走後端驗證、聊天共用同一個元件),所以本輪不必動司機端。 --- +## 🚦 2026-08-01 第二十七輪:後端比 App 新時,進行中的訂單會從畫面上消失(本批修掉 1 個+補完上輪缺口) + +> 本輪掃了兩族,**第一族沒有找到任何問題**——這件事本身也要寫下來, +> 免得下一輪重掃(第十九輪立的規矩)。 + +### 掃過但**沒有**發現問題的:金額與數值格式 + +| 查的東西 | 結論 | +|---|---| +| `formatCentsAsNtd` 的 0/負數/千分位 | ✅ 0 → `NT$ 0`;負數帶 `-`;四捨五入對稱 | +| **畫面上「車資 + 清潔費 = 合計」三個數會不會加不起來** | ✅ 不會。後端 `roundNtd`/`floorNtd` 把每一筆都收斂到整數元,清潔費與手續費一定是整數元,所以三個數的小數位一致、同方向進位 | +| 司機收入頁「營業額 − 手續費 + 清潔費 = 實得」 | ✅ 四個數**全部由後端算好帶回**,App 不自行推導 | +| 完成卡的 `totalCents` 是 App 自己加的 | ✅ 合理——`ride.completed` 的 payload **只有** `fare_amount_cents`/`cleaning_fee_cents`,後端沒送總額(送總額的是 estimate 那支,App 也確實吃後端的) | +| 唯一的例外 | ⚠️ `min_fare_cents` 沒有被收斂成整數元(validator 只檢查範圍)。但它不會破壞上面任何一條等式,且目前無真金流,**不值得為它改後端**。 | + +### 第二族:未知的 ride status code(本批修掉) + +**根因一句話**:`RideStatus.isTerminal` 是白名單,未知碼**不算終態**, +所以 controller 會繼續把它當成進行中的訂單並持續輪詢; +但畫面的 `switch` 走 `default:` **回到叫車表單**—— +手上有一張沒結束的訂單,畫面卻請乘客再叫一輛。 + +三件事同時發生: +1. 那一按**必定**被後端的「已有進行中訂單」擋掉; +2. 整張進行中的訂單在畫面上**完全消失**,乘客不知道自己還有一趟車在跑; +3. controller 還在背景輪詢它——狀態與畫面各說各話。 + +**這是前瞻性的修補,不是現在就會踩到的 bug**:現行後端只有 `0/1/2/3/4/9`, +未知碼只會來自「後端比 App 上架版本新」。但**行為本身是確定的**(測試可重現), +不是「可能會有問題」的猜測。 + +**修法**:新增 `UnknownPhaseContent`——據實說出狀態碼 +(「狀態 7・請更新 App 以看到完整資訊」,客服問「畫面上寫什麼」時這是唯一能對帳的線索)、 +保留「取消行程」這條唯一的脫身出口(能不能取消由後端判斷),**不提供叫車**。 + +**卡片版首頁一併改**:它的 `default:` 是 `SizedBox.shrink()`(更徹底的隱形)。 +那支目前沒被 `app.dart` 使用,但兩邊要一致——否則哪天換回來,這個洞會安靜地回來 +(`pitfall-error-set-but-never-shown` 的「新功能加在沒被使用的那個首頁」是這個 repo 踩過的坑)。 + +### 順手補完第二十六輪留的缺口 + +上一輪只補了常用地點與聊天室的「真畫面接上去了」測試,另外三個畫面留著缺口。 +本輪補齊:遺失物描述 300/預約備註 200/評分評論 200,各自開真表單、貼 emoji、 +斷言擋在 rune 上並看計數器數字。 + +**補的過程踩到一條**(測試專屬,不是產品 bug):預約表單是 `Navigator.push` 出去的 +**新 route**,provider 若放在 `MaterialApp` 的 `home:` 底下,那條 route 的 context +看不到它(`ProviderNotFoundException`)。**production 的 `CustomerApp` 是把 provider +放在 `MaterialApp` 之上的**——所以這是測試結構沒有比照 production,不是 App 的問題。 +查清楚這件事本身很重要:如果 production 也放錯,那就是一個真的會崩的畫面。 + +### 驗收 + +- `flutter analyze` 無 issue、`flutter test` **440 passed**(434 +新 6)、56 個測試檔。 +- **反向驗證**:把 `default:` 改回 `OrderFormContent` → 該案 FAIL + (`Found 1 widget with type "OrderFormContent"`,理由欄印出「有進行中訂單時不可以顯示叫車表單」)。 +- **另有一案守「沒有把正常流程一起改壞」**:`status=0` 仍然是 `SearchingContent`。 +- `flutter build apk --debug --flavor customer` 成功。 + +### 同族還沒碰的角落 + +- **司機端也有一個 `default:`**(接單/行程卡的階段判斷)——本輪只掃乘客端。 + **條件**:下次動到司機端行程卡時順手看一次。 + +--- + ## 下次任務 -> **🎯 2026-08-01 第二十六輪之後的狀態(開工先看這段)** +> **🎯 2026-08-01 第二十七輪之後的狀態(開工先看這段)** +> +> 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時三個 repo 皆為 0。 +> 2. **第十次實查三個外部卡點:依然都不在**。 +> 3. **這一輪學到的一條**:**「掃完沒發現問題」也要寫下來,而且要寫查了什麼、結論是什麼。** +> 本輪金額那族查了五項全部乾淨——不寫的話,下一輪會有人(我)再查一次同樣的東西。 +> 第十九輪立過這條規矩,本輪是第一次拿它來寫「整族都乾淨」。 +> 4. **下一輪候選**(依價值排序): +> - **司機端的階段 `default:`**(接單/行程卡):本輪只掃了乘客端, +> 司機端同樣的 `switch` 還沒看過。**這是目前最具體的一條。** +> - **多裝置同一帳號**(從沒掃過):同一位乘客在兩台裝置登入, +> 一台叫車、另一台的畫面會不會知道?WS 與輪詢各自的行為是什麼? +> - **App 版本比後端新**(本輪的反方向):App 送出後端不認得的欄位/值時, +> 後端是忽略還是 400?目前沒有任何一端寫明這個契約。 + > > 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時三個 repo 的 open PR 皆為 0。 > 2. **第九次實查三個外部卡點:依然都不在**(`android/app/google-services.json`、 diff --git a/lib/customer/screens/customer_home_screen.dart b/lib/customer/screens/customer_home_screen.dart index 6951ac6..4514003 100644 --- a/lib/customer/screens/customer_home_screen.dart +++ b/lib/customer/screens/customer_home_screen.dart @@ -96,14 +96,13 @@ class _CustomerHomeScreenState extends State { _lastShownError = error; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); }); } } - class _CompletedRideCard extends StatelessWidget { const _CompletedRideCard({required this.ctrl}); @@ -127,7 +126,8 @@ class _ActiveRideCard extends StatelessWidget { bool _showTrackingMap(CustomerRide ride, CustomerController ctrl) { if (ride.status != RideStatus.accepted || ctrl.driverArrived) return false; - final hasPickup = (ride.pickupLat != null && ride.pickupLng != null) || + final hasPickup = + (ride.pickupLat != null && ride.pickupLng != null) || ctrl.lastPosition != null; return hasPickup; } @@ -142,7 +142,12 @@ class _ActiveRideCard extends StatelessWidget { case RideStatus.pickedUp: return OnTripContent(ctrl: ctrl); default: - return const SizedBox.shrink(); + // 與地圖版(production 首頁)同一個答案:未知狀態碼=「有事情在進行, + // 只是這版 App 不認得」,不可以把訂單藏起來。 + // 這個卡片版目前沒被 app.dart 使用,但兩邊要一致——否則哪天換回來, + // 這個洞會安靜地回來(見 pitfall-error-set-but-never-shown: + // 「新功能加在沒被使用的那個首頁」是這個 repo 踩過的坑)。 + return UnknownPhaseContent(ctrl: ctrl); } } @@ -174,9 +179,9 @@ class _ActiveRideCard extends StatelessWidget { const SizedBox(height: 8), Text( phase, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 12), _phaseWidget(ride), @@ -218,9 +223,9 @@ class _UpcomingScheduleCard extends StatelessWidget { style: theme.textTheme.bodySmall, ), trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ScheduledRidesScreen()), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const ScheduledRidesScreen())), ), ); } diff --git a/lib/customer/screens/customer_map_home_screen.dart b/lib/customer/screens/customer_map_home_screen.dart index f1a46d0..7979bcc 100644 --- a/lib/customer/screens/customer_map_home_screen.dart +++ b/lib/customer/screens/customer_map_home_screen.dart @@ -95,8 +95,9 @@ class _CustomerMapHomeScreenState extends State { builder: (context, scrollCtrl) => DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), boxShadow: const [ BoxShadow(blurRadius: 12, color: Colors.black26), ], @@ -129,7 +130,8 @@ class _CustomerMapHomeScreenState extends State { // 但乘客一拉開 sheet 就該看得到。 if (ctrl.upcomingSchedules.isNotEmpty) ...[ _UpcomingScheduleTile( - schedule: ctrl.upcomingSchedules.first), + schedule: ctrl.upcomingSchedules.first, + ), const SizedBox(height: 12), ], _sheetContent(ctrl), @@ -172,9 +174,9 @@ class _CustomerMapHomeScreenState extends State { if (error == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); ctrl.clearError(); }); } @@ -192,7 +194,11 @@ class _CustomerMapHomeScreenState extends State { case RideStatus.pickedUp: return OnTripContent(ctrl: ctrl); default: - return OrderFormContent(ctrl: ctrl); + // 走到這裡代表「有一張還沒進終態的訂單,但狀態碼 App 不認得」—— + // 終態的訂單根本不會到這裡(controller 會把 activeRide 清成 null)。 + // **不可以退回叫車表單**:那會把進行中的訂單藏起來,還請乘客去按一個 + // 必定被「已有進行中訂單」擋下的按鈕。 + return UnknownPhaseContent(ctrl: ctrl); } } @@ -212,19 +218,19 @@ class _CustomerMapHomeScreenState extends State { final pickupLat = ride?.pickupLat ?? ctrl.lastPosition?.latitude; final pickupLng = ride?.pickupLng ?? ctrl.lastPosition?.longitude; if (ride != null && pickupLat != null && pickupLng != null) { - markers.add(_pin( - LatLng(pickupLat, pickupLng), - Icons.location_on, - Colors.red, - )); + markers.add( + _pin(LatLng(pickupLat, pickupLng), Icons.location_on, Colors.red), + ); } } if (ctrl.liveDriverLat != null && ctrl.liveDriverLng != null) { - markers.add(_pin( - LatLng(ctrl.liveDriverLat!, ctrl.liveDriverLng!), - Icons.local_taxi, - Colors.green, - )); + markers.add( + _pin( + LatLng(ctrl.liveDriverLat!, ctrl.liveDriverLng!), + Icons.local_taxi, + Colors.green, + ), + ); } return markers; } @@ -237,9 +243,7 @@ class _CustomerMapHomeScreenState extends State { : null; final points = routePolylinePoints(driver, ride.stops); if (points.isEmpty) return const []; - return [ - Polyline(points: points, strokeWidth: 3, color: Colors.blueAccent), - ]; + return [Polyline(points: points, strokeWidth: 3, color: Colors.blueAccent)]; } /// 多停靠點的標記:**下一站全彩醒目、之後的站半透明、已到達灰色、已跳過不畫** @@ -249,10 +253,7 @@ class _CustomerMapHomeScreenState extends State { final next = nextPendingStop(ride.stops); return [ for (final s in visible) - _stopPin( - s, - isNext: next != null && next.id == s.id, - ), + _stopPin(s, isNext: next != null && next.id == s.id), ]; } @@ -293,12 +294,12 @@ class _CustomerMapHomeScreenState extends State { } Marker _pin(LatLng point, IconData icon, Color color) => Marker( - point: point, - width: 40, - height: 40, - alignment: Alignment.topCenter, - child: Icon(icon, color: color, size: 36), - ); + point: point, + width: 40, + height: 40, + alignment: Alignment.topCenter, + child: Icon(icon, color: color, size: 36), + ); @override void dispose() { @@ -307,7 +308,6 @@ class _CustomerMapHomeScreenState extends State { } } - /// Bottom Sheet 裡的「即將到來的預約」列。 /// /// 只提醒下一趟是什麼時候,詳細操作在預約頁——sheet 的空間要留給正在進行的事。 diff --git a/lib/customer/widgets/ride_phase_content.dart b/lib/customer/widgets/ride_phase_content.dart index 1932ca9..689ef37 100644 --- a/lib/customer/widgets/ride_phase_content.dart +++ b/lib/customer/widgets/ride_phase_content.dart @@ -14,7 +14,11 @@ import 'saved_places_bar.dart'; import 'stops_editor.dart'; /// 開啟與司機的即時聊天室(行程中與遺失物協尋共用同一條對話)。 -void openCustomerChat(BuildContext context, CustomerController ctrl, int rideId) { +void openCustomerChat( + BuildContext context, + CustomerController ctrl, + int rideId, +) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => RideChatScreen( @@ -121,6 +125,59 @@ class SearchingContent extends StatelessWidget { } } +/// 後端送來 App 不認得的狀態碼時的落腳處。 +/// +/// **先前這裡會掉進叫車表單**(`_sheetContent` 的 `default:`),那是所有選項裡最糟的一個: +/// 手上明明有一張還沒結束的訂單(`isTerminal` 是白名單,未知碼不算終態, +/// 所以 controller 會繼續輪詢它),畫面卻請乘客再叫一輛——而那一按必定被後端的 +/// 「已有進行中訂單」擋掉。更糟的是整張進行中的訂單在畫面上**完全消失**, +/// 乘客不知道自己還有一趟車在跑。 +/// +/// 未知碼只會來自「後端比 App 新」。這種時候唯一安全的假設是 +/// **「有事情正在進行,只是這個版本的 App 不知道那是什麼」**: +/// 據實說出狀態碼、保留取消這條退路(後端會自己判斷能不能取消),不提供叫車。 +class UnknownPhaseContent extends StatelessWidget { + const UnknownPhaseContent({required this.ctrl, super.key}); + + final CustomerController ctrl; + + @override + Widget build(BuildContext context) { + final ride = ctrl.activeRide; + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Center( + child: Icon( + Icons.local_taxi, + size: 40, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 12), + Center(child: Text('行程進行中', style: theme.textTheme.titleMedium)), + const SizedBox(height: 4), + Center( + child: Text( + // 據實把狀態碼寫出來:客服問「畫面上寫什麼」時,這是唯一能對帳的線索。 + ride == null ? '請稍候' : '${ride.statusLabel}・請更新 App 以看到完整資訊', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(height: 16), + // 取消仍然給——這是唯一的脫身出口,能不能取消由後端判斷。 + OutlinedButton( + onPressed: ctrl.busy ? null : () => ctrl.cancelOrder(), + child: const Text('取消行程'), + ), + ], + ); + } +} + /// 司機途中/已抵達:司機名、ETA/距離 chip、取消。 class DriverEnRouteContent extends StatelessWidget { const DriverEnRouteContent({required this.ctrl, super.key}); @@ -142,10 +199,7 @@ class DriverEnRouteContent extends StatelessWidget { } Widget _chip(BuildContext context, String label) { - return Chip( - label: Text(label), - visualDensity: VisualDensity.compact, - ); + return Chip(label: Text(label), visualDensity: VisualDensity.compact); } @override @@ -160,24 +214,18 @@ class DriverEnRouteContent extends StatelessWidget { if (ctrl.driverName != null) ...[ Text( '司機:${ctrl.driverName}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), ], // O4/O7:車種車牌供路邊對車、電話供直接聯絡(明碼,僅本趟乘客看得到)。 DriverVehicleCard(info: ctrl.driverInfo), if (arrived) ...[ - Text( - '請與司機會合', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('請與司機會合', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), - Text( - '請盡快到上車點與司機會合', - style: Theme.of(context).textTheme.bodyMedium, - ), + Text('請盡快到上車點與司機會合', style: Theme.of(context).textTheme.bodyMedium), ] else if (chips.isNotEmpty) ...[ Wrap(spacing: 8, runSpacing: 4, children: chips), ] else ...[ @@ -211,10 +259,7 @@ class OnTripContent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '行程進行中', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('行程進行中', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), // 多停靠點行程的「目的地」只是最後一站,全程交給進度卡講; // 兩者都顯示會讓乘客以為現在正開往終點。 @@ -245,10 +290,7 @@ class CompletedContent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '行程已完成', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('行程已完成', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text('行程 #${summary.rideId}'), if (summary.driverName != null) ...[ @@ -270,7 +312,11 @@ class CompletedContent extends StatelessWidget { const Divider(height: 16), _FareRow(label: '合計', cents: summary.totalCents!, emphasize: true), ] else - _FareRow(label: '車資', cents: summary.fareAmountCents!, emphasize: true), + _FareRow( + label: '車資', + cents: summary.fareAmountCents!, + emphasize: true, + ), ], const SizedBox(height: 16), // 評分(B5):評過就只剩星等,沒有「改評分」——後端一趟一評。 @@ -288,11 +334,11 @@ class CompletedContent extends StatelessWidget { onPressed: ctrl.ratingSubmitting ? null : () => showRatingSheet( - context, - ctrl: ctrl, - rideId: summary.rideId, - driverName: summary.driverName, - ), + context, + ctrl: ctrl, + rideId: summary.rideId, + driverName: summary.driverName, + ), icon: const Icon(Icons.star_outline), label: const Text('留下評分'), ), @@ -374,11 +420,7 @@ class _OrderFormContentState extends State { if (!ctrl.multiStopEnabled) ...[ // 常用地點快捷:一鍵把住家/公司帶進目的地。座標一起帶過來, // 所以帶入後照樣算得出車資預估(手打地址是算不出來的)。 - SavedPlacesBar( - ctrl: ctrl, - title: '常用目的地', - onPick: _applySavedPlace, - ), + SavedPlacesBar(ctrl: ctrl, title: '常用目的地', onPick: _applySavedPlace), const SizedBox(height: 8), TextField( controller: _dropoff, @@ -473,9 +515,9 @@ class _OrderFormContentState extends State { Future _submit(BuildContext context) async { // 多乘客模式的目的地來自每位乘客的下車點,不看這個欄位(controller 會擋未填完)。 if (!widget.ctrl.multiStopEnabled && _dropoff.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('請先輸入目的地地址')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('請先輸入目的地地址'))); return; } await widget.ctrl.placeOrder( @@ -510,7 +552,11 @@ class _CancelNoticeCard extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.info_outline, size: 20, color: scheme.onErrorContainer), + Icon( + Icons.info_outline, + size: 20, + color: scheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -570,11 +616,18 @@ class _FareEstimateCard extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.receipt_long, size: 20, color: scheme.onSecondaryContainer), + Icon( + Icons.receipt_long, + size: 20, + color: scheme.onSecondaryContainer, + ), const SizedBox(width: 8), - Text('預估車資', - style: theme.textTheme.titleSmall - ?.copyWith(color: scheme.onSecondaryContainer)), + Text( + '預估車資', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + ), + ), const Spacer(), if (ctrl.estimating) const SizedBox( @@ -597,8 +650,9 @@ class _FareEstimateCard extends StatelessWidget { const SizedBox(height: 6), Text( '約 ${_km(est.distanceM)} 公里・${_min(est.durationSec)} 分鐘', - style: theme.textTheme.bodySmall - ?.copyWith(color: scheme.onSecondaryContainer), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSecondaryContainer, + ), ), const SizedBox(height: 4), Text( @@ -645,7 +699,12 @@ class _FareRow extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: emphasize ? theme.textTheme.titleMedium : theme.textTheme.bodyMedium), + Text( + label, + style: emphasize + ? theme.textTheme.titleMedium + : theme.textTheme.bodyMedium, + ), Text(formatCentsAsNtd(cents), style: style), ], ); @@ -666,9 +725,9 @@ class DriverVehicleCard extends StatelessWidget { final uri = Uri(scheme: 'tel', path: phone); if (!await launchUrl(uri)) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('無法撥號,司機電話:$phone')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('無法撥號,司機電話:$phone'))); } } @@ -738,7 +797,9 @@ class VehicleTypePicker extends StatelessWidget { if (bps == 0) return '目前不加收清潔費'; // bps → 百分比;1 位小數但整數時不留 .0(2000 → 20%,1550 → 15.5%)。 final pct = bps / 100; - final text = pct == pct.roundToDouble() ? pct.toStringAsFixed(0) : pct.toStringAsFixed(1); + final text = pct == pct.roundToDouble() + ? pct.toStringAsFixed(0) + : pct.toStringAsFixed(1); return '將加收清潔費 $text%(依車資比例)'; } @@ -769,7 +830,11 @@ class VehicleTypePicker extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Icon(Icons.info_outline, size: 16, color: theme.colorScheme.primary), + Icon( + Icons.info_outline, + size: 16, + color: theme.colorScheme.primary, + ), const SizedBox(width: 6), Expanded( child: Text( diff --git a/test/rune_limit_test.dart b/test/rune_limit_test.dart index 5a5f5e8..966a8df 100644 --- a/test/rune_limit_test.dart +++ b/test/rune_limit_test.dart @@ -7,7 +7,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:line_fleet_app/core/api/customer_api_client.dart'; import 'package:line_fleet_app/core/models/models.dart'; import 'package:line_fleet_app/customer/customer_controller.dart'; +import 'package:line_fleet_app/customer/screens/lost_item_screen.dart'; import 'package:line_fleet_app/customer/screens/saved_places_screen.dart'; +import 'package:line_fleet_app/customer/screens/scheduled_rides_screen.dart'; +import 'package:line_fleet_app/customer/widgets/rating_sheet.dart'; import 'package:line_fleet_app/shared/screens/ride_chat_screen.dart'; import 'package:line_fleet_app/shared/widgets/rune_limit.dart'; import 'package:line_fleet_app/core/theme/app_theme.dart'; @@ -186,6 +189,116 @@ void main() { expect(text.characters.length, 20, reason: '40 ÷ 2 = 20 面完整的旗子'); expect(find.text('40/40'), findsOneWidget, reason: '計數器要說出 rune 數'); }); + + // 第二十六輪只補了常用地點與聊天室的接線測試,另外三個畫面留了缺口 + // (寫在該輪的「同族還沒碰的角落」)。第二十七輪把它補完: + // 三個接法相同、analyze 也綠,但少了這三案,任何一個畫面漏掉 + // `inputFormatters` 都不會有測試變紅。 + testWidgets('遺失物描述:真畫面接上去了,擋在 300 rune', (tester) async { + final ctrl = CustomerController(api: _EmptyPlacesApi()); + addTearDown(ctrl.dispose); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: ChangeNotifierProvider.value( + value: ctrl, + child: const CustomerLostItemScreen(rideId: 1), + ), + ), + ); + await tester.pumpAndSettle(); + + final field = find.widgetWithText(TextField, '物品描述(例:黑色錢包掉在後座)'); + expect(field, findsOneWidget, reason: '前置條件:回報表單有出現'); + await tester.enterText(field, _family * 60); // 60 clusters = 420 runes + await tester.pump(); + + final text = tester.widget(field).controller!.text; + expect(text.runes.length, lessThanOrEqualTo(300)); + expect( + find.text('294/300'), + findsOneWidget, + reason: '300 ÷ 7 = 42 個完整的家庭 emoji = 294 runes', + ); + }); + + testWidgets('預約備註:真畫面接上去了,擋在 200 rune', (tester) async { + final ctrl = CustomerController(api: _EmptyPlacesApi()); + addTearDown(ctrl.dispose); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + // Provider 要放在 MaterialApp **之上**——比照 production 的 + // `CustomerApp`。預約表單是 `Navigator.push` 出去的新 route, + // provider 若放在 `home:` 底下,那條 route 的 context 看不到它。 + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctrl, + child: MaterialApp( + theme: appLightTheme, + home: const ScheduledRidesScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FloatingActionButton, '新增預約')); + await tester.pumpAndSettle(); + + final field = find.widgetWithText(TextField, '給司機的備註(選填)'); + expect(field, findsOneWidget, reason: '前置條件:預約表單有打開'); + await tester.enterText(field, _flag * 150); // 150 clusters = 300 runes + await tester.pump(); + + final text = tester.widget(field).controller!.text; + expect(text.runes.length, lessThanOrEqualTo(200)); + expect(find.text('200/200'), findsOneWidget); + }); + + testWidgets('評分評論:真畫面接上去了,擋在 200 rune', (tester) async { + final ctrl = CustomerController(api: _EmptyPlacesApi()); + addTearDown(ctrl.dispose); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: ChangeNotifierProvider.value( + value: ctrl, + child: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => + showRatingSheet(context, ctrl: ctrl, rideId: 42), + child: const Text('開評分'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('開評分')); + await tester.pumpAndSettle(); + + final field = find.widgetWithText(TextField, '想說的話(選填)'); + expect(field, findsOneWidget, reason: '前置條件:評分對話框有打開'); + await tester.enterText(field, _family * 50); // 50 clusters = 350 runes + await tester.pump(); + + final text = tester.widget(field).controller!.text; + expect(text.runes.length, lessThanOrEqualTo(200)); + expect( + find.text('196/200'), + findsOneWidget, + reason: '200 ÷ 7 = 28 個完整的家庭 emoji = 196 runes', + ); + }); }); } diff --git a/test/unknown_ride_status_test.dart b/test/unknown_ride_status_test.dart new file mode 100644 index 0000000..02aadf9 --- /dev/null +++ b/test/unknown_ride_status_test.dart @@ -0,0 +1,106 @@ +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/models/models.dart'; +import 'package:line_fleet_app/core/theme/app_theme.dart'; +import 'package:line_fleet_app/customer/customer_controller.dart'; +import 'package:line_fleet_app/customer/screens/customer_map_home_screen.dart'; +import 'package:line_fleet_app/customer/widgets/ride_phase_content.dart'; +import 'package:provider/provider.dart'; + +/// 後端比 App 新時會送來的狀態碼。現行後端只有 0/1/2/3/4/9, +/// 但 `RideStatus.isTerminal` 是白名單——未知碼不算終態, +/// 所以 controller 會**繼續把它當成進行中的訂單**並持續輪詢。 +/// 問題只在畫面拿它怎麼辦。 +const _unknownStatus = 7; + +class _QuietApi extends CustomerApiClient { + _QuietApi() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({ + bool upcomingOnly = false, + }) async => const ScheduledRidesResult(rides: [], leadMinutes: 15); + + @override + Future> fetchLostItems() async => const []; +} + +CustomerController _withRide(int status) { + final ctrl = CustomerController(api: _QuietApi()); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + ctrl.setActiveRideForTest( + CustomerRide(rideId: 42, status: status, dropoffAddress: '台北車站'), + ); + return ctrl; +} + +void main() { + group('未知的行程狀態碼', () { + test('前提:未知碼既不算進行中也不算終態', () { + // 這兩個白名單就是本題的來源——unknown 不是 terminal, + // 所以 activeRide 不會被清掉;unknown 也不是 active, + // 所以任何「照階段給操作」的畫面都沒有它的分支。 + expect(RideStatus.isTerminal(_unknownStatus), isFalse); + expect(RideStatus.isActive(_unknownStatus), isFalse); + expect(rideStatusLabel(_unknownStatus), '狀態 7'); + }); + + testWidgets('地圖版首頁:未知狀態不可以退回叫車表單', (tester) async { + final ctrl = _withRide(_unknownStatus); + addTearDown(ctrl.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: ChangeNotifierProvider.value( + value: ctrl, + child: const CustomerMapHomeScreen(), + ), + ), + ); + await tester.pump(); + + // 修改前這裡是 OrderFormContent:手上有一張沒結束的訂單,畫面卻請乘客 + // 再叫一輛——而那一按必定被後端的「已有進行中訂單」擋掉, + // 同時整張進行中的訂單在畫面上完全消失。 + expect( + find.byType(OrderFormContent), + findsNothing, + reason: '有進行中訂單時不可以顯示叫車表單', + ); + expect(find.byType(UnknownPhaseContent), findsOneWidget); + expect(find.text('行程進行中'), findsOneWidget); + // 據實把狀態碼說出來——客服問「畫面上寫什麼」時這是唯一能對帳的線索。 + expect(find.textContaining('狀態 7'), findsOneWidget); + // 取消要留著:這是唯一的脫身出口。 + expect(find.widgetWithText(OutlinedButton, '取消行程'), findsOneWidget); + }); + + testWidgets('已知狀態不受影響(沒有把正常流程一起改壞)', (tester) async { + final ctrl = _withRide(RideStatus.requested); + addTearDown(ctrl.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: appLightTheme, + home: ChangeNotifierProvider.value( + value: ctrl, + child: const CustomerMapHomeScreen(), + ), + ), + ); + await tester.pump(); + + expect(find.byType(SearchingContent), findsOneWidget); + expect(find.byType(UnknownPhaseContent), findsNothing); + }); + }); +}