diff --git a/README.md b/README.md index cfc39ee..4fdbd3e 100644 --- a/README.md +++ b/README.md @@ -294,12 +294,27 @@ SnackBar;FCM token 輪替失敗會冒出司機看不懂也無事可做的紅 另外,**多停靠點行程不再需要裝置定位**:pickup/dropoff 由 stops 推導,後端本來就不看那組座標。 兩端都在 `m6_pixel` 上以 `settings put secure location_mode 0` 實跑驗過。 +- **預約司機+常用地點(2026-07-31)**:乘客可預約**未來**的用車,以及把住家/公司等 + 常去的地點存起來,叫車與預約時一鍵帶入。 + - **預約**:首頁 AppBar「預約司機」→ 選時間(最早可預約時間由後端給,不寫死) + +起訖點+備註。後端在約定時間前 **15 分鐘**自動轉成真訂單進派單池, + 之後就走既有的派單/推播/地圖鏈路。清單分三區:即將到來/車已在路上/過往。 + **已轉單的不能取消**(那張真訂單已在派單池)——取消撞 409 時畫面直接切成 + 「已為你派車」並指出要去行程頁取消該趟訂單,不是丟一句「請稍後再試」。 + - **常用地點**:住家/公司是「插槽」(每人各一筆、設定即覆蓋),其他為自訂地點。 + 判斷是不是住家一律看 `kind` 不比對名稱——名稱是使用者可以改的。 + 帶入時**連座標一起帶**,所以照樣算得出車資預估(手打地址算不出來)。 + - 後端:dispatch 的 `scheduled_rides`/`customer_saved_places`(migration 000025/000026)。 + 假資料見 dispatch 的 `scripts/seed_demo_data.sh`。 + ## 規劃中(尚未實作) > 完整規格與待拍板事項見 [`docs/TODO.md`](docs/TODO.md) 與後端 > [line-fleet-dispatch/docs/TODO.md](../line-fleet-dispatch/docs/TODO.md)。 - 完成後付款(B5 的另一半,需真金流)。 +- 預約的多停靠點、預約專屬推播(「已轉為訂單」/「預約未能成立」)、admin 端的預約管理 + ——三者都寫明了要等什麼條件才做,見 TODO「🗓️ 預約司機+常用地點」那章最後一節。 - 依賴外部資源/實機的項目(A2 真裝置推播、A5 iOS 實機部署與 iOS 推播)——見 TODO。 - **維護項已於 2026-07-28 全數清空**(清殘留 worktree/舊分支、清 dev DB 測試殘留); 只剩「評分的營運動作」等營運說得出要對低分司機做什麼再開。 diff --git a/docs/TODO.md b/docs/TODO.md index a896f39..2b9e387 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-個並補完第二十一輪的實跑尾巴) +- [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能) **四、維護、決策與待辦** - [🧹 清開發殘留 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)。 +- 單元測試:**52 個測試檔、`flutter test` 408 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,191 @@ App 這邊畫面還停在 open → 按「已找到」→ proxy log --- +## 🗓️ 預約司機+常用地點(2026-07-31,跨端新功能) + +> 需求:乘客可以**預約未來的用車**(不是現在叫車),以及把**住家/公司等常去的地點存起來**, +> 叫車與預約時一鍵帶入。後端對應 dispatch 的 `scheduled_rides` 與 `customer_saved_places` +> 兩張新表(migration 000025/000026)。 +> +> PR:本 repo [#95](https://github.com/thothawei/fleet-app/pull/95)、 +> dispatch [#70](https://github.com/thothawei/fleet-dispatch/pull/70)。 + +### 為什麼預約是獨立一張表,不是 rides 加一個狀態 + +`rides` 的既有查詢散布在派單池(`status='requested'`)、乘客 active、歷史、報表、admin 訂單列表。 +多塞一個「還不該被派單」的狀態進去,得**逐一稽核每一支查詢**有沒有把它排除掉—— +漏一支就會出現「司機看到一張三天後才要出發的單」。 +獨立表對既有路徑是純新增、零風險,代價只是幾個欄位重複。 + +### 到點怎麼變成真訂單 + +背景排程器 `ScheduledRideDispatcher` 每 30 秒掃一次,把**約定時間前 15 分鐘**(`ScheduledRideLeadMinutes`) +內的 pending 預約轉成真訂單。轉單走的是**與乘客手動叫車完全同一支** `RideService.CreateByCustomer` +——派單、審計、車種驗證、「同時只能有一張進行中訂單」的規則因此全部自動沿用。 +若另外寫一份建單邏輯,兩條路徑遲早會長歪,而且是預約那條先歪(沒有人在旁邊看著)。 + +三個刻意的設計,每個都對應一種會出事的情況: + +| 設計 | 防的是什麼 | +|---|---| +| 提前 15 分鐘發動 | 08:00 的車 08:00 才找司機注定遲到——派單本身要時間 | +| 建立時最少 20 分鐘後(>提前量) | 比提前量還近的「預約」,使用者要的其實是現在叫車 | +| 認領帶 `attempt_count` 樂觀鎖 | 兩個排程器副本同時掃到同一筆 → 建出**兩張**要付錢的訂單 | + +暫時性失敗(乘客當下還在另一趟行程上)維持 pending 等下一輪,最多 10 次; +永久性失敗(座標無效、車種無效)**立刻**判死,不佔著重試額度撐到約定時間才讓乘客發現沒車。 + +### 常用地點:home/work 是插槽,不是清單項目 + +`home`/`work` 每位乘客各限一筆(DB 部分唯一索引 `uq_saved_place_customer_kind`), +服務層是 **upsert 語意**——UI 上那顆按鈕叫「設定住家」,乘客預期住家換成新地址, +而不是收到「你已經有住家了」再自己去刪舊的。`custom` 才是可以有很多筆的自訂地點。 + +**判斷「這是不是住家」一律看 `kind`,不比對 `label`**:label 是使用者可以改的顯示名稱, +他把住家改叫「家」的那一刻,任何比對文字的寫法都會無聲失效。 + +### 本批 debug 抓到並修掉的兩個 + +1. **登出沒清掉常用地點與預約(隱私外洩)**——`_clearSession` 早就因為「上一個帳號的個人資料」 + 在清行程歷史了,新加的兩份狀態卻漏了。住家與公司是**實體位置**、預約是「這個人什麼時候不在家」, + 比行程歷史更敏感。下一位在這台裝置登入的人一打開叫車頁,快捷列上就是上一位乘客的住家地址。 + 同一族的坑見 `pitfall-device-token-multi-owner`。 + 測試:`test/saved_places_test.dart`「登出殘留」;反向驗證拔掉清理後該支轉紅。 +2. **排程器停機後會把積壓的過期預約全部派車**——`FindDue` 只有上界沒有下界。 + 部署/當機/DB 不可用停了一段時間,重啟時昨天早上的預約會今天下午開一台車到乘客家樓下, + 而且他還得付那趟錢。修法是超過約定時間 30 分鐘(`ScheduledRideExpiryGraceMinutes`)就標 + `failed` 並寫原因——**標 failed 而不是留在 pending**,留著它會永遠掛在「即將到來」, + 而那台車永遠不會來。 + 測試:`TestDispatcherSkipsLongExpiredSchedules`(先取證看到它真的被轉單,才動手修)。 + +### 驗收證據 + +- **後端**:`go build`/`go vet` 乾淨;`internal/service` 新增 15 支測試(真 PostGIS 容器)全綠。 +- **反向驗證做了三次**,其中一次推翻了我自己的假設: + - 拔掉 `FindDue` 的時間條件 → `TestDispatcherOnlyPicksDueSchedules` 轉紅 ✅ + - 拔掉 `ClaimForDispatch` 的樂觀鎖 → `TestDispatcherIsIdempotent` **仍然是綠的** ❌ + ——那支守的是「循序重跑」(第一輪改了狀態,第二輪 `FindDue` 就撈不到),不是併發。 + 補了 `TestDispatcherConcurrentTicksCreateOneRide`,而且**第一版也是假的**: + 兩個 goroutine 各跑一次 `Tick`,先跑完的已經改掉狀態,後跑的撈不到, + 拔掉樂觀鎖連跑三次都綠。改成直接餵兩份 `attempt_count=0` 的相同快照進 `dispatchOne` + 才造得出真重疊——這版拔掉樂觀鎖會建出兩張訂單,連跑兩次都紅 ✅ +- **App**:`flutter analyze` 無 issue、`flutter test` **400 passed**; + 新增 `test/scheduled_ride_test.dart`(17 支,含 widget 分區與車種顯示)與 `test/saved_places_test.dart`(12 支)。 + 同樣做了反向驗證:拔掉 409 的狀態合併、拔掉 `sameSlot` 判斷、拔掉登出清理,三支各自轉紅。 + 其中 `sameSlot` 那次**第一版測試也沒守住**(兩條測試都沒走到那個分支), + 補了「別台裝置改過住家、後端回不同 id」才真正覆蓋到。 +- **端到端**:`scripts/seed_demo_data.sh` 對本機後端實跑成功(見下)—— + 註冊、常用地點 CRUD、預約建立、取消、清單全部走真實 HTTP 200, + 且 gin 路由沒有 panic(新路由與既有 `/customer/rides/:id` 同層混用靜態段與參數段, + 那是**啟動即死、單元測試照樣全綠**的失敗模式,另補了 `scheduled_ride_route_shape_test.go`)。 + 實跑後查 DB 確認三筆 pending 的 `attempt_count` 都是 0(沒有被誤轉單)。 + +### 到點轉單的真環境實跑(不只有測試) + +塞一筆 5 分鐘後到點的預約,在跑著的後端上觀察排程器(30 秒一輪): + +| 輪次 | `attempt_count` | 結果 | +|---|---|---| +| 1–2 | 1 → 2 | 撈到並認領,建單被 `已有進行中的訂單` 擋下 → **維持 pending**、寫 `last_error`、等下一輪 | +| 3(把擋路的示範訂單結掉之後) | 3 | **轉單成功**:`status=dispatched`、`ride_id=39`、`last_error` 清空 | + +交叉驗證訂單 39:起訖點正是預約上填的那兩個字串、`status=0`(已進派單池)。 +這一跑同時證明了三件本來只有測試層證據的事——到期條件真的撈得到、 +暫時性失敗真的是「等下一輪」而不是判死、成功後 `last_error` 真的會被清掉。 + +### 🧪 模擬器實跑(m6_pixel + 本機後端)——抓到三個測試層看不到的問題 + +`flutter test` 400 多支全綠、後端端到端也走通了,把 App 裝到裝置上仍然抓到三個。 +**它們有一個共同形狀:邏輯是對的,但畫面上不成立。** + +| # | 問題 | 為什麼測試層看不到 | +|---|---|---| +| 1 | **預約入口加在沒被使用的畫面上** | `app.dart` 用的是 `CustomerMapHomeScreen`(地圖版),我加在 `CustomerHomeScreen`(卡片版)。widget 測試各測各的,沒有人問「使用者實際看到的是哪一個」 | +| 2 | **登入後不載入常用地點與預約** | `init()`(冷啟動)有載,`_authenticate()` 沒有。單元測試一律 `setSessionForTest` 再手動呼叫 load,**永遠走不到登入那條路徑** | +| 3 | **車種顯示成 `pet`** | 斷言只檢查了「有沒有顯示車種」,沒檢查顯示的是不是人看得懂的字 | + +第 2 個最值得記:`_authenticate` 的註解本來就寫著「比照 `init()` 還原 session」, +卻只帶了 active 與協尋——**新增 init 載入時,要同時問「登入那條路徑呢」**。 + +**第 1 個的根因查出來了**:`CustomerHomeScreen`(卡片版)**production 完全不用**, +只有 widget 測試在用它(測共用元件比較好架設)——它是死碼,但測試讓它看起來還活著, +所以加錯地方不會有任何訊號。已在該類別頂端標註「production 用的不是這個」, +並補 `test/customer_home_entries_test.dart` 斷言**地圖版**首頁上有預約入口 +(反向驗證:把那顆 FAB 的 tooltip 改掉,該支轉紅)。 +沒有整批刪掉卡片版——那是這批範圍外的重構,而那些測試現在確實在守共用元件。 + +實跑同時確認到位的(截圖為證): +- 叫車頁快捷列四顆:住家/公司/媽媽家/健身房,**圖示依 `kind`、排序住家→公司→其他**; + 已有地點時右上角是「管理」,沒有時是「設定住家/公司」。 +- 首頁預約卡「已預約 07/31(五)02:00」+起訖點。 +- 預約頁三區:即將到來 3 筆(近的在前、帶星期)/車已在路上 2 筆/過往 2 筆; + 失敗那筆紅字顯示後端寫的 `last_error`(「已有進行中的訂單」),取消那筆灰字。 +- 「我們會在約定時間前 **15 分鐘**開始為你找車」——這個數字取自後端回應,不是寫死的。 +- **跨端閉環**:「車已在路上」裡的訂單 `#39`,正是稍早在真環境實跑轉單產生的那一筆。 + +### 假資料怎麼塞 + +``` +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 +PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \ + sh scripts/seed_demo_data.sh +``` + +塞出:示範乘客 `demo-customer-1`/`demo123456`,住家+公司+兩個自訂地點, +以及 pending/cancelled/dispatched/failed 四種狀態各至少一筆的預約。 +**主路徑刻意走真實 HTTP API 而不是直接寫 DB**——這樣塞資料的同時也端到端驗過了那些端點, +不會塞出一份「只有 SQL 造得出來、API 其實不接受」的資料。 +只有 `dispatched`/`failed` 非走 SQL 不可:它們是排程器到點後才產生的結果,沒有 API 建得出來。 + +### 這一批**沒有**做的(寫明條件) + +- **預約不支援多停靠點**:`scheduled_rides` 只存單點起訖。多停靠點要另一張 stops 快照表, + 而預約多停靠點的實際需求還沒出現——等有人真的要預約一趟多人共乘再做。 +- **預約沒有專屬推播**:轉單後走的是既有 ride 推播鏈路(乘客端第十一輪已接), + 所以「司機接單了」照樣會通知。缺的是「你的預約已轉為訂單」這則本身, + 以及「預約失敗」的通知——後者要等 `failed` 真的在生產環境發生過再決定值不值得。 +- **admin 端看不到預約**:後端表與 API 都在,admin UI 沒做。等營運說得出要對預約做什麼再開。 +- **司機端不知道自己接的是預約單**(自審時發現,本批刻意沒做):轉單後那就是一張普通訂單, + 司機看不到「約定上車時間是幾點」。提前量 15 分鐘下多半剛好,但司機若 5 分鐘就到, + 乘客可能還沒下樓,而司機不知道該等。 + **要做的話動的是 `rides`**(帶一個 `scheduled_at` 或來源標記)+司機端行程卡顯示, + 屬於功能擴充不是 bug,所以沒有夾帶進這批。 + **條件**:等實際跑過幾趟預約單、司機真的回報「到太早不知道要不要等」再做—— + 現在做等於猜一個還沒發生的問題。 + ## 下次任務 +> **🎯 2026-07-31 這一輪(預約司機+常用地點)——開工先看這段** +> +> 這輪不是 debug 輪,是**跨端新功能**:`scheduled_rides`(預約行程)與 +> `customer_saved_places`(常用地點)兩張新表、9 支新端點、一支背景排程器, +> 加上 App 端的預約頁、常用地點管理頁與叫車頁的快捷帶入。 +> 詳見上方「🗓️ 預約司機+常用地點」專章(含決策理由與三次反向驗證的結果)。 +> +> **這一輪學到的一條(已寫進專章,值得單獨記住)**: +> **綠燈的測試不等於守得住的測試。** 這輪做了三次反向驗證,其中**兩次推翻了我自己的假設**: +> 名字叫 `TestDispatcherIsIdempotent` 的那支,拔掉樂觀鎖照樣綠(它守的是循序重跑,不是併發); +> 補上的併發測試**第一版也是假的**,因為兩個 goroutine 沒有真的重疊。 +> 判準是老規矩:**把防線拔掉,測試會不會紅**——不會紅的那支,它的名字在說謊。 +> App 端的 `sameSlot` 也踩到同一件事(兩條測試都沒走到那個分支)。 +> +> **➡️ 下一輪候選**(依價值排序): +> 1. **預約的通知缺口**——轉單後走既有 ride 推播鏈路(會通知「司機接單了」), +> 但「你的預約已轉為訂單」與「預約失敗」這兩則本身沒有。 +> **條件**:等 `failed` 真的在生產環境發生過,再決定值不值得為它做一條通知。 +> 2. **admin 端看不到預約**——後端表與 API 都在,admin UI 沒做。 +> **條件**:等營運說得出要對預約做什麼(強制取消?改時間?)再開, +> 做在前面只會做出沒人用的畫面(與維護項 7 同一個判準)。 +> 3. **前景服務被系統收走**(Android 省電模式殺掉常駐通知)—— +> 這是第二十二輪盤點時就列為「本族唯一剩下的角落」, +> 但 **2026-07-30 已被 [PR #94](https://github.com/thothawei/fleet-app/pull/94) 認領** +> (`claude/round23-fgs-health`)。**下一輪開工前先確認那支的狀態**,別撞題。 +> +> **開工第一件事仍然是 `gh pr list`(三個 repo)**——這一輪開工時查到 #94 正在進行中, +> 所以刻意避開了它的題目改做新功能。這條規則第三次證明有用。 + + > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** > > 主題是「**推播這條通道從頭到尾打通**」。本 repo 合併了 6 支 PR: diff --git a/lib/core/api/customer_api_client.dart b/lib/core/api/customer_api_client.dart index 3667859..b855cf9 100644 --- a/lib/core/api/customer_api_client.dart +++ b/lib/core/api/customer_api_client.dart @@ -379,6 +379,188 @@ class CustomerApiClient { } } + // ---------- 常用地點(住家/公司/自訂)---------- + + /// 我的常用地點;後端已排好序(住家 → 公司 → 其他)。 + Future> fetchSavedPlaces() async { + try { + final res = await _dio.get>('/customer/places'); + return SavedPlace.listFrom(res.data?['places']); + } on DioException catch (e) { + throw _wrap(e); + } + } + + /// 新增常用地點。 + /// + /// **kind 為 home/work 時是覆蓋語意**:後端會更新既有那一筆而不是回錯, + /// 所以 UI 上「設定住家」可以直接送,不必先查有沒有、也不必先刪舊的。 + Future createSavedPlace({ + required String kind, + required String label, + required String address, + required double lat, + required double lng, + }) async { + try { + final res = await _dio.post>( + '/customer/places', + data: { + 'kind': kind, + 'label': label, + 'address': address, + 'lat': lat, + 'lng': lng, + }, + ); + return SavedPlace.fromJson( + Map.from(res.data!['place'] as Map), + ); + } on DioException catch (e) { + throw _wrap(e); + } + } + + /// 更新常用地點的名稱/地址/座標(kind 不可改,送了後端也會忽略)。 + Future updateSavedPlace( + int id, { + required String label, + required String address, + required double lat, + required double lng, + }) async { + try { + final res = await _dio.put>( + '/customer/places/$id', + data: { + 'label': label, + 'address': address, + 'lat': lat, + 'lng': lng, + }, + ); + return SavedPlace.fromJson( + Map.from(res.data!['place'] as Map), + ); + } on DioException catch (e) { + throw _wrap(e); + } + } + + Future deleteSavedPlace(int id) async { + try { + await _dio.delete('/customer/places/$id'); + } on DioException catch (e) { + throw _wrap(e); + } + } + + // ---------- 預約行程 ---------- + + /// 我的預約。[upcomingOnly] 只回還沒轉單的(首頁那張卡);否則含已轉單/已取消。 + /// + /// 回傳同時帶後端的提前發動分鐘數——「我們會提前幾分鐘幫你找車」這句話要跟後端 + /// 實際行為一致,寫死在 App 端的話改後端就會說謊。 + Future fetchScheduledRides({ + bool upcomingOnly = false, + }) async { + try { + final res = await _dio.get>( + '/customer/scheduled-rides', + queryParameters: upcomingOnly ? {'upcoming': '1'} : null, + ); + return ScheduledRidesResult( + rides: ScheduledRide.listFrom(res.data?['scheduled_rides']), + leadMinutes: (res.data?['lead_minutes'] as num?)?.toInt() ?? 0, + minLeadMinutes: (res.data?['min_lead_minutes'] as num?)?.toInt() ?? 0, + ); + } on DioException catch (e) { + throw _wrap(e); + } + } + + /// 查單筆預約(取消撞 409 後用來重讀現況)。 + Future fetchScheduledRide(int id) async { + try { + final res = + await _dio.get>('/customer/scheduled-rides/$id'); + return ScheduledRide.fromJson( + Map.from(res.data!['scheduled_ride'] as Map), + ); + } on DioException catch (e) { + throw _wrap(e); + } + } + + /// 建立預約。[scheduledAt] 為本地時間,送出前轉 UTC 的 RFC3339。 + Future createScheduledRide({ + required DateTime scheduledAt, + required double pickupLat, + required double pickupLng, + required String pickupAddress, + String? dropoffAddress, + double? dropoffLat, + double? dropoffLng, + String? requiredVehicleType, + String note = '', + }) async { + try { + final res = await _dio.post>( + '/customer/scheduled-rides', + data: { + 'scheduled_at': scheduledAt.toUtc().toIso8601String(), + 'pickup_lat': pickupLat, + 'pickup_lng': pickupLng, + 'pickup_address': pickupAddress, + if (dropoffAddress != null && dropoffAddress.isNotEmpty) + 'dropoff_address': dropoffAddress, + if (dropoffLat != null && dropoffLng != null) ...{ + 'dropoff_lat': dropoffLat, + 'dropoff_lng': dropoffLng, + }, + if (requiredVehicleType != null && requiredVehicleType.isNotEmpty) + 'required_vehicle_type': requiredVehicleType, + if (note.isNotEmpty) 'note': note, + }, + ); + return ScheduledRide.fromJson( + Map.from(res.data!['scheduled_ride'] as Map), + ); + } on DioException catch (e) { + throw _wrap(e); + } + } + + /// 取消預約。 + /// + /// **409 代表它已經被轉成真訂單了**(排程器搶在取消之前發動)。這種情況後端會把 + /// 該筆預約的現況一起回來——不能對乘客說「取消失敗,請稍後再試」,那張訂單已經在 + /// 派單池裡,司機可能正在開過來。回傳的 [ScheduledRide] 讓 UI 直接切成「已為你派車」 + /// 並引導去取消訂單。 + Future cancelScheduledRide(int id) async { + try { + final res = await _dio.post>( + '/customer/scheduled-rides/$id/cancel', + ); + return ScheduledRide.fromJson( + Map.from(res.data!['scheduled_ride'] as Map), + ); + } on DioException catch (e) { + if (e.response?.statusCode == 409) { + final raw = e.response?.data; + if (raw is Map && raw['scheduled_ride'] is Map) { + throw ScheduledRideConflict( + ScheduledRide.fromJson( + Map.from(raw['scheduled_ride'] as Map), + ), + apiErrorMessage(e), + ); + } + } + throw _wrap(e); + } + } + /// 401(登入/註冊以外)= session 失效:通知 controller 清掉它。 /// 詳見 `FleetApiClient._wrap`——兩端同一條規則。 ApiException _wrap(DioException e) { @@ -390,3 +572,35 @@ class CustomerApiClient { return ApiException(apiErrorMessage(e), statusCode: code); } } + +/// 預約清單查詢結果:清單本身+後端的兩個時間參數。 +class ScheduledRidesResult { + const ScheduledRidesResult({ + required this.rides, + required this.leadMinutes, + this.minLeadMinutes = 0, + }); + + final List rides; + + /// 後端會提前這麼多分鐘開始派單(constants.ScheduledRideLeadMinutes)。 + final int leadMinutes; + + /// 建立預約時,距現在至少要有的分鐘數(constants.ScheduledRideMinLeadMinutes)。 + /// **不要在 UI 寫死**——後端把門檻調高之後,寫死的 App 會讓乘客選一個註定被 400 + /// 拒絕的時間,而他要填完整張表才會知道。0 =還沒問過後端,UI 用自己的保底值。 + final int minLeadMinutes; +} + +/// 取消預約時撞上「已被轉成真訂單」(HTTP 409)。 +/// +/// 帶著後端回來的最新狀態,讓 UI 能把畫面換成「已為你派車」而不是宣稱取消失敗。 +class ScheduledRideConflict implements Exception { + ScheduledRideConflict(this.current, this.message); + + final ScheduledRide current; + final String message; + + @override + String toString() => message; +} diff --git a/lib/core/models/models.dart b/lib/core/models/models.dart index c057e40..a315aef 100644 --- a/lib/core/models/models.dart +++ b/lib/core/models/models.dart @@ -819,3 +819,188 @@ class ActiveRide { ); } } + +/// 常用地點的種類(對齊後端 constants.SavedPlaceKind*)。 +/// +/// `home`/`work` 是**語意插槽**,每位乘客各只有一筆;`custom` 才是可以有很多筆的自訂地點。 +/// 判斷「這是不是住家」一律看 [SavedPlace.kind]——`label` 是使用者可以改的顯示名稱, +/// 拿它比對字串會在乘客把住家改名成「家」的那一刻無聲失效。 +abstract final class SavedPlaceKind { + static const home = 'home'; + static const work = 'work'; + static const custom = 'custom'; +} + +/// 乘客的常用地點(住家/公司/自訂),供叫車與預約一鍵帶入起訖點。 +class SavedPlace { + const SavedPlace({ + required this.id, + required this.kind, + required this.label, + required this.address, + required this.lat, + required this.lng, + }); + + final int id; + final String kind; + final String label; + final String address; + final double lat; + final double lng; + + bool get isHome => kind == SavedPlaceKind.home; + bool get isWork => kind == SavedPlaceKind.work; + + /// 插槽類(住家/公司)不可刪只能改——UI 上那兩顆是固定位置的快捷鈕, + /// 刪掉會讓「設定住家」這件事沒有入口。 + bool get isSlot => isHome || isWork; + + factory SavedPlace.fromJson(Map json) { + final point = json['point']; + return SavedPlace( + id: (json['id'] as num).toInt(), + kind: json['kind'] as String? ?? SavedPlaceKind.custom, + label: json['label'] as String? ?? '', + address: json['address'] as String? ?? '', + lat: (point is Map ? (point['lat'] as num?)?.toDouble() : null) ?? 0, + lng: (point is Map ? (point['lng'] as num?)?.toDouble() : null) ?? 0, + ); + } + + static List listFrom(Object? raw) { + if (raw is! List) return []; + return raw + .whereType() + .map((m) => SavedPlace.fromJson(Map.from(m))) + .toList(); + } +} + +/// 預約行程的狀態(對齊後端 constants.ScheduledRideStatus*)。 +abstract final class ScheduledRideStatus { + /// 等待到點;唯一可取消的狀態。 + static const pending = 'pending'; + + /// 已轉成真訂單([ScheduledRide.rideId] 指向它)。要取消得去取消那張訂單。 + static const dispatched = 'dispatched'; + + /// 乘客已取消。 + static const cancelled = 'cancelled'; + + /// 到點後重試用盡仍建不出訂單,原因在 [ScheduledRide.lastError]。 + static const failed = 'failed'; +} + +/// 預約行程:乘客先訂好「什麼時候、從哪到哪」,後端在約定時間前轉成真訂單去派車。 +class ScheduledRide { + const ScheduledRide({ + required this.id, + required this.scheduledAt, + required this.pickupAddress, + required this.pickupLat, + required this.pickupLng, + required this.status, + this.dropoffAddress, + this.dropoffLat, + this.dropoffLng, + this.requiredVehicleType = '', + this.note = '', + this.rideId, + this.lastError = '', + }); + + final int id; + + /// 乘客希望**上車**的時間(本地時區)。後端會比它更早發動派單。 + final DateTime scheduledAt; + final String pickupAddress; + final double pickupLat; + final double pickupLng; + final String? dropoffAddress; + final double? dropoffLat; + final double? dropoffLng; + final String requiredVehicleType; + final String note; + final String status; + + /// 已轉單後指向的真訂單;其餘狀態為 null。 + final int? rideId; + + /// 轉單失敗的原因(failed 時給乘客看)。 + final String lastError; + + bool get isPending => status == ScheduledRideStatus.pending; + bool get isDispatched => status == ScheduledRideStatus.dispatched; + bool get isCancelled => status == ScheduledRideStatus.cancelled; + bool get isFailed => status == ScheduledRideStatus.failed; + + /// 只有還沒轉單的可以取消。已轉單的要取消的是**那張訂單**,不是這筆預約紀錄。 + bool get cancellable => isPending; + + /// 還會發生的預約(首頁那張卡只顯示這種)。 + bool get isUpcoming => isPending; + + String get statusLabel { + switch (status) { + case ScheduledRideStatus.pending: + return '已預約'; + case ScheduledRideStatus.dispatched: + return '已為你派車'; + case ScheduledRideStatus.cancelled: + return '已取消'; + case ScheduledRideStatus.failed: + return '預約未能成立'; + default: + return status; + } + } + + factory ScheduledRide.fromJson(Map json) { + final pickup = json['pickup_point']; + final dropoff = json['dropoff_point']; + final dropAddr = json['dropoff_address'] as String?; + return ScheduledRide( + id: (json['id'] as num).toInt(), + // 後端送 RFC3339 帶時區;一律轉本地時間,畫面上顯示的才會是乘客看得懂的鐘面時間。 + scheduledAt: + DateTime.parse(json['scheduled_at'] as String).toLocal(), + pickupAddress: json['pickup_address'] as String? ?? '', + pickupLat: (pickup is Map ? (pickup['lat'] as num?)?.toDouble() : null) ?? 0, + pickupLng: (pickup is Map ? (pickup['lng'] as num?)?.toDouble() : null) ?? 0, + dropoffAddress: + (dropAddr != null && dropAddr.isNotEmpty) ? dropAddr : null, + dropoffLat: + dropoff is Map ? (dropoff['lat'] as num?)?.toDouble() : null, + dropoffLng: + dropoff is Map ? (dropoff['lng'] as num?)?.toDouble() : null, + requiredVehicleType: json['required_vehicle_type'] as String? ?? '', + note: json['note'] as String? ?? '', + status: json['status'] as String? ?? ScheduledRideStatus.pending, + rideId: (json['ride_id'] as num?)?.toInt(), + lastError: json['last_error'] as String? ?? '', + ); + } + + /// 解析清單。**單筆解析失敗就跳過那一筆**,不讓整份清單連帶炸掉。 + /// + /// `scheduled_at` 是 non-nullable,所以 `fromJson` 用的是會丟例外的 `DateTime.parse` + /// (其他 model 的日期都是 nullable、用 `tryParse`)。而 controller 只接 `ApiException` + /// ——一筆格式異常的 `FormatException` 會直接逸出,整個預約頁掛掉。 + /// 後端目前不可能送出壞格式(欄位 NOT NULL、Go 一律序列化成 RFC3339), + /// 這是**防禦性的**:少一筆總比整頁看不到好。 + static List listFrom(Object? raw) { + if (raw is! List) return []; + final out = []; + for (final m in raw.whereType()) { + try { + out.add(ScheduledRide.fromJson(Map.from(m))); + } on FormatException { + continue; + } on TypeError { + continue; + } + } + return out; + } +} diff --git a/lib/customer/customer_controller.dart b/lib/customer/customer_controller.dart index 94b956b..4d6dfda 100644 --- a/lib/customer/customer_controller.dart +++ b/lib/customer/customer_controller.dart @@ -443,6 +443,279 @@ class CustomerController extends ChangeNotifier { } } + // ---------- 常用地點(住家/公司/自訂)---------- + + List _savedPlaces = const []; + bool _placesLoading = false; + String? _placesError; + + /// 我的常用地點;後端已排好序(住家 → 公司 → 其他)。 + List get savedPlaces => List.unmodifiable(_savedPlaces); + bool get placesLoading => _placesLoading; + String? get placesError => _placesError; + + /// 住家/公司這兩個插槽;沒設過就是 null(UI 據此顯示「設定住家」而不是空白)。 + SavedPlace? get homePlace => _placeOfKind(SavedPlaceKind.home); + SavedPlace? get workPlace => _placeOfKind(SavedPlaceKind.work); + + SavedPlace? _placeOfKind(String kind) { + for (final p in _savedPlaces) { + if (p.kind == kind) return p; + } + return null; + } + + /// 載入常用地點。 + /// + /// [silent] 供背景刷新用:叫車頁那排快捷鈕是輔助功能,載不出來就不顯示即可, + /// 不該讓乘客因為它失敗而看到錯誤橫幅、以為叫不了車。 + Future loadSavedPlaces({bool silent = false}) async { + if (_session == null) return; + if (!silent) { + _placesLoading = true; + _placesError = null; + notifyListeners(); + } + try { + _savedPlaces = await _api.fetchSavedPlaces(); + _placesError = null; + } on ApiException catch (e) { + if (!silent) _placesError = e.message; + } finally { + _placesLoading = false; + notifyListeners(); + } + } + + /// 新增/設定常用地點;成功回 null,失敗回訊息給表單顯示。 + /// + /// kind 為 home/work 時後端是覆蓋語意,所以「設定住家」可以直接送。 + Future savePlace({ + required String kind, + required String label, + required String address, + required double lat, + required double lng, + }) async { + if (_session == null) return '請先登入'; + try { + final saved = await _api.createSavedPlace( + kind: kind, + label: label, + address: address, + lat: lat, + lng: lng, + ); + _mergeSavedPlace(saved); + notifyListeners(); + return null; + } on ApiException catch (e) { + return e.message; + } + } + + /// 更新既有地點(kind 不變)。 + Future updateSavedPlace( + int id, { + required String label, + required String address, + required double lat, + required double lng, + }) async { + if (_session == null) return '請先登入'; + try { + final saved = await _api.updateSavedPlace( + id, + label: label, + address: address, + lat: lat, + lng: lng, + ); + _mergeSavedPlace(saved); + notifyListeners(); + return null; + } on ApiException catch (e) { + return e.message; + } + } + + /// 刪除常用地點。 + Future deleteSavedPlace(int id) async { + if (_session == null) return '請先登入'; + try { + await _api.deleteSavedPlace(id); + _savedPlaces = [ + for (final p in _savedPlaces) + if (p.id != id) p, + ]; + notifyListeners(); + return null; + } on ApiException catch (e) { + return e.message; + } + } + + /// 把一筆新鮮的地點合併進清單(同 id 覆蓋,否則插入)。 + /// + /// **插槽類要另外比對 kind**:住家從無到有時後端給的是新 id,但語意上是取代 + /// 原本那個空插槽;只比 id 的話清單會同時出現兩筆 kind=home。 + void _mergeSavedPlace(SavedPlace saved) { + final next = []; + var replaced = false; + for (final p in _savedPlaces) { + final sameRow = p.id == saved.id; + final sameSlot = saved.isSlot && p.kind == saved.kind; + if (sameRow || sameSlot) { + if (!replaced) { + next.add(saved); + replaced = true; + } + continue; + } + next.add(p); + } + if (!replaced) next.add(saved); + // 維持後端的排序約定:住家 → 公司 → 其他。 + next.sort((a, b) => _placeRank(a).compareTo(_placeRank(b))); + _savedPlaces = next; + } + + static int _placeRank(SavedPlace p) { + if (p.isHome) return 0; + if (p.isWork) return 1; + return 2; + } + + // ---------- 預約行程 ---------- + + List _scheduledRides = const []; + bool _schedulesLoading = false; + String? _schedulesError; + int _scheduleLeadMinutes = 0; + int _scheduleMinLeadMinutes = 0; + + /// 我的預約(近的在前)。 + List get scheduledRides => List.unmodifiable(_scheduledRides); + bool get schedulesLoading => _schedulesLoading; + String? get schedulesError => _schedulesError; + + /// 後端會提前這麼多分鐘開始派單;0 =還沒問過後端。 + /// **不要在 UI 寫死這個數字**——後端改了 App 就會說謊。 + int get scheduleLeadMinutes => _scheduleLeadMinutes; + + /// 建立預約時距現在至少要有的分鐘數;還沒問過後端時退回保底值。 + /// + /// 退路是「App 自己的保底值」而不是 0:0 會讓時間選擇器完全不擋, + /// 乘客選了 3 分鐘後,填完整張表才被後端以 400 拒絕。 + int get scheduleMinLeadMinutes => + _scheduleMinLeadMinutes > 0 ? _scheduleMinLeadMinutes : fallbackMinLeadMinutes; + + /// 問不到後端時用的保底門檻(與後端當前的 ScheduledRideMinLeadMinutes 一致)。 + static const fallbackMinLeadMinutes = 20; + + /// 還沒轉單的預約,供首頁那張「即將到來」的卡。 + List get upcomingSchedules => + List.unmodifiable(_scheduledRides.where((s) => s.isUpcoming)); + + /// 載入預約清單。[silent] 同 [loadSavedPlaces]。 + Future loadScheduledRides({bool silent = false}) async { + if (_session == null) return; + if (!silent) { + _schedulesLoading = true; + _schedulesError = null; + notifyListeners(); + } + try { + final res = await _api.fetchScheduledRides(); + _scheduledRides = res.rides; + if (res.leadMinutes > 0) _scheduleLeadMinutes = res.leadMinutes; + if (res.minLeadMinutes > 0) _scheduleMinLeadMinutes = res.minLeadMinutes; + _schedulesError = null; + } on ApiException catch (e) { + if (!silent) _schedulesError = e.message; + } finally { + _schedulesLoading = false; + notifyListeners(); + } + } + + /// 建立預約;成功回 null,失敗回訊息。 + Future createScheduledRide({ + required DateTime scheduledAt, + required double pickupLat, + required double pickupLng, + required String pickupAddress, + String? dropoffAddress, + double? dropoffLat, + double? dropoffLng, + String? requiredVehicleType, + String note = '', + }) async { + if (_session == null) return '請先登入'; + try { + final created = await _api.createScheduledRide( + scheduledAt: scheduledAt, + pickupLat: pickupLat, + pickupLng: pickupLng, + pickupAddress: pickupAddress, + dropoffAddress: dropoffAddress, + dropoffLat: dropoffLat, + dropoffLng: dropoffLng, + requiredVehicleType: requiredVehicleType, + note: note, + ); + _mergeScheduledRide(created); + notifyListeners(); + return null; + } on ApiException catch (e) { + return e.message; + } + } + + /// 取消預約;成功回 null,失敗回訊息。 + /// + /// **撞上「已被轉成真訂單」時不算失敗的一種**:後端會把該筆預約的現況一起回來, + /// 這裡直接把它併進清單,畫面立刻變成「已為你派車」。若只丟一句「取消失敗,請稍後再試」, + /// 乘客會一直按取消,而那張訂單照樣派出去、司機照樣開過來 + /// (同一個病在第二十輪的 admin 端抓過一次)。 + Future cancelScheduledRide(int id) async { + if (_session == null) return '請先登入'; + try { + final cancelled = await _api.cancelScheduledRide(id); + _mergeScheduledRide(cancelled); + notifyListeners(); + return null; + } on ScheduledRideConflict catch (e) { + _mergeScheduledRide(e.current); + notifyListeners(); + return '這筆預約已經為你派車了,要取消請到行程頁取消該趟訂單。'; + } on ApiException catch (e) { + // 逾時/連線類失敗:不宣稱取消成功,但重讀一次對帳—— + // 後端可能其實已經取消了,只是回應在路上掉了。 + await loadScheduledRides(silent: true); + for (final s in _scheduledRides) { + if (s.id == id && !s.isPending) return null; + } + return e.message; + } + } + + void _mergeScheduledRide(ScheduledRide row) { + final next = []; + var replaced = false; + for (final s in _scheduledRides) { + if (s.id == row.id) { + next.add(row); + replaced = true; + continue; + } + next.add(s); + } + if (!replaced) next.add(row); + next.sort((a, b) => a.scheduledAt.compareTo(b.scheduledAt)); + _scheduledRides = next; + } + /// 聊天室開啟/關閉;開啟時清未讀並停止累計。 void setChatVisible(bool visible) { _chatVisible = visible; @@ -465,6 +738,10 @@ class CustomerController extends ChangeNotifier { await _applySession(saved); await refreshActive(); await refreshLostItems(); + // 常用地點與預約都走 silent:它們是輔助功能,載不出來就先不顯示, + // 不該讓叫車主流程因為它們失敗而冒錯誤橫幅。 + await loadSavedPlaces(silent: true); + await loadScheduledRides(silent: true); } await _bindPushListener(); } @@ -585,6 +862,12 @@ class CustomerController extends ChangeNotifier { await refreshActive(); // 登入即帶出「進行中協尋」banner,不用等下拉刷新(比照 init() 還原 session)。 await refreshLostItems(); + // 常用地點與預約同理。**漏掉這兩行的症狀是模擬器實跑才看得到的**: + // 剛登入完,叫車頁的快捷列是空的、首頁也沒有自己的預約, + // 要等下次冷啟動走 init() 才會出現——單元測試都用 setSessionForTest + // 再手動呼叫 load,永遠走不到登入這條路徑。 + await loadSavedPlaces(silent: true); + await loadScheduledRides(silent: true); } on ApiException catch (e) { _error = e.message; } finally { @@ -666,6 +949,15 @@ class CustomerController extends ChangeNotifier { // 就會在自己的資料載入前先看到前一位乘客的行程與車資。 _rideHistory = []; _historyError = null; + // 常用地點與預約同一個道理,而且更敏感——住家與公司是**實體位置**, + // 預約則是「這個人什麼時候會不在家」。不清的話,下一位在這台裝置登入的人 + // 一打開叫車頁,快捷列上就是上一位乘客的住家地址。 + _savedPlaces = const []; + _placesError = null; + _scheduledRides = const []; + _schedulesError = null; + _scheduleLeadMinutes = 0; + _scheduleMinLeadMinutes = 0; _completedRatingScore = null; _completedRatingRideId = null; _fcmToken = null; diff --git a/lib/customer/screens/customer_home_screen.dart b/lib/customer/screens/customer_home_screen.dart index cfedb99..6951ac6 100644 --- a/lib/customer/screens/customer_home_screen.dart +++ b/lib/customer/screens/customer_home_screen.dart @@ -6,7 +6,16 @@ import '../customer_controller.dart'; import '../widgets/lost_item_banner.dart'; import '../widgets/customer_tracking_map.dart'; import '../widgets/ride_phase_content.dart'; - +import 'scheduled_rides_screen.dart'; + +/// 卡片版乘客首頁。 +/// +/// ⚠️ **production 用的不是這個**——`app.dart` 掛的是 `CustomerMapHomeScreen`(地圖為底)。 +/// 這支目前只有 widget 測試在用(測 `OrderFormContent` 等共用元件比較好架設)。 +/// +/// **加任何入口/橫切呈現之前,先確認要加在哪一個**:只加在這裡的話, +/// 功能寫完、測試全綠,使用者的畫面上卻什麼都沒有——預約司機那批就是這樣踩的。 +/// 兩份都加是刻意的(讓兩個畫面保持一致),但**地圖版才是必須的那一份**。 class CustomerHomeScreen extends StatefulWidget { const CustomerHomeScreen({super.key}); @@ -26,6 +35,13 @@ class _CustomerHomeScreenState extends State { appBar: AppBar( title: Text('你好,${ctrl.session?.name ?? '乘客'}'), actions: [ + IconButton( + tooltip: '預約司機', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ScheduledRidesScreen()), + ), + icon: const Icon(Icons.event), + ), IconButton( tooltip: '登出', onPressed: ctrl.loading ? null : () => ctrl.logout(), @@ -37,6 +53,7 @@ class _CustomerHomeScreenState extends State { onRefresh: () async { await ctrl.refreshActive(); await ctrl.refreshLostItems(); + await ctrl.loadScheduledRides(silent: true); }, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -53,6 +70,12 @@ class _CustomerHomeScreenState extends State { child: OrderFormContent(ctrl: ctrl), ), ), + // 即將到來的預約。**只顯示最近一筆**——首頁是「現在要做什麼」的地方, + // 把整份預約清單攤在這裡會把叫車表單推到看不見的位置。 + if (ctrl.upcomingSchedules.isNotEmpty) ...[ + const SizedBox(height: 12), + _UpcomingScheduleCard(schedule: ctrl.upcomingSchedules.first), + ], // 進行中的遺失物協尋(WS 即時更新狀態) for (final item in ctrl.lostItems) ...[ const SizedBox(height: 12), @@ -172,3 +195,33 @@ class _ActiveRideCard extends StatelessWidget { ); } } + +/// 首頁的「即將到來的預約」卡:只提醒下一趟是什麼時候,詳細操作在預約頁。 +class _UpcomingScheduleCard extends StatelessWidget { + const _UpcomingScheduleCard({required this.schedule}); + + final ScheduledRide schedule; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: ListTile( + leading: const Icon(Icons.event), + title: Text('已預約 ${formatScheduleTime(schedule.scheduledAt)}'), + subtitle: Text( + schedule.dropoffAddress == null + ? schedule.pickupAddress + : '${schedule.pickupAddress} → ${schedule.dropoffAddress}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + trailing: const Icon(Icons.chevron_right), + 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 7c6b95d..f1a46d0 100644 --- a/lib/customer/screens/customer_map_home_screen.dart +++ b/lib/customer/screens/customer_map_home_screen.dart @@ -10,6 +10,7 @@ import '../customer_controller.dart'; import '../widgets/lost_item_banner.dart'; import '../widgets/ride_phase_content.dart'; import 'ride_history_screen.dart'; +import 'scheduled_rides_screen.dart'; /// 地圖為底+Bottom Sheet 主畫面(spec §2.1)。 /// 圖磚走 OpenStreetMap(flutter_map),不需任何 API key。 @@ -67,6 +68,17 @@ class _CustomerMapHomeScreenState extends State { child: const Icon(Icons.receipt_long), ), const SizedBox(height: 12), + FloatingActionButton.small( + heroTag: 'schedules', + tooltip: '預約司機', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ScheduledRidesScreen(), + ), + ), + child: const Icon(Icons.event), + ), + const SizedBox(height: 12), FloatingActionButton.small( heroTag: 'logout', tooltip: '登出', @@ -112,6 +124,14 @@ class _CustomerMapHomeScreenState extends State { LostItemBanner(item: item), const SizedBox(height: 12), ], + // 即將到來的預約(只顯示最近一筆)。放在協尋之後、階段內容之前: + // 它是「等一下會發生的事」,比正在編輯的叫車表單優先度低, + // 但乘客一拉開 sheet 就該看得到。 + if (ctrl.upcomingSchedules.isNotEmpty) ...[ + _UpcomingScheduleTile( + schedule: ctrl.upcomingSchedules.first), + const SizedBox(height: 12), + ], _sheetContent(ctrl), ], ), @@ -286,3 +306,37 @@ class _CustomerMapHomeScreenState extends State { super.dispose(); } } + + +/// Bottom Sheet 裡的「即將到來的預約」列。 +/// +/// 只提醒下一趟是什麼時候,詳細操作在預約頁——sheet 的空間要留給正在進行的事。 +class _UpcomingScheduleTile extends StatelessWidget { + const _UpcomingScheduleTile({required this.schedule}); + + final ScheduledRide schedule; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: ListTile( + leading: const Icon(Icons.event), + title: Text('已預約 ${formatScheduleTime(schedule.scheduledAt)}'), + subtitle: Text( + schedule.dropoffAddress == null + ? schedule.pickupAddress + : '${schedule.pickupAddress} → ${schedule.dropoffAddress}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ScheduledRidesScreen()), + ), + ), + ); + } +} diff --git a/lib/customer/screens/saved_places_screen.dart b/lib/customer/screens/saved_places_screen.dart new file mode 100644 index 0000000..700fa22 --- /dev/null +++ b/lib/customer/screens/saved_places_screen.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../core/models/models.dart'; +import '../customer_controller.dart'; +import '../widgets/saved_places_bar.dart' show savedPlaceIcon; +import 'map_picker_screen.dart'; + +/// 常用地點管理:設定住家/公司,以及自訂地點的新增、改名、刪除。 +class SavedPlacesScreen extends StatefulWidget { + const SavedPlacesScreen({super.key}); + + @override + State createState() => _SavedPlacesScreenState(); +} + +class _SavedPlacesScreenState extends State { + @override + void initState() { + super.initState(); + // 進畫面才載入(不是 App 啟動就載):這是輔助功能,不該在冷啟動時多打一支 API。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().loadSavedPlaces(); + }); + } + + @override + Widget build(BuildContext context) { + final ctrl = context.watch(); + final custom = [ + for (final p in ctrl.savedPlaces) + if (!p.isSlot) p, + ]; + + return Scaffold( + appBar: AppBar(title: const Text('常用地點')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _edit(context, kind: SavedPlaceKind.custom), + icon: const Icon(Icons.add_location_alt), + label: const Text('新增地點'), + ), + body: RefreshIndicator( + onRefresh: () => ctrl.loadSavedPlaces(), + child: ctrl.placesLoading && ctrl.savedPlaces.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + if (ctrl.placesError != null) ...[ + _ErrorBanner( + message: ctrl.placesError!, + onRetry: () => ctrl.loadSavedPlaces(), + ), + const SizedBox(height: 12), + ], + Text( + '設定好住家與公司,叫車和預約時就能一鍵帶入,不必每次重打地址。', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + _SlotTile( + kind: SavedPlaceKind.home, + icon: Icons.home, + title: '住家', + place: ctrl.homePlace, + onEdit: () => _edit( + context, + kind: SavedPlaceKind.home, + existing: ctrl.homePlace, + ), + ), + const SizedBox(height: 8), + _SlotTile( + kind: SavedPlaceKind.work, + icon: Icons.work, + title: '公司', + place: ctrl.workPlace, + onEdit: () => _edit( + context, + kind: SavedPlaceKind.work, + existing: ctrl.workPlace, + ), + ), + const SizedBox(height: 24), + Text('其他地點', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (custom.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('還沒有其他常用地點。'), + ) + else + for (final p in custom) + Card( + child: ListTile( + leading: Icon(savedPlaceIcon(p)), + title: Text(p.label), + subtitle: Text(p.address), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: '編輯', + icon: const Icon(Icons.edit), + onPressed: () => + _edit(context, kind: p.kind, existing: p), + ), + IconButton( + tooltip: '刪除', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(context, p), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Future _edit( + BuildContext context, { + required String kind, + SavedPlace? existing, + }) async { + final ctrl = context.read(); + final messenger = ScaffoldMessenger.of(context); + final result = await showModalBottomSheet<_PlaceDraft>( + context: context, + isScrollControlled: true, + builder: (_) => _PlaceEditorSheet(kind: kind, existing: existing), + ); + if (result == null) return; + + final error = existing == null + ? await ctrl.savePlace( + kind: kind, + label: result.label, + address: result.address, + lat: result.lat, + lng: result.lng, + ) + : await ctrl.updateSavedPlace( + existing.id, + label: result.label, + address: result.address, + lat: result.lat, + lng: result.lng, + ); + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(error ?? '已儲存「${result.label}」')), + ); + } + + Future _confirmDelete(BuildContext context, SavedPlace place) async { + final ctrl = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ok = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('刪除常用地點'), + content: Text('要刪除「${place.label}」嗎?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogCtx).pop(true), + child: const Text('刪除'), + ), + ], + ), + ); + if (ok != true) return; + final error = await ctrl.deleteSavedPlace(place.id); + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(error ?? '已刪除「${place.label}」')), + ); + } +} + +/// 住家/公司這種「每人一筆」的插槽列。 +/// +/// 沒設過時顯示「設定」而不是空白列——空白列看起來像壞掉,而這兩格是這個畫面的主角。 +class _SlotTile extends StatelessWidget { + const _SlotTile({ + required this.kind, + required this.icon, + required this.title, + required this.place, + required this.onEdit, + }); + + final String kind; + final IconData icon; + final String title; + final SavedPlace? place; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final set = place != null; + return Card( + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(set ? place!.address : '尚未設定'), + trailing: TextButton( + onPressed: onEdit, + child: Text(set ? '變更' : '設定'), + ), + onTap: onEdit, + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + TextButton(onPressed: onRetry, child: const Text('重試')), + ], + ), + ); + } +} + +/// 編輯表單的回傳值。 +class _PlaceDraft { + const _PlaceDraft({ + required this.label, + required this.address, + required this.lat, + required this.lng, + }); + + final String label; + final String address; + final double lat; + final double lng; +} + +/// 地點編輯表單。 +/// +/// **座標一律來自地圖選點**,沒有讓使用者手打經緯度的欄位——手打的座標對不上地址時, +/// 司機會被導到另一個地方,而那是最難查的一種錯。 +class _PlaceEditorSheet extends StatefulWidget { + const _PlaceEditorSheet({required this.kind, this.existing}); + + final String kind; + final SavedPlace? existing; + + @override + State<_PlaceEditorSheet> createState() => _PlaceEditorSheetState(); +} + +class _PlaceEditorSheetState extends State<_PlaceEditorSheet> { + late final TextEditingController _label = + TextEditingController(text: widget.existing?.label ?? _defaultLabel()); + late final TextEditingController _address = + TextEditingController(text: widget.existing?.address ?? ''); + late double? _lat = widget.existing?.lat; + late double? _lng = widget.existing?.lng; + + String _defaultLabel() { + switch (widget.kind) { + case SavedPlaceKind.home: + return '住家'; + case SavedPlaceKind.work: + return '公司'; + default: + return ''; + } + } + + @override + void dispose() { + _label.dispose(); + _address.dispose(); + super.dispose(); + } + + bool get _ready => + _label.text.trim().isNotEmpty && + _address.text.trim().isNotEmpty && + _lat != null && + _lng != null; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.existing == null ? '新增常用地點' : '編輯常用地點', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _label, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: '名稱', + hintText: '例如:健身房、媽媽家', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _address, + readOnly: true, + onTap: _pickOnMap, + decoration: InputDecoration( + labelText: '地址', + hintText: '點這裡在地圖上選位置', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.place), + suffixIcon: IconButton( + icon: const Icon(Icons.map), + onPressed: _pickOnMap, + ), + ), + ), + const SizedBox(height: 8), + if (_lat == null) + Text( + '請先在地圖上選一個位置——座標要跟地址對得上,司機才不會被導到別的地方。', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _ready + ? () => Navigator.of(context).pop( + _PlaceDraft( + label: _label.text.trim(), + address: _address.text.trim(), + lat: _lat!, + lng: _lng!, + ), + ) + : null, + child: const Text('儲存'), + ), + ], + ), + ); + } + + Future _pickOnMap() async { + final picked = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MapPickerScreen( + initial: _lat != null && _lng != null ? LatLng(_lat!, _lng!) : null, + ), + ), + ); + if (picked == null || picked.address.isEmpty) return; + setState(() { + _address.text = picked.address; + _lat = picked.lat; + _lng = picked.lng; + }); + } +} diff --git a/lib/customer/screens/scheduled_rides_screen.dart b/lib/customer/screens/scheduled_rides_screen.dart new file mode 100644 index 0000000..6a116f2 --- /dev/null +++ b/lib/customer/screens/scheduled_rides_screen.dart @@ -0,0 +1,546 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../core/models/models.dart'; +import '../customer_controller.dart'; +import '../widgets/saved_places_bar.dart'; +import 'map_picker_screen.dart'; + +/// 預約司機:我的預約清單+建立新預約。 +class ScheduledRidesScreen extends StatefulWidget { + const ScheduledRidesScreen({super.key}); + + @override + State createState() => _ScheduledRidesScreenState(); +} + +class _ScheduledRidesScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final ctrl = context.read(); + ctrl.loadScheduledRides(); + // 常用地點是建立預約時的快捷來源,順手載一份(失敗就沒有快捷鈕,不擋預約)。 + ctrl.loadSavedPlaces(silent: true); + }); + } + + @override + Widget build(BuildContext context) { + final ctrl = context.watch(); + final upcoming = ctrl.upcomingSchedules; + // 已轉單的**不算過往**——車正在來的路上。把它跟已取消/未成立混在同一區, + // 乘客會以為那筆預約已經結束了。 + final dispatched = [ + for (final s in ctrl.scheduledRides) + if (s.isDispatched) s, + ]; + final past = [ + for (final s in ctrl.scheduledRides) + if (!s.isUpcoming && !s.isDispatched) s, + ]; + + return Scaffold( + appBar: AppBar(title: const Text('預約司機')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _createSchedule(context), + icon: const Icon(Icons.event), + label: const Text('新增預約'), + ), + body: RefreshIndicator( + onRefresh: () => ctrl.loadScheduledRides(), + child: ctrl.schedulesLoading && ctrl.scheduledRides.isEmpty + ? const Center(child: CircularProgressIndicator()) + : ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + if (ctrl.schedulesError != null) ...[ + _ScheduleErrorBanner( + message: ctrl.schedulesError!, + onRetry: () => ctrl.loadScheduledRides(), + ), + const SizedBox(height: 12), + ], + if (ctrl.scheduleLeadMinutes > 0) + Text( + '我們會在約定時間前 ${ctrl.scheduleLeadMinutes} 分鐘開始為你找車。', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + if (upcoming.isEmpty && dispatched.isEmpty && past.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: Text('還沒有任何預約。')), + ), + if (upcoming.isNotEmpty) ...[ + Text('即將到來', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final s in upcoming) + ScheduledRideCard( + schedule: s, + onCancel: () => _cancel(context, s), + ), + const SizedBox(height: 24), + ], + if (dispatched.isNotEmpty) ...[ + Text('車已在路上', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final s in dispatched) ScheduledRideCard(schedule: s), + const SizedBox(height: 24), + ], + if (past.isNotEmpty) ...[ + Text('過往預約', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final s in past) ScheduledRideCard(schedule: s), + ], + ], + ), + ), + ); + } + + Future _cancel(BuildContext context, ScheduledRide s) async { + final ctrl = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ok = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('取消預約'), + content: Text('要取消 ${formatScheduleTime(s.scheduledAt)} 的預約嗎?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(false), + child: const Text('先不要'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogCtx).pop(true), + child: const Text('取消預約'), + ), + ], + ), + ); + if (ok != true) return; + final error = await ctrl.cancelScheduledRide(s.id); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(error ?? '預約已取消'))); + } + + Future _createSchedule(BuildContext context) async { + final ctrl = context.read(); + final messenger = ScaffoldMessenger.of(context); + final draft = await Navigator.of(context).push<_ScheduleDraft>( + MaterialPageRoute(builder: (_) => const _ScheduleEditorScreen()), + ); + if (draft == null) return; + final error = await ctrl.createScheduledRide( + scheduledAt: draft.at, + pickupLat: draft.pickupLat, + pickupLng: draft.pickupLng, + pickupAddress: draft.pickupAddress, + dropoffAddress: draft.dropoffAddress, + dropoffLat: draft.dropoffLat, + dropoffLng: draft.dropoffLng, + note: draft.note, + ); + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(error ?? '已預約 ${formatScheduleTime(draft.at)}'), + ), + ); + } +} + +/// 單筆預約卡。可取消時才給取消鈕——已轉單的要取消的是那張訂單,不是這筆紀錄。 +class ScheduledRideCard extends StatelessWidget { + const ScheduledRideCard({required this.schedule, this.onCancel, super.key}); + + final ScheduledRide schedule; + final VoidCallback? onCancel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(_statusIcon, size: 18, color: _statusColor(context)), + const SizedBox(width: 6), + Expanded( + child: Text( + formatScheduleTime(schedule.scheduledAt), + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Text( + schedule.statusLabel, + style: theme.textTheme.labelLarge + ?.copyWith(color: _statusColor(context)), + ), + ], + ), + const SizedBox(height: 10), + _Line(icon: Icons.my_location, text: schedule.pickupAddress), + if (schedule.dropoffAddress != null) ...[ + const SizedBox(height: 4), + _Line(icon: Icons.place, text: schedule.dropoffAddress!), + ], + // 車種顯示名由前端對應(見 VehicleType 的說明:後端一律只送 code)。 + // 未知 code 整行不顯示——顯示原始 code 對乘客沒有意義, + // 顯示「—」也只是把空洞畫出來。 + if (VehicleType.fromCode(schedule.requiredVehicleType) != null) ...[ + const SizedBox(height: 4), + _Line( + icon: Icons.directions_car, + text: '指定車種:' + '${VehicleType.fromCode(schedule.requiredVehicleType)!.label}', + ), + ], + if (schedule.note.isNotEmpty) ...[ + const SizedBox(height: 4), + _Line(icon: Icons.sticky_note_2, text: schedule.note), + ], + // 已轉單:告訴乘客車已經在找了,以及要取消得去哪裡取消。 + if (schedule.isDispatched) ...[ + const SizedBox(height: 8), + Text( + '已為你建立訂單${schedule.rideId != null ? ' #${schedule.rideId}' : ''},' + '要取消請到首頁取消該趟行程。', + style: theme.textTheme.bodySmall, + ), + ], + // 失敗:把後端記下的原因如實給乘客,不要只寫「預約失敗」。 + if (schedule.isFailed && schedule.lastError.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + '原因:${schedule.lastError}', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error), + ), + ], + if (onCancel != null && schedule.cancellable) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.event_busy), + label: const Text('取消預約'), + ), + ], + ], + ), + ), + ); + } + + IconData get _statusIcon { + if (schedule.isDispatched) return Icons.local_taxi; + if (schedule.isCancelled) return Icons.event_busy; + if (schedule.isFailed) return Icons.error_outline; + return Icons.schedule; + } + + Color _statusColor(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (schedule.isFailed) return scheme.error; + if (schedule.isDispatched) return scheme.primary; + if (schedule.isCancelled) return scheme.outline; + return scheme.secondary; + } +} + +class _Line extends StatelessWidget { + const _Line({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: Theme.of(context).colorScheme.outline), + const SizedBox(width: 6), + Expanded(child: Text(text)), + ], + ); + } +} + +class _ScheduleErrorBanner extends StatelessWidget { + const _ScheduleErrorBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text(message, + style: TextStyle(color: scheme.onErrorContainer)), + ), + TextButton(onPressed: onRetry, child: const Text('重試')), + ], + ), + ); + } +} + +/// 預約表單的回傳值。 +class _ScheduleDraft { + const _ScheduleDraft({ + required this.at, + required this.pickupAddress, + required this.pickupLat, + required this.pickupLng, + this.dropoffAddress, + this.dropoffLat, + this.dropoffLng, + this.note = '', + }); + + final DateTime at; + final String pickupAddress; + final double pickupLat; + final double pickupLng; + final String? dropoffAddress; + final double? dropoffLat; + final double? dropoffLng; + final String note; +} + +/// 建立預約的表單:時間 → 上車點 → 目的地 → 備註。 +class _ScheduleEditorScreen extends StatefulWidget { + const _ScheduleEditorScreen(); + + @override + State<_ScheduleEditorScreen> createState() => _ScheduleEditorScreenState(); +} + +class _ScheduleEditorScreenState extends State<_ScheduleEditorScreen> { + DateTime? _at; + String? _pickupAddress; + double? _pickupLat; + double? _pickupLng; + String? _dropoffAddress; + double? _dropoffLat; + double? _dropoffLng; + final _note = TextEditingController(); + + @override + void dispose() { + _note.dispose(); + super.dispose(); + } + + bool get _ready => + _at != null && + _pickupLat != null && + _pickupLng != null && + (_pickupAddress ?? '').isNotEmpty; + + @override + Widget build(BuildContext context) { + final ctrl = context.watch(); + return Scaffold( + appBar: AppBar(title: const Text('新增預約')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('什麼時候用車?', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.schedule), + title: Text(_at == null ? '選擇時間' : formatScheduleTime(_at!)), + subtitle: Text('最早可預約 ${ctrl.scheduleMinLeadMinutes} 分鐘後'), + trailing: const Icon(Icons.chevron_right), + onTap: _pickDateTime, + ), + ), + const SizedBox(height: 20), + if (ctrl.savedPlaces.isNotEmpty) ...[ + SavedPlacesBar( + ctrl: ctrl, + title: '從常用地點帶入上車點', + onPick: (p) => setState(() { + _pickupAddress = p.address; + _pickupLat = p.lat; + _pickupLng = p.lng; + }), + ), + const SizedBox(height: 12), + ], + Text('從哪裡上車?', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.my_location), + title: Text(_pickupAddress ?? '在地圖上選上車點'), + trailing: const Icon(Icons.map), + onTap: () => _pickPoint(isPickup: true), + ), + ), + const SizedBox(height: 20), + if (ctrl.savedPlaces.isNotEmpty) ...[ + SavedPlacesBar( + ctrl: ctrl, + title: '從常用地點帶入目的地', + onPick: (p) => setState(() { + _dropoffAddress = p.address; + _dropoffLat = p.lat; + _dropoffLng = p.lng; + }), + ), + const SizedBox(height: 12), + ], + Text('要去哪裡?(選填)', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.place), + title: Text(_dropoffAddress ?? '在地圖上選目的地'), + trailing: const Icon(Icons.map), + onTap: () => _pickPoint(isPickup: false), + ), + ), + const SizedBox(height: 20), + TextField( + controller: _note, + maxLength: 200, + decoration: const InputDecoration( + labelText: '給司機的備註(選填)', + hintText: '例如:有一件大行李', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _ready ? _submit : null, + icon: const Icon(Icons.event_available), + label: const Text('建立預約'), + ), + if (!_ready) ...[ + const SizedBox(height: 8), + Text( + '請先選好時間與上車點。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ); + } + + void _submit() { + Navigator.of(context).pop( + _ScheduleDraft( + at: _at!, + pickupAddress: _pickupAddress!, + pickupLat: _pickupLat!, + pickupLng: _pickupLng!, + dropoffAddress: _dropoffAddress, + dropoffLat: _dropoffLat, + dropoffLng: _dropoffLng, + note: _note.text.trim(), + ), + ); + } + + Future _pickDateTime() async { + // 門檻來自後端(見 CustomerController.scheduleMinLeadMinutes)—— + // 在 App 端也擋一次,是為了讓乘客**在按下送出之前**就知道時間太近, + // 而不是填完整張表才收到 400。後端那道仍然是權威。 + final minLead = context.read().scheduleMinLeadMinutes; + final now = DateTime.now(); + final earliest = now.add(Duration(minutes: minLead)); + final messenger = ScaffoldMessenger.of(context); + final date = await showDatePicker( + context: context, + initialDate: earliest, + firstDate: DateTime(now.year, now.month, now.day), + lastDate: now.add(const Duration(days: 30)), + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(earliest), + ); + if (time == null || !mounted) return; + final at = + DateTime(date.year, date.month, date.day, time.hour, time.minute); + if (at.isBefore(earliest)) { + messenger.showSnackBar( + SnackBar( + content: Text( + '預約時間至少要在 $minLead 分鐘後。想馬上用車請直接叫車。', + ), + ), + ); + return; + } + setState(() => _at = at); + } + + Future _pickPoint({required bool isPickup}) async { + final ctrl = context.read(); + final pos = ctrl.lastPosition; + final current = isPickup + ? (_pickupLat != null ? LatLng(_pickupLat!, _pickupLng!) : null) + : (_dropoffLat != null ? LatLng(_dropoffLat!, _dropoffLng!) : null); + final picked = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MapPickerScreen( + initial: current ?? + (pos != null ? LatLng(pos.latitude, pos.longitude) : null), + ), + ), + ); + if (picked == null || picked.address.isEmpty) return; + setState(() { + if (isPickup) { + _pickupAddress = picked.address; + _pickupLat = picked.lat; + _pickupLng = picked.lng; + } else { + _dropoffAddress = picked.address; + _dropoffLat = picked.lat; + _dropoffLng = picked.lng; + } + }); + } +} + +/// 預約時間的顯示格式(本地時間)。 +/// +/// 刻意帶星期:「8/3 09:00」看不出是星期幾,而預約多半是「下週一早上」這種想法。 +String formatScheduleTime(DateTime at) { + const weekdays = ['一', '二', '三', '四', '五', '六', '日']; + final w = weekdays[(at.weekday - 1).clamp(0, 6)]; + final mm = at.month.toString().padLeft(2, '0'); + final dd = at.day.toString().padLeft(2, '0'); + final hh = at.hour.toString().padLeft(2, '0'); + final min = at.minute.toString().padLeft(2, '0'); + return '$mm/$dd($w)$hh:$min'; +} diff --git a/lib/customer/widgets/ride_phase_content.dart b/lib/customer/widgets/ride_phase_content.dart index cc76127..1932ca9 100644 --- a/lib/customer/widgets/ride_phase_content.dart +++ b/lib/customer/widgets/ride_phase_content.dart @@ -10,6 +10,7 @@ import '../screens/lost_item_screen.dart'; import '../screens/map_picker_screen.dart'; import 'rating_sheet.dart'; import 'ride_stops_progress.dart'; +import 'saved_places_bar.dart'; import 'stops_editor.dart'; /// 開啟與司機的即時聊天室(行程中與遺失物協尋共用同一條對話)。 @@ -371,6 +372,14 @@ class _OrderFormContentState extends State { // 多乘客模式(N3)下每位乘客各自有上/下車點,單一目的地欄位不再適用—— // 同時顯示兩者會讓人以為要各填一次。 if (!ctrl.multiStopEnabled) ...[ + // 常用地點快捷:一鍵把住家/公司帶進目的地。座標一起帶過來, + // 所以帶入後照樣算得出車資預估(手打地址是算不出來的)。 + SavedPlacesBar( + ctrl: ctrl, + title: '常用目的地', + onPick: _applySavedPlace, + ), + const SizedBox(height: 8), TextField( controller: _dropoff, onChanged: (_) { @@ -427,6 +436,19 @@ class _OrderFormContentState extends State { ); } + /// 把常用地點帶進目的地欄。 + /// + /// **程式化設定 `text` 不會觸發 `onChanged`**,所以座標不會被那段「手動編輯就丟棄座標」 + /// 的邏輯清掉——這正是要的:常用地點本來就帶著跟地址對得上的座標。 + void _applySavedPlace(SavedPlace place) { + setState(() { + _dropoff.text = place.address; + _dropoffLat = place.lat; + _dropoffLng = place.lng; + }); + widget.ctrl.setEstimateDropoff(place.lat, place.lng); + } + Future _pickOnMap(BuildContext context) async { final pos = widget.ctrl.lastPosition; final picked = await Navigator.of(context).push( diff --git a/lib/customer/widgets/saved_places_bar.dart b/lib/customer/widgets/saved_places_bar.dart new file mode 100644 index 0000000..15cf54f --- /dev/null +++ b/lib/customer/widgets/saved_places_bar.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../../core/models/models.dart'; +import '../customer_controller.dart'; +import '../screens/saved_places_screen.dart'; + +/// 常用地點快捷列:一排晶片,點下去把該地點帶進目的地(或上車點)。 +/// +/// **一個地點都還沒設時整條不顯示**,只留一顆「管理」入口——空的快捷列佔著版面卻 +/// 什麼也做不到,反而讓叫車表單變長。 +class SavedPlacesBar extends StatelessWidget { + const SavedPlacesBar({ + required this.ctrl, + required this.onPick, + this.title = '常用地點', + super.key, + }); + + final CustomerController ctrl; + + /// 選中某個地點時回呼(帶回地址與座標,呼叫端決定要填進哪一欄)。 + final void Function(SavedPlace place) onPick; + + final String title; + + @override + Widget build(BuildContext context) { + final places = ctrl.savedPlaces; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text(title, style: Theme.of(context).textTheme.titleSmall), + ), + TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SavedPlacesScreen()), + ), + icon: const Icon(Icons.tune, size: 18), + label: Text(places.isEmpty ? '設定住家/公司' : '管理'), + ), + ], + ), + if (places.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final p in places) + ActionChip( + avatar: Icon(savedPlaceIcon(p), size: 18), + label: Text(p.label), + onPressed: () => onPick(p), + ), + ], + ), + ], + ); + } +} + +/// 依語意插槽給圖示。**看 kind 不看 label**——乘客把住家改名成「家」之後, +/// 比對文字的寫法會無聲地掉回預設圖示。 +IconData savedPlaceIcon(SavedPlace place) { + if (place.isHome) return Icons.home; + if (place.isWork) return Icons.work; + return Icons.star; +} diff --git a/test/app_lifecycle_test.dart b/test/app_lifecycle_test.dart index 4908156..28fd682 100644 --- a/test/app_lifecycle_test.dart +++ b/test/app_lifecycle_test.dart @@ -364,6 +364,16 @@ class _RecordingWs extends FleetWsClient { } class _CountingCustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _CountingCustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/chat_lost_item_test.dart b/test/chat_lost_item_test.dart index 6d6291f..e69a941 100644 --- a/test/chat_lost_item_test.dart +++ b/test/chat_lost_item_test.dart @@ -185,6 +185,16 @@ void main() { } class _FakeCustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeCustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/chat_push_test.dart b/test/chat_push_test.dart index d104c24..7fceb60 100644 --- a/test/chat_push_test.dart +++ b/test/chat_push_test.dart @@ -261,6 +261,16 @@ class _DriverApi extends FleetApiClient { } class _CustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _CustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_cancel_notice_test.dart b/test/customer_cancel_notice_test.dart index 82b3d6f..1346844 100644 --- a/test/customer_cancel_notice_test.dart +++ b/test/customer_cancel_notice_test.dart @@ -10,6 +10,16 @@ import 'package:line_fleet_app/customer/widgets/ride_phase_content.dart'; /// active 一律回 null(取消後行程不再屬 active);費率固定回 2000(測試不打網路)。 class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @override diff --git a/test/customer_completed_race_test.dart b/test/customer_completed_race_test.dart index 6345eae..4b1cf57 100644 --- a/test/customer_completed_race_test.dart +++ b/test/customer_completed_race_test.dart @@ -72,6 +72,16 @@ void main() { /// active API 一律回 null(模擬「行程已完成不再屬 active」)。 class _NullActiveApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _NullActiveApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_controller_vehicle_test.dart b/test/customer_controller_vehicle_test.dart index 97e139d..022806a 100644 --- a/test/customer_controller_vehicle_test.dart +++ b/test/customer_controller_vehicle_test.dart @@ -160,6 +160,16 @@ CustomerRide _ride(int id) => CustomerRide.fromJson({ }); class _VehicleFakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _VehicleFakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); int petFeeBps = 2000; diff --git a/test/customer_create_conflict_test.dart b/test/customer_create_conflict_test.dart index 1db495e..788829b 100644 --- a/test/customer_create_conflict_test.dart +++ b/test/customer_create_conflict_test.dart @@ -161,6 +161,16 @@ Future _customer(_ConflictApi api) async { } class _ConflictApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _ConflictApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_driver_contact_restore_test.dart b/test/customer_driver_contact_restore_test.dart index cf62e9f..c86c0fa 100644 --- a/test/customer_driver_contact_restore_test.dart +++ b/test/customer_driver_contact_restore_test.dart @@ -102,6 +102,16 @@ const Map _acceptedRideJson = { }; class _AcceptedActiveApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _AcceptedActiveApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @@ -112,6 +122,16 @@ class _AcceptedActiveApi extends CustomerApiClient { /// 已派單但司機還沒接:後端不帶任何 driver_* 鍵。 class _AssignedActiveApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _AssignedActiveApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_error_visibility_test.dart b/test/customer_error_visibility_test.dart index 3f8aa3a..81da176 100644 --- a/test/customer_error_visibility_test.dart +++ b/test/customer_error_visibility_test.dart @@ -133,6 +133,16 @@ void main() { /// 模擬 token 失效的後端:任何查詢都回 401。 class _FailingApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FailingApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @override @@ -142,6 +152,16 @@ class _FailingApi extends CustomerApiClient { /// 第一次查詢成功(回一筆進行中行程並啟動輪詢),之後一律斷線。 class _FlakyAfterFirstApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FlakyAfterFirstApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_fare_estimate_test.dart b/test/customer_fare_estimate_test.dart index 755163b..ada02af 100644 --- a/test/customer_fare_estimate_test.dart +++ b/test/customer_fare_estimate_test.dart @@ -6,6 +6,16 @@ import 'package:line_fleet_app/customer/customer_controller.dart'; /// 記錄 estimateFare 收到的參數,回一筆可控的預估;可設定為丟例外(測靜默失敗)。 class _FakeEstimateApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeEstimateApi(); int calls = 0; diff --git a/test/customer_home_entries_test.dart b/test/customer_home_entries_test.dart new file mode 100644 index 0000000..e4e8e76 --- /dev/null +++ b/test/customer_home_entries_test.dart @@ -0,0 +1,61 @@ +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/customer/customer_controller.dart'; +import 'package:line_fleet_app/customer/screens/customer_map_home_screen.dart'; +import 'package:provider/provider.dart'; + +/// production 首頁上的入口。 +/// +/// **這支守的是一個真的踩過的坑**:預約司機的入口與「即將到來」卡原本加在 +/// `CustomerHomeScreen`(卡片版),而 `app.dart` 掛的是 `CustomerMapHomeScreen` +/// (地圖版)——功能寫完、測試全綠、畫面上根本沒有那顆按鈕。 +/// 兩個同類畫面並存時,「我改的那個」跟「使用者看到的那個」要分別確認。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('地圖版首頁要有「預約司機」入口', (tester) async { + final ctrl = CustomerController(api: _StubApi()); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctrl, + child: const MaterialApp(home: CustomerMapHomeScreen()), + ), + ); + await tester.pump(); + + expect( + find.byTooltip('預約司機'), + findsOneWidget, + reason: '入口不在 production 首頁上=功能無法從 UI 到達', + ); + }); +} + +class _StubApi extends CustomerApiClient { + _StubApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => null; + + @override + Future> fetchLostItems() async => []; + + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({ + bool upcomingOnly = false, + }) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); +} diff --git a/test/customer_home_widget_test.dart b/test/customer_home_widget_test.dart index 724c82f..b37f009 100644 --- a/test/customer_home_widget_test.dart +++ b/test/customer_home_widget_test.dart @@ -78,6 +78,16 @@ void main() { } class _FakeCustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeCustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_location_exits_test.dart b/test/customer_location_exits_test.dart index e489786..379a4b1 100644 --- a/test/customer_location_exits_test.dart +++ b/test/customer_location_exits_test.dart @@ -221,6 +221,16 @@ class _FakeLocator implements CustomerLocator { } class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); int createCalls = 0; diff --git a/test/customer_login_lost_items_test.dart b/test/customer_login_lost_items_test.dart index 646047b..a3fe1c6 100644 --- a/test/customer_login_lost_items_test.dart +++ b/test/customer_login_lost_items_test.dart @@ -50,6 +50,16 @@ class _MemoryCustomerStorage extends CustomerTokenStorage { } class _FakeCustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeCustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_lost_item_entry_test.dart b/test/customer_lost_item_entry_test.dart index 69af244..72a3ca8 100644 --- a/test/customer_lost_item_entry_test.dart +++ b/test/customer_lost_item_entry_test.dart @@ -120,6 +120,16 @@ void main() { } class _StubApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _StubApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @override diff --git a/test/customer_lost_item_history_entry_test.dart b/test/customer_lost_item_history_entry_test.dart index 3f013e9..4bd9da9 100644 --- a/test/customer_lost_item_history_entry_test.dart +++ b/test/customer_lost_item_history_entry_test.dart @@ -108,6 +108,16 @@ CustomerRideSummary _summary({ ); class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); List history = const []; diff --git a/test/customer_lost_item_refresh_test.dart b/test/customer_lost_item_refresh_test.dart index 436bdbc..4ca003f 100644 --- a/test/customer_lost_item_refresh_test.dart +++ b/test/customer_lost_item_refresh_test.dart @@ -46,6 +46,16 @@ void main() { } class _StaleListApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _StaleListApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/customer_passengers_test.dart b/test/customer_passengers_test.dart index 1abbd8c..32f79da 100644 --- a/test/customer_passengers_test.dart +++ b/test/customer_passengers_test.dart @@ -128,6 +128,16 @@ void main() { } class _StopsFakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _StopsFakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); List lastStops = const []; diff --git a/test/customer_push_test.dart b/test/customer_push_test.dart index ba9d465..73e393c 100644 --- a/test/customer_push_test.dart +++ b/test/customer_push_test.dart @@ -187,6 +187,16 @@ Future _customer( } class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); final registered = <(String, String)>[]; diff --git a/test/customer_rating_test.dart b/test/customer_rating_test.dart index 86b0f9f..cd2a24e 100644 --- a/test/customer_rating_test.dart +++ b/test/customer_rating_test.dart @@ -11,6 +11,16 @@ import 'package:line_fleet_app/customer/widgets/ride_phase_content.dart'; import 'package:provider/provider.dart'; class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); List history = const []; diff --git a/test/customer_redispatch_test.dart b/test/customer_redispatch_test.dart index 98ebeb4..39e412e 100644 --- a/test/customer_redispatch_test.dart +++ b/test/customer_redispatch_test.dart @@ -109,6 +109,16 @@ void main() { } class _StubApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _StubApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @override diff --git a/test/customer_ride_history_test.dart b/test/customer_ride_history_test.dart index 0f42a0c..d192e6e 100644 --- a/test/customer_ride_history_test.dart +++ b/test/customer_ride_history_test.dart @@ -9,6 +9,16 @@ import 'package:line_fleet_app/customer/screens/ride_history_screen.dart'; import 'package:provider/provider.dart'; class _FakeApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _FakeApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); List history = const []; diff --git a/test/lost_item_timeout_test.dart b/test/lost_item_timeout_test.dart index 37cec56..d10d286 100644 --- a/test/lost_item_timeout_test.dart +++ b/test/lost_item_timeout_test.dart @@ -222,6 +222,16 @@ Future _driver(FleetApiClient api) async { /// 乘客端假後端:協尋寫入一律以 [failWith] 失敗, /// 對帳查詢回 [freshItem](或以 [byRideFailure] 失敗)。 class _CustomerLostItemApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _CustomerLostItemApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); diff --git a/test/saved_places_test.dart b/test/saved_places_test.dart new file mode 100644 index 0000000..058c368 --- /dev/null +++ b/test/saved_places_test.dart @@ -0,0 +1,401 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/services.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/customer/customer_controller.dart'; + +CustomerController _loggedIn(CustomerApiClient api) { + final ctrl = CustomerController(api: api); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + return ctrl; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + _logoutLeakTests(); + _loginLoadsTests(); + + group('常用地點', () { + test('homePlace/workPlace 依 kind 取,不依 label', () async { + // 乘客把住家改名成「家」之後,任何比對 label 的寫法都會無聲失效。 + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '家'), + _place(2, SavedPlaceKind.work, '辦公室'), + _place(3, SavedPlaceKind.custom, '住家'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + expect(ctrl.homePlace?.id, 1); + expect(ctrl.workPlace?.id, 2); + // 名字叫「住家」的自訂地點不該被當成住家插槽。 + expect(ctrl.homePlace?.label, '家'); + }); + + test('沒設過住家時 homePlace 為 null(UI 才知道要顯示「設定」)', () async { + final ctrl = _loggedIn(_PlacesApi(places: [])); + await ctrl.loadSavedPlaces(); + expect(ctrl.homePlace, isNull); + expect(ctrl.workPlace, isNull); + }); + + test('重設住家是覆蓋,不會變成兩筆 home', () async { + // 後端對 home/work 是 upsert,回來的是**新的 id**(原本沒有住家)。 + // 若合併只比 id,清單就會同時出現兩筆 kind=home。 + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '住家', address: '舊家'), + _place(2, SavedPlaceKind.custom, '健身房'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + api.nextCreated = _place(1, SavedPlaceKind.home, '住家', address: '新家'); + final error = await ctrl.savePlace( + kind: SavedPlaceKind.home, + label: '住家', + address: '新家', + lat: 25.01, + lng: 121.46, + ); + + expect(error, isNull); + final homes = ctrl.savedPlaces.where((p) => p.isHome).toList(); + expect(homes, hasLength(1), reason: '住家只能有一筆'); + expect(homes.single.address, '新家'); + expect(ctrl.savedPlaces, hasLength(2)); + }); + + test('別台裝置改過住家(後端回不同 id)→ 本地仍只留一筆', () async { + // 這條才是「比 kind 不只比 id」真正守著的情境: + // 這台的清單還記得住家是 id=1,但另一台裝置已經把它刪掉重建成 id=7。 + // 只比 id 的話,合併後清單會同時出現兩筆 kind=home, + // 叫車頁的快捷列就會冒出兩顆「住家」。 + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '住家', address: '本地記得的舊家'), + _place(2, SavedPlaceKind.custom, '健身房'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + api.nextCreated = _place(7, SavedPlaceKind.home, '住家', address: '新家'); + await ctrl.savePlace( + kind: SavedPlaceKind.home, + label: '住家', + address: '新家', + lat: 25.01, + lng: 121.46, + ); + + final homes = ctrl.savedPlaces.where((p) => p.isHome).toList(); + expect(homes, hasLength(1), reason: '住家只能有一筆,即使後端換了 id'); + expect(homes.single.id, 7); + expect(homes.single.address, '新家'); + expect(ctrl.savedPlaces, hasLength(2)); + }); + + test('後端給新 id 的住家也只留一筆(先前沒有住家的情況)', () async { + final api = _PlacesApi(places: [ + _place(5, SavedPlaceKind.custom, '健身房'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + api.nextCreated = _place(9, SavedPlaceKind.home, '住家', address: '新家'); + await ctrl.savePlace( + kind: SavedPlaceKind.home, + label: '住家', + address: '新家', + lat: 25.01, + lng: 121.46, + ); + + expect(ctrl.savedPlaces.where((p) => p.isHome), hasLength(1)); + expect(ctrl.homePlace?.id, 9); + }); + + test('清單排序:住家 → 公司 → 其他', () async { + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.custom, '健身房'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + api.nextCreated = _place(2, SavedPlaceKind.work, '公司'); + await ctrl.savePlace( + kind: SavedPlaceKind.work, + label: '公司', + address: '內湖', + lat: 25.07, + lng: 121.57, + ); + api.nextCreated = _place(3, SavedPlaceKind.home, '住家'); + await ctrl.savePlace( + kind: SavedPlaceKind.home, + label: '住家', + address: '大安', + lat: 25.02, + lng: 121.54, + ); + + expect( + ctrl.savedPlaces.map((p) => p.kind), + [SavedPlaceKind.home, SavedPlaceKind.work, SavedPlaceKind.custom], + ); + }); + + test('刪除後從清單移除', () async { + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '住家'), + _place(2, SavedPlaceKind.custom, '健身房'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + final error = await ctrl.deleteSavedPlace(2); + + expect(error, isNull); + expect(ctrl.savedPlaces.map((p) => p.id), [1]); + }); + + test('新增失敗要把訊息回給表單,清單不動', () async { + final api = _PlacesApi( + places: [_place(1, SavedPlaceKind.home, '住家')], + createThrows: ApiException('地址不可為空'), + ); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + + final error = await ctrl.savePlace( + kind: SavedPlaceKind.custom, + label: '健身房', + address: '', + lat: 25.0, + lng: 121.5, + ); + + expect(error, '地址不可為空'); + expect(ctrl.savedPlaces, hasLength(1)); + }); + + test('silent 載入失敗不設錯誤(叫車頁的快捷鈕載不出來不該擋叫車)', () async { + final api = _PlacesApi(listThrows: ApiException('伺服器忙碌')); + final ctrl = _loggedIn(api); + + await ctrl.loadSavedPlaces(silent: true); + expect(ctrl.placesError, isNull); + + await ctrl.loadSavedPlaces(); + expect(ctrl.placesError, '伺服器忙碌'); + }); + }); + + group('SavedPlace 解析', () { + test('point 巢狀座標與 isSlot', () { + final home = SavedPlace.fromJson(const { + 'id': 1, + 'kind': 'home', + 'label': '住家', + 'address': '台北市大安區', + 'point': {'lat': 25.0261, 'lng': 121.5435}, + }); + expect(home.lat, 25.0261); + expect(home.lng, 121.5435); + expect(home.isHome, isTrue); + expect(home.isSlot, isTrue); + + final custom = SavedPlace.fromJson(const { + 'id': 2, + 'kind': 'custom', + 'label': '健身房', + 'address': '信義區', + 'point': {'lat': 25.04, 'lng': 121.56}, + }); + expect(custom.isSlot, isFalse); + }); + + test('kind 缺席時退回 custom(不能誤判成插槽)', () { + final p = SavedPlace.fromJson(const { + 'id': 3, + 'label': '某處', + 'address': '某地址', + 'point': {'lat': 25.0, 'lng': 121.5}, + }); + expect(p.kind, SavedPlaceKind.custom); + expect(p.isSlot, isFalse); + }); + }); +} + +SavedPlace _place(int id, String kind, String label, {String address = '地址'}) => + SavedPlace( + id: id, + kind: kind, + label: label, + address: address, + lat: 25.0, + lng: 121.5, + ); + +class _PlacesApi extends CustomerApiClient { + _PlacesApi({ + this.places = const [], + this.createThrows, + this.listThrows, + }) : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + List places; + final ApiException? createThrows; + final ApiException? listThrows; + SavedPlace? nextCreated; + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => null; + + @override + Future> fetchLostItems() async => []; + + @override + Future fetchScheduledRides({ + bool upcomingOnly = false, + }) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + + @override + Future> fetchSavedPlaces() async { + if (listThrows != null) throw listThrows!; + return places; + } + + @override + Future createSavedPlace({ + required String kind, + required String label, + required String address, + required double lat, + required double lng, + }) async { + if (createThrows != null) throw createThrows!; + return nextCreated ?? + SavedPlace( + id: 100, + kind: kind, + label: label, + address: address, + lat: lat, + lng: lng, + ); + } + + @override + Future deleteSavedPlace(int id) async {} + + @override + Future login({ + required String lineUserId, + required String password, + }) async => + const CustomerLoginResult(customerId: 1, token: 'tok', name: '示範乘客'); +} + +/// 登出後的殘留檢查。 +/// +/// 住家與公司是**實體位置**,預約則是行程計畫——兩者都比行程歷史更敏感, +/// 而 `_clearSession` 早就因為同樣的理由在清行程歷史了(見該處註解)。 +/// 換人登入後如果沒清,下一位乘客一打開叫車頁就會看到上一位的住家地址。 +void _logoutLeakTests() { + group('登出殘留', () { + setUp(() { + // logout() 會清 secure storage;測試環境沒有那個 plugin, + // 不 mock 的話會在 MissingPluginException 就中斷,根本走不到要驗的清理邏輯。 + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + null, + ); + }); + + test('登出後不留下上一位乘客的常用地點與預約', () async { + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '住家', address: '台北市大安區和平東路'), + _place(2, SavedPlaceKind.work, '公司', address: '內湖瑞光路'), + ]); + final ctrl = _loggedIn(api); + await ctrl.loadSavedPlaces(); + await ctrl.loadScheduledRides(); + + expect(ctrl.savedPlaces, isNotEmpty, reason: '前置條件:載進來了'); + + await ctrl.logout(); + + expect( + ctrl.savedPlaces, + isEmpty, + reason: '住家地址是實體位置,換人登入不能還看得到', + ); + expect(ctrl.homePlace, isNull); + expect(ctrl.workPlace, isNull); + expect(ctrl.scheduledRides, isEmpty); + expect(ctrl.upcomingSchedules, isEmpty); + }); + }); +} + +/// 登入之後也要把常用地點與預約帶出來。 +/// +/// `init()`(冷啟動還原 session)有載,登入路徑卻沒有——乘客剛登入完, +/// 叫車頁的快捷列是空的、首頁也看不到自己的預約,要等下次冷啟動才會出現。 +/// **這是模擬器實跑才抓到的**:單元測試都用 setSessionForTest 再手動呼叫 load, +/// 永遠走不到登入那條路徑。 +void _loginLoadsTests() { + group('登入後的載入', () { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + null, + ); + }); + + test('登入成功 → 常用地點與預約都帶出來(比照 init())', () async { + final api = _PlacesApi(places: [ + _place(1, SavedPlaceKind.home, '住家'), + _place(2, SavedPlaceKind.work, '公司'), + ]); + final ctrl = CustomerController(api: api); + + await ctrl.login(lineUserId: 'demo-customer-1', password: 'demo123456'); + + expect(ctrl.isLoggedIn, isTrue, reason: '前置條件:登入要成功'); + expect( + ctrl.savedPlaces, + isNotEmpty, + reason: '登入完叫車頁的快捷列就該有東西,不該等下次冷啟動', + ); + expect(ctrl.homePlace, isNotNull); + }); + }); +} diff --git a/test/scheduled_ride_test.dart b/test/scheduled_ride_test.dart new file mode 100644 index 0000000..4d60cac --- /dev/null +++ b/test/scheduled_ride_test.dart @@ -0,0 +1,451 @@ +import 'package:dio/dio.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:flutter/material.dart'; +import 'package:line_fleet_app/customer/customer_controller.dart'; +import 'package:line_fleet_app/customer/screens/scheduled_rides_screen.dart'; +import 'package:provider/provider.dart'; + +/// 建一個已登入的 controller(沿用既有測試的 setSessionForTest 慣例)。 +CustomerController loggedInController(CustomerApiClient api) { + final ctrl = CustomerController(api: api); + ctrl.setSessionForTest( + const CustomerSession(customerId: 1, token: 'tok', name: '小美'), + ); + return ctrl; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + _minLeadTests(); + _sectionTests(); + _parseResilienceTests(); + _vehicleLabelTests(); + + group('預約行程', () { + test('載入清單後 upcoming 只留 pending,已轉單/取消/失敗都不算', () async { + final api = _ScheduleApi(rides: [ + _schedule(1, ScheduledRideStatus.pending, hours: 3), + _schedule(2, ScheduledRideStatus.dispatched, hours: 1, rideId: 77), + _schedule(3, ScheduledRideStatus.cancelled, hours: 5), + _schedule(4, ScheduledRideStatus.failed, hours: -2), + ]); + final ctrl = loggedInController(api); + + await ctrl.loadScheduledRides(); + + expect(ctrl.scheduledRides.length, 4); + expect(ctrl.upcomingSchedules.map((s) => s.id), [1]); + // lead_minutes 要從後端來,不能是 App 寫死的——後端改了 App 才不會說謊。 + expect(ctrl.scheduleLeadMinutes, 15); + }); + + test('清單依約定時間由近到遠排序', () async { + final api = _ScheduleApi(rides: [ + _schedule(1, ScheduledRideStatus.pending, hours: 8), + _schedule(2, ScheduledRideStatus.pending, hours: 2), + _schedule(3, ScheduledRideStatus.pending, hours: 5), + ]); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + + // 後端已排好序,但建立新預約後 controller 會自己重排——這裡驗的是那條排序約定。 + await ctrl.createScheduledRide( + scheduledAt: DateTime.now().add(const Duration(hours: 3)), + pickupLat: 25.03, + pickupLng: 121.56, + pickupAddress: '台北車站', + ); + + final times = ctrl.scheduledRides.map((s) => s.scheduledAt).toList(); + for (var i = 1; i < times.length; i++) { + expect( + times[i].isBefore(times[i - 1]), + isFalse, + reason: '第 $i 筆比前一筆早,排序壞了:$times', + ); + } + }); + + test('取消成功後那筆變成 cancelled,且不再出現在 upcoming', () async { + final api = _ScheduleApi(rides: [ + _schedule(1, ScheduledRideStatus.pending, hours: 3), + ]); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + expect(ctrl.upcomingSchedules, hasLength(1)); + + final error = await ctrl.cancelScheduledRide(1); + + expect(error, isNull); + expect(ctrl.scheduledRides.single.isCancelled, isTrue); + expect(ctrl.upcomingSchedules, isEmpty); + }); + + test('取消時已被轉成訂單(409)→ 不能宣稱失敗,畫面要換成「已派車」', () async { + // 這是這條流程最要命的一種壞法:排程器搶在取消前發動,App 卻只丟一句 + // 「取消失敗,請稍後再試」——乘客會一直按取消,而車照樣開過來。 + final api = _ScheduleApi( + rides: [_schedule(1, ScheduledRideStatus.pending, hours: 1)], + cancelConflictWith: + _schedule(1, ScheduledRideStatus.dispatched, hours: 1, rideId: 42), + ); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + + final message = await ctrl.cancelScheduledRide(1); + + // 要回一句說得出下一步的話,而不是「請稍後再試」。 + expect(message, isNotNull); + expect(message, contains('已經為你派車')); + expect(message, contains('行程頁')); + // 而且狀態必須就地更新成 dispatched,不能還停在 pending。 + final after = ctrl.scheduledRides.single; + expect(after.isDispatched, isTrue); + expect(after.rideId, 42); + expect(after.cancellable, isFalse); + expect(ctrl.upcomingSchedules, isEmpty); + }); + + test('取消逾時但後端其實已取消 → 重讀對帳後不報錯', () async { + // 回應在路上掉了。若直接把逾時訊息丟給乘客,他會再按一次取消, + // 然後撞上「已無法取消」而更困惑——其實第一次就成功了。 + final api = _ScheduleApi( + rides: [_schedule(1, ScheduledRideStatus.pending, hours: 3)], + cancelThrows: ApiException('連線逾時'), + reloadAfterCancel: [_schedule(1, ScheduledRideStatus.cancelled, hours: 3)], + ); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + + final error = await ctrl.cancelScheduledRide(1); + + expect(error, isNull, reason: '後端已經取消了,不該還告訴乘客失敗'); + expect(ctrl.scheduledRides.single.isCancelled, isTrue); + }); + + test('取消逾時且後端確實沒取消 → 要如實回報失敗', () async { + // 上一條的負向對照:沒有這條,「逾時一律回 null」也會讓上一條變綠。 + final api = _ScheduleApi( + rides: [_schedule(1, ScheduledRideStatus.pending, hours: 3)], + cancelThrows: ApiException('連線逾時'), + reloadAfterCancel: [_schedule(1, ScheduledRideStatus.pending, hours: 3)], + ); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + + final error = await ctrl.cancelScheduledRide(1); + + expect(error, '連線逾時'); + expect(ctrl.scheduledRides.single.isPending, isTrue); + }); + + test('載入失敗時 silent 不設錯誤(叫車主流程不該被輔助功能打斷)', () async { + final api = _ScheduleApi(listThrows: ApiException('伺服器忙碌')); + final ctrl = loggedInController(api); + + await ctrl.loadScheduledRides(silent: true); + expect(ctrl.schedulesError, isNull); + + await ctrl.loadScheduledRides(); + expect(ctrl.schedulesError, '伺服器忙碌'); + }); + }); + + group('ScheduledRide 解析', () { + test('後端 RFC3339 帶時區 → 轉成本地時間', () { + final s = ScheduledRide.fromJson({ + 'id': 9, + 'scheduled_at': '2026-08-01T02:30:00Z', + 'pickup_point': {'lat': 25.03, 'lng': 121.56}, + 'pickup_address': '台北車站', + 'status': 'pending', + }); + expect(s.scheduledAt.isUtc, isFalse); + expect( + s.scheduledAt.toUtc(), + DateTime.utc(2026, 8, 1, 2, 30), + ); + }); + + test('缺 dropoff 時為 null,不是空字串', () { + final s = ScheduledRide.fromJson({ + 'id': 9, + 'scheduled_at': '2026-08-01T02:30:00Z', + 'pickup_point': {'lat': 25.03, 'lng': 121.56}, + 'pickup_address': '台北車站', + 'dropoff_address': '', + 'status': 'pending', + }); + expect(s.dropoffAddress, isNull); + expect(s.dropoffLat, isNull); + }); + }); +} + +Map _scheduleJson( + int id, + String status, { + required int hours, + int? rideId, +}) => + { + 'id': id, + 'scheduled_at': + DateTime.now().add(Duration(hours: hours)).toUtc().toIso8601String(), + 'pickup_point': {'lat': 25.0261, 'lng': 121.5435}, + 'pickup_address': '住家', + 'dropoff_point': {'lat': 25.0797, 'lng': 121.5750}, + 'dropoff_address': '公司', + 'status': status, + 'ride_id': rideId, + 'last_error': status == ScheduledRideStatus.failed ? '已有進行中的訂單' : '', + }; + +ScheduledRide _schedule( + int id, + String status, { + required int hours, + int? rideId, +}) => + ScheduledRide.fromJson( + _scheduleJson(id, status, hours: hours, rideId: rideId)); + +class _ScheduleApi extends CustomerApiClient { + _ScheduleApi({ + this.rides = const [], + this.cancelConflictWith, + this.cancelThrows, + this.reloadAfterCancel, + this.listThrows, + this.minLeadMinutes = 20, + }) : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); + + List rides; + final ScheduledRide? cancelConflictWith; + final ApiException? cancelThrows; + final List? reloadAfterCancel; + final ApiException? listThrows; + final int minLeadMinutes; + bool _cancelled = false; + + @override + void setToken(String? token) {} + + @override + Future activeRide() async => null; + + @override + Future> fetchLostItems() async => []; + + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({ + bool upcomingOnly = false, + }) async { + if (listThrows != null) throw listThrows!; + final rows = _cancelled && reloadAfterCancel != null + ? reloadAfterCancel! + : rides; + return ScheduledRidesResult( + rides: rows, + leadMinutes: 15, + minLeadMinutes: minLeadMinutes, + ); + } + + @override + Future createScheduledRide({ + required DateTime scheduledAt, + required double pickupLat, + required double pickupLng, + required String pickupAddress, + String? dropoffAddress, + double? dropoffLat, + double? dropoffLng, + String? requiredVehicleType, + String note = '', + }) async { + return ScheduledRide( + id: 999, + scheduledAt: scheduledAt, + pickupAddress: pickupAddress, + pickupLat: pickupLat, + pickupLng: pickupLng, + status: ScheduledRideStatus.pending, + ); + } + + @override + Future cancelScheduledRide(int id) async { + _cancelled = true; + if (cancelConflictWith != null) { + throw ScheduledRideConflict(cancelConflictWith!, '此預約已無法取消'); + } + if (cancelThrows != null) throw cancelThrows!; + final target = rides.firstWhere((s) => s.id == id); + final cancelled = ScheduledRide( + id: target.id, + scheduledAt: target.scheduledAt, + pickupAddress: target.pickupAddress, + pickupLat: target.pickupLat, + pickupLng: target.pickupLng, + dropoffAddress: target.dropoffAddress, + status: ScheduledRideStatus.cancelled, + ); + rides = [ + for (final s in rides) + if (s.id == id) cancelled else s, + ]; + return cancelled; + } +} + +/// 建立預約的最小前置時間必須來自後端,不能寫死在 App。 +/// +/// 後端把門檻調高之後,寫死的 App 仍會讓乘客選一個註定被 400 拒絕的時間, +/// 而他要填完整張表才會知道。 +void _minLeadTests() { + group('最小前置時間', () { + test('後端給多少就用多少', () async { + final api = _ScheduleApi(rides: [], minLeadMinutes: 45); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + expect(ctrl.scheduleMinLeadMinutes, 45); + }); + + test('後端沒給(舊版)→ 退回保底值,不是 0', () async { + // 退回 0 會讓時間選擇器完全不擋,反而比寫死更糟。 + final api = _ScheduleApi(rides: [], minLeadMinutes: 0); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + expect(ctrl.scheduleMinLeadMinutes, + CustomerController.fallbackMinLeadMinutes); + expect(ctrl.scheduleMinLeadMinutes, greaterThan(0)); + }); + }); +} + +/// 預約頁的分區:已轉單的**不能**掉進「過往預約」。 +/// +/// 車正在來的路上,跟已取消/未成立混在同一區,乘客會以為那筆已經結束了。 +void _sectionTests() { + group('預約頁分區', () { + testWidgets('已轉單的歸在「已為你派車」,不在「過往預約」', (tester) async { + final api = _ScheduleApi(rides: [ + _schedule(1, ScheduledRideStatus.pending, hours: 3), + _schedule(2, ScheduledRideStatus.dispatched, hours: 1, rideId: 42), + _schedule(3, ScheduledRideStatus.cancelled, hours: 5), + ]); + final ctrl = loggedInController(api); + await ctrl.loadScheduledRides(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctrl, + child: const MaterialApp(home: ScheduledRidesScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('即將到來'), findsOneWidget); + // 區塊標題刻意不叫「已為你派車」——那句話是卡片上的狀態標籤, + // 兩者同名會讓 finder 抓到兩個節點,也會讓畫面上同一句話重複出現。 + expect(find.text('車已在路上'), findsOneWidget); + expect(find.text('過往預約'), findsOneWidget); + + // 進行中的區塊必須排在已結束的上面——順序本身就是訊息。 + final dispatchedY = tester.getTopLeft(find.text('車已在路上')).dy; + final pastY = tester.getTopLeft(find.text('過往預約')).dy; + expect(dispatchedY, lessThan(pastY)); + }); + + testWidgets('沒有任何預約時給空狀態,不是三個空標題', (tester) async { + final ctrl = loggedInController(_ScheduleApi(rides: [])); + await ctrl.loadScheduledRides(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctrl, + child: const MaterialApp(home: ScheduledRidesScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('還沒有任何預約。'), findsOneWidget); + expect(find.text('即將到來'), findsNothing); + expect(find.text('車已在路上'), findsNothing); + expect(find.text('過往預約'), findsNothing); + }); + }); +} + +/// 清單解析的容錯:一筆壞掉不該讓整份清單消失。 +void _parseResilienceTests() { + group('清單解析容錯', () { + test('某筆 scheduled_at 格式壞掉 → 只跳過那筆,其餘照常', () { + final list = ScheduledRide.listFrom([ + _scheduleJson(1, ScheduledRideStatus.pending, hours: 3), + { + 'id': 2, + 'scheduled_at': '不是時間', + 'pickup_point': {'lat': 25.0, 'lng': 121.5}, + 'pickup_address': '某處', + 'status': 'pending', + }, + _scheduleJson(3, ScheduledRideStatus.pending, hours: 5), + ]); + expect(list.map((s) => s.id), [1, 3]); + }); + + test('缺 id 這種必填欄位也跳過,不整份炸掉', () { + final list = ScheduledRide.listFrom([ + {'scheduled_at': '2026-08-01T02:30:00Z', 'status': 'pending'}, + _scheduleJson(9, ScheduledRideStatus.pending, hours: 2), + ]); + expect(list.map((s) => s.id), [9]); + }); + }); +} + +/// 車種要顯示中文名,不是後端 code。 +/// +/// 既有約定寫在 `VehicleType` 的註解上:後端 API/WS 一律只送 code,顯示名由前端對應。 +/// 直接把 `pet` 印在畫面上,乘客看不懂那是什麼。 +void _vehicleLabelTests() { + group('預約卡的車種顯示', () { + testWidgets('pet → 顯示「寵物用車」,畫面上不出現原始 code', (tester) async { + final s = ScheduledRide.fromJson({ + ..._scheduleJson(1, ScheduledRideStatus.pending, hours: 3), + 'required_vehicle_type': 'pet', + }); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: ScheduledRideCard(schedule: s))), + ); + + expect(find.textContaining('寵物用車'), findsOneWidget); + expect(find.textContaining('pet'), findsNothing); + }); + + testWidgets('後端出現 App 不認得的車種 → 整行不顯示,不印 code 也不印「—」', + (tester) async { + final s = ScheduledRide.fromJson({ + ..._scheduleJson(2, ScheduledRideStatus.pending, hours: 3), + 'required_vehicle_type': 'hovercraft', + }); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: ScheduledRideCard(schedule: s))), + ); + + expect(find.textContaining('指定車種'), findsNothing); + expect(find.textContaining('hovercraft'), findsNothing); + }); + }); +} diff --git a/test/session_expiry_test.dart b/test/session_expiry_test.dart index 9509cca..313a444 100644 --- a/test/session_expiry_test.dart +++ b/test/session_expiry_test.dart @@ -333,6 +333,16 @@ Position _fakePosition() => Position( /// 還原 session 時就撞上 401 的 fake:複製真 client 在 401 的兩個動作 /// (通知 controller + 換成可行動的訊息)。 class _ExpiredCustomerApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _ExpiredCustomerApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @@ -350,6 +360,16 @@ class _ExpiredCustomerApi extends CustomerApiClient { } class _HistoryApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _HistoryApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @override diff --git a/test/weak_network_test.dart b/test/weak_network_test.dart index db48707..7c3776d 100644 --- a/test/weak_network_test.dart +++ b/test/weak_network_test.dart @@ -411,6 +411,16 @@ class _TimeoutAcceptApi extends FleetApiClient { /// 建單請求逾時的假後端。 class _TimeoutCreateApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _TimeoutCreateApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api'))); @@ -443,6 +453,16 @@ class _TimeoutCreateApi extends CustomerApiClient { /// 取消「掛在半路」的假後端:`cancelRide` 一律以 [cancelFailure] 失敗, /// `activeRide` 回 [activeNow]=後端此刻的真實現況(對帳問的就是它)。 class _TimeoutCancelApi extends CustomerApiClient { + // 預約/常用地點:controller.init() 會載這兩份。沒覆寫的話會走真實 Dio 打網路, + // 在測試裡變成不確定的非同步延遲,把不相干的測試拖成 flaky(實測會讓 + // customer_location_exits 的預估 notifyListeners 落在 dispose 之後)。 + @override + Future> fetchSavedPlaces() async => const []; + + @override + Future fetchScheduledRides({bool upcomingOnly = false}) async => + const ScheduledRidesResult(rides: [], leadMinutes: 15); + _TimeoutCancelApi() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.invalid/api')));