payfrit-app/lib/services/api.dart
John Mizerek 33f7128b40 feat: implement user authentication with login screen
- Add LoginScreen with form validation and error handling
- Add Api.login() method with LoginResponse model
- Add login route to AppRouter
- Update SplashScreen to check auth status and route to login if needed
- Store auth token in Api service for authenticated requests
- Fix restaurant selection to work with authenticated users
2025-12-29 10:01:35 -08:00

307 lines
8.4 KiB
Dart

import "dart:convert";
import "package:http/http.dart" as http;
import "../models/restaurant.dart";
import "../models/service_point.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 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 Api {
static String? _userToken;
// MVP hardcode
static int _mvpBusinessId = 17;
static void setAuthToken(String? token) => _userToken = token;
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("API_BASE_URL");
if (v.isEmpty) {
throw StateError(
"API_BASE_URL is not set. Example (Android emulator): "
"--dart-define=API_BASE_URL=http://10.0.2.2:8888/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 (_) {}
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);
// ignore: avoid_print
print("API GET => $url");
// ignore: avoid_print
print("STATUS => ${resp.statusCode}");
// ignore: avoid_print
print("BODY => ${body.length > 2000 ? body.substring(0, 2000) : 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);
// ignore: avoid_print
print("API POST => $url");
// ignore: avoid_print
print("BODY IN => ${jsonEncode(payload)}");
// ignore: avoid_print
print("STATUS => ${resp.statusCode}");
// ignore: avoid_print
print("BODY OUT => ${body.length > 2000 ? body.substring(0, 2000) : 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 void logout() {
setAuthToken(null);
clearCookies();
}
// -------------------------
// Businesses (legacy model name: Restaurant)
// -------------------------
static Future<ApiRawResponse> listRestaurantsRaw() async {
return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
}
static Future<List<Restaurant>> listRestaurants() async {
final raw = await listRestaurantsRaw();
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;
}
// -------------------------
// Ordering API (stubs referenced by OrderHomeScreen)
// -------------------------
static Future<dynamic> listMenuItems({required int businessId}) async {
throw StateError("endpoint_not_implemented: Api.listMenuItems");
}
static Future<dynamic> getOrCreateCart({
required int userId,
required int businessId,
required int servicePointId,
required int orderTypeId,
}) async {
throw StateError("endpoint_not_implemented: Api.getOrCreateCart");
}
static Future<dynamic> getCart({required int orderId}) async {
throw StateError("endpoint_not_implemented: Api.getCart");
}
static Future<void> setLineItem({
required int orderId,
required int parentOrderLineItemId,
required int itemId,
required int qty,
required List<int> selectedChildItemIds,
}) async {
throw StateError("endpoint_not_implemented: Api.setLineItem");
}
static Future<void> submitOrder({required int orderId}) async {
throw StateError("endpoint_not_implemented: Api.submitOrder");
}
}