Update project
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../service/ai_service/api_service.dart';
|
||||
|
||||
class AiSearchController extends GetxController {
|
||||
final AiSearchService service = AiSearchService();
|
||||
|
||||
RxBool loading = false.obs;
|
||||
RxMap<String, dynamic> result = <String, dynamic>{}.obs;
|
||||
|
||||
Future<void> search(String query) async {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
final data = await service.search(query);
|
||||
|
||||
result.value = data;
|
||||
} catch (e) {
|
||||
Get.snackbar(
|
||||
"Error",
|
||||
e.toString(),
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
485
lib/controllers/search_controller/product_match.dart
Normal file
485
lib/controllers/search_controller/product_match.dart
Normal file
@@ -0,0 +1,485 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../modules/search/grocery_item.dart';
|
||||
|
||||
// ─── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class StoreProduct {
|
||||
final int productId;
|
||||
final String productName;
|
||||
final String productImage;
|
||||
final double productCost;
|
||||
final double discountValue;
|
||||
final String productUnit;
|
||||
final String unitValue;
|
||||
final String productStatus;
|
||||
final int subcategoryId;
|
||||
final String subcategoryName;
|
||||
|
||||
const StoreProduct({
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
required this.productImage,
|
||||
required this.productCost,
|
||||
required this.discountValue,
|
||||
required this.productUnit,
|
||||
required this.unitValue,
|
||||
required this.productStatus,
|
||||
required this.subcategoryId,
|
||||
required this.subcategoryName,
|
||||
});
|
||||
|
||||
factory StoreProduct.fromJson(Map<String, dynamic> json, String subcategoryName) {
|
||||
return StoreProduct(
|
||||
productId: json['productid'] ?? 0,
|
||||
productName: json['productname'] ?? '',
|
||||
productImage: json['productimage'] ?? '',
|
||||
productCost: (json['productcost'] ?? 0).toDouble(),
|
||||
discountValue: (json['discountvalue'] ?? 0).toDouble(),
|
||||
productUnit: json['productunit'] ?? '',
|
||||
unitValue: json['unitvalue'] ?? '',
|
||||
productStatus: json['productstatus'] ?? '',
|
||||
subcategoryId: json['subcategoryid'] ?? 0,
|
||||
subcategoryName: subcategoryName,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isAvailable => productStatus == 'available';
|
||||
|
||||
double get finalPrice {
|
||||
if (discountValue <= 0) return productCost;
|
||||
return productCost - (productCost * discountValue / 100);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreInfo {
|
||||
final int tenantId;
|
||||
final String tenantName;
|
||||
final String address;
|
||||
final String suburb;
|
||||
final String city;
|
||||
final String latitude;
|
||||
final String longitude;
|
||||
final int locationId;
|
||||
final String locationName;
|
||||
final int categoryId;
|
||||
final int ordersCount;
|
||||
|
||||
const StoreInfo({
|
||||
required this.tenantId,
|
||||
required this.tenantName,
|
||||
required this.address,
|
||||
required this.suburb,
|
||||
required this.city,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.locationId,
|
||||
required this.locationName,
|
||||
required this.categoryId,
|
||||
required this.ordersCount,
|
||||
});
|
||||
|
||||
factory StoreInfo.fromJson(Map<String, dynamic> json) {
|
||||
return StoreInfo(
|
||||
tenantId: json['tenantid'] ?? 0,
|
||||
tenantName: json['tenantname'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
suburb: json['suburb'] ?? '',
|
||||
city: json['city'] ?? '',
|
||||
latitude: json['latitude'] ?? '',
|
||||
longitude: json['longitude'] ?? '',
|
||||
locationId: json['locationid'] ?? 0,
|
||||
locationName: json['locationname'] ?? '',
|
||||
categoryId: json['categoryid'] ?? 0,
|
||||
ordersCount: json['orderscount'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single AI item matched to one or more store products across stores.
|
||||
class MatchedItem {
|
||||
final GroceryItem aiItem;
|
||||
|
||||
/// storeName → list of matched products in that store
|
||||
final Map<String, List<StoreProduct>> storeMatches;
|
||||
|
||||
const MatchedItem({
|
||||
required this.aiItem,
|
||||
required this.storeMatches,
|
||||
});
|
||||
|
||||
bool get hasMatches => storeMatches.isNotEmpty;
|
||||
|
||||
int get totalMatchCount =>
|
||||
storeMatches.values.fold(0, (sum, list) => sum + list.length);
|
||||
}
|
||||
|
||||
// ─── Controller ───────────────────────────────────────────────────────────────
|
||||
|
||||
class StoreMatchController extends ChangeNotifier {
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
static const int _customerId = 6060;
|
||||
static const double _latitude = 11.0050728;
|
||||
static const double _longitude = 76.9508513;
|
||||
|
||||
static const String _baseUrl = 'https://fiesta.nearle.app/live/api/v1/mob';
|
||||
static const String _tenantsEndpoint = '$_baseUrl/tenants/getcustomertenants';
|
||||
static const String _productsEndpoint = '$_baseUrl/products/getproductsbysubcategory';
|
||||
|
||||
static const String _groqApiKey =
|
||||
'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';
|
||||
static const String _groqUrl =
|
||||
'https://api.groq.com/openai/v1/chat/completions';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
List<StoreInfo> stores = [];
|
||||
final Map<String, List<StoreProduct>> _storeProducts = {};
|
||||
List<MatchedItem> matchedResults = [];
|
||||
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
|
||||
Future<void> matchAiItems(List<GroceryItem> aiItems) async {
|
||||
if (aiItems.isEmpty) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
matchedResults = [];
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Step 1 — fetch all nearby stores
|
||||
await _fetchStores();
|
||||
|
||||
if (stores.isEmpty) {
|
||||
error = 'No stores found nearby.';
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2 — fetch products for every store concurrently
|
||||
await _fetchAllStoreProducts();
|
||||
|
||||
// Step 3 — AI matching
|
||||
await _performAiMatching(aiItems);
|
||||
|
||||
// Step 4 — print results
|
||||
_printMatchResults();
|
||||
|
||||
} catch (e, st) {
|
||||
error = 'Something went wrong: $e';
|
||||
debugPrint('[StoreMatch] Error: $e\n$st');
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 1: Fetch stores ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _fetchStores() async {
|
||||
final uri = Uri.parse(_tenantsEndpoint).replace(queryParameters: {
|
||||
'customerid': '$_customerId',
|
||||
'tenant': '0',
|
||||
'latitude': '$_latitude',
|
||||
'longitude': '$_longitude',
|
||||
'categoryid': '0',
|
||||
});
|
||||
|
||||
debugPrint('[StoreMatch] Fetching stores → $uri');
|
||||
|
||||
final res = await http.get(uri);
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Stores API error ${res.statusCode}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (json['status'] != true) {
|
||||
throw Exception('Stores API returned status=false: ${json['message']}');
|
||||
}
|
||||
|
||||
final details = (json['details'] as List?) ?? [];
|
||||
|
||||
final seen = <String>{};
|
||||
stores = details
|
||||
.map((d) => StoreInfo.fromJson(d as Map<String, dynamic>))
|
||||
.where((s) {
|
||||
final key = '${s.tenantId}_${s.locationId}';
|
||||
return seen.add(key);
|
||||
}).toList();
|
||||
|
||||
debugPrint('[StoreMatch] Fetched ${stores.length} store-location(s).');
|
||||
}
|
||||
|
||||
// ── Step 2: Fetch products for every store ────────────────────────────────
|
||||
|
||||
Future<void> _fetchAllStoreProducts() async {
|
||||
await Future.wait(stores.map(_fetchProductsForStore));
|
||||
}
|
||||
|
||||
Future<void> _fetchProductsForStore(StoreInfo store) async {
|
||||
final storeKey = '${store.tenantId}_${store.locationId}';
|
||||
|
||||
final uri = Uri.parse(_productsEndpoint).replace(queryParameters: {
|
||||
'categoryid': '${store.categoryId == 0 ? 2 : store.categoryId}',
|
||||
'tenantid': '${store.tenantId}',
|
||||
'locationid': '${store.locationId}',
|
||||
});
|
||||
|
||||
debugPrint('[StoreMatch] Fetching products for ${store.tenantName} → $uri');
|
||||
|
||||
try {
|
||||
final res = await http.get(uri);
|
||||
if (res.statusCode != 200) return;
|
||||
|
||||
final json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (json['status'] != true || json['data'] == null) return;
|
||||
|
||||
final details = (json['data']['details'] as List?) ?? [];
|
||||
|
||||
final products = <StoreProduct>[];
|
||||
for (final cat in details) {
|
||||
final subcatName = (cat['subcategoryname'] as String?) ?? '';
|
||||
final productList = (cat['products'] as List?) ?? [];
|
||||
for (final p in productList) {
|
||||
products.add(
|
||||
StoreProduct.fromJson(p as Map<String, dynamic>, subcatName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_storeProducts[storeKey] = products;
|
||||
debugPrint(
|
||||
'[StoreMatch] ${store.tenantName} (${store.locationName}): '
|
||||
'${products.length} products loaded.',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[StoreMatch] Failed to fetch products for $storeKey: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: AI matching ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _performAiMatching(List<GroceryItem> aiItems) async {
|
||||
// Build the AI item list string
|
||||
final aiItemsList = aiItems
|
||||
.map((item) => '- ${item.name} (${item.quantity})')
|
||||
.join('\n');
|
||||
|
||||
// Build the store products list string
|
||||
// Format: "storeName — locationName|productName"
|
||||
final productLines = <String>[];
|
||||
for (final store in stores) {
|
||||
final storeKey = '${store.tenantId}_${store.locationId}';
|
||||
final products = _storeProducts[storeKey] ?? [];
|
||||
final storeLabel = '${store.tenantName} — ${store.locationName}';
|
||||
for (final p in products) {
|
||||
productLines.add('$storeLabel|${p.productName}');
|
||||
}
|
||||
}
|
||||
|
||||
if (productLines.isEmpty) {
|
||||
debugPrint('[StoreMatch] No store products to match against.');
|
||||
matchedResults = aiItems
|
||||
.map((item) => MatchedItem(aiItem: item, storeMatches: {}))
|
||||
.toList();
|
||||
return;
|
||||
}
|
||||
|
||||
final productListString = productLines.join('\n');
|
||||
|
||||
debugPrint('[StoreMatch] Sending ${aiItems.length} AI items and '
|
||||
'${productLines.length} store products to Groq for matching...');
|
||||
|
||||
final prompt = '''
|
||||
You are a strict grocery product matching engine.
|
||||
|
||||
RULES:
|
||||
- Match each AI item ONLY to products whose name is genuinely the same item.
|
||||
- "Chicken Biryani" must ONLY match products containing "biryani" or "chicken biryani".
|
||||
- "Curd" must NEVER match "Biryani". "Eggs" must NEVER match "Rice".
|
||||
- Be flexible with brand names: "Basmati Rice" matches "India Gate Basmati Rice 1kg".
|
||||
- If you are not confident, return an empty matches array.
|
||||
- The "matches" array must contain EXACT product names copied from the STORE PRODUCTS list below. Do NOT paraphrase or modify them.
|
||||
- Return ONLY a raw JSON array. No markdown, no explanation, nothing else.
|
||||
|
||||
Each element must have:
|
||||
- "aiItem": the exact AI item name from the list below
|
||||
- "matches": array of exact product name strings from the store products list
|
||||
|
||||
═══════════════════════
|
||||
AI ITEMS (user is looking for):
|
||||
$aiItemsList
|
||||
|
||||
═══════════════════════
|
||||
STORE PRODUCTS (format: storeName|productName):
|
||||
$productListString
|
||||
═══════════════════════
|
||||
|
||||
Return format example:
|
||||
[
|
||||
{"aiItem": "Chicken Biryani", "matches": ["Chicken Biryani (Full)", "Biryani Chicken"]},
|
||||
{"aiItem": "Eggs", "matches": ["Farm Fresh Eggs 6pcs"]},
|
||||
{"aiItem": "Pizza", "matches": []}
|
||||
]
|
||||
''';
|
||||
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse(_groqUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_groqApiKey',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': 'llama-3.3-70b-versatile',
|
||||
'max_tokens': 1000,
|
||||
'messages': [
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Groq matching API error ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final content = data['choices'][0]['message']['content'] as String;
|
||||
|
||||
// Extract JSON array from response
|
||||
final start = content.indexOf('[');
|
||||
final end = content.lastIndexOf(']') + 1;
|
||||
if (start == -1 || end == 0) {
|
||||
throw Exception('No JSON array in Groq matching response');
|
||||
}
|
||||
|
||||
final List parsed = jsonDecode(content.substring(start, end)) as List;
|
||||
|
||||
// Build productName (lowercase) → (storeLabel, StoreProduct) lookup
|
||||
// Key: "storeName — locationName|productname_lowercase"
|
||||
// This avoids cross-store collisions if two stores have the same product name
|
||||
final productLookup = <String, MapEntry<String, StoreProduct>>{};
|
||||
for (final store in stores) {
|
||||
final storeKey = '${store.tenantId}_${store.locationId}';
|
||||
final products = _storeProducts[storeKey] ?? [];
|
||||
final storeLabel = '${store.tenantName} — ${store.locationName}';
|
||||
for (final p in products) {
|
||||
final lookupKey = '$storeLabel|${p.productName.toLowerCase().trim()}';
|
||||
productLookup[lookupKey] = MapEntry(storeLabel, p);
|
||||
}
|
||||
}
|
||||
|
||||
// Build MatchedItem list from AI response
|
||||
matchedResults = aiItems.map((aiItem) {
|
||||
final entry = parsed.firstWhere(
|
||||
(e) =>
|
||||
(e['aiItem'] as String).toLowerCase().trim() ==
|
||||
aiItem.name.toLowerCase().trim(),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (entry == null) {
|
||||
return MatchedItem(aiItem: aiItem, storeMatches: {});
|
||||
}
|
||||
|
||||
final matchedNames = ((entry['matches'] as List?) ?? [])
|
||||
.map((e) => (e as String).trim())
|
||||
.toList();
|
||||
|
||||
// Group matched products by store name
|
||||
final storeMatches = <String, List<StoreProduct>>{};
|
||||
|
||||
for (final matchedName in matchedNames) {
|
||||
final matchedNameLower = matchedName.toLowerCase();
|
||||
|
||||
// Search across all stores for this product name
|
||||
for (final store in stores) {
|
||||
final storeKey = '${store.tenantId}_${store.locationId}';
|
||||
final storeLabel = '${store.tenantName} — ${store.locationName}';
|
||||
final lookupKey = '$storeLabel|$matchedNameLower';
|
||||
|
||||
final found = productLookup[lookupKey];
|
||||
if (found != null) {
|
||||
storeMatches
|
||||
.putIfAbsent(found.key, () => [])
|
||||
.add(found.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MatchedItem(aiItem: aiItem, storeMatches: storeMatches);
|
||||
}).toList();
|
||||
|
||||
} catch (e, st) {
|
||||
debugPrint('[StoreMatch] AI matching failed: $e\n$st');
|
||||
// Fallback: return all items with no matches rather than crashing
|
||||
matchedResults = aiItems
|
||||
.map((item) => MatchedItem(aiItem: item, storeMatches: {}))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Print results ─────────────────────────────────────────────────
|
||||
|
||||
void _printMatchResults() {
|
||||
debugPrint('\n${'═' * 60}');
|
||||
debugPrint(' AI ITEM → STORE MATCH RESULTS (AI-powered)');
|
||||
debugPrint('${'═' * 60}');
|
||||
|
||||
for (final result in matchedResults) {
|
||||
debugPrint('\n▶ AI Item: "${result.aiItem.name}" '
|
||||
'(${result.aiItem.quantity}) ${result.aiItem.emoji}');
|
||||
|
||||
if (!result.hasMatches) {
|
||||
debugPrint(' ✗ No matching products found in any store.');
|
||||
continue;
|
||||
}
|
||||
|
||||
result.storeMatches.forEach((storeName, products) {
|
||||
debugPrint(' ✔ $storeName');
|
||||
for (final p in products) {
|
||||
final discount = p.discountValue > 0
|
||||
? ' [${p.discountValue.toStringAsFixed(0)}% off → ₹${p.finalPrice.toStringAsFixed(2)}]'
|
||||
: '';
|
||||
final status = p.isAvailable ? '✅' : '⚠ outofstock';
|
||||
debugPrint(
|
||||
' • ${p.productName} | ₹${p.productCost}'
|
||||
'$discount | ${p.productUnit} | $status',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debugPrint('\n${'═' * 60}');
|
||||
final matchedCount = matchedResults.where((r) => r.hasMatches).length;
|
||||
debugPrint(
|
||||
' SUMMARY: ${matchedCount}/${matchedResults.length} AI items matched '
|
||||
'across ${stores.length} stores.',
|
||||
);
|
||||
debugPrint('${'═' * 60}\n');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
void reset() {
|
||||
stores = [];
|
||||
_storeProducts.clear();
|
||||
matchedResults = [];
|
||||
error = null;
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<MatchedItem> get foundItems =>
|
||||
matchedResults.where((r) => r.hasMatches).toList();
|
||||
|
||||
List<MatchedItem> get notFoundItems =>
|
||||
matchedResults.where((r) => !r.hasMatches).toList();
|
||||
}
|
||||
295
lib/controllers/search_controller/search_controller.dart
Normal file
295
lib/controllers/search_controller/search_controller.dart
Normal file
@@ -0,0 +1,295 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nearledaily/controllers/search_controller/product_match.dart';
|
||||
import '../../modules/search/grocery_item.dart';
|
||||
|
||||
class GrocerySearchController extends ChangeNotifier {
|
||||
// ─── Controllers & Focus ──────────────────────────────────────────────────
|
||||
|
||||
final TextEditingController textController = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
// ─── Animation ────────────────────────────────────────────────────────────
|
||||
|
||||
late AnimationController cartBarController;
|
||||
late Animation<Offset> cartBarSlide;
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
List<GroceryItem> items = [];
|
||||
List<GroceryItem> cartItems = [];
|
||||
bool loading = false;
|
||||
bool hasSearched = false;
|
||||
String lastQuery = '';
|
||||
|
||||
// ─── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static const String _apiKey =
|
||||
'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';
|
||||
|
||||
// ─── Quick Chips ──────────────────────────────────────────────────────────
|
||||
|
||||
final List<Map<String, String>> quickChips = [
|
||||
{'label': '🍛 Biryani', 'query': 'Biryani'},
|
||||
{'label': '🍝 Pasta', 'query': 'Pasta'},
|
||||
{'label': '🎂 Cake', 'query': 'Cake'},
|
||||
{'label': '🥞 Pancakes', 'query': 'Pancakes'},
|
||||
{'label': '🫘 Dal Tadka', 'query': 'Dal Tadka'},
|
||||
{'label': '🥘 Idli Sambar', 'query': 'Idli Sambar'},
|
||||
{'label': '🍕 Pizza', 'query': 'Pizza'},
|
||||
{'label': '🍜 Noodles', 'query': 'Noodles'},
|
||||
];
|
||||
|
||||
final List<Map<String, String>> naturalChips = [
|
||||
{
|
||||
'label': '🤧 I have a cold — what should I eat?',
|
||||
'query': 'I have a cold, what fruits or foods should I eat?'
|
||||
},
|
||||
{
|
||||
'label': '💪 Post-workout nutrition',
|
||||
'query': 'Post-workout recovery foods and snacks'
|
||||
},
|
||||
{
|
||||
'label': '🍽️ Biryani + Curd Rice together',
|
||||
'query': 'I want to make biryani and also curd rice'
|
||||
},
|
||||
{
|
||||
'label': '🍳 Quick egg breakfast',
|
||||
'query': 'Quick breakfast with eggs and bread'
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Init / Dispose ───────────────────────────────────────────────────────
|
||||
|
||||
void init(TickerProvider vsync) {
|
||||
// Register StoreMatchController if not already registered
|
||||
if (!Get.isRegistered<StoreMatchController>()) {
|
||||
Get.put(StoreMatchController());
|
||||
}
|
||||
initAnimations(vsync);
|
||||
}
|
||||
|
||||
void initAnimations(TickerProvider vsync) {
|
||||
cartBarController = AnimationController(
|
||||
vsync: vsync,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
cartBarSlide = Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: cartBarController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
}
|
||||
|
||||
void disposeAll() {
|
||||
textController.dispose();
|
||||
focusNode.dispose();
|
||||
cartBarController.dispose();
|
||||
}
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> search(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) return;
|
||||
|
||||
focusNode.unfocus();
|
||||
loading = true;
|
||||
hasSearched = true;
|
||||
lastQuery = q;
|
||||
items = [];
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse('https://api.groq.com/openai/v1/chat/completions'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_apiKey',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': 'llama-3.1-8b-instant',
|
||||
'max_tokens': 800,
|
||||
'messages': [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': '''You are a smart product search engine for a super-app with grocery stores, restaurants, pharmacies, and general stores.
|
||||
|
||||
Your ONLY job is to return a raw JSON array. No markdown, no code fences, no explanations. Raw JSON only.
|
||||
|
||||
═══════════════════════════════════════════
|
||||
GOLDEN RULE — LITERAL FIRST:
|
||||
If the user types a RAW INGREDIENT, PRODUCT, or ITEM NAME (e.g. "tomato", "milk", "onion", "shampoo", "paracetamol"),
|
||||
return THAT EXACT ITEM as a grocery/store product. Do NOT transform it into a dish or recipe.
|
||||
═══════════════════════════════════════════
|
||||
|
||||
INTENT DETECTION — pick ONE mode:
|
||||
|
||||
MODE 1 — DIRECT PRODUCT SEARCH (user types a product/ingredient name):
|
||||
Signals: single word or short phrase that IS a product (e.g. "tomato", "milk", "bread", "eggs", "rice")
|
||||
Action: Return that exact product with 2–4 size/variant options only.
|
||||
Do NOT suggest dishes made from it.
|
||||
type: "grocery"
|
||||
Example — query "tomato":
|
||||
[
|
||||
{"name": "Tomato", "quantity": "500g", "emoji": "🍅", "type": "grocery"},
|
||||
{"name": "Tomato", "quantity": "1 kg", "emoji": "🍅", "type": "grocery"},
|
||||
{"name": "Cherry Tomato", "quantity": "250g", "emoji": "🍅", "type": "grocery"}
|
||||
]
|
||||
|
||||
MODE 2 — RESTAURANT ORDER (user wants a ready-made dish delivered):
|
||||
Signals: dish name WITH food-order context, or words like "order", "hungry", "eat",
|
||||
"food delivery", "i want to eat [dish]", "get me [dish]"
|
||||
Action: Return the dish as 1–3 variants.
|
||||
type: "restaurant"
|
||||
Example — query "i want biryani":
|
||||
[
|
||||
{"name": "Chicken Biryani", "quantity": "1 portion", "emoji": "🍛", "type": "restaurant"},
|
||||
{"name": "Veg Biryani", "quantity": "1 portion", "emoji": "🍚", "type": "restaurant"}
|
||||
]
|
||||
|
||||
MODE 3 — COOK AT HOME (user wants to cook):
|
||||
Signals: "cook", "recipe", "ingredients for", "how to make", "groceries for", "i will make"
|
||||
Action: Return grocery ingredient list (6–12 items).
|
||||
type: "grocery"
|
||||
|
||||
MODE 4 — SITUATION / NEED (health, mood, lifestyle):
|
||||
Signals: "sick", "fever", "diet", "weight loss", "party", "snacks", "breakfast", "workout"
|
||||
Action: Return 5–10 relevant products. Mix types if appropriate.
|
||||
|
||||
═══════════════════════════════════════════
|
||||
STRICT ACCURACY RULES:
|
||||
- A product name is a product — never auto-upgrade it to a dish
|
||||
- Only return items the user actually needs — no upsells, no extras
|
||||
- If query is ambiguous between product vs dish, choose MODE 1 (product)
|
||||
- "tomato" → tomatoes, NOT tomato rice or tomato sauce
|
||||
- "chicken" → raw chicken cuts, NOT chicken curry or chicken burger
|
||||
- "lemon" → lemons, NOT lemon juice or lemon cake
|
||||
═══════════════════════════════════════════
|
||||
|
||||
OUTPUT RULES:
|
||||
- Raw JSON array only. Nothing else.
|
||||
- Every item: exactly 4 keys — "name", "quantity", "emoji", "type"
|
||||
- "type": only "restaurant" or "grocery"
|
||||
- No duplicates. No nulls. No empty strings.
|
||||
- Max 6 items total unless MODE 3 (ingredients list).''',
|
||||
},
|
||||
{'role': 'user', 'content': q},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('API error ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final content = data['choices'][0]['message']['content'] as String;
|
||||
|
||||
final start = content.indexOf('[');
|
||||
final end = content.lastIndexOf(']') + 1;
|
||||
if (start == -1 || end == 0) {
|
||||
throw Exception('No JSON array found in response');
|
||||
}
|
||||
|
||||
final List parsed =
|
||||
jsonDecode(content.substring(start, end)) as List;
|
||||
|
||||
final newItems = parsed
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) =>
|
||||
GroceryItem.fromJson(e.key, e.value as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Preserve cart state for already-added items
|
||||
for (final item in newItems) {
|
||||
if (cartItems.any((c) => c.name == item.name)) {
|
||||
item.inCart = true;
|
||||
}
|
||||
}
|
||||
|
||||
items = newItems;
|
||||
|
||||
// ── Trigger store matching ──────────────────────────────────────────
|
||||
if (items.isNotEmpty) {
|
||||
try {
|
||||
final storeCtrl = Get.find<StoreMatchController>();
|
||||
await storeCtrl.matchAiItems(items);
|
||||
} catch (e, st) {
|
||||
print('[StoreMatch] Error during matching: $e');
|
||||
print(' StackTrace: $st');
|
||||
}
|
||||
}
|
||||
|
||||
} on FormatException catch (e) {
|
||||
print('[GrocerySearch] FormatException: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
print('[GrocerySearch] Network error: $e');
|
||||
} catch (e, st) {
|
||||
print('[GrocerySearch] Unexpected error: $e');
|
||||
print(' StackTrace: $st');
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
// ─── Cart ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void toggleCart(GroceryItem item) {
|
||||
HapticFeedback.lightImpact();
|
||||
if (item.inCart) {
|
||||
item.inCart = false;
|
||||
cartItems.removeWhere((c) => c.id == item.id && c.name == item.name);
|
||||
} else {
|
||||
item.inCart = true;
|
||||
cartItems.add(item);
|
||||
}
|
||||
cartItems.isNotEmpty
|
||||
? cartBarController.forward()
|
||||
: cartBarController.reverse();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addAllToCart() {
|
||||
HapticFeedback.mediumImpact();
|
||||
for (final item in items) {
|
||||
if (!item.inCart) {
|
||||
item.inCart = true;
|
||||
cartItems.add(item);
|
||||
}
|
||||
}
|
||||
cartBarController.forward();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get allInCart => items.isNotEmpty && items.every((i) => i.inCart);
|
||||
|
||||
void removeFromCart(GroceryItem item) {
|
||||
item.inCart = false;
|
||||
cartItems.removeWhere((c) => c.id == item.id && c.name == item.name);
|
||||
if (cartItems.isEmpty) cartBarController.reverse();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void clearSearch() {
|
||||
textController.clear();
|
||||
hasSearched = false;
|
||||
items = [];
|
||||
lastQuery = '';
|
||||
focusNode.requestFocus();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void fillSearch(String query) {
|
||||
textController.text = query;
|
||||
search(query);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user