Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。
至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。
詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。

**目前**:`flutter analyze` 無 issue、`flutter test` **425 passed**(54 個測試檔,2026-08-01 實跑)。
~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。
**目前**:`flutter analyze` 無 issue、`flutter test` **434 passed**(55 個測試檔,2026-08-01 實跑)。
~~425 passed~~/~~414 passed~~/~~383 passed~~/~~377 passed~~/~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。

**2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪):
先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)——
Expand Down
89 changes: 87 additions & 2 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
- [🗓️ 預約司機+常用地點(跨端新功能,含 debug 兩個)](#-預約司機常用地點2026-07-31跨端新功能)
- [📜 第二十四輪:「我的行程」只看得到最近 20 趟](#-2026-07-31-第二十四輪我的行程只看得到最近-20-趟本批修掉)
- [🔁 第二十五輪:分頁尾巴會卡死在轉圈(回頭 debug 第二十四輪自己)](#-2026-08-01-第二十五輪分頁的尾巴會卡死在轉圈回頭-debug-第二十四輪自己本批修掉-2-個)
- [🔤 第二十六輪:App 的字數上限與後端數的不是同一種單位](#-2026-08-01-第二十六輪app-的字數上限與後端數的不是同一種單位本批修掉-6-個欄位)

**四、維護、決策與待辦**
- [🧹 清開發殘留 worktree/舊分支(維護項 5)](#-清開發殘留-worktree舊分支維護項-52026-07-28-完成)
Expand All @@ -93,7 +94,7 @@
- **UI/UX 翻新(2026-07-10)**:LINE 綠亮暗雙主題;司機駕駛情境 UI;乘客地圖為底+卡片降級。靜態驗收 49 tests 通過;模擬器主鏈路待後端 docker 可起後補跑。
**登入/註冊頁 2026-07-23 補齊翻新**(先前是唯一漏網畫面),詳見下方「🔐 登入頁 UI/UX 翻新+驗證」。
- **座標導航(2026-07-10)**:司機端目的地導航改吃後端 `dropoff_point` 座標,地址僅供顯示與退路。
- 單元測試:**54 個測試檔、`flutter test` 425 passed**(2026-08-01 第二十四輪併回 main 並修完尾巴那兩個洞後實測
- 單元測試:**55 個測試檔、`flutter test` 434 passed**(2026-08-01 第二十六輪後實測
`flutter analyze` 無 issue、`flutter build apk --debug --flavor customer` 成功)。
~~(54 項)~~ 是 2026-07-10 的數字,長期沒更新,已更正——**本節的數字請跟著最後盤點日一起改**。
- 遠端:`github.com/thothawei/fleet-app`。**2026-07-29 實查**:`git ls-remote --heads origin` 只有 `main`、
Expand Down Expand Up @@ -2461,9 +2462,93 @@ PSQL_DSN='postgres://fleet:change_me@127.0.0.1:5433/fleet?sslmode=disable' \

---

## 🔤 2026-08-01 第二十六輪:App 的字數上限與後端數的不是同一種單位(本批修掉 6 個欄位)

> 本輪掃的族是**跨端輸入邊界對帳**——App 的欄位上限 vs 後端 validator。
> 從沒掃過,而且掃出來的東西比預期硬:不只有三個欄位沒擋,
> **有擋的那三個擋的單位也是錯的**。

### 根因一句話

Flutter 的 `TextField.maxLength` 數的是 **grapheme cluster**(使用者感知的「一個字」),
後端每一支上限數的都是 **rune**(`utf8.RuneCountInString` / `len([]rune(...))`)——
對 ASCII 與中文相同,對 emoji 差到 7 倍。

### 取證(不是查文件,是實跑探針)

```
「👨‍👩‍👧‍👦」characters=1 runes=7 「🇹🇼」characters=1 runes=2 「👍🏽」characters=1 runes=2
TextField(maxLength: 5) 擋完後 → characters=5 runes=35
```

`maxLength: 5` 放行了 **35 個 rune**。於是評分畫面的計數器顯示「200/200 沒超過」,
送出卻被後端以 400 擋下,而錯誤訊息(「評論長度超過上限」)**不會說上限是多少**——
對照著計數器看只會更困惑。

### 六個欄位的對帳表

| 欄位 | 後端上限(rune) | 修改前的 App | 判定 |
|---|---|---|---|
| 評分評論 | `ratingCommentMaxRunes` 200 | `maxLength: 200`(數 cluster) | ❌ 單位錯 |
| 遺失物描述 | `lostItemDescMaxRunes` 300 | `maxLength: 300`(數 cluster) | ❌ 單位錯 |
| 預約備註 | `maxScheduleNoteRunes` 200 | `maxLength: 200`(數 cluster) | ❌ 單位錯 |
| 聊天訊息 | `chatMaxRunes` 500 | **完全沒有上限** | ❌ 只能送出後才知道 |
| 常用地點名稱 | `maxPlaceLabelRunes` 40 | **完全沒有上限** | ❌ 同上 |
| 常用地點地址 | `maxPlaceAddressRunes` 200 | 唯讀(地圖選點反查) | ⚠️ 見下 |

### 修法

- 新增共用的 [`RuneLimitingTextInputFormatter`](../lib/shared/widgets/rune_limit.dart)
+ `runeCounter()`:**擋的單位與顯示的數字都與後端一致**。
- **裁切落在 cluster 邊界**,不是直接砍 rune 陣列——從 ZWJ 序列中間切下去會留下
半個字(孤立的修飾符/ZWJ 尾巴),畫面會出現使用者沒打過的東西。寧可少收一個完整的字。
- 保留 `maxLength` 只為了讓 Flutter 畫出計數器的位置,實際強制交給 formatter
(`maxLengthEnforcement: none`)——兩套同時生效會先被 cluster 那套擋掉。
- 聊天輸入框**不放計數器**:訊息框下面多一行 `x/500` 太吵,而 500 字平常打不到。
- **常用地點「地址」刻意不加限制器**:它是唯讀的、由地圖選點反查填入。
真的超過 200 rune 時應該讓後端拒絕,而不是在客戶端把地址默默截短——
**截短過的地址是錯的地址**,司機會開到別的地方去。

### 驗收

- `flutter analyze` 無 issue、`flutter test` **434 passed**(425 +新 9)。
- **反向驗證兩個半邊各一次**:
- 拿掉聊天輸入框的 formatter(回到先前無上限)→ 700 個 rune 整段進得去,該案 FAIL。
- 把限制器改回數 cluster(=`maxLength` 的行為)→ **5 案 FAIL**。
- **有一個案子專門驗「真畫面接上去了」**(常用地點名稱:開編輯表單→輸入 30 面旗子
→只留 20 面、計數器顯示 `40/40`)——只驗共用元件本身的話,
某個畫面漏接 `inputFormatters` 不會有任何測試變紅。
- `flutter build apk --debug --flavor customer` 成功。

### 同族還沒碰的角落

- **遺失物描述/預約備註/評分評論這三個畫面沒有各自的「真畫面接上去了」測試**
(只有常用地點與聊天室有)。三個接法完全相同、`analyze` 也綠,
但嚴格說它們的接線目前靠 review 不靠測試。**條件**:下次動到這三個畫面時順手補。
- **司機端沒有任何自由輸入欄位**(車牌走後端驗證、聊天共用同一個元件),所以本輪不必動司機端。

---

## 下次任務

> **🎯 2026-08-01 第二十五輪之後的狀態(開工先看這段)**
> **🎯 2026-08-01 第二十六輪之後的狀態(開工先看這段)**
>
> 1. **`gh pr list`(三個 repo)照樣是第一件事**——本輪開工時三個 repo 的 open PR 皆為 0。
> 2. **第九次實查三個外部卡點:依然都不在**(`android/app/google-services.json`、
> `ios/Runner/GoogleService-Info.plist` 不存在、`xcrun devicectl list devices` → No devices found)。
> 3. **這一輪學到的一條(值得單獨記住)**:
> **「兩邊都有做限制」不等於「限制得住」——要先問兩邊數的是不是同一種單位。**
> 評分/協尋/預約那三個欄位的 `maxLength` 數字與後端**完全相同**(200/300/200),
> 程式碼上看起來對得整整齊齊,實際上一個數 grapheme、一個數 rune。
> 對帳跨端契約時,**單位與數值要分開問兩次**。
> 4. **下一輪候選**(依價值排序):
> - **同族收尾**:遺失物描述/預約備註/評分評論三個畫面補「真畫面接上去了」的測試
> (本輪只補了常用地點與聊天室)。**條件**:下次動到那三個畫面時順手做,不必為它單開一輪。
> - **數值與金額格式**(從沒掃過):`formatCentsAsNtd` 的 0/負數/大數、
> 車資與清潔費相加的邊界、收入頁的月聚合。與本輪同一類(跨端表示法對帳)。
> - **狀態機**(從沒掃過):後端新增 ride status code 時 App 的退路
> (目前 `statusLabel` 有 default,但「未知狀態要不要給操作鈕」沒有統一答案)。

>
> 1. **`gh pr list`(三個 repo)照樣是第一件事。** 本輪開工時:app 只有 #97(就是本輪在收的那支)、
> dispatch #72(redis 讓開埠號)、admin 0。
Expand Down
7 changes: 7 additions & 0 deletions lib/customer/screens/lost_item_screen.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';

import '../../core/api/fleet_api_client.dart' show ApiException;
import '../../core/models/models.dart';
import '../../core/util/money.dart';
import '../../shared/screens/ride_chat_screen.dart';
import '../../shared/widgets/rune_limit.dart';
import '../customer_controller.dart';

/// 乘客端遺失物協尋:對已完成行程回報遺失 → 顯示處理費 → 與司機即時對話 →
Expand Down Expand Up @@ -222,7 +224,12 @@ class _ReportForm extends StatelessWidget {
TextField(
controller: description,
maxLines: 3,
// 與後端 lostItemDescMaxRunes(300)對齊,且數的是 rune 不是 grapheme
// (見 RuneLimitingTextInputFormatter 的說明)。
inputFormatters: const [RuneLimitingTextInputFormatter(300)],
maxLength: 300,
maxLengthEnforcement: MaxLengthEnforcement.none,
buildCounter: runeCounter(300, description),
decoration: const InputDecoration(
labelText: '物品描述(例:黑色錢包掉在後座)',
border: OutlineInputBorder(),
Expand Down
8 changes: 8 additions & 0 deletions lib/customer/screens/saved_places_screen.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';

import '../../core/models/models.dart';
import '../../shared/widgets/rune_limit.dart';
import '../customer_controller.dart';
import '../widgets/saved_places_bar.dart' show savedPlaceIcon;
import 'map_picker_screen.dart';
Expand Down Expand Up @@ -335,6 +337,12 @@ class _PlaceEditorSheetState extends State<_PlaceEditorSheet> {
TextField(
controller: _label,
onChanged: (_) => setState(() {}),
// 先前**完全沒有上限**:後端 maxPlaceLabelRunes 是 40,超過會回
// 「地點名稱過長」,但那句話沒說上限是多少,使用者只能亂猜著砍。
inputFormatters: const [RuneLimitingTextInputFormatter(40)],
maxLength: 40,
maxLengthEnforcement: MaxLengthEnforcement.none,
buildCounter: runeCounter(40, _label),
decoration: const InputDecoration(
labelText: '名稱',
hintText: '例如:健身房、媽媽家',
Expand Down
7 changes: 7 additions & 0 deletions lib/customer/screens/scheduled_rides_screen.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';

import '../../core/models/models.dart';
import '../../shared/widgets/rune_limit.dart';
import '../customer_controller.dart';
import '../widgets/saved_places_bar.dart';
import 'map_picker_screen.dart';
Expand Down Expand Up @@ -428,7 +430,12 @@ class _ScheduleEditorScreenState extends State<_ScheduleEditorScreen> {
const SizedBox(height: 20),
TextField(
controller: _note,
// 與後端 maxScheduleNoteRunes(200)對齊,且數的是 rune 不是 grapheme
// (見 RuneLimitingTextInputFormatter 的說明)。
inputFormatters: const [RuneLimitingTextInputFormatter(200)],
maxLength: 200,
maxLengthEnforcement: MaxLengthEnforcement.none,
buildCounter: runeCounter(200, _note),
decoration: const InputDecoration(
labelText: '給司機的備註(選填)',
hintText: '例如:有一件大行李',
Expand Down
10 changes: 9 additions & 1 deletion lib/customer/widgets/rating_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../../shared/widgets/rune_limit.dart';

import '../customer_controller.dart';

Expand Down Expand Up @@ -109,7 +112,12 @@ class _RatingSheetState extends State<_RatingSheet> {
controller: _commentCtrl,
enabled: !_submitting,
maxLines: 3,
maxLength: 200, // 與後端 ratingCommentMaxRunes 對齊
// 與後端 ratingCommentMaxRunes(200)對齊。**用 rune 不用 maxLength**:
// maxLength 數的是 grapheme cluster,emoji 會讓計數器說沒超過而後端擋下。
inputFormatters: const [RuneLimitingTextInputFormatter(200)],
maxLength: 200,
maxLengthEnforcement: MaxLengthEnforcement.none,
buildCounter: runeCounter(200, _commentCtrl),
decoration: const InputDecoration(
labelText: '想說的話(選填)',
border: OutlineInputBorder(),
Expand Down
9 changes: 9 additions & 0 deletions lib/shared/screens/ride_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../core/api/fleet_api_client.dart' show ApiException;
import '../../core/models/models.dart';
import '../widgets/app_lifecycle_reactor.dart';
import '../widgets/rune_limit.dart';

/// 乘客↔司機共用聊天室。
/// - 歷史:進場以 REST 載入,之後以 afterId 增量補讀(WS 斷線重連保底)。
Expand Down Expand Up @@ -264,6 +265,14 @@ class _RideChatScreenState extends State<RideChatScreen> {
controller: _input,
minLines: 1,
maxLines: 4,
// 先前**完全沒有上限**:後端 chatMaxRunes 是 500,超過回 400
// 「訊息長度超過上限」——訊息會留在輸入框(送出路徑本來就這樣設計),
// 但那句話沒說上限是多少,只能一段一段刪著試。
// 這裡不放計數器:聊天輸入框下面多一行 `x/500` 太吵,
// 而 500 字對一則訊息來說平常打不到。
inputFormatters: const [
RuneLimitingTextInputFormatter(500),
],
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
Expand Down
76 changes: 76 additions & 0 deletions lib/shared/widgets/rune_limit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// 依 **rune 數**(Unicode code point)限制輸入長度,並提供同一單位的計數器。
///
/// **為什麼不直接用 `TextField.maxLength`**:它數的是 grapheme cluster
/// (使用者感知的「一個字」),而後端每一支上限數的都是 rune
/// (`utf8.RuneCountInString` / `len([]rune(...))`)。兩者對 ASCII 與中文相同,
/// 對 emoji 差很多——實測 `maxLength: 5` 會放行 5 個 ZWJ 家庭 emoji = **35 個 rune**:
///
/// ```
/// 「👨‍👩‍👧‍👦」characters=1 runes=7 「🇹🇼」characters=1 runes=2 「👍🏽」characters=1 runes=2
/// ```
///
/// 於是畫面上的計數器顯示「200/200 沒超過」,送出卻被後端以 400 擋下,
/// 而錯誤訊息(「評論長度超過上限」)**不會告訴使用者到底該砍掉多少**——
/// 對照計數器只會更困惑。擋在同一個單位上,這條路才不會出現。
class RuneLimitingTextInputFormatter extends TextInputFormatter {
const RuneLimitingTextInputFormatter(this.maxRunes)
: assert(maxRunes > 0, 'maxRunes 必須為正');

final int maxRunes;

@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (runeCount(newValue.text) <= maxRunes) return newValue;
// **以 grapheme cluster 為單位裁切**,不是直接砍 rune 陣列:
// 從 ZWJ 序列中間切下去會留下半個字(孤立的修飾符/ZWJ 尾巴),
// 畫面會出現使用者沒打過的東西。寧可少收一個完整的字。
final buffer = StringBuffer();
var used = 0;
for (final cluster in newValue.text.characters) {
final n = cluster.runes.length;
if (used + n > maxRunes) break;
buffer.write(cluster);
used += n;
}
final text = buffer.toString();
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange.empty,
);
}
}

/// 與後端同一單位的長度計算。
int runeCount(String s) => s.runes.length;

/// `TextField.buildCounter` 用的計數器,顯示的數字與後端檢查的是同一個。
///
/// 沿用 `maxLength` 的 `x/y` 形狀(使用者已經認得),只是把 x 換成 rune 數。
/// **要傳 controller**:counter 不是 `EditableText` 的後代,用 context 往上找拿不到
/// 目前文字,只能退回 framework 給的 `currentLength`——那正是 grapheme 數。
/// `TextField` 會跟著 controller 變動重建,所以這裡讀到的一定是最新值。
InputCounterWidgetBuilder runeCounter(
int maxRunes,
TextEditingController controller,
) {
return (
BuildContext context, {
required int currentLength,
required int? maxLength,
required bool isFocused,
}) {
final used = runeCount(controller.text);
return Text(
'$used/$maxRunes',
style: Theme.of(context).textTheme.bodySmall,
semanticsLabel: '已輸入 $used 字,上限 $maxRunes 字',
);
};
}
Loading
Loading