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? _selectedBusinessId;
int? _selectedServicePointId; int? _selectedServicePointId;
int? _userId;
int? _cartOrderId;
String? _cartOrderUuid;
int? get selectedBusinessId => _selectedBusinessId; int? get selectedBusinessId => _selectedBusinessId;
int? get selectedServicePointId => _selectedServicePointId; 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 => bool get hasLocationSelection =>
_selectedBusinessId != null && _selectedServicePointId != null; _selectedBusinessId != null && _selectedServicePointId != null;
void setBusiness(int businessId) { void setBusiness(int businessId) {
_selectedBusinessId = businessId; _selectedBusinessId = businessId;
_selectedServicePointId = null; _selectedServicePointId = null;
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners(); notifyListeners();
} }
void setServicePoint(int servicePointId) { void setServicePoint(int servicePointId) {
_selectedServicePointId = 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(); notifyListeners();
} }
void clearAll() { void clearAll() {
_selectedBusinessId = null; _selectedBusinessId = null;
_selectedServicePointId = null; _selectedServicePointId = null;
_cartOrderId = null;
_cartOrderUuid = null;
notifyListeners(); notifyListeners();
} }
} }

View file

@ -1,20 +1,12 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "app/app_router.dart"; import "app/app_router.dart" show AppRoutes;
import "app/app_state.dart"; import "app/app_state.dart" show AppState;
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
runApp(const PayfritApp());
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AppState()),
],
child: const PayfritApp(),
),
);
} }
class PayfritApp extends StatelessWidget { class PayfritApp extends StatelessWidget {
@ -22,18 +14,16 @@ class PayfritApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MultiProvider(
title: "PAYFRIT", providers: [
debugShowCheckedModeBanner: false, ChangeNotifierProvider<AppState>(create: (_) => AppState()),
theme: ThemeData( ],
brightness: Brightness.dark, child: MaterialApp(
useMaterial3: true, debugShowCheckedModeBanner: false,
colorScheme: const ColorScheme.dark(), title: "Payfrit",
initialRoute: AppRoutes.splash,
routes: AppRoutes.routes,
), ),
// Use initialRoute + routes (NO home), so splash always shows first.
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:flutter/material.dart";
import "package:provider/provider.dart"; import "../services/api.dart";
import "../app/app_router.dart"; class OrderHomeScreen extends StatefulWidget {
import "../app/app_state.dart"; // 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({
const OrderHomeScreen({super.key}); super.key,
this.businessId,
this.servicePointId,
this.userId,
});
@override
State<OrderHomeScreen> createState() => _OrderHomeScreenState();
}
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<AppState>(); final orderId = _orderId;
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,
);
});
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text("Order (Compile-Only MVP)")),
title: const Text("PAYFRIT"), body: ListView(
actions: [ padding: const EdgeInsets.all(16),
IconButton( children: [
tooltip: "Change location", const Text(
onPressed: () { "This screen exists only to compile cleanly against Api.dart.\n"
context.read<AppState>().clearAll(); "No menu, no models, no polish.",
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.restaurantSelect,
(route) => false,
);
},
icon: const Icon(Icons.location_off),
), ),
], const SizedBox(height: 16),
),
body: Center( _LabeledField(label: "BusinessID", controller: _businessCtl, enabled: !_busy),
child: Padding( const SizedBox(height: 10),
padding: const EdgeInsets.all(18), _LabeledField(label: "ServicePointID", controller: _servicePointCtl, enabled: !_busy),
child: Column( const SizedBox(height: 10),
mainAxisSize: MainAxisSize.min, _LabeledField(label: "UserID", controller: _userCtl, enabled: !_busy),
const SizedBox(height: 16),
Row(
children: [ children: [
const Text( Expanded(
"MVP Scaffold", child: ElevatedButton(
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700), onPressed: _busy ? null : _createOrLoadCart,
child: const Text("Create/Load Cart"),
),
), ),
const SizedBox(height: 12), const SizedBox(width: 12),
Text("BusinessID: ${state.selectedBusinessId ?? "-"}"), Expanded(
Text("ServicePointID: ${state.selectedServicePointId ?? "-"}"), child: OutlinedButton(
const SizedBox(height: 18), onPressed: (_busy || orderId == null) ? null : _refreshCart,
const Text( child: const Text("Refresh Cart"),
"Next: menu + cart + order submission.\n(Well carry ServicePointID through every request.)", ),
textAlign: TextAlign.center,
), ),
], ],
), ),
),
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),
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:flutter/material.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/restaurant.dart"; import "../models/restaurant.dart";
import "../models/service_point.dart";
import "../services/api.dart"; import "../services/api.dart";
class RestaurantSelectScreen extends StatefulWidget { class RestaurantSelectScreen extends StatefulWidget {
@ -25,20 +28,45 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
} }
Future<List<Restaurant>> _load() async { Future<List<Restaurant>> _load() async {
// Fetch raw first so we can show real output if empty/mismatched keys.
final raw = await Api.listRestaurantsRaw(); final raw = await Api.listRestaurantsRaw();
_debugLastRaw = raw.rawBody; _debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode; _debugLastStatus = raw.statusCode;
// Then parse strictly (will throw with helpful details).
return Api.listRestaurants(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Select Restaurant"), title: const Text("Select Business"),
), ),
body: FutureBuilder<List<Restaurant>>( body: FutureBuilder<List<Restaurant>>(
future: _future, future: _future,
@ -49,7 +77,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
if (snapshot.hasError) { if (snapshot.hasError) {
return _ErrorPane( return _ErrorPane(
title: "Restaurants Load Failed", title: "Businesses Load Failed",
message: "${snapshot.error}", message: "${snapshot.error}",
statusCode: _debugLastStatus, statusCode: _debugLastStatus,
raw: _debugLastRaw, raw: _debugLastRaw,
@ -60,8 +88,8 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
final items = snapshot.data ?? const <Restaurant>[]; final items = snapshot.data ?? const <Restaurant>[];
if (items.isEmpty) { if (items.isEmpty) {
return _ErrorPane( return _ErrorPane(
title: "No Restaurants Returned", title: "No Businesses Returned",
message: "The API returned an empty list. We need to confirm the endpoint + JSON keys.", message: "The API returned an empty list.",
statusCode: _debugLastStatus, statusCode: _debugLastStatus,
raw: _debugLastRaw, raw: _debugLastRaw,
onRetry: () => setState(() => _future = _load()), onRetry: () => setState(() => _future = _load()),
@ -76,10 +104,7 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
return ListTile( return ListTile(
title: Text(r.name), title: Text(r.name),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () => _selectBusinessAndContinue(r),
context.read<AppState>().setBusiness(r.businessId);
Navigator.of(context).pushNamed(AppRoutes.servicePointSelect);
},
); );
}, },
); );
@ -107,7 +132,7 @@ class _ErrorPane extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final rawText = raw ?? "(no body captured)"; final rawText = raw ?? "(no body captured)";
final showRaw = rawText.length > 0; final showRaw = rawText.isNotEmpty;
return SingleChildScrollView( return SingleChildScrollView(
child: Center( child: Center(

View file

@ -1,10 +1,8 @@
import "package:flutter/material.dart"; // lib/screens/service_point_select_screen.dart
import "package:provider/provider.dart";
import "../app/app_router.dart"; import "package:flutter/material.dart";
import "../app/app_state.dart";
import "../models/service_point.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../models/service_point.dart";
class ServicePointSelectScreen extends StatefulWidget { class ServicePointSelectScreen extends StatefulWidget {
const ServicePointSelectScreen({super.key}); const ServicePointSelectScreen({super.key});
@ -14,27 +12,75 @@ class ServicePointSelectScreen extends StatefulWidget {
} }
class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> { class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
Future<List<ServicePoint>>? _future; late Future<List<ServicePoint>> _future;
// MVP HARD CODE
static const int _mvpBusinessId = 17;
@override @override
void didChangeDependencies() { void initState() {
super.didChangeDependencies(); super.initState();
final businessId = context.read<AppState>().selectedBusinessId; _future = _load();
if (businessId == null) return; }
_future ??= Api.listServicePoints(businessId: businessId);
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 @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Select Service Point"), title: const Text("Select Service Point"),
@ -45,19 +91,38 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return _ErrorPane( return Center(
message: "Failed to load service points.\n${snapshot.error}", child: Padding(
onRetry: () => setState(() => _future = Api.listServicePoints(businessId: businessId)), padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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"),
),
],
),
),
); );
} }
final items = snapshot.data ?? const <ServicePoint>[]; final items = snapshot.data ?? const <ServicePoint>[];
if (items.isEmpty) { if (items.isEmpty) {
return _ErrorPane( return const Center(child: Text("No service points found."));
message: "No service points returned.",
onRetry: () => setState(() => _future = Api.listServicePoints(businessId: businessId)),
);
} }
return ListView.separated( return ListView.separated(
@ -65,15 +130,14 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final sp = items[i]; final sp = items[i];
final name = _nameFor(sp, i);
return ListTile( return ListTile(
title: Text(sp.name), title: Text(name),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
context.read<AppState>().setServicePoint(sp.servicePointId); // Return the selected service point to the previous screen.
Navigator.of(context).pushNamedAndRemoveUntil( Navigator.of(context).pop<ServicePoint>(sp);
AppRoutes.orderHome,
(route) => false,
);
}, },
); );
}, },
@ -83,30 +147,3 @@ class _ServicePointSelectScreenState extends State<ServicePointSelectScreen> {
); );
} }
} }
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),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(
onPressed: onRetry,
child: const Text("Retry"),
),
],
),
),
);
}
}

View file

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