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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)——
Expand Down
90 changes: 85 additions & 5 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-完成)
Expand All @@ -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`、
Expand Down Expand Up @@ -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`、
Expand Down
29 changes: 17 additions & 12 deletions lib/customer/screens/customer_home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,13 @@ class _CustomerHomeScreenState extends State<CustomerHomeScreen> {
_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});

Expand All @@ -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;
}
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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())),
),
);
}
Expand Down
62 changes: 31 additions & 31 deletions lib/customer/screens/customer_map_home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
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),
],
Expand Down Expand Up @@ -129,7 +130,8 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
// 但乘客一拉開 sheet 就該看得到。
if (ctrl.upcomingSchedules.isNotEmpty) ...[
_UpcomingScheduleTile(
schedule: ctrl.upcomingSchedules.first),
schedule: ctrl.upcomingSchedules.first,
),
const SizedBox(height: 12),
],
_sheetContent(ctrl),
Expand Down Expand Up @@ -172,9 +174,9 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
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();
});
}
Expand All @@ -192,7 +194,11 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
case RideStatus.pickedUp:
return OnTripContent(ctrl: ctrl);
default:
return OrderFormContent(ctrl: ctrl);
// 走到這裡代表「有一張還沒進終態的訂單,但狀態碼 App 不認得」——
// 終態的訂單根本不會到這裡(controller 會把 activeRide 清成 null)。
// **不可以退回叫車表單**:那會把進行中的訂單藏起來,還請乘客去按一個
// 必定被「已有進行中訂單」擋下的按鈕。
return UnknownPhaseContent(ctrl: ctrl);
}
}

Expand All @@ -212,19 +218,19 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
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;
}
Expand All @@ -237,9 +243,7 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
: 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)];
}

/// 多停靠點的標記:**下一站全彩醒目、之後的站半透明、已到達灰色、已跳過不畫**
Expand All @@ -249,10 +253,7 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
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),
];
}

Expand Down Expand Up @@ -293,12 +294,12 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
}

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() {
Expand All @@ -307,7 +308,6 @@ class _CustomerMapHomeScreenState extends State<CustomerMapHomeScreen> {
}
}


/// Bottom Sheet 裡的「即將到來的預約」列。
///
/// 只提醒下一趟是什麼時候,詳細操作在預約頁——sheet 的空間要留給正在進行的事。
Expand Down
Loading
Loading