Files
2026-07-23 15:36:11 +05:30

295 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 24 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 13 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 (612 items).
type: "grocery"
MODE 4 — SITUATION / NEED (health, mood, lifestyle):
Signals: "sick", "fever", "diet", "weight loss", "party", "snacks", "breakfast", "workout"
Action: Return 510 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);
}
}