Update project

This commit is contained in:
2026-07-23 15:36:11 +05:30
parent 1197cfc161
commit 14fffa40c6
84 changed files with 20918 additions and 2761 deletions

View File

@@ -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;
}
}
}

View 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();
}

View 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 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);
}
}