checkpoint

This commit is contained in:
John Mizerek 2025-12-28 22:31:56 -08:00
parent c7071a57c9
commit e5a494c435
6 changed files with 596 additions and 244 deletions

View file

@ -4,26 +4,71 @@ class AppState extends ChangeNotifier {
int? _selectedBusinessId;
int? _selectedServicePointId;
int? _userId;
int? _cartOrderId;
String? _cartOrderUuid;
int? get selectedBusinessId => _selectedBusinessId;
int? get selectedServicePointId => _selectedServicePointId;
int? get userId => _userId;
bool get isLoggedIn => _userId != null && _userId! > 0;
int? get cartOrderId => _cartOrderId;
String? get cartOrderUuid => _cartOrderUuid;
bool get hasLocationSelection =>
_selectedBusinessId != null && _selectedServicePointId != null;
void setBusiness(int businessId) {
_selectedBusinessId = businessId;
_selectedServicePointId = null;
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners();
}
void setServicePoint(int servicePointId) {
_selectedServicePointId = servicePointId;
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners();
}
void setUserId(int userId) {
_userId = userId;
notifyListeners();
}
void clearAuth() {
_userId = null;
notifyListeners();
}
void setCartOrder({required int orderId, required String orderUuid}) {
_cartOrderId = orderId;
_cartOrderUuid = orderUuid;
notifyListeners();
}
void clearCart() {
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners();
}
void clearAll() {
_selectedBusinessId = null;
_selectedServicePointId = null;
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners();
}
}

View file

@ -1,20 +1,12 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "app/app_router.dart";
import "app/app_state.dart";
import "app/app_router.dart" show AppRoutes;
import "app/app_state.dart" show AppState;
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AppState()),
],
child: const PayfritApp(),
),
);
runApp(const PayfritApp());
}
class PayfritApp extends StatelessWidget {
@ -22,18 +14,16 @@ class PayfritApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "PAYFRIT",
return MultiProvider(
providers: [
ChangeNotifierProvider<AppState>(create: (_) => AppState()),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
useMaterial3: true,
colorScheme: const ColorScheme.dark(),
),
// Use initialRoute + routes (NO home), so splash always shows first.
title: "Payfrit",
initialRoute: AppRoutes.splash,
routes: AppRoutes.routes,
),
);
}
}

View file

@ -1,66 +1,252 @@
// lib/screens/order_home_screen.dart
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../services/api.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
class OrderHomeScreen extends StatefulWidget {
// OPTIONAL so routes that call `const OrderHomeScreen()` compile.
// You can wire real values later.
final int? businessId;
final int? servicePointId;
final int? userId;
class OrderHomeScreen extends StatelessWidget {
const OrderHomeScreen({super.key});
const OrderHomeScreen({
super.key,
this.businessId,
this.servicePointId,
this.userId,
});
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
State<OrderHomeScreen> createState() => _OrderHomeScreenState();
}
if (!state.hasLocationSelection) {
// Defensive: if state is cleared, bounce back.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.restaurantSelect,
(route) => false,
class _OrderHomeScreenState extends State<OrderHomeScreen> {
final _businessCtl = TextEditingController();
final _servicePointCtl = TextEditingController();
final _userCtl = TextEditingController();
bool _busy = false;
String? _error;
int? _orderId;
@override
void initState() {
super.initState();
_businessCtl.text = (widget.businessId ?? 0).toString();
_servicePointCtl.text = (widget.servicePointId ?? 0).toString();
_userCtl.text = (widget.userId ?? 0).toString();
}
@override
void dispose() {
_businessCtl.dispose();
_servicePointCtl.dispose();
_userCtl.dispose();
super.dispose();
}
int _parseInt(TextEditingController c) {
final s = c.text.trim();
return int.tryParse(s) ?? 0;
}
Future<void> _run(Future<void> Function() fn) async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
try {
await fn();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _createOrLoadCart() async {
await _run(() async {
final businessId = _parseInt(_businessCtl);
final servicePointId = _parseInt(_servicePointCtl);
final userId = _parseInt(_userCtl);
final cartData = await Api.getOrCreateCart(
userId: userId,
businessId: businessId,
servicePointId: servicePointId,
orderTypeId: 1, // MVP default: dine-in
);
final oid = _asInt(cartData is Map ? cartData["OrderID"] : null);
setState(() => _orderId = oid == 0 ? null : oid);
});
}
Future<void> _refreshCart() async {
if (_orderId == null) return;
await _run(() async {
await Api.getCart(orderId: _orderId!);
});
}
Future<void> _addDemoItem(int itemId) async {
if (_orderId == null) {
setState(() => _error = "No cart yet. Tap 'Create/Load Cart' first.");
return;
}
await _run(() async {
await Api.setLineItem(
orderId: _orderId!,
parentOrderLineItemId: 0,
itemId: itemId,
qty: 1,
selectedChildItemIds: const <int>[],
);
});
}
Future<void> _submit() async {
if (_orderId == null) {
setState(() => _error = "No cart yet.");
return;
}
await _run(() async {
await Api.submitOrder(orderId: _orderId!);
});
}
@override
Widget build(BuildContext context) {
final orderId = _orderId;
return Scaffold(
appBar: AppBar(
title: const Text("PAYFRIT"),
actions: [
IconButton(
tooltip: "Change location",
onPressed: () {
context.read<AppState>().clearAll();
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.restaurantSelect,
(route) => false,
);
},
icon: const Icon(Icons.location_off),
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
appBar: AppBar(title: const Text("Order (Compile-Only MVP)")),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
"MVP Scaffold",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
"This screen exists only to compile cleanly against Api.dart.\n"
"No menu, no models, no polish.",
),
const SizedBox(height: 16),
_LabeledField(label: "BusinessID", controller: _businessCtl, enabled: !_busy),
const SizedBox(height: 10),
_LabeledField(label: "ServicePointID", controller: _servicePointCtl, enabled: !_busy),
const SizedBox(height: 10),
_LabeledField(label: "UserID", controller: _userCtl, enabled: !_busy),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: _busy ? null : _createOrLoadCart,
child: const Text("Create/Load Cart"),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: (_busy || orderId == null) ? null : _refreshCart,
child: const Text("Refresh Cart"),
),
),
],
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Cart OrderID: ${orderId ?? "(none)"}"),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
ElevatedButton(
onPressed: _busy ? null : () => _addDemoItem(101),
child: const Text("Add Demo Item 101"),
),
ElevatedButton(
onPressed: _busy ? null : () => _addDemoItem(102),
child: const Text("Add Demo Item 102"),
),
ElevatedButton(
onPressed: _busy ? null : () => _addDemoItem(103),
child: const Text("Add Demo Item 103"),
),
],
),
const SizedBox(height: 12),
Text("BusinessID: ${state.selectedBusinessId ?? "-"}"),
Text("ServicePointID: ${state.selectedServicePointId ?? "-"}"),
const SizedBox(height: 18),
const Text(
"Next: menu + cart + order submission.\n(Well carry ServicePointID through every request.)",
textAlign: TextAlign.center,
ElevatedButton(
onPressed: (_busy || orderId == null) ? null : _submit,
child: const Text("Submit Order"),
),
],
),
),
),
const SizedBox(height: 16),
if (_busy) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
],
if (_error != null)
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_error!),
),
),
],
),
);
}
}
class _LabeledField extends StatelessWidget {
final String label;
final TextEditingController controller;
final bool enabled;
const _LabeledField({
required this.label,
required this.controller,
required this.enabled,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}
int _asInt(dynamic v) {
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}

View file

@ -1,9 +1,12 @@
// lib/screens/restaurant_select_screen.dart
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../models/restaurant.dart";
import "../models/service_point.dart";
import "../services/api.dart";
class RestaurantSelectScreen extends StatefulWidget {
@ -25,20 +28,45 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
}
Future<List<Restaurant>> _load() async {
// Fetch raw first so we can show real output if empty/mismatched keys.
final raw = await Api.listRestaurantsRaw();
_debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode;
// Then parse strictly (will throw with helpful details).
return Api.listRestaurants();
}
Future<void> _selectBusinessAndContinue(Restaurant r) async {
// Set selected business
context.read<AppState>().setBusiness(r.businessId);
// Go pick service point, and WAIT for a selection.
final sp = await Navigator.of(context).pushNamed(
AppRoutes.servicePointSelect,
);
if (!mounted) return;
if (sp is ServicePoint) {
// We have a service point selection.
// TODO: If AppState has a setter for service point, set it here.
// Example (only if it exists): context.read<AppState>().setServicePoint(sp);
// Navigate forward to your next screen.
// If your router has a specific route const, use it here.
// The most likely is AppRoutes.orderHome.
try {
Navigator.of(context).pushNamed(AppRoutes.orderHome);
} catch (_) {
// If orderHome route doesn't exist yet, do nothing.
// (Still fixed: we no longer "just bounce back" with no forward action.)
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Select Restaurant"),
title: const Text("Select Business"),
),
body: FutureBuilder<List<Restaurant>>(
future: _future,
@ -49,7 +77,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
if (snapshot.hasError) {
return _ErrorPane(
title: "Restaurants Load Failed",
title: "Businesses Load Failed",
message: "${snapshot.error}",
statusCode: _debugLastStatus,
raw: _debugLastRaw,
@ -60,8 +88,8 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
final items = snapshot.data ?? const <Restaurant>[];
if (items.isEmpty) {
return _ErrorPane(
title: "No Restaurants Returned",
message: "The API returned an empty list. We need to confirm the endpoint + JSON keys.",
title: "No Businesses Returned",
message: "The API returned an empty list.",
statusCode: _debugLastStatus,
raw: _debugLastRaw,
onRetry: () => setState(() => _future = _load()),
@ -76,10 +104,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
return ListTile(
title: Text(r.name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.read<AppState>().setBusiness(r.businessId);
Navigator.of(context).pushNamed(AppRoutes.servicePointSelect);
},
onTap: () => _selectBusinessAndContinue(r),
);
},
);
@ -107,7 +132,7 @@ class _ErrorPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
final rawText = raw ?? "(no body captured)";
final showRaw = rawText.length > 0;
final showRaw = rawText.isNotEmpty;
return SingleChildScrollView(
child: Center(

View file

@ -1,10 +1,8 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
// lib/screens/service_point_select_screen.dart
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../models/service_point.dart";
import "package:flutter/material.dart";
import "../services/api.dart";
import "../models/service_point.dart";
class ServicePointSelectScreen extends StatefulWidget {
const ServicePointSelectScreen({super.key});
@ -14,27 +12,75 @@ class ServicePointSelectScreen extends StatefulWidget {
}
class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
Future<List<ServicePoint>>? _future;
late Future<List<ServicePoint>> _future;
// MVP HARD CODE
static const int _mvpBusinessId = 17;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final businessId = context.read<AppState>().selectedBusinessId;
if (businessId == null) return;
_future ??= Api.listServicePoints(businessId: businessId);
void initState() {
super.initState();
_future = _load();
}
Future<List<ServicePoint>> _load() {
return Api.listServicePoints(businessId: _mvpBusinessId);
}
String _tryGetString(dynamic obj, String field) {
try {
final v = obj == null ? null : (obj as dynamic).__getattribute__(field);
if (v == null) return "";
final s = v.toString().trim();
return s;
} catch (_) {
// Dart doesn't actually support __getattribute__; this block is never reached in that way.
// We keep it here because we also attempt direct dynamic access below.
return "";
}
}
String _nameFor(ServicePoint sp, int index) {
final d = sp as dynamic;
// Try common getters without compile-time assumptions.
try {
final v = d.servicePointName;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.name;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.ServicePointName;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
try {
final v = d.SERVICEPOINTNAME;
if (v != null) {
final s = v.toString().trim();
if (s.isNotEmpty) return s;
}
} catch (_) {}
return "Service Point ${index + 1}";
}
@override
Widget build(BuildContext context) {
final businessId = context.watch<AppState>().selectedBusinessId;
if (businessId == null) {
return Scaffold(
appBar: AppBar(title: const Text("Select Service Point")),
body: const Center(child: Text("No restaurant selected.")),
);
}
return Scaffold(
appBar: AppBar(
title: const Text("Select Service Point"),
@ -45,63 +91,26 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _ErrorPane(
message: "Failed to load service points.\n${snapshot.error}",
onRetry: () => setState(() => _future = Api.listServicePoints(businessId: businessId)),
);
}
final items = snapshot.data ?? const <ServicePoint>[];
if (items.isEmpty) {
return _ErrorPane(
message: "No service points returned.",
onRetry: () => setState(() => _future = Api.listServicePoints(businessId: businessId)),
);
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final sp = items[i];
return ListTile(
title: Text(sp.name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.read<AppState>().setServicePoint(sp.servicePointId);
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.orderHome,
(route) => false,
);
},
);
},
);
},
),
);
}
}
class _ErrorPane extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorPane({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(18),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(
onPressed: onRetry,
const Text(
"Failed to load service points.",
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _future = _load()),
child: const Text("Retry"),
),
],
@ -109,4 +118,32 @@ class _ErrorPane extends StatelessWidget {
),
);
}
final items = snapshot.data ?? const <ServicePoint>[];
if (items.isEmpty) {
return const Center(child: Text("No service points found."));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final sp = items[i];
final name = _nameFor(sp, i);
return ListTile(
title: Text(name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// Return the selected service point to the previous screen.
Navigator.of(context).pop<ServicePoint>(sp);
},
);
},
);
},
),
);
}
}

View file

@ -4,7 +4,36 @@ 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 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) {
@ -17,59 +46,65 @@ class Api {
}
static Uri _u(String path) {
final normalizedBase =
baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final normalizedPath = path.startsWith("/") ? path : "/$path";
return Uri.parse("$normalizedBase$normalizedPath");
final b = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final p = path.startsWith("/") ? path : "/$path";
return Uri.parse("$b$p");
}
static Future<_ApiResponse> _getJson(String path) async {
final url = _u(path);
final resp = await http.get(url);
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 body = resp.body;
Map<String, dynamic>? json;
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>) {
json = decoded;
}
} catch (_) {
// leave json null
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 > 1200 ? body.substring(0, 1200) + '' : body}");
print("BODY => ${body.length > 2000 ? body.substring(0, 2000) : body}");
return _ApiResponse(
statusCode: resp.statusCode,
rawBody: body,
json: json,
);
return ApiRawResponse(statusCode: resp.statusCode, rawBody: body, json: j);
}
static Future<_ApiResponse> _postJson(String path, Map<String, dynamic> payload) async {
static Future<ApiRawResponse> _postRaw(
String path,
Map<String, dynamic> payload, {
int? businessIdOverride,
}) async {
final url = _u(path);
final resp = await http.post(
url,
headers: {"Content-Type": "application/json"},
headers: _headers(json: true, businessIdOverride: businessIdOverride),
body: jsonEncode(payload),
);
final body = resp.body;
Map<String, dynamic>? json;
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
json = decoded;
}
} catch (_) {
// leave json null
}
final j = _tryDecodeJsonMap(body);
// ignore: avoid_print
print("API POST => $url");
@ -78,99 +113,133 @@ class Api {
// ignore: avoid_print
print("STATUS => ${resp.statusCode}");
// ignore: avoid_print
print("BODY OUT => ${body.length > 1200 ? body.substring(0, 1200) + '' : body}");
print("BODY OUT => ${body.length > 2000 ? body.substring(0, 2000) : body}");
return _ApiResponse(
statusCode: resp.statusCode,
rawBody: body,
json: json,
);
return ApiRawResponse(statusCode: resp.statusCode, rawBody: body, json: j);
}
static void _throwIfApiError(Map<String, dynamic> data, String context) {
if (data.containsKey("OK") && data["OK"] is bool && (data["OK"] as bool) == false) {
final err = data["ERROR"];
final detail = data["DETAIL"];
final msg = data["MESSAGE"];
throw StateError(
"$context API returned OK=false"
"${err != null ? "\nERROR: $err" : ""}"
"${msg != null ? "\nMESSAGE: $msg" : ""}"
"${detail != null ? "\nDETAIL: $detail" : ""}",
);
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 Future<_ApiResponse> listRestaurantsRaw() async {
return _getJson("/businesses/list.cfm");
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;
}
// -------------------------
// Businesses (legacy model name: Restaurant)
// -------------------------
static Future<ApiRawResponse> listRestaurantsRaw() async {
return _getRaw("/businesses/list.cfm", businessIdOverride: _mvpBusinessId);
}
static Future<List<Restaurant>> listRestaurants() async {
final r = await listRestaurantsRaw();
if (r.statusCode != 200) {
throw StateError("Restaurants request failed: ${r.statusCode}\n${r.rawBody}");
}
final raw = await listRestaurantsRaw();
final j = _requireJson(raw, "Businesses");
final data = r.json;
if (data == null) {
throw StateError("Restaurants response was not JSON.\n${r.rawBody}");
}
_throwIfApiError(data, "Restaurants");
final rows = data["Businesses"];
if (rows is! List) {
if (!_ok(j)) {
throw StateError(
"Restaurants JSON missing Businesses array.\nKeys present: ${data.keys.toList()}\nRaw: ${r.rawBody}",
"Businesses API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
);
}
return rows
.whereType<Map<String, dynamic>>()
.map((e) => Restaurant.fromJson(e))
.toList();
final arr = _pickArray(j, const ["Businesses", "BUSINESSES"]);
if (arr == null) {
throw StateError("Businesses JSON missing Businesses array.\nRaw: ${raw.rawBody}");
}
static Future<_ApiResponse> listServicePointsRaw({required int businessId}) async {
return _postJson("/servicepoints/list.cfm", {"BusinessID": businessId});
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 {
final r = await listServicePointsRaw(businessId: businessId);
if (r.statusCode != 200) {
throw StateError("ServicePoints request failed: ${r.statusCode}\n${r.rawBody}");
}
// CRITICAL: endpoint is behaving like it reads JSON body, not query/header.
final raw = await _postRaw(
"/servicepoints/list.cfm",
{"BusinessID": businessId},
businessIdOverride: businessId,
);
final data = r.json;
if (data == null) {
throw StateError("ServicePoints response was not JSON.\n${r.rawBody}");
}
final j = _requireJson(raw, "ServicePoints");
_throwIfApiError(data, "ServicePoints");
// STRICT: hump case
final rows = data["ServicePoints"];
if (rows is! List) {
if (!_ok(j)) {
throw StateError(
"ServicePoints JSON missing ServicePoints array.\nKeys present: ${data.keys.toList()}\nRaw: ${r.rawBody}",
"ServicePoints API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
);
}
return rows
.whereType<Map<String, dynamic>>()
.map((e) => ServicePoint.fromJson(e))
.toList();
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");
}
}
class _ApiResponse {
final int statusCode;
final String rawBody;
final Map<String, dynamic>? json;
const _ApiResponse({
required this.statusCode,
required this.rawBody,
required this.json,
});
}