295 lines
11 KiB
Dart
295 lines
11 KiB
Dart
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);
|
||
}
|
||
} |