Skip to content
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。
- **我的行程歷史(2026-07-19)**:乘客首頁右上「我的行程」→ 列出過去行程
(狀態/路線/時間/車資);**有司機的行程可事後「聯絡司機」**開對話
(沿用 `RideChatScreen`)。後端 `GET /customer/rides`(只回本人,LEFT JOIN 司機名)。
**分頁(2026-07-31)**:一次 20 筆,捲到底自動再要一頁——這個畫面是事後聯絡司機/
申報遺失物/補評分的唯一入口,固定 20 筆等於第 21 趟以前的行程再也打不開。
後端沒有 offset/cursor,所以「載入更多」是**把 `limit` 加大重讀**(上限 5000);
下拉刷新保持已展開的筆數,登出才收回一頁。
- **乘客端多停靠點行程進度(2026-07-21)**:多乘客訂單在地圖上依序畫出全程停靠點
(乘客標籤 A/B…)+「司機→下一站→之後待處理站」折線,sheet 內「行程進度 N/M 站」
與全程清單。司機每標記一站,WS **`ride.stop_updated`**(payload 帶整趟 stops)即時更新,
Expand Down Expand Up @@ -215,8 +219,8 @@ REST 一定完整,而推播要傳達的資訊只有「有事發生了」。
至此三端齊備:**乘客評 → 司機看得到自己的平均分 → 營運看得出誰評價低**。
詳見 [`docs/TODO.md`](docs/TODO.md)「⭐ 乘客評分司機」。

**目前**:`flutter analyze` 無 issue、`flutter test` **377 passed**(50 個測試檔,2026-07-30 實跑)。
~~361 passed~~/~~356 passed~~/~~351 passed~~/~~339 passed~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。
**目前**:`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~~ 是漏更新的舊數字——**這一行請跟著最後一次實跑一起改**。

**2026-07-30 弱網逾時對帳的實跑收尾**(詳見 [`docs/TODO.md`](docs/TODO.md) 第十四~十五輪):
先做了一支「請求照送、回應吃掉」的代理 [`tool/lossy_proxy.py`](tool/lossy_proxy.py)——
Expand Down Expand Up @@ -293,6 +297,11 @@ SnackBar;FCM token 輪替失敗會冒出司機看不懂也無事可做的紅
其中「系統定位服務被關」原本是**完全靜默**的(例外穿出 `placeOrder`,畫面一句話都沒有)。
另外,**多停靠點行程不再需要裝置定位**:pickup/dropoff 由 stops 推導,後端本來就不看那組座標。
兩端都在 `m6_pixel` 上以 `settings put secure location_mode 0` 實跑驗過。
**第二十三輪**再補一個真裝置才會發生的洞:**App 被系統收掉(或司機自己滑掉)再開時,
行程會被還原,但定位回報整段消失**——乘客端的司機 marker 定格、抵達圍籬不觸發、
里程軌跡缺一段,而司機看到的只是 hero 上那句「離線」。冷啟動還原到進行中行程時會接回回報
(只在已有權限時、且不在回前景時做,那顆離線鈕要按得掉)。
同一台裝置、同一張行程跑過修改前後兩個版本:修好的版本重開 8 秒後恢復回報,修改前 40 秒零筆。

- **預約司機+常用地點(2026-07-31)**:乘客可預約**未來**的用車,以及把住家/公司等
常去的地點存起來,叫車與預約時一鍵帶入。
Expand Down
270 changes: 265 additions & 5 deletions docs/TODO.md

Large diffs are not rendered by default.

64 changes: 62 additions & 2 deletions lib/customer/customer_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class CustomerController extends ChangeNotifier {
// WS 即時到手後只做保底對帳,輪詢間隔放寬。
static const _pollInterval = Duration(seconds: 15);

/// 「我的行程」一次要幾筆;捲到底再多要一頁。
static const historyPageSize = 20;

CustomerSession? _session;
bool _loading = false;
String? _error;
Expand Down Expand Up @@ -110,6 +113,13 @@ class CustomerController extends ChangeNotifier {
List<CustomerRideSummary> _rideHistory = [];
bool _historyLoading = false;
String? _historyError;
// 分頁:目前跟後端要幾筆、還有沒有更舊的、載入更多的進行中/失敗狀態。
// 沒有這一組的話清單就只有最近 20 筆——而「我的行程」是事後聯絡司機、
// 申報遺失物、補評分的**唯一入口**,第 21 趟以前的行程等於再也打不開。
int _historyWindow = historyPageSize;
bool _historyHasMore = false;
bool _historyLoadingMore = false;
String? _historyMoreError;
// session 失效清理中;並發的 401(輪詢+使用者操作同時)不重入清理。
bool _sessionExpiring = false;

Expand Down Expand Up @@ -366,14 +376,31 @@ class CustomerController extends ChangeNotifier {
bool get historyLoading => _historyLoading;
String? get historyError => _historyError;

/// 載入歷史行程(進「我的行程」畫面時呼叫)。
/// 是否還有更舊的行程沒載進來(捲到底時要不要繼續要)。
bool get historyHasMore => _historyHasMore;

/// 正在載入更舊的行程(首載/下拉刷新走 `historyLoading`,兩者不共用)。
bool get historyLoadingMore => _historyLoadingMore;

/// 「載入更多」那一次失敗的訊息。**與 `historyError` 分開**:已經載進來的
/// 行程還在畫面上,錯誤只該長在清單尾巴,不能讓整頁變成錯誤畫面。
String? get historyMoreError => _historyMoreError;

/// 載入歷史行程(進「我的行程」畫面時呼叫;下拉刷新也是這支)。
///
/// **刷新時要求的是目前已展開的筆數**(`_historyWindow`),不是固定第一頁——
/// 已經捲到第 60 筆的人下拉刷新,清單不該縮回 20 筆。
Future<void> loadRideHistory() async {
if (_session == null) return;
_historyLoading = true;
_historyError = null;
_historyMoreError = null;
notifyListeners();
final want = _historyWindow;
try {
_rideHistory = await _api.fetchRideHistory();
final rows = await _api.fetchRideHistory(limit: want);
_rideHistory = rows;
_historyHasMore = rows.length >= want;
} on ApiException catch (e) {
_historyError = e.message;
} finally {
Expand All @@ -382,6 +409,36 @@ class CustomerController extends ChangeNotifier {
}
}

/// 再往下要一頁更舊的行程(捲到清單底部時觸發)。
///
/// 後端 `GET /api/customer/rides` **只有 `limit`、沒有 offset/cursor**,
/// 所以這裡是「把 limit 加大重讀一次」而不是接續抓下一段:回來的永遠是
/// 完整的前 N 筆,順帶把已顯示那幾筆的評分/協尋狀態一起更新。
/// 筆數量級到上百筆(後端上限 `MaxListRows`=5000)再改 cursor 才划算。
Future<void> loadMoreRideHistory() async {
if (_session == null) return;
// 沒有更多、或已經有一個請求在飛,就不要再發(捲動會連續觸發很多次)。
if (!_historyHasMore || _historyLoading || _historyLoadingMore) return;
_historyLoadingMore = true;
_historyMoreError = null;
notifyListeners();
final want = _historyWindow + historyPageSize;
try {
final rows = await _api.fetchRideHistory(limit: want);
_rideHistory = rows;
_historyWindow = want;
// 回滿 = 後面可能還有。剛好整除時會多要一次、下一次才收掉尾巴,
// 這是沒有 cursor 的必然代價,比「少一頁永遠看不到」好。
_historyHasMore = rows.length >= want;
} on ApiException catch (e) {
// 視窗**不推進**:重試時才會重新要同一段,已載入的清單原樣留著。
_historyMoreError = e.message;
} finally {
_historyLoadingMore = false;
notifyListeners();
}
}

/// 對已完成行程評分司機(B5)。成功回 null,失敗回可直接顯示的中文訊息。
///
/// **不寫進 `_error`**:評分是使用者當下在對話框裡做的動作,錯誤要留在對話框上,
Expand Down Expand Up @@ -949,6 +1006,9 @@ class CustomerController extends ChangeNotifier {
// 就會在自己的資料載入前先看到前一位乘客的行程與車資。
_rideHistory = [];
_historyError = null;
_historyMoreError = null;
_historyWindow = historyPageSize;
_historyHasMore = false;
// 常用地點與預約同一個道理,而且更敏感——住家與公司是**實體位置**,
// 預約則是「這個人什麼時候會不在家」。不清的話,下一位在這台裝置登入的人
// 一打開叫車頁,快捷列上就是上一位乘客的住家地址。
Expand Down
88 changes: 84 additions & 4 deletions lib/customer/screens/ride_history_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,87 @@ class _CustomerRideHistoryScreenState extends State<CustomerRideHistoryScreen> {
],
);
}
// 尾巴那一格:還有更舊的行程時放「載入中」,載入更多失敗時放重試。
final hasFooter = ctrl.historyHasMore || ctrl.historyMoreError != null;
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: ctrl.rideHistory.length,
itemBuilder: (context, i) =>
_RideHistoryCard(ctrl: ctrl, ride: ctrl.rideHistory[i]),
itemCount: ctrl.rideHistory.length + (hasFooter ? 1 : 0),
itemBuilder: (context, i) {
if (i == ctrl.rideHistory.length) return _HistoryFooter(ctrl: ctrl);
return _RideHistoryCard(ctrl: ctrl, ride: ctrl.rideHistory[i]);
},
);
}
}

/// 清單尾巴:被建出來就代表使用者已經捲到底,直接去要下一頁。
///
/// **不是按鈕**——舊行程是「想起有東西掉在車上」時才會去翻的,
/// 多一次點擊就多一個放棄點;失敗時才退化成看得懂的重試按鈕。
class _HistoryFooter extends StatefulWidget {
const _HistoryFooter({required this.ctrl});

final CustomerController ctrl;

@override
State<_HistoryFooter> createState() => _HistoryFooterState();
}

class _HistoryFooterState extends State<_HistoryFooter> {
@override
void initState() {
super.initState();
// build 期間不可改 provider 狀態,排到下一影格(同畫面 initState 的作法)。
WidgetsBinding.instance.addPostFrameCallback((_) => _autoLoad());
}

@override
void didUpdateWidget(covariant _HistoryFooter oldWidget) {
super.didUpdateWidget(oldWidget);
// **只靠 initState 會卡死**:尾巴被建出來的那一刻若剛好有別的請求在飛
//(下拉刷新、或首載還沒回來),controller 的重入防護會把這次補讀擋掉,
// 而擋掉之後沒有任何人再試一次——尾巴就永遠停在轉圈,第 21 筆之後再也進不來。
// 尾巴還在畫面上代表使用者仍停在底部,所以每次重建都補問一次。
WidgetsBinding.instance.addPostFrameCallback((_) => _autoLoad());
}

/// 自動補讀。**失敗過就停手**交給重試按鈕:`didUpdateWidget` 會跟著每一次
/// `notifyListeners` 觸發,自動重試在後端還沒恢復時會變成連續打點。
void _autoLoad() {
if (!mounted || widget.ctrl.historyMoreError != null) return;
_load();
}

void _load() {
if (!mounted) return;
// 重入由 controller 擋(有請求在飛就直接 return)。
widget.ctrl.loadMoreRideHistory();
}

@override
Widget build(BuildContext context) {
final err = widget.ctrl.historyMoreError;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Center(
child: err == null
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
: Column(
children: [
Text(err, textAlign: TextAlign.center),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('載入更多'),
),
],
),
),
);
}
}
Expand All @@ -93,7 +169,11 @@ class _RideHistoryCard extends StatelessWidget {
final local = t.toLocal();
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
return '${_months[local.month]}${local.day}日 $hh:$mm';
// **跨年的行程從分頁上線後才翻得到**(在那之前只看得到最近 20 趟)。
// 「1月5日」看不出是今年還是去年,而這頁是事後申報遺失物、補評分的入口——
// 找錯年份就等於找錯那一趟。今年的不加年份,免得每張卡都變長。
final year = local.year == DateTime.now().year ? '' : '${local.year}年';
return '$year${_months[local.month]}${local.day}日 $hh:$mm';
}

void _openChat(BuildContext context) {
Expand Down
76 changes: 72 additions & 4 deletions lib/driver/driver_controller.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
Expand All @@ -17,9 +18,29 @@ import '../core/ws/fleet_ws_client.dart';
/// 定位串流的來源;測試以此換掉真 GPS(比照 `FleetWsClientFactory`)。
typedef DriverPositionStreamFactory = Stream<Position> Function(LocationSettings);

/// 上線前的權限確認(可能會彈系統視窗);測試以此換掉平台對話框。
typedef DriverLocationPermissionCheck = Future<bool> Function();

Stream<Position> _geolocatorPositionStream(LocationSettings settings) =>
Geolocator.getPositionStream(locationSettings: settings);

/// 「現在的定位權限」——**只查不請求**,冷啟動自動恢復回報時用。
/// `null` = 查不到(平台不可用,例如單元測試沒有 platform channel):
/// **查不到與被拒絕是兩件事**,前者什麼都不該做,後者要跟司機說一聲。
typedef DriverLocationPermissionProbe = Future<LocationPermission?> Function();

Future<LocationPermission?> _geolocatorPermissionProbe() async {
// 測試環境沒有 platform channel,這支呼叫的 Future **永遠不會完成**——
// `init()` await 下去就把整個測試卡到 timeout(實測 driver_home_widget_test
// 從 4.8 秒變成 7 分鐘以上)。「查不到」正是這裡該有的答案。
if (Platform.environment.containsKey('FLUTTER_TEST')) return null;
try {
return await Geolocator.checkPermission();
} catch (_) {
return null;
}
}

/// 司機端狀態:登入、上線、WS 派單、行程操作。
class DriverController extends ChangeNotifier {
DriverController({
Expand All @@ -28,11 +49,16 @@ class DriverController extends ChangeNotifier {
FleetWsClientFactory? wsFactory,
FleetPushService? push,
DriverPositionStreamFactory? positionStream,
DriverLocationPermissionCheck? locationPermissions,
DriverLocationPermissionProbe? locationPermissionProbe,
}) : _storage = storage ?? TokenStorage(),
_api = api ?? FleetApiClient(),
_wsFactory = wsFactory ?? FleetWsClient.new,
_push = push ?? NoOpFleetPushService(),
_positionStream = positionStream ?? _geolocatorPositionStream,
_ensurePermissions =
locationPermissions ?? ensureDriverLocationPermissions,
_permissionProbe = locationPermissionProbe ?? _geolocatorPermissionProbe,
_ws = FleetWsClient(onEvent: (_) {}) {
// token 過期/失效時把司機送回登入頁(見 _handleUnauthorized)。
_api.onUnauthorized = _handleUnauthorized;
Expand All @@ -43,6 +69,8 @@ class DriverController extends ChangeNotifier {
final FleetWsClientFactory _wsFactory;
final FleetPushService _push;
final DriverPositionStreamFactory _positionStream;
final DriverLocationPermissionCheck _ensurePermissions;
final DriverLocationPermissionProbe _permissionProbe;
FleetWsClient _ws;

AuthSession? _session;
Expand Down Expand Up @@ -222,8 +250,12 @@ class DriverController extends ChangeNotifier {
if (_session == null) return;
try {
_vehicle = await _api.fetchVehicle();
// **只清自己造成的那則**:這裡原本無條件 `_setError(null)`,於是任何排在
// 它前面設好的訊息都會被洗掉——`init()` 裡「需要定位權限才能把位置回報給乘客」
// 就是這樣消失的(設了、也通知了,下一行就被抹掉,畫面上什麼都沒有)。
// 同一種病第二十一輪在位置回報探針上修過一次:清錯誤要指名清哪一則。
if (_vehicleLoadFailed) _setError(null);
_vehicleLoadFailed = false;
_setError(null);
} on ApiException catch (e) {
_setApiError(e);
_vehicleLoadFailed = true;
Expand Down Expand Up @@ -306,6 +338,7 @@ class DriverController extends ChangeNotifier {
if (saved != null) {
await _applySession(saved);
await _restoreActiveRide();
await _resumeReportingForRestoredRide();
await refreshLostItems();
// O3 gate 的 App 端引導:一還原 session 就查車輛,_DriverRoot 才知道要不要跳設定頁。
await refreshVehicle();
Expand Down Expand Up @@ -374,6 +407,39 @@ class DriverController extends ChangeNotifier {
}
}

/// 冷啟動還原到進行中行程時,把定位回報接回來。
///
/// **這是行程中位置回報唯一會整段消失的路徑**:App 被系統收掉(省電模式殺前景服務、
/// 廠商清背景),或司機自己把它從最近工作清單滑掉,再打開——`_restoreActiveRide`
/// 會把行程還原、行程卡照樣顯示「導航/已上車/完成」,但 `_online` 一律從 false 起、
/// 定位串流也沒起來,於是**整趟零位置回報**:乘客端的司機 marker 定格、ETA 不再更新,
/// 後端的抵達圍籬不會觸發,F3 里程的軌跡缺一整段(車資會偏低)。
/// 而 hero 只寫「離線/目前不會收到派單」——在載客途中,那句話講的是接不接新單,
/// 司機不會從它聯想到「乘客看不到我在動」。
///
/// **只在冷啟動做**:App 還活著時 `_online == false` 一定是司機自己按的,
/// 回前景時再自動上線等於那顆離線鈕按不掉。
///
/// **而且只在已經有權限時做**:冷啟動就彈系統權限視窗太侵入(司機可能只是想看歷史),
/// 沒權限時留一則可行動的訊息,等他自己按上線再彈。
Future<void> _resumeReportingForRestoredRide() async {
if (_session == null || _online || _activeRide == null) return;
const denied = '需要定位權限才能把位置回報給乘客';
switch (await _permissionProbe()) {
case LocationPermission.always:
case LocationPermission.whileInUse:
await _goOnline(deniedMessage: denied);
case LocationPermission.denied:
case LocationPermission.deniedForever:
case LocationPermission.unableToDetermine:
// 不彈視窗,只說一聲——司機按上線時才會走到請求那條。
_setError(denied);
notifyListeners();
case null:
break; // 查不到權限狀態:不自動恢復,也不編故事。
}
}

/// App 從背景回到前景(由 `AppLifecycleReactor` 呼叫)。
///
/// 司機端**沒有任何輪詢**:`ride.assigned`/`ride.cancelled`/`ride.completed`
Expand Down Expand Up @@ -557,11 +623,13 @@ class DriverController extends ChangeNotifier {
}
}

Future<void> goOnline() async {
Future<void> goOnline() => _goOnline(deniedMessage: '需要定位權限才能上線');

Future<void> _goOnline({required String deniedMessage}) async {
if (_session == null) return;
final ok = await ensureDriverLocationPermissions();
final ok = await _ensurePermissions();
if (!ok) {
_setError('需要定位權限才能上線');
_setError(deniedMessage);
notifyListeners();
return;
}
Expand Down
Loading
Loading