Features: - Beacon scanner service for detecting nearby beacons - Beacon cache for offline-first beacon resolution - Preload cache for instant menu display - Business selector screen for multi-location support - Rescan button widget for quick beacon refresh - Sign-in dialog for guest checkout flow - Task type model for server tasks Improvements: - Enhanced menu browsing with category filtering - Improved cart view with better modifier display - Order history with detailed order tracking - Chat screen improvements - Better error handling in API service Fixes: - CashApp payment return crash fix - Modifier nesting issues resolved - Auto-expand modifier groups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1560 lines
45 KiB
Dart
1560 lines
45 KiB
Dart
import "dart:convert";
|
|
import "package:http/http.dart" as http;
|
|
|
|
import "../models/cart.dart";
|
|
import "../models/chat_message.dart";
|
|
import "../models/menu_item.dart";
|
|
import "../models/order_detail.dart";
|
|
import "../models/order_history.dart";
|
|
import "../models/restaurant.dart";
|
|
import "../models/service_point.dart";
|
|
import "../models/task_type.dart";
|
|
import "../models/user_profile.dart";
|
|
import "auth_storage.dart";
|
|
|
|
class ApiRawResponse {
|
|
final int statusCode;
|
|
final String rawBody;
|
|
final Map<String, dynamic>? json;
|
|
|
|
const ApiRawResponse({
|
|
required this.statusCode,
|
|
required this.rawBody,
|
|
required this.json,
|
|
});
|
|
}
|
|
|
|
class MenuItemsResult {
|
|
final List<MenuItem> items;
|
|
final String? brandColor;
|
|
|
|
const MenuItemsResult({required this.items, this.brandColor});
|
|
}
|
|
|
|
class LoginResponse {
|
|
final int userId;
|
|
final String userFirstName;
|
|
final String token;
|
|
|
|
const LoginResponse({
|
|
required this.userId,
|
|
required this.userFirstName,
|
|
required this.token,
|
|
});
|
|
|
|
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
|
return LoginResponse(
|
|
userId: (json["UserID"] as num).toInt(),
|
|
userFirstName: (json["UserFirstName"] as String?) ?? "",
|
|
token: (json["Token"] as String?) ?? "",
|
|
);
|
|
}
|
|
}
|
|
|
|
class SendOtpResponse {
|
|
final String uuid;
|
|
final String message;
|
|
|
|
const SendOtpResponse({
|
|
required this.uuid,
|
|
required this.message,
|
|
});
|
|
|
|
factory SendOtpResponse.fromJson(Map<String, dynamic> json) {
|
|
// Try both uppercase and lowercase keys for compatibility
|
|
final uuid = (json["UUID"] as String?) ?? (json["uuid"] as String?) ?? "";
|
|
final message = (json["MESSAGE"] as String?) ?? (json["message"] as String?) ?? "";
|
|
return SendOtpResponse(
|
|
uuid: uuid,
|
|
message: message,
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserSearchResult {
|
|
final int userId;
|
|
final String name;
|
|
final String phone;
|
|
final String email;
|
|
final String avatarUrl;
|
|
|
|
const UserSearchResult({
|
|
required this.userId,
|
|
required this.name,
|
|
required this.phone,
|
|
required this.email,
|
|
required this.avatarUrl,
|
|
});
|
|
}
|
|
|
|
class VerifyOtpResponse {
|
|
final int userId;
|
|
final String token;
|
|
final bool needsProfile;
|
|
final String userFirstName;
|
|
final bool isEmailVerified;
|
|
|
|
const VerifyOtpResponse({
|
|
required this.userId,
|
|
required this.token,
|
|
required this.needsProfile,
|
|
required this.userFirstName,
|
|
required this.isEmailVerified,
|
|
});
|
|
|
|
factory VerifyOtpResponse.fromJson(Map<String, dynamic> json) {
|
|
return VerifyOtpResponse(
|
|
userId: (json["UserID"] as num).toInt(),
|
|
token: (json["Token"] as String?) ?? "",
|
|
needsProfile: (json["NeedsProfile"] as bool?) ?? true,
|
|
userFirstName: (json["UserFirstName"] as String?) ?? "",
|
|
isEmailVerified: (json["IsEmailVerified"] as bool?) ?? false,
|
|
);
|
|
}
|
|
}
|
|
|
|
class Api {
|
|
static String? _userToken;
|
|
|
|
// MVP hardcode
|
|
static int _mvpBusinessId = 17;
|
|
|
|
static void setAuthToken(String? token) => _userToken = token;
|
|
static void clearAuthToken() => _userToken = null;
|
|
|
|
static void setBusinessId(int? businessId) {
|
|
if (businessId != null && businessId > 0) {
|
|
_mvpBusinessId = businessId;
|
|
}
|
|
}
|
|
|
|
static void clearCookies() {
|
|
// no-op
|
|
}
|
|
|
|
static String get baseUrl {
|
|
const v = String.fromEnvironment("PAYFRIT_API_BASE_URL");
|
|
if (v.isEmpty) {
|
|
return "https://biz.payfrit.com/api";
|
|
}
|
|
return v;
|
|
}
|
|
|
|
static Uri _u(String path) {
|
|
final b = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
|
final p = path.startsWith("/") ? path : "/$path";
|
|
return Uri.parse("$b$p");
|
|
}
|
|
|
|
static Map<String, String> _headers({required bool json, int? businessIdOverride}) {
|
|
final h = <String, String>{};
|
|
if (json) h["Content-Type"] = "application/json; charset=utf-8";
|
|
|
|
final tok = _userToken;
|
|
if (tok != null && tok.isNotEmpty) {
|
|
h["X-User-Token"] = tok;
|
|
}
|
|
|
|
final int bid = (businessIdOverride != null && businessIdOverride > 0) ? businessIdOverride : _mvpBusinessId;
|
|
h["X-Business-ID"] = bid.toString();
|
|
|
|
return h;
|
|
}
|
|
|
|
static Map<String, dynamic>? _tryDecodeJsonMap(String body) {
|
|
try {
|
|
final decoded = jsonDecode(body);
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
} catch (_) {
|
|
// If JSON parsing fails, try to extract JSON from the body
|
|
// (handles cases where debug output is appended after JSON)
|
|
final jsonStart = body.indexOf('{');
|
|
final jsonEnd = body.lastIndexOf('}');
|
|
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
|
try {
|
|
final jsonPart = body.substring(jsonStart, jsonEnd + 1);
|
|
final decoded = jsonDecode(jsonPart);
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Future<ApiRawResponse> _getRaw(String path, {int? businessIdOverride}) async {
|
|
final url = _u(path);
|
|
final resp = await http.get(url, headers: _headers(json: false, businessIdOverride: businessIdOverride));
|
|
|
|
final body = resp.body;
|
|
final j = _tryDecodeJsonMap(body);
|
|
|
|
return ApiRawResponse(statusCode: resp.statusCode, rawBody: body, json: j);
|
|
}
|
|
|
|
static Future<ApiRawResponse> _postRaw(
|
|
String path,
|
|
Map<String, dynamic> payload, {
|
|
int? businessIdOverride,
|
|
}) async {
|
|
final url = _u(path);
|
|
final resp = await http.post(
|
|
url,
|
|
headers: _headers(json: true, businessIdOverride: businessIdOverride),
|
|
body: jsonEncode(payload),
|
|
);
|
|
|
|
final body = resp.body;
|
|
final j = _tryDecodeJsonMap(body);
|
|
|
|
return ApiRawResponse(statusCode: resp.statusCode, rawBody: body, json: j);
|
|
}
|
|
|
|
static bool _ok(Map<String, dynamic> j) => j["OK"] == true || j["ok"] == true;
|
|
|
|
static String _err(Map<String, dynamic> j) => (j["ERROR"] ?? j["error"] ?? "").toString();
|
|
|
|
static List<dynamic>? _pickArray(Map<String, dynamic> j, List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = j[k];
|
|
if (v is List) return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Map<String, dynamic> _requireJson(ApiRawResponse raw, String label) {
|
|
final j = raw.json;
|
|
if (j == null) {
|
|
throw StateError("$label request failed: ${raw.statusCode}\nNon-JSON response.");
|
|
}
|
|
return j;
|
|
}
|
|
|
|
// -------------------------
|
|
// Authentication
|
|
// -------------------------
|
|
|
|
static Future<LoginResponse> login({
|
|
required String username,
|
|
required String password,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/auth/login.cfm",
|
|
{
|
|
"username": username,
|
|
"password": password,
|
|
},
|
|
);
|
|
|
|
final j = _requireJson(raw, "Login");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "bad_credentials") {
|
|
throw StateError("Invalid email/phone or password");
|
|
} else if (err == "missing_fields") {
|
|
throw StateError("Username and password are required");
|
|
} else {
|
|
throw StateError("Login failed: $err");
|
|
}
|
|
}
|
|
|
|
final response = LoginResponse.fromJson(j);
|
|
|
|
// Store token for future requests
|
|
setAuthToken(response.token);
|
|
|
|
return response;
|
|
}
|
|
|
|
static Future<void> logout() async {
|
|
setAuthToken(null);
|
|
clearCookies();
|
|
await AuthStorage.clearAuth();
|
|
}
|
|
|
|
// -------------------------
|
|
// Signup / OTP Verification
|
|
// -------------------------
|
|
|
|
/// Send OTP to phone number for signup
|
|
/// Returns UUID to use in verifyOtp
|
|
static Future<SendOtpResponse> sendOtp({required String phone}) async {
|
|
final raw = await _postRaw("/auth/sendOTP.cfm", {"phone": phone});
|
|
final j = _requireJson(raw, "SendOTP");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "phone_exists") {
|
|
throw StateError("This phone number already has an account. Please login instead.");
|
|
} else if (err == "invalid_phone") {
|
|
throw StateError("Please enter a valid 10-digit phone number");
|
|
} else {
|
|
throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}");
|
|
}
|
|
}
|
|
|
|
return SendOtpResponse.fromJson(j);
|
|
}
|
|
|
|
/// Verify OTP and get auth token
|
|
/// If needsProfile is true, call completeProfile next
|
|
static Future<VerifyOtpResponse> verifyOtp({
|
|
required String uuid,
|
|
required String otp,
|
|
}) async {
|
|
final raw = await _postRaw("/auth/verifyOTP.cfm", {
|
|
"uuid": uuid,
|
|
"otp": otp,
|
|
});
|
|
final j = _requireJson(raw, "VerifyOTP");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "invalid_otp") {
|
|
throw StateError("Invalid verification code. Please try again.");
|
|
} else if (err == "expired") {
|
|
throw StateError("Verification expired. Please request a new code.");
|
|
} else {
|
|
throw StateError("Verification failed: ${j["MESSAGE"] ?? err}");
|
|
}
|
|
}
|
|
|
|
final response = VerifyOtpResponse.fromJson(j);
|
|
|
|
// Store token for future requests
|
|
setAuthToken(response.token);
|
|
|
|
return response;
|
|
}
|
|
|
|
/// Complete user profile after phone verification
|
|
static Future<void> completeProfile({
|
|
required String firstName,
|
|
required String lastName,
|
|
required String email,
|
|
}) async {
|
|
final raw = await _postRaw("/auth/completeProfile.cfm", {
|
|
"firstName": firstName,
|
|
"lastName": lastName,
|
|
"email": email,
|
|
});
|
|
final j = _requireJson(raw, "CompleteProfile");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "email_exists") {
|
|
throw StateError("This email is already associated with another account");
|
|
} else if (err == "invalid_email") {
|
|
throw StateError("Please enter a valid email address");
|
|
} else if (err == "unauthorized") {
|
|
throw StateError("Authentication failed - please try signing up again");
|
|
} else {
|
|
throw StateError("Failed to save profile: ${j["MESSAGE"] ?? err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resend OTP to the same phone (uses existing UUID)
|
|
static Future<SendOtpResponse> resendOtp({required String phone}) async {
|
|
return sendOtp(phone: phone);
|
|
}
|
|
|
|
// -------------------------
|
|
// Login via OTP (for existing verified accounts)
|
|
// -------------------------
|
|
|
|
/// Send OTP to phone number for LOGIN (existing accounts only)
|
|
static Future<SendOtpResponse> sendLoginOtp({required String phone}) async {
|
|
final raw = await _postRaw("/auth/loginOTP.cfm", {"phone": phone});
|
|
final j = _requireJson(raw, "LoginOTP");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "no_account") {
|
|
throw StateError("No account found with this phone number. Please sign up first.");
|
|
} else if (err == "invalid_phone") {
|
|
throw StateError("Please enter a valid 10-digit phone number");
|
|
} else {
|
|
throw StateError("Failed to send code: ${j["MESSAGE"] ?? err}");
|
|
}
|
|
}
|
|
|
|
return SendOtpResponse.fromJson(j);
|
|
}
|
|
|
|
/// Verify OTP for LOGIN and get auth token
|
|
static Future<LoginResponse> verifyLoginOtp({
|
|
required String uuid,
|
|
required String otp,
|
|
}) async {
|
|
final raw = await _postRaw("/auth/verifyLoginOTP.cfm", {
|
|
"uuid": uuid,
|
|
"otp": otp,
|
|
});
|
|
final j = _requireJson(raw, "VerifyLoginOTP");
|
|
|
|
if (!_ok(j)) {
|
|
final err = _err(j);
|
|
if (err == "invalid_otp") {
|
|
throw StateError("Invalid code. Please try again.");
|
|
} else if (err == "expired") {
|
|
throw StateError("Session expired. Please request a new code.");
|
|
} else {
|
|
throw StateError("Login failed: ${j["MESSAGE"] ?? err}");
|
|
}
|
|
}
|
|
|
|
final response = LoginResponse.fromJson(j);
|
|
|
|
// Store token for future requests
|
|
setAuthToken(response.token);
|
|
|
|
return response;
|
|
}
|
|
|
|
// -------------------------
|
|
// Businesses (legacy model name: Restaurant)
|
|
// -------------------------
|
|
|
|
static Future<ApiRawResponse> listRestaurantsRaw({double? lat, double? lng}) async {
|
|
if (lat != null && lng != null) {
|
|
return _postRaw("/businesses/list.cfm", {"lat": lat, "lng": lng}, businessIdOverride: _mvpBusinessId);
|
|
}
|
|
return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
|
|
}
|
|
|
|
static Future<List<Restaurant>> listRestaurants({double? lat, double? lng}) async {
|
|
final raw = await listRestaurantsRaw(lat: lat, lng: lng);
|
|
final j = _requireJson(raw, "Businesses");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"Businesses API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["Businesses", "BUSINESSES"]);
|
|
if (arr == null) {
|
|
throw StateError("Businesses JSON missing Businesses array.\nRaw: ${raw.rawBody}");
|
|
}
|
|
|
|
final out = <Restaurant>[];
|
|
for (final e in arr) {
|
|
if (e is Map<String, dynamic>) {
|
|
out.add(Restaurant.fromJson(e));
|
|
} else if (e is Map) {
|
|
out.add(Restaurant.fromJson(e.cast<String, dynamic>()));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// -------------------------
|
|
// Service Points
|
|
// -------------------------
|
|
|
|
static Future<List<ServicePoint>> listServicePoints({required int businessId}) async {
|
|
// CRITICAL: endpoint is behaving like it reads JSON body, not query/header.
|
|
final raw = await _postRaw(
|
|
"/servicepoints/list.cfm",
|
|
{"BusinessID": businessId},
|
|
businessIdOverride: businessId,
|
|
);
|
|
|
|
final j = _requireJson(raw, "ServicePoints");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"ServicePoints API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["ServicePoints", "SERVICEPOINTS"]);
|
|
if (arr == null) {
|
|
throw StateError("ServicePoints JSON missing ServicePoints array.\nRaw: ${raw.rawBody}");
|
|
}
|
|
|
|
final out = <ServicePoint>[];
|
|
for (final e in arr) {
|
|
if (e is Map<String, dynamic>) {
|
|
out.add(ServicePoint.fromJson(e));
|
|
} else if (e is Map) {
|
|
out.add(ServicePoint.fromJson(e.cast<String, dynamic>()));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// -------------------------
|
|
// Menu Items
|
|
// -------------------------
|
|
|
|
static Future<MenuItemsResult> listMenuItems({required int businessId}) async {
|
|
final raw = await _postRaw(
|
|
"/menu/items.cfm",
|
|
{"BusinessID": businessId},
|
|
businessIdOverride: businessId,
|
|
);
|
|
|
|
final j = _requireJson(raw, "MenuItems");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"MenuItems API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["Items", "ITEMS"]);
|
|
if (arr == null) {
|
|
throw StateError("MenuItems JSON missing Items array.\nRaw: ${raw.rawBody}");
|
|
}
|
|
|
|
final out = <MenuItem>[];
|
|
for (final e in arr) {
|
|
if (e is Map<String, dynamic>) {
|
|
out.add(MenuItem.fromJson(e));
|
|
} else if (e is Map) {
|
|
out.add(MenuItem.fromJson(e.cast<String, dynamic>()));
|
|
}
|
|
}
|
|
|
|
// Extract brand color if provided
|
|
String? brandColor;
|
|
final bc = j["BRANDCOLOR"] ?? j["BrandColor"] ?? j["brandColor"];
|
|
if (bc is String && bc.isNotEmpty) {
|
|
brandColor = bc;
|
|
}
|
|
|
|
return MenuItemsResult(items: out, brandColor: brandColor);
|
|
}
|
|
|
|
// -------------------------
|
|
// Cart & Orders
|
|
// -------------------------
|
|
|
|
static Future<Cart> getOrCreateCart({
|
|
required int userId,
|
|
required int businessId,
|
|
required int servicePointId,
|
|
required int orderTypeId,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/getOrCreateCart.cfm",
|
|
{
|
|
"OrderUserID": userId,
|
|
"BusinessID": businessId,
|
|
"OrderServicePointID": servicePointId,
|
|
"OrderTypeID": orderTypeId,
|
|
},
|
|
businessIdOverride: businessId,
|
|
);
|
|
|
|
final j = _requireJson(raw, "GetOrCreateCart");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"GetOrCreateCart API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["MESSAGE"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
return Cart.fromJson(j);
|
|
}
|
|
|
|
static Future<Cart> getCart({required int orderId}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/getCart.cfm",
|
|
{"OrderID": orderId},
|
|
);
|
|
|
|
final j = _requireJson(raw, "GetCart");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"GetCart API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
return Cart.fromJson(j);
|
|
}
|
|
|
|
/// Check if user has an active cart (status=0) - used at app startup
|
|
static Future<ActiveCartInfo?> getActiveCart({required int userId}) async {
|
|
final raw = await _getRaw("/orders/getActiveCart.cfm?UserID=$userId");
|
|
|
|
final j = _requireJson(raw, "GetActiveCart");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"GetActiveCart API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
if (j["HAS_CART"] == true && j["CART"] != null) {
|
|
return ActiveCartInfo.fromJson(j["CART"]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Future<Cart> setLineItem({
|
|
required int orderId,
|
|
required int parentOrderLineItemId,
|
|
required int itemId,
|
|
required bool isSelected,
|
|
int quantity = 1,
|
|
String? remark,
|
|
bool forceNew = false,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/setLineItem.cfm",
|
|
{
|
|
"OrderID": orderId,
|
|
"ParentOrderLineItemID": parentOrderLineItemId,
|
|
"ItemID": itemId,
|
|
"IsSelected": isSelected,
|
|
"Quantity": quantity,
|
|
if (remark != null && remark.isNotEmpty) "Remark": remark,
|
|
if (forceNew) "ForceNew": true,
|
|
},
|
|
);
|
|
|
|
final j = _requireJson(raw, "SetLineItem");
|
|
|
|
if (!_ok(j)) {
|
|
// Log debug info if available
|
|
final debugItem = j["DEBUG_ITEM"];
|
|
if (debugItem != null) {
|
|
print("[API] SetLineItem DEBUG_ITEM: $debugItem");
|
|
}
|
|
throw StateError(
|
|
"SetLineItem failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}${debugItem != null ? ' | DEBUG: $debugItem' : ''}",
|
|
);
|
|
}
|
|
|
|
return Cart.fromJson(j);
|
|
}
|
|
|
|
/// Set the order type (delivery/takeaway) on an existing cart
|
|
static Future<Cart> setOrderType({
|
|
required int orderId,
|
|
required int orderTypeId,
|
|
int? addressId,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/setOrderType.cfm",
|
|
{
|
|
"OrderID": orderId,
|
|
"OrderTypeID": orderTypeId,
|
|
if (addressId != null) "AddressID": addressId,
|
|
},
|
|
);
|
|
|
|
final j = _requireJson(raw, "SetOrderType");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"SetOrderType failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
|
);
|
|
}
|
|
|
|
return Cart.fromJson(j);
|
|
}
|
|
|
|
/// Abandon an order (mark as abandoned, clear items)
|
|
static Future<void> abandonOrder({required int orderId}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/abandonOrder.cfm",
|
|
{"OrderID": orderId},
|
|
);
|
|
|
|
final j = _requireJson(raw, "AbandonOrder");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"AbandonOrder failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
|
);
|
|
}
|
|
}
|
|
|
|
static Future<void> submitOrder({required int orderId}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/submit.cfm",
|
|
{"OrderID": orderId},
|
|
);
|
|
|
|
final j = _requireJson(raw, "SubmitOrder");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"SubmitOrder API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> checkOrderStatus({
|
|
required int orderId,
|
|
required int lastKnownStatusId,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/orders/checkStatusUpdate.cfm",
|
|
{
|
|
"OrderID": orderId,
|
|
"LastKnownStatusID": lastKnownStatusId,
|
|
},
|
|
);
|
|
|
|
final j = _requireJson(raw, "CheckOrderStatus");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"CheckOrderStatus API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
return j;
|
|
}
|
|
|
|
// -------------------------
|
|
// Tasks / Service Requests
|
|
// -------------------------
|
|
|
|
/// Get requestable task types for a business (for bell icon menu)
|
|
static Future<List<TaskType>> getTaskTypes({required int businessId}) async {
|
|
final raw = await _postRaw("/tasks/listTypes.cfm", {"BusinessID": businessId});
|
|
final j = _requireJson(raw, "GetTaskTypes");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"GetTaskTypes failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
|
);
|
|
}
|
|
|
|
final arr = j["TASK_TYPES"] as List<dynamic>? ?? [];
|
|
return arr.map((e) => TaskType.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
/// Call server to the table - creates a service request task
|
|
static Future<void> callServer({
|
|
required int businessId,
|
|
required int servicePointId,
|
|
int? orderId,
|
|
int? userId,
|
|
String? message,
|
|
int? taskTypeId,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
"BusinessID": businessId,
|
|
"ServicePointID": servicePointId,
|
|
};
|
|
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
|
if (userId != null && userId > 0) body["UserID"] = userId;
|
|
if (message != null && message.isNotEmpty) body["Message"] = message;
|
|
if (taskTypeId != null && taskTypeId > 0) body["TaskTypeID"] = taskTypeId;
|
|
|
|
final raw = await _postRaw("/tasks/callServer.cfm", body);
|
|
final j = _requireJson(raw, "CallServer");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"CallServer failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
|
);
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// Beacons
|
|
// -------------------------
|
|
|
|
/// Lookup beacons by UUID - sends found UUIDs to server to check if registered
|
|
static Future<List<BeaconLookupResult>> lookupBeacons(List<String> uuids) async {
|
|
if (uuids.isEmpty) return [];
|
|
|
|
final raw = await _postRaw("/beacons/lookup.cfm", {"UUIDs": uuids});
|
|
final j = _requireJson(raw, "LookupBeacons");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"LookupBeacons API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = j["BEACONS"] as List<dynamic>? ?? [];
|
|
final results = <BeaconLookupResult>[];
|
|
|
|
for (final e in arr) {
|
|
if (e is! Map) continue;
|
|
results.add(BeaconLookupResult(
|
|
uuid: (e["UUID"] as String?) ?? "",
|
|
beaconId: _parseInt(e["BeaconID"]) ?? 0,
|
|
beaconName: (e["BeaconName"] as String?) ?? "",
|
|
businessId: _parseInt(e["BusinessID"]) ?? 0,
|
|
businessName: (e["BusinessName"] as String?) ?? "",
|
|
servicePointId: _parseInt(e["ServicePointID"]) ?? 0,
|
|
servicePointName: (e["ServicePointName"] as String?) ?? "",
|
|
parentBusinessId: _parseInt(e["ParentBusinessID"]) ?? 0,
|
|
parentBusinessName: (e["ParentBusinessName"] as String?) ?? "",
|
|
hasChildren: e["HasChildren"] == true,
|
|
));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// Get beacons for a specific business (optimized for rescan)
|
|
static Future<Map<String, int>> listBeaconsByBusiness({required int businessId}) async {
|
|
final raw = await _postRaw("/beacons/list.cfm", {"BusinessID": businessId});
|
|
final j = _requireJson(raw, "ListBeaconsByBusiness");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"ListBeaconsByBusiness API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["BEACONS", "beacons"]);
|
|
if (arr == null) return {};
|
|
|
|
final Map<String, int> uuidToBeaconId = {};
|
|
|
|
for (final e in arr) {
|
|
if (e is! Map) continue;
|
|
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
|
|
|
|
final uuid = (item["UUID"] ?? item["uuid"] ?? "").toString().trim().toUpperCase();
|
|
final beaconId = item["BeaconID"] ?? item["BEACONID"] ?? item["beaconId"];
|
|
|
|
if (uuid.isNotEmpty && beaconId is num) {
|
|
uuidToBeaconId[uuid] = beaconId.toInt();
|
|
}
|
|
}
|
|
|
|
return uuidToBeaconId;
|
|
}
|
|
|
|
/// @deprecated Use lookupBeacons instead - this downloads ALL beacons which doesn't scale
|
|
static Future<Map<String, int>> listAllBeacons() async {
|
|
final raw = await _getRaw("/beacons/list_all.cfm");
|
|
final j = _requireJson(raw, "ListAllBeacons");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"ListAllBeacons API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["items", "ITEMS"]);
|
|
if (arr == null) return {};
|
|
|
|
final Map<String, int> uuidToBeaconId = {};
|
|
|
|
for (final e in arr) {
|
|
if (e is! Map) continue;
|
|
final item = e is Map<String, dynamic> ? e : e.cast<String, dynamic>();
|
|
|
|
final uuid = (item["BeaconUUID"] ?? item["BEACONUUID"] ?? "").toString().trim().toUpperCase();
|
|
final beaconId = item["BeaconID"] ?? item["BEACONID"];
|
|
|
|
if (uuid.isNotEmpty && beaconId is num) {
|
|
uuidToBeaconId[uuid] = beaconId.toInt();
|
|
}
|
|
}
|
|
|
|
return uuidToBeaconId;
|
|
}
|
|
|
|
static Future<BeaconBusinessMapping> getBusinessFromBeacon({
|
|
required int beaconId,
|
|
}) async {
|
|
final raw = await _postRaw(
|
|
"/beacons/getBusinessFromBeacon.cfm",
|
|
{"BeaconID": beaconId},
|
|
);
|
|
final j = _requireJson(raw, "GetBusinessFromBeacon");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"GetBusinessFromBeacon API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}",
|
|
);
|
|
}
|
|
|
|
final beacon = j["BEACON"] as Map<String, dynamic>? ?? {};
|
|
final business = j["BUSINESS"] as Map<String, dynamic>? ?? {};
|
|
final servicePoint = j["SERVICEPOINT"] as Map<String, dynamic>? ?? {};
|
|
|
|
// Parse child businesses if present
|
|
final List<ChildBusiness> childBusinesses = [];
|
|
final businessesArr = j["BUSINESSES"] as List<dynamic>?;
|
|
if (businessesArr != null) {
|
|
for (final b in businessesArr) {
|
|
if (b is Map) {
|
|
childBusinesses.add(ChildBusiness(
|
|
businessId: _parseInt(b["BusinessID"]) ?? 0,
|
|
businessName: (b["BusinessName"] as String?) ?? "",
|
|
servicePointId: _parseInt(b["ServicePointID"]) ?? 0,
|
|
servicePointName: (b["ServicePointName"] as String?) ?? "",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse parent if present
|
|
BeaconParent? parent;
|
|
final parentMap = j["PARENT"] as Map<String, dynamic>?;
|
|
if (parentMap != null) {
|
|
parent = BeaconParent(
|
|
businessId: _parseInt(parentMap["BusinessID"]) ?? 0,
|
|
businessName: (parentMap["BusinessName"] as String?) ?? "",
|
|
);
|
|
}
|
|
|
|
return BeaconBusinessMapping(
|
|
beaconId: _parseInt(beacon["BeaconID"]) ?? 0,
|
|
beaconName: (beacon["BeaconName"] as String?) ?? "",
|
|
businessId: _parseInt(business["BusinessID"]) ?? 0,
|
|
businessName: (business["BusinessName"] as String?) ?? "",
|
|
servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0,
|
|
servicePointName: (servicePoint["ServicePointName"] as String?) ?? "",
|
|
businesses: childBusinesses,
|
|
parent: parent,
|
|
);
|
|
}
|
|
|
|
/// Get child businesses for a parent business
|
|
static Future<List<ChildBusiness>> getChildBusinesses({
|
|
required int businessId,
|
|
}) async {
|
|
final raw = await _getRaw("/businesses/getChildren.cfm?BusinessID=$businessId");
|
|
final j = _requireJson(raw, "GetChildBusinesses");
|
|
|
|
if (!_ok(j)) {
|
|
return [];
|
|
}
|
|
|
|
final List<ChildBusiness> children = [];
|
|
final arr = _pickArray(j, const ["BUSINESSES", "businesses"]);
|
|
if (arr != null) {
|
|
for (final b in arr) {
|
|
if (b is Map) {
|
|
children.add(ChildBusiness(
|
|
businessId: _parseInt(b["BusinessID"]) ?? 0,
|
|
businessName: (b["BusinessName"] as String?) ?? "",
|
|
servicePointId: _parseInt(b["ServicePointID"]) ?? 0,
|
|
servicePointName: (b["ServicePointName"] as String?) ?? "",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
|
|
static int? _parseInt(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is int) return value;
|
|
if (value is num) return value.toInt();
|
|
if (value is String) {
|
|
if (value.isEmpty) return null;
|
|
return int.tryParse(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Check if user has pending orders at a business (for pickup detection)
|
|
static Future<List<PendingOrder>> getPendingOrdersForUser({
|
|
required int userId,
|
|
required int businessId,
|
|
}) async {
|
|
final raw = await _getRaw(
|
|
"/orders/getPendingForUser.cfm?UserID=$userId&BusinessID=$businessId",
|
|
);
|
|
final j = _requireJson(raw, "GetPendingOrdersForUser");
|
|
|
|
if (!_ok(j)) {
|
|
return []; // Return empty list on error
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["ORDERS", "orders"]);
|
|
if (arr == null) return [];
|
|
|
|
return arr.map((e) {
|
|
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
|
return PendingOrder.fromJson(item);
|
|
}).toList();
|
|
}
|
|
|
|
/// Get list of states/provinces for address forms
|
|
static Future<List<StateInfo>> getStates() async {
|
|
final raw = await _getRaw("/addresses/states.cfm");
|
|
final j = _requireJson(raw, "GetStates");
|
|
|
|
if (!_ok(j)) {
|
|
return [];
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["STATES", "states"]);
|
|
if (arr == null) return [];
|
|
|
|
return arr.map((e) {
|
|
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
|
return StateInfo.fromJson(item);
|
|
}).toList();
|
|
}
|
|
|
|
/// Get user's delivery addresses
|
|
static Future<List<DeliveryAddress>> getDeliveryAddresses() async {
|
|
final raw = await _getRaw("/addresses/list.cfm");
|
|
final j = _requireJson(raw, "GetDeliveryAddresses");
|
|
|
|
if (!_ok(j)) {
|
|
return [];
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["ADDRESSES", "addresses"]);
|
|
if (arr == null) return [];
|
|
|
|
return arr.map((e) {
|
|
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
|
return DeliveryAddress.fromJson(item);
|
|
}).toList();
|
|
}
|
|
|
|
/// Add a new delivery address
|
|
static Future<DeliveryAddress> addDeliveryAddress({
|
|
required String line1,
|
|
required String city,
|
|
required int stateId,
|
|
required String zipCode,
|
|
String? line2,
|
|
String? label,
|
|
bool setAsDefault = false,
|
|
}) async {
|
|
final raw = await _postRaw("/addresses/add.cfm", {
|
|
"Line1": line1,
|
|
"City": city,
|
|
"StateID": stateId,
|
|
"ZIPCode": zipCode,
|
|
if (line2 != null) "Line2": line2,
|
|
if (label != null) "Label": label,
|
|
"SetAsDefault": setAsDefault,
|
|
});
|
|
|
|
final j = _requireJson(raw, "AddDeliveryAddress");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("AddDeliveryAddress failed: ${_err(j)}");
|
|
}
|
|
|
|
final addressData = j["ADDRESS"] as Map<String, dynamic>? ?? {};
|
|
return DeliveryAddress.fromJson(addressData);
|
|
}
|
|
|
|
/// Delete a delivery address
|
|
static Future<void> deleteDeliveryAddress(int addressId) async {
|
|
final raw = await _postRaw("/addresses/delete.cfm", {
|
|
"AddressID": addressId,
|
|
});
|
|
|
|
final j = _requireJson(raw, "DeleteDeliveryAddress");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("DeleteDeliveryAddress failed: ${_err(j)}");
|
|
}
|
|
}
|
|
|
|
/// Set an address as the default delivery address
|
|
static Future<void> setDefaultDeliveryAddress(int addressId) async {
|
|
final raw = await _postRaw("/addresses/setDefault.cfm", {
|
|
"AddressID": addressId,
|
|
});
|
|
|
|
final j = _requireJson(raw, "SetDefaultDeliveryAddress");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("SetDefaultDeliveryAddress failed: ${_err(j)}");
|
|
}
|
|
}
|
|
|
|
/// Get user's avatar URL
|
|
static Future<AvatarInfo> getAvatar() async {
|
|
final raw = await _getRaw("/auth/avatar.cfm");
|
|
final j = _requireJson(raw, "GetAvatar");
|
|
|
|
if (!_ok(j)) {
|
|
return const AvatarInfo(hasAvatar: false, avatarUrl: null);
|
|
}
|
|
|
|
return AvatarInfo(
|
|
hasAvatar: j["HAS_AVATAR"] == true,
|
|
avatarUrl: j["AVATAR_URL"] as String?,
|
|
);
|
|
}
|
|
|
|
/// Upload user avatar image
|
|
static Future<String> uploadAvatar(String filePath) async {
|
|
final uri = _u("/auth/avatar.cfm");
|
|
|
|
final request = http.MultipartRequest("POST", uri);
|
|
|
|
// Add auth headers
|
|
final tok = _userToken;
|
|
if (tok != null && tok.isNotEmpty) {
|
|
request.headers["X-User-Token"] = tok;
|
|
}
|
|
request.headers["X-Business-ID"] = _mvpBusinessId.toString();
|
|
|
|
// Add the file
|
|
request.files.add(await http.MultipartFile.fromPath("avatar", filePath));
|
|
|
|
final streamedResponse = await request.send();
|
|
final response = await http.Response.fromStream(streamedResponse);
|
|
|
|
final j = _tryDecodeJsonMap(response.body);
|
|
if (j == null) {
|
|
throw StateError("UploadAvatar: Invalid JSON response");
|
|
}
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("UploadAvatar failed: ${_err(j)}");
|
|
}
|
|
|
|
return j["AVATAR_URL"] as String? ?? "";
|
|
}
|
|
|
|
/// Get order history for current user
|
|
static Future<OrderHistoryResponse> getOrderHistory({int limit = 20, int offset = 0}) async {
|
|
final raw = await _getRaw("/orders/history.cfm?limit=$limit&offset=$offset");
|
|
final j = _requireJson(raw, "GetOrderHistory");
|
|
|
|
if (!_ok(j)) {
|
|
final detail = j["DETAIL"] ?? j["detail"] ?? "";
|
|
final debugLine = j["DEBUG_LINE"] ?? j["debug_line"] ?? "";
|
|
throw StateError("GetOrderHistory failed: ${_err(j)} - $detail (line: $debugLine)");
|
|
}
|
|
|
|
final ordersJson = j["ORDERS"] as List<dynamic>? ?? [];
|
|
final orders = ordersJson
|
|
.map((e) => OrderHistoryItem.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
return OrderHistoryResponse(
|
|
orders: orders,
|
|
totalCount: (j["TOTAL_COUNT"] as num?)?.toInt() ?? orders.length,
|
|
);
|
|
}
|
|
|
|
/// Search for users by phone, email, or name (for group order invites)
|
|
static Future<List<UserSearchResult>> searchUsers({
|
|
required String query,
|
|
int? currentUserId,
|
|
}) async {
|
|
if (query.length < 3) return [];
|
|
|
|
final raw = await _postRaw("/users/search.cfm", {
|
|
"Query": query,
|
|
"CurrentUserID": currentUserId ?? 0,
|
|
});
|
|
final j = _requireJson(raw, "SearchUsers");
|
|
|
|
if (!_ok(j)) {
|
|
return [];
|
|
}
|
|
|
|
final usersJson = j["USERS"] as List<dynamic>? ?? [];
|
|
return usersJson.map((e) {
|
|
final user = e as Map<String, dynamic>;
|
|
return UserSearchResult(
|
|
userId: (user["UserID"] as num).toInt(),
|
|
name: user["Name"] as String? ?? "",
|
|
phone: user["Phone"] as String? ?? "",
|
|
email: user["Email"] as String? ?? "",
|
|
avatarUrl: user["AvatarUrl"] as String? ?? "",
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
/// Get user profile
|
|
static Future<UserProfile> getProfile() async {
|
|
final raw = await _getRaw("/auth/profile.cfm");
|
|
final j = _requireJson(raw, "GetProfile");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("GetProfile failed: ${_err(j)}");
|
|
}
|
|
|
|
final userData = j["USER"] as Map<String, dynamic>? ?? {};
|
|
return UserProfile.fromJson(userData);
|
|
}
|
|
|
|
/// Update user profile
|
|
static Future<UserProfile> updateProfile({String? firstName, String? lastName}) async {
|
|
final body = <String, dynamic>{};
|
|
if (firstName != null) body["firstName"] = firstName;
|
|
if (lastName != null) body["lastName"] = lastName;
|
|
|
|
final raw = await _postRaw("/auth/profile.cfm", body);
|
|
final j = _requireJson(raw, "UpdateProfile");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("UpdateProfile failed: ${_err(j)}");
|
|
}
|
|
|
|
final userData = j["USER"] as Map<String, dynamic>? ?? {};
|
|
return UserProfile.fromJson(userData);
|
|
}
|
|
|
|
/// Get order detail by ID
|
|
static Future<OrderDetail> getOrderDetail({required int orderId}) async {
|
|
final raw = await _getRaw("/orders/getDetail.cfm?OrderID=$orderId");
|
|
final j = _requireJson(raw, "GetOrderDetail");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("GetOrderDetail failed: ${_err(j)}");
|
|
}
|
|
|
|
final orderData = j["ORDER"] as Map<String, dynamic>? ?? {};
|
|
return OrderDetail.fromJson(orderData);
|
|
}
|
|
|
|
// -------------------------
|
|
// Chat
|
|
// -------------------------
|
|
|
|
/// Check if there's an active chat for the user at a service point
|
|
/// Returns the task ID if found, null otherwise
|
|
static Future<int?> getActiveChat({
|
|
required int businessId,
|
|
required int servicePointId,
|
|
int? userId,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
"BusinessID": businessId,
|
|
"ServicePointID": servicePointId,
|
|
};
|
|
if (userId != null && userId > 0) body["UserID"] = userId;
|
|
|
|
final raw = await _postRaw("/chat/getActiveChat.cfm", body);
|
|
final j = _requireJson(raw, "GetActiveChat");
|
|
|
|
if (!_ok(j)) {
|
|
return null;
|
|
}
|
|
|
|
final hasActiveChat = j["HAS_ACTIVE_CHAT"] == true;
|
|
if (!hasActiveChat) return null;
|
|
|
|
final taskId = (j["TASK_ID"] as num?)?.toInt();
|
|
return (taskId != null && taskId > 0) ? taskId : null;
|
|
}
|
|
|
|
/// Create a chat task and return the task ID
|
|
static Future<int> createChatTask({
|
|
required int businessId,
|
|
required int servicePointId,
|
|
int? orderId,
|
|
int? userId,
|
|
String? initialMessage,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
"BusinessID": businessId,
|
|
"ServicePointID": servicePointId,
|
|
};
|
|
if (orderId != null && orderId > 0) body["OrderID"] = orderId;
|
|
if (userId != null && userId > 0) body["UserID"] = userId;
|
|
if (initialMessage != null && initialMessage.isNotEmpty) {
|
|
body["Message"] = initialMessage;
|
|
}
|
|
|
|
final raw = await _postRaw("/tasks/createChat.cfm", body);
|
|
final j = _requireJson(raw, "CreateChatTask");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError(
|
|
"CreateChatTask failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
|
|
);
|
|
}
|
|
|
|
return (j["TaskID"] ?? j["TASK_ID"] as num).toInt();
|
|
}
|
|
|
|
/// Get chat messages for a task
|
|
/// Returns messages and whether the chat has been closed by the worker
|
|
static Future<({List<ChatMessage> messages, bool chatClosed})> getChatMessages({
|
|
required int taskId,
|
|
int? afterMessageId,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
"TaskID": taskId,
|
|
};
|
|
if (afterMessageId != null && afterMessageId > 0) {
|
|
body["AfterMessageID"] = afterMessageId;
|
|
}
|
|
|
|
final raw = await _postRaw("/chat/getMessages.cfm", body);
|
|
final j = _requireJson(raw, "GetChatMessages");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("GetChatMessages failed: ${_err(j)}");
|
|
}
|
|
|
|
final arr = _pickArray(j, const ["MESSAGES", "messages"]);
|
|
final messages = arr == null
|
|
? <ChatMessage>[]
|
|
: arr.map((e) {
|
|
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
|
|
return ChatMessage.fromJson(item);
|
|
}).toList();
|
|
|
|
// Check if chat has been closed (task completed)
|
|
final chatClosed = j["CHAT_CLOSED"] == true || j["chat_closed"] == true;
|
|
|
|
return (messages: messages, chatClosed: chatClosed);
|
|
}
|
|
|
|
/// Send a chat message (HTTP fallback when WebSocket unavailable)
|
|
static Future<int> sendChatMessage({
|
|
required int taskId,
|
|
required String message,
|
|
int? userId,
|
|
String? senderType,
|
|
}) async {
|
|
final body = <String, dynamic>{
|
|
"TaskID": taskId,
|
|
"Message": message,
|
|
};
|
|
if (userId != null) body["UserID"] = userId;
|
|
if (senderType != null) body["SenderType"] = senderType;
|
|
|
|
final raw = await _postRaw("/chat/sendMessage.cfm", body);
|
|
final j = _requireJson(raw, "SendChatMessage");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("SendChatMessage failed: ${_err(j)}");
|
|
}
|
|
|
|
return ((j["MessageID"] ?? j["MESSAGE_ID"]) as num).toInt();
|
|
}
|
|
|
|
/// Mark chat messages as read
|
|
static Future<void> markChatMessagesRead({
|
|
required int taskId,
|
|
required String readerType,
|
|
}) async {
|
|
final raw = await _postRaw("/chat/markRead.cfm", {
|
|
"TaskID": taskId,
|
|
"ReaderType": readerType,
|
|
});
|
|
final j = _requireJson(raw, "MarkChatMessagesRead");
|
|
|
|
if (!_ok(j)) {
|
|
throw StateError("MarkChatMessagesRead failed: ${_err(j)}");
|
|
}
|
|
}
|
|
|
|
/// Get auth token for WebSocket authentication
|
|
static String? get authToken => _userToken;
|
|
}
|
|
|
|
class OrderHistoryResponse {
|
|
final List<OrderHistoryItem> orders;
|
|
final int totalCount;
|
|
|
|
const OrderHistoryResponse({
|
|
required this.orders,
|
|
required this.totalCount,
|
|
});
|
|
}
|
|
|
|
class ChildBusiness {
|
|
final int businessId;
|
|
final String businessName;
|
|
final int servicePointId;
|
|
final String servicePointName;
|
|
|
|
const ChildBusiness({
|
|
required this.businessId,
|
|
required this.businessName,
|
|
this.servicePointId = 0,
|
|
this.servicePointName = "",
|
|
});
|
|
}
|
|
|
|
class BeaconBusinessMapping {
|
|
final int beaconId;
|
|
final String beaconName;
|
|
final int businessId;
|
|
final String businessName;
|
|
final int servicePointId;
|
|
final String servicePointName;
|
|
final List<ChildBusiness> businesses;
|
|
final BeaconParent? parent;
|
|
|
|
const BeaconBusinessMapping({
|
|
required this.beaconId,
|
|
required this.beaconName,
|
|
required this.businessId,
|
|
required this.businessName,
|
|
required this.servicePointId,
|
|
required this.servicePointName,
|
|
this.businesses = const [],
|
|
this.parent,
|
|
});
|
|
}
|
|
|
|
class BeaconParent {
|
|
final int businessId;
|
|
final String businessName;
|
|
|
|
const BeaconParent({
|
|
required this.businessId,
|
|
required this.businessName,
|
|
});
|
|
}
|
|
|
|
/// Result from beacon UUID lookup - contains all info needed to navigate
|
|
class BeaconLookupResult {
|
|
final String uuid;
|
|
final int beaconId;
|
|
final String beaconName;
|
|
final int businessId;
|
|
final String businessName;
|
|
final int servicePointId;
|
|
final String servicePointName;
|
|
final int parentBusinessId;
|
|
final String parentBusinessName;
|
|
final bool hasChildren;
|
|
|
|
const BeaconLookupResult({
|
|
required this.uuid,
|
|
required this.beaconId,
|
|
required this.beaconName,
|
|
required this.businessId,
|
|
required this.businessName,
|
|
required this.servicePointId,
|
|
required this.servicePointName,
|
|
this.parentBusinessId = 0,
|
|
this.parentBusinessName = "",
|
|
this.hasChildren = false,
|
|
});
|
|
|
|
bool get hasParent => parentBusinessId > 0;
|
|
}
|
|
|
|
class PendingOrder {
|
|
final int orderId;
|
|
final String orderUuid;
|
|
final int orderTypeId;
|
|
final String orderTypeName;
|
|
final int statusId;
|
|
final String statusName;
|
|
final String submittedOn;
|
|
final int servicePointId;
|
|
final String servicePointName;
|
|
final String businessName;
|
|
final double subtotal;
|
|
|
|
const PendingOrder({
|
|
required this.orderId,
|
|
required this.orderUuid,
|
|
required this.orderTypeId,
|
|
required this.orderTypeName,
|
|
required this.statusId,
|
|
required this.statusName,
|
|
required this.submittedOn,
|
|
required this.servicePointId,
|
|
required this.servicePointName,
|
|
required this.businessName,
|
|
required this.subtotal,
|
|
});
|
|
|
|
factory PendingOrder.fromJson(Map<String, dynamic> json) {
|
|
return PendingOrder(
|
|
orderId: (json["OrderID"] ?? json["ORDERID"] ?? 0) as int,
|
|
orderUuid: (json["OrderUUID"] ?? json["ORDERUUID"] ?? "") as String,
|
|
orderTypeId: (json["OrderTypeID"] ?? json["ORDERTYPEID"] ?? 0) as int,
|
|
orderTypeName: (json["OrderTypeName"] ?? json["ORDERTYPENAME"] ?? "") as String,
|
|
statusId: (json["OrderStatusID"] ?? json["ORDERSTATUSID"] ?? 0) as int,
|
|
statusName: (json["StatusName"] ?? json["STATUSNAME"] ?? "") as String,
|
|
submittedOn: (json["SubmittedOn"] ?? json["SUBMITTEDON"] ?? "") as String,
|
|
servicePointId: (json["ServicePointID"] ?? json["SERVICEPOINTID"] ?? 0) as int,
|
|
servicePointName: (json["ServicePointName"] ?? json["SERVICEPOINTNAME"] ?? "") as String,
|
|
businessName: (json["BusinessName"] ?? json["BUSINESSNAME"] ?? "") as String,
|
|
subtotal: ((json["Subtotal"] ?? json["SUBTOTAL"] ?? 0) as num).toDouble(),
|
|
);
|
|
}
|
|
|
|
bool get isReady => statusId == 3;
|
|
bool get isPreparing => statusId == 2;
|
|
bool get isSubmitted => statusId == 1;
|
|
}
|
|
|
|
class DeliveryAddress {
|
|
final int addressId;
|
|
final String label;
|
|
final bool isDefault;
|
|
final String line1;
|
|
final String line2;
|
|
final String city;
|
|
final int stateId;
|
|
final String stateAbbr;
|
|
final String stateName;
|
|
final String zipCode;
|
|
final String displayText;
|
|
|
|
const DeliveryAddress({
|
|
required this.addressId,
|
|
required this.label,
|
|
required this.isDefault,
|
|
required this.line1,
|
|
required this.line2,
|
|
required this.city,
|
|
required this.stateId,
|
|
required this.stateAbbr,
|
|
required this.stateName,
|
|
required this.zipCode,
|
|
required this.displayText,
|
|
});
|
|
|
|
factory DeliveryAddress.fromJson(Map<String, dynamic> json) {
|
|
return DeliveryAddress(
|
|
addressId: (json["AddressID"] ?? json["ADDRESSID"] ?? 0) as int,
|
|
label: (json["Label"] ?? json["LABEL"] ?? "Address") as String,
|
|
isDefault: (json["IsDefault"] ?? json["ISDEFAULT"] ?? false) == true,
|
|
line1: (json["Line1"] ?? json["LINE1"] ?? "") as String,
|
|
line2: (json["Line2"] ?? json["LINE2"] ?? "") as String,
|
|
city: (json["City"] ?? json["CITY"] ?? "") as String,
|
|
stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int,
|
|
stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "") as String,
|
|
stateName: (json["StateName"] ?? json["STATENAME"] ?? "") as String,
|
|
zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "") as String,
|
|
displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "") as String,
|
|
);
|
|
}
|
|
}
|
|
|
|
class AvatarInfo {
|
|
final bool hasAvatar;
|
|
final String? avatarUrl;
|
|
|
|
const AvatarInfo({
|
|
required this.hasAvatar,
|
|
required this.avatarUrl,
|
|
});
|
|
}
|
|
|
|
class StateInfo {
|
|
final int stateId;
|
|
final String abbr;
|
|
final String name;
|
|
|
|
const StateInfo({
|
|
required this.stateId,
|
|
required this.abbr,
|
|
required this.name,
|
|
});
|
|
|
|
factory StateInfo.fromJson(Map<String, dynamic> json) {
|
|
return StateInfo(
|
|
stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int,
|
|
abbr: (json["Abbr"] ?? json["ABBR"] ?? "") as String,
|
|
name: (json["Name"] ?? json["NAME"] ?? "") as String,
|
|
);
|
|
}
|
|
}
|