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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 測試殘留);
只剩「評分的營運動作」等營運說得出要對低分司機做什麼再開。
Expand Down
186 changes: 185 additions & 1 deletion docs/TODO.md

Large diffs are not rendered by default.

214 changes: 214 additions & 0 deletions lib/core/api/customer_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,188 @@ class CustomerApiClient {
}
}

// ---------- 常用地點(住家/公司/自訂)----------

/// 我的常用地點;後端已排好序(住家 → 公司 → 其他)。
Future<List<SavedPlace>> fetchSavedPlaces() async {
try {
final res = await _dio.get<Map<String, dynamic>>('/customer/places');
return SavedPlace.listFrom(res.data?['places']);
} on DioException catch (e) {
throw _wrap(e);
}
}

/// 新增常用地點。
///
/// **kind 為 home/work 時是覆蓋語意**:後端會更新既有那一筆而不是回錯,
/// 所以 UI 上「設定住家」可以直接送,不必先查有沒有、也不必先刪舊的。
Future<SavedPlace> createSavedPlace({
required String kind,
required String label,
required String address,
required double lat,
required double lng,
}) async {
try {
final res = await _dio.post<Map<String, dynamic>>(
'/customer/places',
data: {
'kind': kind,
'label': label,
'address': address,
'lat': lat,
'lng': lng,
},
);
return SavedPlace.fromJson(
Map<String, dynamic>.from(res.data!['place'] as Map),
);
} on DioException catch (e) {
throw _wrap(e);
}
}

/// 更新常用地點的名稱/地址/座標(kind 不可改,送了後端也會忽略)。
Future<SavedPlace> updateSavedPlace(
int id, {
required String label,
required String address,
required double lat,
required double lng,
}) async {
try {
final res = await _dio.put<Map<String, dynamic>>(
'/customer/places/$id',
data: {
'label': label,
'address': address,
'lat': lat,
'lng': lng,
},
);
return SavedPlace.fromJson(
Map<String, dynamic>.from(res.data!['place'] as Map),
);
} on DioException catch (e) {
throw _wrap(e);
}
}

Future<void> deleteSavedPlace(int id) async {
try {
await _dio.delete('/customer/places/$id');
} on DioException catch (e) {
throw _wrap(e);
}
}

// ---------- 預約行程 ----------

/// 我的預約。[upcomingOnly] 只回還沒轉單的(首頁那張卡);否則含已轉單/已取消。
///
/// 回傳同時帶後端的提前發動分鐘數——「我們會提前幾分鐘幫你找車」這句話要跟後端
/// 實際行為一致,寫死在 App 端的話改後端就會說謊。
Future<ScheduledRidesResult> fetchScheduledRides({
bool upcomingOnly = false,
}) async {
try {
final res = await _dio.get<Map<String, dynamic>>(
'/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<ScheduledRide> fetchScheduledRide(int id) async {
try {
final res =
await _dio.get<Map<String, dynamic>>('/customer/scheduled-rides/$id');
return ScheduledRide.fromJson(
Map<String, dynamic>.from(res.data!['scheduled_ride'] as Map),
);
} on DioException catch (e) {
throw _wrap(e);
}
}

/// 建立預約。[scheduledAt] 為本地時間,送出前轉 UTC 的 RFC3339。
Future<ScheduledRide> 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<Map<String, dynamic>>(
'/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<String, dynamic>.from(res.data!['scheduled_ride'] as Map),
);
} on DioException catch (e) {
throw _wrap(e);
}
}

/// 取消預約。
///
/// **409 代表它已經被轉成真訂單了**(排程器搶在取消之前發動)。這種情況後端會把
/// 該筆預約的現況一起回來——不能對乘客說「取消失敗,請稍後再試」,那張訂單已經在
/// 派單池裡,司機可能正在開過來。回傳的 [ScheduledRide] 讓 UI 直接切成「已為你派車」
/// 並引導去取消訂單。
Future<ScheduledRide> cancelScheduledRide(int id) async {
try {
final res = await _dio.post<Map<String, dynamic>>(
'/customer/scheduled-rides/$id/cancel',
);
return ScheduledRide.fromJson(
Map<String, dynamic>.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<String, dynamic>.from(raw['scheduled_ride'] as Map),
),
apiErrorMessage(e),
);
}
}
throw _wrap(e);
}
}

/// 401(登入/註冊以外)= session 失效:通知 controller 清掉它。
/// 詳見 `FleetApiClient._wrap`——兩端同一條規則。
ApiException _wrap(DioException e) {
Expand All @@ -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<ScheduledRide> 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;
}
Loading
Loading