diff --git a/README.md b/README.md index ade693e..6548033 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。 詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。 -**目前**:`flutter analyze` 無 issue、`flutter test` **356 passed**(47 個測試檔,2026-07-30 實跑)。 -~~351 passed~~/~~339 passed~~/~~328 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 +**目前**:`flutter analyze` 無 issue、`flutter test` **361 passed**(48 個測試檔,2026-07-30 實跑)。 +~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。 **2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪): 先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)—— @@ -238,8 +238,13 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。 乘客看到「評分失敗」,再按一次只會拿到 409,而他其實已經評過了。 改成逾時/409 時查一次 `GET /customer/rides/:id` 的 `ride.rating.score`; 模擬器實跑驗到表單自己關閉、那一列變成 ★★★★☆「已評分」。 - **至此 App 兩端的寫入路徑逾時對帳全部補完**,只剩聊天送出刻意不做 - (訊息沒有唯一狀態,需要後端冪等鍵,見 `docs/TODO.md`)。 + **至此 App 兩端的寫入路徑逾時對帳全部補完**。 +- **聊天送出**(第十八輪,跨端):這條沒有唯一狀態可查——「同內容再送一次」本來就合法, + 所以改由客戶端產生冪等鍵、後端據此去重(dispatch + [#68](https://github.com/thothawei/fleet-dispatch/pull/68) 的 migration 000024)。 + App 端逾時後補讀並**用鍵比對**:找到=上一次其實送出了(泡泡出現、輸入框清空、不報錯); + 找不到就留著內容與**同一個鍵**讓他重試——重試因為冪等而不會多一則。 + 模擬器實跑+真後端 curl 都驗過:同鍵重送回既有那筆,不同鍵的同內容仍是兩則。 **2026-07-30 這一批**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十二~十三輪): - **對話訊息也會推播**:先前對話只走 WS,對方 App 一離開前景就收不到,訊息躺在伺服器上 diff --git a/docs/TODO.md b/docs/TODO.md index 8efcd79..fcd9326 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1824,6 +1824,58 @@ DB 裡那位乘客只有一筆新訂單,證明 App 沒有重送。 --- +## 💬 2026-07-30 第十八輪:聊天送出的冪等鍵(跨端,逾時對帳最後一條) + +> 第十七輪把逾時對帳整族清完時,唯一剩下的就是聊天送出,並且**刻意沒做**—— +> 當時的判斷是「這條不能照抄,需要後端冪等鍵,屬跨端決策」。這一輪就是把那個決策做掉。 + +### 為什麼這條不能用「查一次狀態」對帳 + +其他寫入(接單/取消/協尋/評分)在後端都有**唯一狀態**:查一次就知道生效沒。 +訊息沒有——「同內容再送一次」本來就是合法行為, +所以 App 無法只靠 `afterId` 補讀分辨「上一次其實送出了」與「使用者真的想再說一次」。 +**唯一的解法是由客戶端給一個鍵、後端據此去重。** + +### 後端(dispatch [#68](https://github.com/thothawei/fleet-dispatch/pull/68)) + +- migration 000024:`ride_messages` 加 `client_msg_id VARCHAR(64)` + partial unique index + `(ride_id, sender_role, sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL`。 + 去重範圍刻意是「同趟同發話者」而非全表——鍵由客戶端產生,跨使用者撞號不該讓後方那位發不出訊息。 +- `ChatService.SendWithClientID`:帶鍵時先查既有那筆 → 有就回它,**不重複寫入也不重複推播** + (否則對方會看到同一句話兩次)。查鍵排在**授權之後**,不然外人拿鍵去試就能反推那趟有沒有這則訊息。 +- 反向確認分兩層:只關掉服務層預查 → 測試仍過(DB 唯一索引 + `Create` 後的 fallback 接手); + 兩層都關 → FAIL 於 `duplicate key ... uq_ride_messages_client_msg_id`。 + **兩層防線各自都足夠**——第一次只關一層時測試沒紅,差點誤判成「測試沒釘住」。 + +### App 端(本 repo) + +- `RideMessage` 多 `clientMsgId`;兩端 `sendMessage` 多具名參數 `clientMsgId` + (用 null-aware element `'client_msg_id': ?clientMsgId`,沒帶時請求形狀不變)。 +- `ride_chat_screen`: + - 這一則的鍵在第一次送出時產生,**失敗時留著**(`_pendingClientMsgId`)—— + 重試沿用同一個鍵才不會在後端變成兩則;送出確認落地才清掉,下一則拿新的鍵。 + - 逾時(`statusCode == null`)→ 補讀 `afterId` 之後的訊息,**用鍵比對**: + 找到=上一次其實送出了 → 泡泡出現、輸入框清空、不顯示錯誤。 + 找不到 → 留著錯誤與內容讓他重試(重試安全)。 + - 補讀到的其他訊息(對方同時說的話)一併顯示——既然問了就別浪費。 + - 明確拒絕(有狀態碼,如 400 訊息過長)**不對帳**。 + +### 驗收 + +- App:`flutter analyze` 無 issue、`flutter test` **361 passed**(356 +新 5)。 + 反向確認兩層:關掉補讀對帳 → 2 案 FAIL;重試不沿用同一個鍵 → 1 案 FAIL。 +- 後端:6 案整合測試(testcontainers)本機全綠;CI 純單元集照過。 +- **模擬器實跑(blackhole `POST:/api/rides/\d+/messages` + `ws_block`,WS 全程被擋)**: + 1. 代理 log `上游回 200,不交還 App` = 後端真的寫入了。 + 2. 逾時 15 秒後出現對帳那次 `GET /api/rides/26/messages`。 + 3. 畫面出現自己的綠色泡泡、輸入框清空、**沒有錯誤橫幅**。 + 4. DB `ride_messages` 只有一則,`client_msg_id = customer-1785385960604-0`(`role-毫秒-序號`)。 +- **冪等性在真後端上直接驗過**(curl,dev DB 已跑 migration 24): + - 帶**同一個鍵**重送 → 回 `id=6`(連 `created_at` 都是原本那筆),DB 沒有新增。 + - 帶**不同鍵**、同樣內容 → 新的 `id=7`。「真的想再說一次」照樣成立。 + +--- + ## 下次任務 > **🎯 2026-07-30 這一輪做完了什麼(開工先看這段)** @@ -1933,7 +1985,10 @@ DB 裡那位乘客只有一筆新訂單,證明 App 沒有重送。 > ✅ **`submitRating` 已於第十七輪做掉**(見上方專段,含模擬器實跑)。 > **逾時對帳這一族到此清完**,現況表在第十七輪那段。 > -> **➡️ 剩下唯一一條逾時路徑:聊天送出(`ride_chat_screen._send`)——刻意先不做**: +> ~~**➡️ 剩下唯一一條逾時路徑:聊天送出——刻意先不做**~~ +> ✅ **已於第十八輪做掉(跨端)**:後端加冪等鍵(dispatch #68 的 migration 000024)、 +> App 端逾時後用鍵比對補讀,見上方專段。**逾時對帳整族到此完全清完。** +> 下面這段保留當時的判斷理由——它解釋了為什麼這條非得動後端: > 訊息可能其實送出了,畫面只顯示錯誤、輸入框內容留著,乘客重送 → > **後端多一筆重複訊息**(沒有冪等鍵)。 > **不能照抄協尋/評分那套判準**:協尋單與評分在後端都是唯一狀態(查一次就知道生效沒), diff --git a/lib/core/api/customer_api_client.dart b/lib/core/api/customer_api_client.dart index 297c8ff..3667859 100644 --- a/lib/core/api/customer_api_client.dart +++ b/lib/core/api/customer_api_client.dart @@ -252,11 +252,23 @@ class CustomerApiClient { } /// 發送訊息;即時遞送由後端透過 WS chat.message 推給雙方。 - Future sendMessage(int rideId, String body) async { + /// 送出一則訊息。 + /// + /// [clientMsgId] 是**冪等鍵**(後端 dispatch #68):帶同一個鍵重送,後端會回既有那筆、 + /// 不會多一則訊息。送出逾時後的重試一定要沿用同一個鍵——不然對方會看到同一句話兩次。 + Future sendMessage( + int rideId, + String body, { + String? clientMsgId, + }) async { try { final res = await _dio.post>( '/rides/$rideId/messages', - data: {'body': body}, + data: { + 'body': body, + // null-aware element:沒帶鍵時整個欄位不出現(維持既有請求形狀)。 + 'client_msg_id': ?clientMsgId, + }, ); return RideMessage.fromJson( Map.from(res.data!['message'] as Map), diff --git a/lib/core/api/fleet_api_client.dart b/lib/core/api/fleet_api_client.dart index d0682da..e68318c 100644 --- a/lib/core/api/fleet_api_client.dart +++ b/lib/core/api/fleet_api_client.dart @@ -262,11 +262,23 @@ class FleetApiClient { } /// 發送訊息;即時遞送由後端透過 WS chat.message 推給雙方。 - Future sendMessage(int rideId, String body) async { + /// 送出一則訊息。 + /// + /// [clientMsgId] 是**冪等鍵**(後端 dispatch #68):帶同一個鍵重送,後端會回既有那筆、 + /// 不會多一則訊息。送出逾時後的重試一定要沿用同一個鍵——不然對方會看到同一句話兩次。 + Future sendMessage( + int rideId, + String body, { + String? clientMsgId, + }) async { try { final res = await _dio.post>( '/rides/$rideId/messages', - data: {'body': body}, + data: { + 'body': body, + // null-aware element:沒帶鍵時整個欄位不出現(維持既有請求形狀)。 + 'client_msg_id': ?clientMsgId, + }, ); return RideMessage.fromJson( Map.from(res.data!['message'] as Map), diff --git a/lib/core/models/models.dart b/lib/core/models/models.dart index de231a3..c057e40 100644 --- a/lib/core/models/models.dart +++ b/lib/core/models/models.dart @@ -583,6 +583,7 @@ class RideMessage { required this.senderId, required this.body, this.createdAt, + this.clientMsgId, }); final int id; @@ -594,6 +595,13 @@ class RideMessage { final String body; final DateTime? createdAt; + /// 送出時帶的冪等鍵(後端 `client_msg_id`);null =這則沒帶 + /// (對方送的、舊訊息、或 LINE 等非 App 來源)。 + /// + /// **逾時後就是靠它認出「上一次其實送出了」**:訊息在後端沒有唯一狀態, + /// 「同內容再送一次」本來就合法,所以補讀時只能用這個鍵比對,不能比內容。 + final String? clientMsgId; + factory RideMessage.fromJson(Map json) { return RideMessage( id: (json['id'] as num).toInt(), @@ -602,6 +610,7 @@ class RideMessage { senderId: (json['sender_id'] as num?)?.toInt() ?? 0, body: json['body'] as String? ?? '', createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''), + clientMsgId: json['client_msg_id'] as String?, ); } } diff --git a/lib/customer/customer_controller.dart b/lib/customer/customer_controller.dart index bea510f..ca99ad0 100644 --- a/lib/customer/customer_controller.dart +++ b/lib/customer/customer_controller.dart @@ -972,8 +972,9 @@ class CustomerController extends ChangeNotifier { Future> fetchMessages(int rideId, {int afterId = 0}) => _api.fetchMessages(rideId, afterId: afterId); - Future sendMessage(int rideId, String body) => - _api.sendMessage(rideId, body); + Future sendMessage(int rideId, String body, + {String? clientMsgId}) => + _api.sendMessage(rideId, body, clientMsgId: clientMsgId); /// 叫車:以目前 GPS 為上車點,帶乘客輸入的上車/目的地地址; /// 若目的地由地圖選點取得,另帶精確座標(dropoffLat/Lng)。 diff --git a/lib/driver/driver_controller.dart b/lib/driver/driver_controller.dart index e20678c..9d20032 100644 --- a/lib/driver/driver_controller.dart +++ b/lib/driver/driver_controller.dart @@ -852,8 +852,9 @@ class DriverController extends ChangeNotifier { Future> fetchMessages(int rideId, {int afterId = 0}) => _api.fetchMessages(rideId, afterId: afterId); - Future sendMessage(int rideId, String body) => - _api.sendMessage(rideId, body); + Future sendMessage(int rideId, String body, + {String? clientMsgId}) => + _api.sendMessage(rideId, body, clientMsgId: clientMsgId); /// 重新拉未結案協尋工作清單(登入後、遺失物頁下拉)。 Future refreshLostItems() async { diff --git a/lib/shared/screens/ride_chat_screen.dart b/lib/shared/screens/ride_chat_screen.dart index d5be5e9..9103365 100644 --- a/lib/shared/screens/ride_chat_screen.dart +++ b/lib/shared/screens/ride_chat_screen.dart @@ -28,7 +28,10 @@ class RideChatScreen extends StatefulWidget { final String title; final Future> Function(int rideId, {int afterId}) loadHistory; - final Future Function(int rideId, String body) send; + /// 送出一則訊息。[clientMsgId] 是冪等鍵——同一則訊息的重試沿用同一個鍵, + /// 後端據此去重(dispatch #68),所以重送不會讓對方看到同一句話兩次。 + final Future Function(int rideId, String body, + {String? clientMsgId}) send; final Stream incoming; /// 進出聊天室通知 controller(清未讀/暫停未讀累計)。 @@ -48,6 +51,12 @@ class _RideChatScreenState extends State { bool _sending = false; String? _error; + // 目前這一則「還沒確認落地」的訊息用的冪等鍵。 + // 送出成功(或對帳確認其實成功)就清掉,讓下一則拿新的鍵; + // **失敗時要留著**——重試沿用同一個鍵才不會在後端變成兩則。 + String? _pendingClientMsgId; + var _clientMsgSeq = 0; + @override void initState() { super.initState(); @@ -124,24 +133,76 @@ class _RideChatScreenState extends State { Future _send() async { final body = _input.text.trim(); if (body.isEmpty || _sending) return; + // 這一則的鍵:第一次送出時產生,重試時**沿用**(後端據此去重)。 + final clientMsgId = _pendingClientMsgId ??= _newClientMsgId(); setState(() => _sending = true); try { - final msg = await widget.send(widget.rideId, body); + final msg = + await widget.send(widget.rideId, body, clientMsgId: clientMsgId); if (!mounted) return; - setState(() { - _append(msg); - _input.clear(); - _error = null; - }); - _jumpToBottom(); + _acceptSent(msg); } on ApiException catch (e) { if (!mounted) return; + // **逾時不代表沒送出**:後端可能已經寫入,只是回應遺失。 + // 訊息沒有唯一狀態可查(「同內容再送一次」本來就合法),所以補讀回來 + // 用**冪等鍵**比對——找到就是上一次其實送出了。 + final recovered = e.statusCode == null + ? await _findSentByClientMsgId(clientMsgId) + : null; + if (!mounted) return; + if (recovered != null) { + _acceptSent(recovered); + return; + } + // 沒找到(或這一問也失敗)→ 留著錯誤、輸入內容與同一個鍵,讓他重試。 + // 重試是安全的:後端帶同鍵不會多一則。 setState(() => _error = e.message); } finally { if (mounted) setState(() => _sending = false); } } + /// 訊息確認落地:附加到畫面、清空輸入框與錯誤,並讓下一則取得新的鍵。 + void _acceptSent(RideMessage msg) { + setState(() { + _append(msg); + _input.clear(); + _error = null; + _pendingClientMsgId = null; + }); + _jumpToBottom(); + } + + /// 逾時後的對帳:補讀最後一則之後的訊息,看有沒有帶著這個鍵的那一則。 + /// + /// 補讀到的其他訊息(對方同時說的話)也一併顯示——既然問了就別浪費。 + /// 這一問本身失敗時回 null(不知道就不亂改,維持原本的錯誤)。 + Future _findSentByClientMsgId(String clientMsgId) async { + try { + final afterId = _messages.isEmpty ? 0 : _messages.last.id; + final fresh = await widget.loadHistory(widget.rideId, afterId: afterId); + RideMessage? mine; + for (final m in fresh) { + if (m.clientMsgId == clientMsgId) { + mine = m; + } else { + setState(() => _append(m)); + } + } + return mine; + } on ApiException { + return null; + } + } + + /// 產生這一則訊息的冪等鍵。 + /// + /// 不需要全域唯一——後端的去重範圍是「同一趟行程的同一位發話者」, + /// 所以「同一個聊天室內、同一支 App 執行期間不重複」就夠了。 + /// 時間戳(毫秒)+序號可避開同一毫秒連送兩則的碰撞。 + String _newClientMsgId() => + '${widget.selfRole}-${DateTime.now().millisecondsSinceEpoch}-${_clientMsgSeq++}'; + @override Widget build(BuildContext context) { // 沿用 app root 那支 reactor:`inactive` 不算回前景(通知列下拉、來電橫幅都會觸發它, diff --git a/test/chat_resume_test.dart b/test/chat_resume_test.dart index a41b1d2..216086a 100644 --- a/test/chat_resume_test.dart +++ b/test/chat_resume_test.dart @@ -125,6 +125,7 @@ class _FakeChat { return history.where((m) => m.id > afterId).toList(); } - Future send(int rideId, String body) async => + Future send(int rideId, String body, + {String? clientMsgId}) async => _msg(99, body); } diff --git a/test/chat_send_retry_test.dart b/test/chat_send_retry_test.dart new file mode 100644 index 0000000..87c7bde --- /dev/null +++ b/test/chat_send_retry_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:line_fleet_app/core/api/fleet_api_client.dart' show ApiException; +import 'package:line_fleet_app/core/models/models.dart'; +import 'package:line_fleet_app/core/theme/app_theme.dart'; +import 'package:line_fleet_app/shared/screens/ride_chat_screen.dart'; + +/// 聊天送出逾時:後端可能其實收到了、只是回應遺失。 +/// +/// **這條路徑不能像其他寫入那樣「查一次狀態」對帳**——訊息在後端沒有唯一狀態, +/// 「同內容再送一次」本來就是合法行為。所以改由客戶端產生冪等鍵 +/// (後端 dispatch #68 據此去重),逾時後補讀比對那個鍵: +/// 找到 = 上一次其實送出了;找不到 = 留著內容與**同一個鍵**讓他重試(重送不會多一則)。 +void main() { + testWidgets('逾時後補讀發現其實送出了 → 泡泡出現、輸入框清空、沒有錯誤', (tester) async { + final api = _FakeChat(); + await tester.pumpWidget(_screen(api)); + await tester.pumpAndSettle(); + + // 送出逾時,但後端其實寫進去了(帶著我們那個鍵)。 + api.sendFailure = ApiException('請求逾時,請稍後再試'); + api.landedAsIfSent = true; + await tester.enterText(find.byType(TextField), '我在門口了'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(find.text('我在門口了'), findsOneWidget, reason: '訊息其實在後端了,畫面就該有它'); + expect(find.text('請求逾時,請稍後再試'), findsNothing, + reason: '送出成功卻說逾時=畫面在說謊(同第十五輪那個病)'); + expect(tester.widget(find.byType(TextField)).controller?.text, '', + reason: '已經送出去了,輸入框不該還留著那句話'); + }); + + testWidgets('逾時後補讀發現真的沒送出 → 留著錯誤與內容,重試沿用同一個鍵', (tester) async { + final api = _FakeChat(); + await tester.pumpWidget(_screen(api)); + await tester.pumpAndSettle(); + + api.sendFailure = ApiException('請求逾時,請稍後再試'); + api.landedAsIfSent = false; + await tester.enterText(find.byType(TextField), '我在門口了'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(find.text('請求逾時,請稍後再試'), findsOneWidget); + expect(tester.widget(find.byType(TextField)).controller?.text, + '我在門口了', + reason: '沒送出去就要留著內容,不然使用者得重打一次'); + + // 使用者按送出重試——後端能不能去重,全看這次帶的鍵跟上次是不是同一個。 + api.sendFailure = null; + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(api.sentKeys.length, 2); + expect(api.sentKeys[0], api.sentKeys[1], + reason: '重試換了新鍵的話,後端會認成兩則不同的訊息——對方看到同一句話兩次'); + expect(find.text('我在門口了'), findsOneWidget); + }); + + testWidgets('送出成功後,下一則要用新的鍵', (tester) async { + final api = _FakeChat(); + await tester.pumpWidget(_screen(api)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '第一句'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '第二句'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(api.sentKeys.length, 2); + expect(api.sentKeys[0], isNot(api.sentKeys[1]), + reason: '沿用同一個鍵會讓第二句被後端當成第一句的重送而吃掉'); + }); + + testWidgets('對帳補讀時,對方同時說的話也要顯示', (tester) async { + final api = _FakeChat(); + await tester.pumpWidget(_screen(api)); + await tester.pumpAndSettle(); + + api.sendFailure = ApiException('請求逾時,請稍後再試'); + api.landedAsIfSent = true; + api.othersDuringReconcile = [_incoming(41, '我快到了')]; + await tester.enterText(find.byType(TextField), '好,我等你'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(find.text('好,我等你'), findsOneWidget); + expect(find.text('我快到了'), findsOneWidget, + reason: '既然為了對帳問了一次,補讀到的訊息就不該丟掉'); + }); + + testWidgets('後端明確拒絕(有狀態碼)時不對帳——它本來就沒收下這則', (tester) async { + final api = _FakeChat(); + await tester.pumpWidget(_screen(api)); + await tester.pumpAndSettle(); + final historyCallsBefore = api.historyCalls; + + api.sendFailure = ApiException('訊息長度超過上限', statusCode: 400); + await tester.enterText(find.byType(TextField), '很長的訊息'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + expect(api.historyCalls, historyCallsBefore, reason: '400 不是「不知道成不成功」'); + expect(find.text('訊息長度超過上限'), findsOneWidget); + }); +} + +Widget _screen(_FakeChat api) => MaterialApp( + theme: appLightTheme, + home: RideChatScreen( + rideId: 9, + selfRole: 'driver', + title: '聯絡乘客(行程 #9)', + loadHistory: api.loadHistory, + send: api.send, + incoming: api.incoming.stream, + ), + ); + +RideMessage _incoming(int id, String body) => RideMessage( + id: id, + rideId: 9, + senderRole: 'customer', + senderId: 3, + body: body, + ); + +/// 假聊天後端。 +/// +/// [sendFailure] 設了就讓送出失敗;[landedAsIfSent] =「後端其實寫進去了」—— +/// 補讀時就會回一則帶著那個鍵的訊息(模擬回應遺失的正向競態)。 +class _FakeChat { + final incoming = StreamController.broadcast(); + final sentKeys = []; + + ApiException? sendFailure; + bool landedAsIfSent = false; + List othersDuringReconcile = const []; + int historyCalls = 0; + + String? _lastBody; + int _nextId = 100; + + Future> loadHistory(int rideId, {int afterId = 0}) async { + historyCalls++; + final rows = [...othersDuringReconcile]; + if (landedAsIfSent && sentKeys.isNotEmpty) { + rows.add(RideMessage( + id: _nextId++, + rideId: 9, + senderRole: 'driver', + senderId: 7, + body: _lastBody ?? '', + clientMsgId: sentKeys.last, + )); + } + return rows.where((m) => m.id > afterId).toList(); + } + + Future send(int rideId, String body, + {String? clientMsgId}) async { + sentKeys.add(clientMsgId); + _lastBody = body; + if (sendFailure != null) throw sendFailure!; + return RideMessage( + id: _nextId++, + rideId: 9, + senderRole: 'driver', + senderId: 7, + body: body, + clientMsgId: clientMsgId, + ); + } +}