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

@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -14,16 +15,20 @@ import '../../constants/error_constants.dart';
import '../../data/authentication/auth_request.dart';
import '../../data/authentication/auth_response.dart';
import '../../domain/repository/authentication/auth_repository.dart';
import '../../service/firebase_analytics/analytics_service.dart';
import '../../view/authentication/costomer_create_view.dart';
import '../../view/authentication/verification_view.dart';
import '../../view/dashboard_view/dashboard_view.dart';
import '../../view/home_view.dart';
import '../tenant_controller /tenant_list.dart';
import '../tenant_list/tenant_controller.dart';
class AuthController extends GetxController {
final FirebaseAnalytics analytics = FirebaseAnalytics.instance;
LoginRepository loginRepository = LoginRepository();
final TenantController tenantControllers = Get.put(TenantController());
var isLoading = false.obs;
int? activeStatus;
@@ -129,6 +134,16 @@ class AuthController extends GetxController {
ErrorConstants.apiError.value = false;
await AnalyticsService.logEvent(
'login_success',
parameters: {
'customer_id': customerId.toString(),
'name': result?.details?.firstname ?? 'unknown',
'email': result?.details?.email ?? 'unknown',
'city': result?.details?.suburb ?? 'unknown',
},
);
isNewUser=false;
validateDevice(deviceId ?? '');

View File

@@ -10,7 +10,7 @@ import '../../modules/authentication/auth.dart';
import '../../modules/product/product.dart';
import '../../modules/tenant/get_tenant.dart' hide Customer;
import '../../service/dio.dart';
import '../tenant_controller /tenant_list.dart'; // New Product modules
import '../tenant_list/tenant_controller.dart';
class CartController extends GetxController {
var cartItems = <CartItem>[].obs;
@@ -606,6 +606,7 @@ class CartController extends GetxController {
}
/// Clear cart
void clearCart() => cartItems.clear();
@@ -617,6 +618,8 @@ class CartController extends GetxController {
cartItems.fold(0, (sum, item) => sum + ((item.product.productcost ?? 0) * item.quantity));
}
/// Cart item class
class CartItem {
final Product product;

View File

@@ -22,7 +22,7 @@ import '../../modules/tenant/category.dart';
import '../../modules/tenant/get_tenant.dart';
import '../../view/authentication/costomer_create_view.dart';
import '../../widgets/text_widget.dart';
import '../tenant_controller /tenant_list.dart';
import '../tenant_list/tenant_controller.dart';
class DashboardController extends GetxController {
// Loading state

View File

@@ -7,6 +7,7 @@ import '../../modules/orders/create_order.dart';
class OrderController extends GetxController {
final CreateOrderProvider provider = CreateOrderProvider();
var isLoading = false.obs;
String? lastError; // 👈 add this
Future<CreateOrderResponse?> createOrder(CreateOrderRequest request) async {
try {
@@ -17,20 +18,19 @@ class OrderController extends GetxController {
isLoading.value = false;
if (response.status == 'accepted') {
print(response.status);
print("✅ Order Success");
} else {
print("❌ Order Failed");
}
return response; // ✅ VERY IMPORTANT
return response;
} catch (e) {
isLoading.value = false;
print("🔥 ERROR: $e");
print("🔥 ERROR: $e"); // already printing e here
print("🔥 ERROR TYPE: ${e.runtimeType}"); // 👈 add this to see the exact class
return null; // ✅ return null on error
return null;
}
}}

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

View File

View File

@@ -0,0 +1,86 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
/// Holds the two vector embeddings: one for the cropped product image,
/// one for the OCR-extracted label text.
class VectorEmbeddingResult {
final List<double> imageVector;
final List<double> textVector;
const VectorEmbeddingResult({
required this.imageVector,
required this.textVector,
});
}
/// Computes vector embeddings entirely on-device — no backend call.
///
/// NOTE: these are lightweight placeholder embeddings (a downsampled
/// grayscale pixel vector for the image, a character-hashing vector for
/// the text), NOT a trained model like CLIP. They're deterministic and
/// good enough to wire up printing/plumbing now. Swap `_imageVector` /
/// `_textVector` for a real on-device model (e.g. a TFLite feature
/// extractor) or a backend call later without touching the call site.
class VectorService {
static const int _imageVectorSize = 64; // 8x8 downsampled grayscale
static const int _textVectorSize = 32; // hashed char buckets
static Future<VectorEmbeddingResult> getEmbeddings({
required String imagePath,
required String labelText,
}) async {
final imageVector = await _imageVector(imagePath);
final textVector = _textVector(labelText);
return VectorEmbeddingResult(
imageVector: imageVector,
textVector: textVector,
);
}
/// Downsamples the image to an 8x8 grayscale grid and flattens it into
/// a normalized (0-1) vector of length [_imageVectorSize].
static Future<List<double>> _imageVector(String imagePath) async {
final bytes = await File(imagePath).readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null) {
return List<double>.filled(_imageVectorSize, 0);
}
final side = math.sqrt(_imageVectorSize).round(); // 8
final resized = img.copyResize(decoded, width: side, height: side);
final gray = img.grayscale(resized);
final vector = <double>[];
for (var y = 0; y < side; y++) {
for (var x = 0; x < side; x++) {
final pixel = gray.getPixel(x, y);
vector.add(pixel.r / 255.0); // grayscale => r == g == b
}
}
return vector;
}
/// Simple character-hashing bag-of-characters vector, normalized so
/// values sum to 1 (empty text => all zeros).
static List<double> _textVector(String text) {
final vector = List<double>.filled(_textVectorSize, 0);
final normalized = text.toLowerCase();
if (normalized.isEmpty) return vector;
for (final rune in normalized.runes) {
final bucket = rune % _textVectorSize;
vector[bucket] += 1;
}
final total = vector.fold<double>(0, (sum, v) => sum + v);
if (total > 0) {
for (var i = 0; i < vector.length; i++) {
vector[i] = vector[i] / total;
}
}
return vector;
}
}

View File

@@ -68,8 +68,10 @@ class LoginProvider {
CustomerFullView? profile;
final customDio = CustomDio();
final url = "${ApiConstants.fetchProfile}customerid=$customerId&contactno=''";
try {
print(url);
final response = await customDio.getData(url,
headers: {
'x-hasura-admin-secret': 'nearle-admin-secret',

View File

@@ -0,0 +1,316 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../modules/search_model/search_model.dart';
import '../../widgets/fav_button.dart';
class ProductCard extends StatefulWidget {
final ProductItem item;
final VoidCallback? onAddTap;
final VoidCallback? onIncrementTap;
final VoidCallback? onDecrementTap;
final int quantity; // 0 = show "Add", >0 = show stepper
const ProductCard({
super.key,
required this.item,
this.onAddTap,
this.onIncrementTap,
this.onDecrementTap,
this.quantity = 0,
});
@override
State<ProductCard> createState() => _ProductCardState();
}
class _ProductCardState extends State<ProductCard> {
bool _isFav = false;
@override
void initState() {
super.initState();
_isFav = widget.item.isFavorite;
}
@override
Widget build(BuildContext context) {
final item = widget.item;
final original = double.tryParse(
item.originalPrice.replaceAll(RegExp(r'[^\d.]'), ''),
) ??
0;
final current = double.tryParse(
item.price.replaceAll(RegExp(r'[^\d.]'), ''),
) ??
0;
final discount = original > 0
? ((original - current) / original * 100).round()
: 0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Image area ──
Stack(
children: [
// Product image
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
child: CachedNetworkImage(
imageUrl: item.image,
height: 100,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (context, url, error) => Container(
height: 140,
color: const Color(0xFFF3F0FF),
child: Image.network('https://i0.wp.com/www.chitrasfoodbook.com/wp-content/uploads/2014/08/vegetables-for-salad.jpg?w=1200&ssl=1'),
),
),
),
// Discount badge — top left
if (discount > 0)
Positioned(
top: 10,
left: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF662582),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$discount% OFF',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
),
// Favourite button — top right
Positioned(
top: 8,
right: 8,
child: FavButton(
size: 32,
initialValue: _isFav,
onChanged: (val) => setState(() => _isFav = val),
),
),
// Add / Stepper button — bottom right ON image
Positioned(
bottom: 10,
right: 10,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: widget.quantity <= 0
? GestureDetector(
key: const ValueKey('add_button'), // important!
onTap: widget.onAddTap,
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.add_rounded,
color: Color(0xFF662582),
size: 20,
),
),
)
: Container(
key: const ValueKey('counter_row'), // important!
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: widget.onDecrementTap,
child: const Icon(
Icons.remove_rounded,
color: Color(0xFF662582),
size: 18,
),
),
const SizedBox(width: 6),
Text(
'${widget.quantity}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: widget.onIncrementTap,
child: const Icon(
Icons.add_rounded,
color: Color(0xFF662582),
size: 18,
),
),
],
),
),
),
),
],
),
// ── Info area ──
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category
Text(
item.category,
style: const TextStyle(
fontSize: 10.5,
color: Color(0xFF662582),
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
// Product name
Text(
item.name,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
// Rating row
Row(
children: [
const Icon(
Icons.star_rounded,
size: 13,
color: Color(0xFFFFC107),
),
const SizedBox(width: 3),
Text(
'${item.rating}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 3),
Text(
'(${item.reviewCount})',
style: const TextStyle(
fontSize: 10.5,
color: Color(0xFF9E9E9E),
),
),
],
),
const SizedBox(height: 8),
// Price row — no button here anymore
Row(
children: [
Text(
item.price,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 6),
// if (discount > 0)
// Text(
// item.originalPrice,
// style: const TextStyle(
// fontSize: 11,
// color: Color(0xFF9E9E9E),
// decoration: TextDecoration.lineThrough,
// ),
// ),
],
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
/// Manages all scroll, cart-bar visibility, back-to-top, and
/// network-retry logic for StoreViewPage.
class StoreViewController extends GetxController {
// ── Scroll ──────────────────────────────────────────────
final ScrollController scrollController = ScrollController();
double scrollOffset = 0.0;
bool showBackToTop = false;
bool hideCartBar = false;
Timer? _cartTimer;
// ── Mini-cart ────────────────────────────────────────────
RxBool showMiniCart = false.obs;
// ── Search ───────────────────────────────────────────────
final TextEditingController searchController = TextEditingController();
// ── Misc ─────────────────────────────────────────────────
var selectedCategoryId = 0.obs;
@override
void onInit() {
super.onInit();
scrollController.addListener(_onScroll);
}
@override
void onClose() {
scrollController.removeListener(_onScroll);
scrollController.dispose();
searchController.dispose();
_cartTimer?.cancel();
super.onClose();
}
// ── Scroll handler (used by the ScrollController listener) ──
void _onScroll() {
scrollOffset = scrollController.offset;
showBackToTop = scrollController.offset > 300;
update(['scroll']);
}
// ── Notification-based handler (NestedScrollView body) ──────
bool handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollUpdateNotification &&
notification.metrics.axis == Axis.vertical) {
final double pixels = notification.metrics.pixels;
final bool isScrollingUp = pixels < scrollOffset;
scrollOffset = pixels;
if (isScrollingUp && pixels > 300) {
showBackToTop = true;
} else if (pixels <= 300 || !isScrollingUp) {
showBackToTop = false;
}
// Hide cart bar immediately on scroll
if (!hideCartBar) {
hideCartBar = true;
showMiniCart.value = false;
update(['cartBar']);
}
// Re-show cart bar after scroll stops
_cartTimer?.cancel();
_cartTimer = Timer(const Duration(milliseconds: 250), () {
if (hideCartBar) {
hideCartBar = false;
update(['cartBar']);
}
});
update(['scroll']);
}
return false;
}
// ── Background colour based on scroll position ───────────────
Color backgroundColorFor(List<Color> colors) {
if (colors.isEmpty) return Colors.white;
return colors[(scrollOffset ~/ 300) % colors.length];
}
// ── Scroll to top ─────────────────────────────────────────────
void scrollToTop() {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
);
}
// ── Network retry ─────────────────────────────────────────────
Future<void> retryNetworkCall(Future<void> Function() onSuccess) async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.mobile ||
connectivityResult == ConnectivityResult.wifi) {
debugPrint('✅ Internet available, retrying...');
await onSuccess();
} else {
debugPrint('❌ Still no internet connection');
Get.snackbar(
'No Internet',
'Please check your connection and try again.',
backgroundColor: Colors.grey[200],
colorText: Colors.black,
snackPosition: SnackPosition.TOP,
);
}
}
}

View File

@@ -0,0 +1,454 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart';
import '../../../../constants/color_constants.dart';
import '../../../../constants/font_constants.dart';
import '../../../../controllers/account_controller/profile.dart';
import '../../../../controllers/cart_controller/cart.dart';
import '../../../../controllers/dashboard_controller/dashboard_controller.dart';
import '../../../../controllers/tenant_list/tenant_controller.dart';
import '../../../../service/firebase_analytics/analytics_service.dart';
import '../../../../view/dashboard_view/tenant_profile.dart';
import '../../../../view/product/tenant_products.dart';
import '../../../../widgets/text_widget.dart';
import '../controller/store_controller.dart';
import '../widgets/store_widgets.dart' hide ZoomIconButton;
class StoreViewPage extends StatefulWidget {
const StoreViewPage({super.key});
@override
State<StoreViewPage> createState() => _StoreViewPageState();
}
class _StoreViewPageState extends State<StoreViewPage> {
// ── Controllers ──────────────────────────────────────────────
final StoreViewController storeCtrl = Get.put(StoreViewController());
final TenantController tenantCtrl = Get.put(TenantController());
final CartController cartCtrl = Get.put(CartController());
bool status = true;
// ─────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
);
return SafeArea(
child: Scaffold(
body: GetBuilder<StoreViewController>(
id: 'scroll',
builder: (ctrl) {
return Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
color: ctrl.backgroundColorFor(ColorConstants.bgColors),
child: NestedScrollViewPlus(
physics: const ClampingScrollPhysics(),
controller: ctrl.scrollController,
headerSliverBuilder: (context, _) => [
CupertinoSliverRefreshControl(
onRefresh: () async {
await tenantCtrl.loadTenants();
await Future.delayed(const Duration(milliseconds: 500));
},
),
],
body: NotificationListener<ScrollNotification>(
onNotification: ctrl.handleScrollNotification,
child: CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 12)),
// ── Section header ──────────────────────────────
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: ReusableTextWidget(
text: "Near by Stores",
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
),
// ── Store list ──────────────────────────────────
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0),
sliver: Obx(() {
// No internet
if (!tenantCtrl.isConnected.value) {
return SliverToBoxAdapter(
child: NoInternetWidget(
onRetry: () => ctrl.retryNetworkCall(
() async => await tenantCtrl.loadTenants(),
),
),
);
}
// Loading
if (tenantCtrl.isLoading.value) {
return const StoreListShimmer();
}
// Empty
if (tenantCtrl.tenants.isEmpty) {
return const SliverToBoxAdapter(
child: NoStoresFound(),
);
}
// Store list
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = tenantCtrl.tenants[index];
return ZoomOnTap(
onTap: () => _openStore(item),
child: _StoreCard(
item: item,
status: status,
onInfoTap: () =>
Get.to(StoreOverviewScreen()),
onCategoryArrowTap: () =>
_openStore(item),
onSubcategoryTap: (_) =>
_openStore(item),
),
);
},
childCount: tenantCtrl.tenants.length,
),
);
}),
),
const SliverToBoxAdapter(child: SizedBox(height: 220)),
SliverToBoxAdapter(
child: Image.asset(
'assets/images/nearle_copyrights.png'),
),
],
),
),
),
),
],
);
},
),
),
);
}
// ── Helpers ────────────────────────────────────────────────────
void _openStore(dynamic item) async {
Get.to(() => ProductsScreen(
tenantId: item.tenantid!,
locationId: item.locationid!,
categoryId: item.categoryid!,
tenantName: item.tenantname!,
locationname: item.locationname!,
tenantLocation: item.suburb!,
tenantImage: item.tenantimage!,
tenantloc: item.locationid!,
subCategoryName: '',
));
await AnalyticsService.logEvent(
'store_viewed',
parameters: {
'store_id': item.tenantid!,
'store_name': item.tenantname!,
'locatiom': item.locationname!,
},
);
}
}
// ─────────────────────────────────────────────────────────────────
// Private store card widget (lives here because it needs item data)
// ─────────────────────────────────────────────────────────────────
class _StoreCard extends StatelessWidget {
final dynamic item;
final bool status;
final VoidCallback onInfoTap;
final VoidCallback onCategoryArrowTap;
final void Function(dynamic subcategory) onSubcategoryTap;
const _StoreCard({
required this.item,
required this.status,
required this.onInfoTap,
required this.onCategoryArrowTap,
required this.onSubcategoryTap,
});
double _rs(BuildContext context, double size) =>
size * (MediaQuery.of(context).size.width / 390);
double _rh(BuildContext context, double size) =>
size * (MediaQuery.of(context).size.height / 844);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12, top: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE1E5EA), width: 0.55),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.10),
spreadRadius: 0,
blurRadius: 20,
offset: const Offset(0, 6),
),
BoxShadow(
color: Colors.black.withOpacity(0.06),
spreadRadius: 0,
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header row ──────────────────────────────────────
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: item.tenantname!,
color: Colors.black.withOpacity(0.7),
fontFamily: FontConstants.fontFamily,
fontSize: _rs(context, 23),
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
Row(
children: [
Icon(Icons.location_on,
color: Colors.grey, size: _rs(context, 11)),
ReusableTextWidget(
text: item.locationname!,
color: Colors.black.withOpacity(0.7),
fontFamily: FontConstants.fontFamily,
fontSize: _rs(context, 10),
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
],
),
ZoomIconButton(
onTap: onInfoTap,
icon: Icons.info_outline,
size: 25,
color: Colors.black87,
),
],
),
),
// ── Body: closed or open ─────────────────────────────
status == false
? const ClosedStoreWidget()
: Column(
children: [
// Banner
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
height: _rh(context, 150),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: (item.tenantbanner != null &&
item.tenantbanner!.isNotEmpty)
? Image.network(
item.tenantbanner!,
fit: BoxFit.fill,
errorBuilder: (_, __, ___) => Center(
child: Icon(
Icons.image_not_supported_outlined,
size: _rs(context, 40),
color: Colors.white),
),
)
: Image.network(
'https://img.freepik.com/free-psd/healthy-eating-lifestyle-banner-template_23-2149087275.jpg',
fit: BoxFit.cover,
width: double.infinity,
),
),
),
// Category row
Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ReusableTextWidget(
text: 'Category',
color: Colors.black.withOpacity(0.7),
fontFamily: FontConstants.fontFamily,
fontSize: _rs(context, 15),
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
IconButton(
onPressed: onCategoryArrowTap,
icon: Icon(
Icons.arrow_circle_right_outlined,
color: Colors.black.withOpacity(0.6),
size: _rs(context, 24),
),
),
],
),
),
// Subcategories
SizedBox(
height: _rh(context, 160),
child: (item.subcategories != null &&
item.subcategories!.isNotEmpty)
? ListView.builder(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12),
itemCount: item.subcategories!.length,
itemBuilder: (context, index) {
final product = item.subcategories![index];
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onSubcategoryTap(product),
child: Container(
margin: const EdgeInsets.only(
bottom: 14, right: 12, top: 2),
height: _rh(context, 180),
width: _rs(context, 120),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.black12, width: 0.20),
boxShadow: [
BoxShadow(
color:
Colors.black.withOpacity(0.10),
spreadRadius: 0,
blurRadius: 7,
offset: const Offset(0, 3),
),
],
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
SizedBox(height: _rh(context, 12)),
ClipOval(
child: Image.network(
product.image ?? '',
width: _rs(context, 80),
height: _rs(context, 80),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(
Icons.image_not_supported,
color: Colors.grey,
size: _rs(context, 30),
),
loadingBuilder:
(_, child, progress) {
if (progress == null)
return child;
return const Center(
child:
CircularProgressIndicator(
color: Colors.grey),
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8),
child: Center(
child: ReusableTextWidget(
text: product.subcatname!,
color: Colors.black
.withOpacity(0.7),
fontFamily:
FontConstants.fontFamily,
fontSize: _rs(context, 12),
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
SizedBox(height: _rh(context, 12)),
],
),
),
);
},
)
: const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'No products available',
style: TextStyle(color: Colors.grey),
),
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,425 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../constants/color_constants.dart';
import '../../../../constants/font_constants.dart';
import '../../../../widgets/text_widget.dart';
// ─────────────────────────────────────────────
// Zoom-on-tap wrapper
// ─────────────────────────────────────────────
class ZoomOnTap extends StatefulWidget {
final Widget child;
final VoidCallback onTap;
const ZoomOnTap({
super.key,
required this.child,
required this.onTap,
});
@override
State<ZoomOnTap> createState() => _ZoomOnTapState();
}
class _ZoomOnTapState extends State<ZoomOnTap> {
double _scale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _scale = 0.96),
onTapUp: (_) {
setState(() => _scale = 1.0);
widget.onTap();
},
onTapCancel: () => setState(() => _scale = 1.0),
child: AnimatedScale(
scale: _scale,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: widget.child,
),
);
}
}
// ─────────────────────────────────────────────
// Zoom icon button
// ─────────────────────────────────────────────
class ZoomIconButton extends StatefulWidget {
final VoidCallback onTap;
final IconData icon;
final double size;
final Color color;
const ZoomIconButton({
super.key,
required this.onTap,
required this.icon,
this.size = 24,
this.color = Colors.black87,
});
@override
State<ZoomIconButton> createState() => _ZoomIconButtonState();
}
class _ZoomIconButtonState extends State<ZoomIconButton> {
double _scale = 1.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _scale = 0.85),
onTapUp: (_) {
setState(() => _scale = 1.0);
widget.onTap();
},
onTapCancel: () => setState(() => _scale = 1.0),
child: AnimatedScale(
scale: _scale,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Icon(widget.icon, size: widget.size, color: widget.color),
),
);
}
}
// ─────────────────────────────────────────────
// Search bar
// ─────────────────────────────────────────────
class StoreSearchBar extends StatelessWidget {
final TextEditingController controller;
final String hint;
final VoidCallback? onTap;
const StoreSearchBar({
super.key,
required this.controller,
this.hint = 'Search',
this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(Icons.search, color: Colors.grey.shade600),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
onTap: onTap,
decoration: InputDecoration(
hintText: hint,
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────
// No internet widget
// ─────────────────────────────────────────────
class NoInternetWidget extends StatelessWidget {
final VoidCallback onRetry;
const NoInternetWidget({super.key, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Lottie.asset(
'assets/lotties/no_internet.json',
width: 200,
height: 200,
fit: BoxFit.contain,
),
const SizedBox(height: 16),
ReusableTextWidget(
text: 'No Internet Connection',
color: Colors.grey[700]!,
fontFamily: FontConstants.fontFamily,
fontSize: 18,
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: onRetry,
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Retry',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────
// No stores found
// ─────────────────────────────────────────────
class NoStoresFound extends StatelessWidget {
const NoStoresFound({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Lottie.asset('assets/lotties/QR Code scan on phone.json'),
const SizedBox(height: 10),
ReusableTextWidget(
text: 'We don\'t deliver to this location yet. Try browsing nearby stores instead.',
textAlign: TextAlign.center,
maxLines: 2,
fontSize: 14,
),
],
);
}
}
// ─────────────────────────────────────────────
// Closed store UI
// ─────────────────────────────────────────────
class ClosedStoreWidget extends StatelessWidget {
const ClosedStoreWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: Lottie.asset(
'assets/lotties/shop.json',
height: 140,
repeat: true,
animate: true,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
Text(
'Store is currently closed',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black.withOpacity(0.7),
),
),
const SizedBox(height: 6),
Text(
'Please come back later',
style: TextStyle(
fontSize: 14,
color: Colors.black.withOpacity(0.7),
),
),
const SizedBox(height: 20),
],
),
);
}
}
// ─────────────────────────────────────────────
// List shimmer (loading placeholder)
// ─────────────────────────────────────────────
class StoreListShimmer extends StatelessWidget {
const StoreListShimmer({super.key});
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 160,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(12),
),
),
const SizedBox(height: 12),
Container(
height: 16,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(
height: 14,
width: MediaQuery.of(context).size.width * 0.4,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Container(
height: 36,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 36,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
),
),
);
},
childCount: 5,
),
);
}
}
// ─────────────────────────────────────────────
// Grid shimmer (subcategory loading placeholder)
// ─────────────────────────────────────────────
class SubcategoryGridShimmer extends StatelessWidget {
const SubcategoryGridShimmer({super.key});
@override
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
double maxCrossAxisExtent = 250;
if (screenWidth > 1200) {
maxCrossAxisExtent = 300;
} else if (screenWidth > 800) {
maxCrossAxisExtent = 280;
}
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(top: 10, left: 10, right: 10),
child: Container(
height: 140,
width: 140,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Container(height: 20, width: 100, color: Colors.white),
),
Container(
height: 35,
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)),
),
),
],
),
),
);
},
childCount: 6,
),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
mainAxisExtent: 230,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
// Models for:
// GET /live/api/v1/mob/products/getproductsbysubcategory
// ?categoryid=&tenantid=&locationid=
//
// Mirrors the response shape exactly (field names/casing kept as-is, e.g.
// the API's inconsistent "Subcategoryname" on the product object vs.
// "subcategoryname" on the section object).
class GroceryProductsResponse {
final int code;
final GroceryData data;
final String message;
final bool status;
GroceryProductsResponse({
required this.code,
required this.data,
required this.message,
required this.status,
});
factory GroceryProductsResponse.fromJson(Map<String, dynamic> json) {
return GroceryProductsResponse(
code: _asInt(json['code']),
data: GroceryData.fromJson(
(json['data'] as Map<String, dynamic>?) ?? const {}),
message: (json['message'] as String?) ?? '',
status: (json['status'] as bool?) ?? false,
);
}
}
class GroceryData {
final String address;
final String city;
final List<GrocerySubcategory> details;
final String licenseno;
final String locationname;
final String pickuplat;
final int pickuplocationid;
final String pickuplong;
final String postcode;
final String primarycontact;
final String primaryemail;
final String suburb;
final String tenantname;
GroceryData({
required this.address,
required this.city,
required this.details,
required this.licenseno,
required this.locationname,
required this.pickuplat,
required this.pickuplocationid,
required this.pickuplong,
required this.postcode,
required this.primarycontact,
required this.primaryemail,
required this.suburb,
required this.tenantname,
});
factory GroceryData.fromJson(Map<String, dynamic> json) {
return GroceryData(
address: (json['address'] as String?) ?? '',
city: (json['city'] as String?) ?? '',
details: ((json['details'] as List<dynamic>?) ?? const [])
.map((e) => GrocerySubcategory.fromJson(e as Map<String, dynamic>))
.toList(),
licenseno: (json['licenseno'] as String?) ?? '',
locationname: (json['locationname'] as String?) ?? '',
pickuplat: (json['pickuplat'] as String?) ?? '',
pickuplocationid: _asInt(json['pickuplocationid']),
pickuplong: (json['pickuplong'] as String?) ?? '',
postcode: (json['postcode'] as String?) ?? '',
primarycontact: (json['primarycontact'] as String?) ?? '',
primaryemail: (json['primaryemail'] as String?) ?? '',
suburb: (json['suburb'] as String?) ?? '',
tenantname: (json['tenantname'] as String?) ?? '',
);
}
}
/// One row in `details`, e.g. "Vegetables & Fruits", "Dairy, Deli & Egg".
class GrocerySubcategory {
final int subcategoryId;
final String subcategoryName;
final String image;
final List<GroceryProduct> products;
GrocerySubcategory({
required this.subcategoryId,
required this.subcategoryName,
required this.image,
required this.products,
});
factory GrocerySubcategory.fromJson(Map<String, dynamic> json) {
return GrocerySubcategory(
subcategoryId: _asInt(json['subcategoryid']),
subcategoryName: (json['subcategoryname'] as String?) ?? 'Other',
image: (json['image'] as String?) ?? '',
products: ((json['products'] as List<dynamic>?) ?? const [])
.map((e) => GroceryProduct.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
class GroceryProduct {
final int productId;
final int applocationId;
final int productLocationId;
final int tenantId;
final int categoryId;
final String categoryName;
final int subcategoryId;
// Note: the API returns this capitalized on the product object.
final String subcategoryNameOnProduct;
final int discountId;
final num discountValue;
final String productName;
final String productDesc;
final String? productSku;
final String? productBrand;
final String productUnit;
final String unitValue;
final String? topPicks;
final num productCost;
final num? taxAmount;
final num? taxPercent;
final num productTax;
final int productStock;
final int productCombo;
final int variants;
final int quantity;
final num? retailPrice;
final num? diffPrice;
final num? diffPercent;
final int approve;
final String productStatus;
final String? productImage;
GroceryProduct({
required this.productId,
required this.applocationId,
required this.productLocationId,
required this.tenantId,
required this.categoryId,
required this.categoryName,
required this.subcategoryId,
required this.subcategoryNameOnProduct,
required this.discountId,
required this.discountValue,
required this.productName,
required this.productDesc,
this.productSku,
this.productBrand,
required this.productUnit,
required this.unitValue,
this.topPicks,
required this.productCost,
this.taxAmount,
this.taxPercent,
required this.productTax,
required this.productStock,
required this.productCombo,
required this.variants,
required this.quantity,
this.retailPrice,
this.diffPrice,
this.diffPercent,
required this.approve,
required this.productStatus,
this.productImage,
});
/// Selling price shown to the customer: retail price if set, else cost.
num get displayPrice => retailPrice ?? productCost;
/// Struck-through "was" price — only meaningful when the API sent a
/// positive diffprice (an actual discount amount).
num get displayOriginalPrice =>
(diffPrice != null && diffPrice! > 0) ? displayPrice + diffPrice! : displayPrice;
bool get isInStock => productStatus.toLowerCase() == 'available';
bool get isActive => productStatus.toLowerCase() != 'inactive';
factory GroceryProduct.fromJson(Map<String, dynamic> json) {
return GroceryProduct(
productId: _asInt(json['productid']),
applocationId: _asInt(json['applocationid']),
productLocationId: _asInt(json['productlocationid']),
tenantId: _asInt(json['tenantid']),
categoryId: _asInt(json['categoryid']),
categoryName: (json['categoryname'] as String?) ?? '',
subcategoryId: _asInt(json['subcategoryid']),
subcategoryNameOnProduct: (json['Subcategoryname'] as String?) ?? '',
discountId: _asInt(json['discountid']),
discountValue: _asNum(json['discountvalue']),
productName: (json['productname'] as String?) ?? 'Product',
productDesc: (json['productdesc'] as String?) ?? '',
productSku: json['productsku'] as String?,
productBrand: json['productbrand'] as String?,
productUnit: (json['productunit'] as String?) ?? '',
unitValue: (json['unitvalue'] as String?) ?? '',
topPicks: json['toppicks'] as String?,
productCost: _asNum(json['productcost']),
taxAmount: json['taxamount'] == null ? null : _asNum(json['taxamount']),
taxPercent:
json['taxpercent'] == null ? null : _asNum(json['taxpercent']),
productTax: _asNum(json['producttax']),
productStock: _asInt(json['productstock']),
productCombo: _asInt(json['productcombo']),
variants: _asInt(json['variants']),
quantity: _asInt(json['quantity']),
retailPrice:
json['retailprice'] == null ? null : _asNum(json['retailprice']),
diffPrice: json['diffprice'] == null ? null : _asNum(json['diffprice']),
diffPercent:
json['diffpercent'] == null ? null : _asNum(json['diffpercent']),
approve: _asInt(json['approve']),
productStatus: (json['productstatus'] as String?) ?? '',
productImage: json['productimage'] as String?,
);
}
}
int _asInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString()) ?? 0;
}
num _asNum(dynamic v) {
if (v == null) return 0;
if (v is num) return v;
return num.tryParse(v.toString()) ?? 0;
}

View File

@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
// TODO: point this at wherever your real `Product` model (the one used by
// CartController.addToCart) actually lives.
import '../../../../modules/product/product.dart';
/// One subcategory row returned by the API, e.g. "Vegetables & Fruits",
/// holding the real `Product` model straight from the API — the same type
/// CartController.addToCart expects, so no adapter/mapping is needed to
/// add a grocery item to the cart.
class GrocerySubcategorySection {
final int subcategoryId;
final String subcategoryName;
final String image;
final List<Product> products;
GrocerySubcategorySection({
required this.subcategoryId,
required this.subcategoryName,
required this.image,
required this.products,
});
}
class GroceryRepository {
static const String _baseUrl =
'https://fiesta.nearle.app/live/api/v1/mob/products/getproductsbysubcategory';
/// Fetches grocery products for the given category/tenant/location and
/// returns them grouped by subcategory, in the order the API returns them.
///
/// Throws an [Exception] with a readable message on failure so the caller
/// can show a retry UI.
Future<List<GrocerySubcategorySection>> fetchGroceryProducts({
required int categoryId,
required int tenantId,
required int locationId,
}) async {
final uri = Uri.parse(
'$_baseUrl?categoryid=$categoryId&tenantid=$tenantId&locationid=$locationId',
);
late final http.Response response;
try {
print('Fetching grocery products from API...');
print(uri);
response = await http.get(uri).timeout(const Duration(seconds: 15));
} catch (_) {
throw Exception('Could not reach the server. Check your connection.');
}
if (response.statusCode != 200) {
throw Exception(
'Failed to load grocery products (HTTP ${response.statusCode})');
}
late final Map<String, dynamic> body;
try {
body = jsonDecode(response.body) as Map<String, dynamic>;
} catch (_) {
throw Exception('Received an unexpected response from the server.');
}
if (body['status'] != true) {
throw Exception(
(body['message'] as String?) ?? 'Failed to load grocery products');
}
final List<dynamic> details =
(body['data'] as Map<String, dynamic>?)?['details'] as List<dynamic>? ??
[];
return details.map((rawSection) {
final section = rawSection as Map<String, dynamic>;
final rawProducts = (section['products'] as List<dynamic>?) ?? [];
return GrocerySubcategorySection(
subcategoryId: (section['subcategoryid'] as num?)?.toInt() ?? 0,
subcategoryName: (section['subcategoryname'] as String?) ?? 'Other',
image: (section['image'] as String?) ?? 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRNKer1bKTGDnr5Um463cjhRTpKstbPxh5DxA2hrAr1egSWr7VK42aLkiI&s=10',
// Uses your existing Product.fromJson, so tenantid/productid/etc.
// needed by addToCart are preserved straight from the API.
products: Product.fromJsonList(rawProducts),
);
}).toList();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../modules/search_model/search_model.dart';
import '../../../Products/product_card.dart';
import 'edit_object.dart';
/// Full-screen camera-based visual product search, styled after
/// Google Lens / Amazon-style "search by image" camera views.
///
/// Capturing or picking a photo now hands off to [CropConfirmScreen],
/// which shows a draggable crop box (pre-filled from on-device object
/// detection) so the user can pick exactly which product to search for.
class LensSearchScreen extends StatefulWidget {
const LensSearchScreen({super.key});
@override
State<LensSearchScreen> createState() => _LensSearchScreenState();
}
class _LensSearchScreenState extends State<LensSearchScreen>
with WidgetsBindingObserver {
CameraController? _controller;
List<CameraDescription> _cameras = [];
bool _flashOn = false;
bool _isInitializing = true;
String? _error;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupCamera();
}
Future<void> _setupCamera() async {
try {
_cameras = await availableCameras();
if (_cameras.isEmpty) {
setState(() {
_error = 'No camera found on this device.';
_isInitializing = false;
});
return;
}
final backCamera = _cameras.firstWhere(
(cam) => cam.lensDirection == CameraLensDirection.back,
orElse: () => _cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.high,
enableAudio: false,
);
await controller.initialize();
if (!mounted) return;
setState(() {
_controller = controller;
_isInitializing = false;
});
} catch (e) {
setState(() {
_error = 'Could not start camera: $e';
_isInitializing = false;
});
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
if (state == AppLifecycleState.inactive) {
controller.dispose();
} else if (state == AppLifecycleState.resumed) {
_setupCamera();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller?.dispose();
super.dispose();
}
Future<void> _toggleFlash() async {
final controller = _controller;
if (controller == null) return;
final next = !_flashOn;
try {
await controller.setFlashMode(next ? FlashMode.torch : FlashMode.off);
setState(() => _flashOn = next);
} catch (_) {
// Some devices/emulators don't support torch mode; ignore silently.
}
}
Future<void> _capturePhoto() async {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) return;
if (controller.value.isTakingPicture) return;
try {
final file = await controller.takePicture();
if (!mounted) return;
_goToCropConfirm(file.path);
} catch (e) {
_showSnack('Failed to capture photo: $e');
}
}
Future<void> _pickFromGallery() async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery);
if (picked == null) return;
if (!mounted) return;
_goToCropConfirm(picked.path);
}
void _goToCropConfirm(String imagePath) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CropConfirmScreen(imagePath: imagePath),
),
);
}
void _showSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraPreview(),
Positioned(
top: 0,
left: 0,
right: 0,
height: 140,
child: IgnorePointer(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
),
),
),
SafeArea(
child: Column(
children: [
_buildTopBar(),
const Spacer(),
_buildHintPill(),
const SizedBox(height: 24),
_buildBottomBar(),
const SizedBox(height: 12),
],
),
),
],
),
);
}
Widget _buildCameraPreview() {
if (_isInitializing) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_error != null || _controller == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error ?? 'Camera unavailable',
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
);
}
return FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.previewSize?.height ?? 1,
height: _controller!.value.previewSize?.width ?? 1,
child: CameraPreview(_controller!),
),
);
}
Widget _buildTopBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
const Expanded(child: _LensWordmark()),
_CircleIconButton(
icon: _flashOn ? Icons.flash_on : Icons.flash_on_outlined,
onTap: _toggleFlash,
),
const SizedBox(width: 14),
_CircleIconButton(
icon: Icons.help_outline,
onTap: () =>
_showSnack('Point the camera at a product to search for it.'),
),
],
),
);
}
Widget _buildHintPill() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.55),
borderRadius: BorderRadius.circular(24),
),
child: const Text(
'Take a photo to search products',
style: TextStyle(color: Colors.white, fontSize: 14),
),
);
}
Widget _buildBottomBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_CircleIconButton(
icon: Icons.add_photo_alternate_outlined,
onTap: _pickFromGallery,
size: 48,
),
_ShutterButton(onTap: _capturePhoto),
_CircleIconButton(
icon: Icons.qr_code_scanner,
onTap: () => _showSnack('Barcode scan mode coming soon.'),
size: 48,
),
],
),
);
}
}
class _LensWordmark extends StatelessWidget {
const _LensWordmark();
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: const [
Text(
'Nearle',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
SizedBox(width: 6),
Text(
'ai',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w400,
),
),
SizedBox(width: 2),
Icon(Icons.auto_awesome, color: Colors.white, size: 14),
],
);
}
}
class _CircleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final double size;
const _CircleIconButton({
required this.icon,
required this.onTap,
this.size = 36,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black.withOpacity(0.35),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: size,
height: size,
child: Icon(icon, color: Colors.white, size: size * 0.5),
),
),
);
}
}
class _ShutterButton extends StatelessWidget {
final VoidCallback onTap;
const _ShutterButton({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(color: Colors.white54, width: 4),
),
child: const Icon(Icons.search, color: Colors.black87, size: 30),
),
);
}
}
/// Results screen shown after crop confirmation.
///
/// [extractedText] is whatever on-device OCR (ML Kit Text Recognition)
/// found inside the user's cropped selection — e.g. a brand name like
/// "OPPO", model numbers, or other label text. It's shown in a dedicated
/// card with a "Copy" button so the user can grab it (paste into notes,
/// a search bar, a print dialog, wherever).
///
/// Replace `_fetchMatches` with a real call to your product-search API.
class SearchResultsScreen extends StatelessWidget {
final String imagePath;
final bool wasDetected;
final String extractedText;
const SearchResultsScreen({
super.key,
required this.imagePath,
this.wasDetected = false,
this.extractedText = '',
});
Future<void> _copyText(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: extractedText));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Text copied to clipboard')),
);
}
/// Placeholder matches until this is wired to a real product-search API.
/// Swap this out for the actual API response mapped into [ProductItem]s.
/// NOTE: adjust field names here if they don't match your ProductItem
/// constructor exactly (image, category, name, rating, reviewCount,
/// price, originalPrice, isFavorite are assumed based on ProductCard).
List<ProductItem> _mockMatches() {
return List.generate(10, (index) {
final price = 15 + index * 7.5;
final original = price + 10;
return ProductItem(
image: 'https://picsum.photos/seed/product$index/300/300',
category: 'Electronics',
name: 'Matched product ${index + 1}',
rating: 4.5,
reviewCount: 20 + index,
price: '\$${price.toStringAsFixed(2)}',
originalPrice: '\$${original.toStringAsFixed(2)}',
isFavorite: false,
);
});
}
@override
Widget build(BuildContext context) {
final hasText = extractedText.trim().isNotEmpty;
final matches = _mockMatches();
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
automaticallyImplyLeading: false,
animateColor: false,
title: const Text('Search results')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(imagePath),
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 56,
height: 56,
color: Colors.grey.shade300,
child: const Icon(Icons.image),
),
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Showing matches for your cropped selection',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
),
],
),
),
if (hasText) _buildDetectedTextCard(context),
const SizedBox(height: 8),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 0.72,
),
itemCount: matches.length,
itemBuilder: (context, index) => ProductCard(item: matches[index]),
),
),
],
),
);
}
Widget _buildDetectedTextCard(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.text_snippet_outlined,
size: 18, color: Colors.grey.shade700),
const SizedBox(width: 6),
const Text(
'Detected text',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
const Spacer(),
TextButton.icon(
onPressed: () => _copyText(context),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 6),
SelectableText(
extractedText,
style: const TextStyle(fontSize: 14, height: 1.4),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,597 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_object_detection/google_mlkit_object_detection.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image/image.dart' as img;
import 'package:lottie/lottie.dart';
import 'package:path_provider/path_provider.dart';
import '../../../../core/services/vector_service.dart';
import 'camera_search.dart';
/// Interactive crop-confirmation screen shown after a photo is captured
/// or picked. Mirrors the Google Lens / Amazon "search by image" pattern:
/// 1. The photo is shown with a draggable crop rectangle, pre-filled
/// with an ML Kit object-detection guess once it's ready.
/// 2. The user can drag the four corner handles (or the box itself) to
/// adjust which region gets searched.
/// 3. Tapping the confirm button crops the image, runs on-device OCR
/// on the cropped region to pull out any product text (brand,
/// model, etc.), sends both the image and text to the backend to
/// get vector embeddings, then shows an animated "Finding similar
/// products" sheet before navigating to the results screen.
class CropConfirmScreen extends StatefulWidget {
final String imagePath;
const CropConfirmScreen({super.key, required this.imagePath});
@override
State<CropConfirmScreen> createState() => _CropConfirmScreenState();
}
enum _Corner { topLeft, topRight, bottomLeft, bottomRight }
class _CropConfirmScreenState extends State<CropConfirmScreen> {
final ObjectDetector _objectDetector = ObjectDetector(
options: ObjectDetectorOptions(
mode: DetectionMode.single,
classifyObjects: false,
multipleObjects: true,
),
);
final TextRecognizer _textRecognizer = TextRecognizer();
// Original decoded image dimensions (pixels).
double? _imgWidth;
double? _imgHeight;
// Crop rectangle in *original image pixel* coordinates.
Rect? _cropRect;
bool _isDetecting = true;
bool _isProcessing = false;
// Updated every build so gesture callbacks can convert screen <-> image
// coordinates using the current layout.
double _scale = 1;
double _offsetX = 0;
double _offsetY = 0;
@override
void initState() {
super.initState();
_init();
}
@override
void dispose() {
_objectDetector.close();
_textRecognizer.close();
super.dispose();
}
Future<void> _init() async {
final bytes = await File(widget.imagePath).readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null || !mounted) return;
final w = decoded.width.toDouble();
final h = decoded.height.toDouble();
// Fallback: centered rect covering 80% of the image, used until (or
// unless) object detection finds something.
const inset = 0.1;
final fallback = Rect.fromLTRB(
w * inset,
h * inset,
w * (1 - inset),
h * (1 - inset),
);
setState(() {
_imgWidth = w;
_imgHeight = h;
_cropRect = fallback;
});
try {
final inputImage = InputImage.fromFilePath(widget.imagePath);
final objects = await _objectDetector.processImage(inputImage);
if (!mounted || objects.isEmpty) {
setState(() => _isDetecting = false);
return;
}
objects.sort((a, b) {
final areaA = a.boundingBox.width * a.boundingBox.height;
final areaB = b.boundingBox.width * b.boundingBox.height;
return areaB.compareTo(areaA);
});
final box = objects.first.boundingBox;
setState(() {
_cropRect = Rect.fromLTRB(
box.left.clamp(0, w),
box.top.clamp(0, h),
box.right.clamp(0, w),
box.bottom.clamp(0, h),
);
_isDetecting = false;
});
} catch (_) {
if (mounted) setState(() => _isDetecting = false);
}
}
double get _minCropSize {
if (_imgWidth == null || _imgHeight == null) return 40;
return math.min(_imgWidth!, _imgHeight!) * 0.12;
}
void _updateCorner(_Corner corner, Offset screenDelta) {
if (_cropRect == null) return;
final dx = screenDelta.dx / _scale;
final dy = screenDelta.dy / _scale;
final w = _imgWidth!;
final h = _imgHeight!;
final min = _minCropSize;
Rect r = _cropRect!;
switch (corner) {
case _Corner.topLeft:
final newLeft = (r.left + dx).clamp(0.0, r.right - min);
final newTop = (r.top + dy).clamp(0.0, r.bottom - min);
r = Rect.fromLTRB(newLeft, newTop, r.right, r.bottom);
break;
case _Corner.topRight:
final newRight = (r.right + dx).clamp(r.left + min, w);
final newTop = (r.top + dy).clamp(0.0, r.bottom - min);
r = Rect.fromLTRB(r.left, newTop, newRight, r.bottom);
break;
case _Corner.bottomLeft:
final newLeft = (r.left + dx).clamp(0.0, r.right - min);
final newBottom = (r.bottom + dy).clamp(r.top + min, h);
r = Rect.fromLTRB(newLeft, r.top, r.right, newBottom);
break;
case _Corner.bottomRight:
final newRight = (r.right + dx).clamp(r.left + min, w);
final newBottom = (r.bottom + dy).clamp(r.top + min, h);
r = Rect.fromLTRB(r.left, r.top, newRight, newBottom);
break;
}
setState(() => _cropRect = r);
}
void _moveRect(Offset screenDelta) {
if (_cropRect == null) return;
final dx = screenDelta.dx / _scale;
final dy = screenDelta.dy / _scale;
final w = _imgWidth!;
final h = _imgHeight!;
Rect r = _cropRect!.shift(Offset(dx, dy));
// Clamp so the rect stays fully inside the image bounds.
if (r.left < 0) r = r.shift(Offset(-r.left, 0));
if (r.top < 0) r = r.shift(Offset(0, -r.top));
if (r.right > w) r = r.shift(Offset(w - r.right, 0));
if (r.bottom > h) r = r.shift(Offset(0, h - r.bottom));
setState(() => _cropRect = r);
}
Future<void> _confirmCrop() async {
if (_cropRect == null || _isProcessing) return;
setState(() => _isProcessing = true);
try {
final bytes = await File(widget.imagePath).readAsBytes();
final original = img.decodeImage(bytes);
if (original == null) throw Exception('Could not decode image');
final r = _cropRect!;
final x = r.left.toInt().clamp(0, original.width - 1);
final y = r.top.toInt().clamp(0, original.height - 1);
final cw = r.width.toInt().clamp(1, original.width - x);
final ch = r.height.toInt().clamp(1, original.height - y);
final cropped = img.copyCrop(original, x: x, y: y, width: cw, height: ch);
final croppedBytes = img.encodeJpg(cropped, quality: 92);
final dir = await getTemporaryDirectory();
final croppedPath =
'${dir.path}/cropped_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(croppedPath).writeAsBytes(croppedBytes);
// Run on-device text recognition on the cropped region only, so we
// pick up whatever brand/model/label text is inside the user's box.
final extractedText = await _recognizeText(croppedPath);
// Send the cropped image + extracted text to the backend, which
// converts each into a vector embedding and returns both.
VectorEmbeddingResult? embeddings;
try {
embeddings = await VectorService.getEmbeddings(
imagePath: croppedPath,
labelText: extractedText,
);
debugPrint(
'Image vector (${embeddings.imageVector.length} dims): ${embeddings.imageVector}');
debugPrint(
'Text vector (${embeddings.textVector.length} dims): ${embeddings.textVector}');
} catch (e) {
debugPrint('Vector embedding failed: $e');
// decide whether to continue to results without vectors, or bail out
}
// Give the "Finding similar products" sheet a moment to be visible
// before navigating — this is where a real search API call would
// happen instead of a fixed delay.
await Future.delayed(const Duration(milliseconds: 1400));
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => SearchResultsScreen(
imagePath: croppedPath,
wasDetected: true,
extractedText: extractedText,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isProcessing = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not crop photo: $e')),
);
}
}
}
/// Runs ML Kit text recognition on [path] and returns the recognized
/// text (empty string if nothing was found or recognition failed).
Future<String> _recognizeText(String path) async {
try {
final inputImage = InputImage.fromFilePath(path);
final result = await _textRecognizer.processImage(inputImage);
return result.text.trim();
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF15141B),
body: SafeArea(
child: Column(
children: [
_buildTopBar(),
const SizedBox(height: 8),
Expanded(child: _buildImageArea()),
const SizedBox(height: 16),
if (!_isProcessing) _buildHintPill(),
const SizedBox(height: 16),
],
),
),
floatingActionButton: _isProcessing
? null
: FloatingActionButton(
backgroundColor: Colors.white,
onPressed: _confirmCrop,
child: const Icon(Icons.check, color: Colors.black87),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
bottomSheet: _isProcessing ? _buildProcessingSheet() : null,
);
}
Widget _buildTopBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).maybePop(),
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: const [
Text(
'Nearle',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
SizedBox(width: 6),
Text(
'ai',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontStyle: FontStyle.italic,
),
),
SizedBox(width: 2),
Icon(Icons.auto_awesome, color: Colors.white, size: 14),
],
),
const Spacer(),
const SizedBox(width: 48), // balances the back button
],
),
);
}
Widget _buildHintPill() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(24),
),
child: Text(
_isDetecting ? 'Detecting product…' : 'Drag corners to adjust',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
);
}
Widget _buildImageArea() {
if (_imgWidth == null || _imgHeight == null || _cropRect == null) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final availW = constraints.maxWidth;
final availH = constraints.maxHeight;
_scale = math.min(availW / _imgWidth!, availH / _imgHeight!);
final dispW = _imgWidth! * _scale;
final dispH = _imgHeight! * _scale;
_offsetX = (availW - dispW) / 2;
_offsetY = (availH - dispH) / 2;
final r = _cropRect!;
final screenRect = Rect.fromLTRB(
_offsetX + r.left * _scale,
_offsetY + r.top * _scale,
_offsetX + r.right * _scale,
_offsetY + r.bottom * _scale,
);
return Stack(
children: [
Positioned(
left: _offsetX,
top: _offsetY,
width: dispW,
height: dispH,
child: Image.file(File(widget.imagePath), fit: BoxFit.fill),
),
..._buildDarkMask(screenRect, availW, availH),
Positioned.fromRect(
rect: screenRect,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: (d) => _moveRect(d.delta),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
),
..._buildCornerHandles(screenRect),
],
);
},
);
}
List<Widget> _buildDarkMask(Rect r, double availW, double availH) {
const maskColor = Colors.black54;
return [
Positioned(
left: 0,
top: 0,
right: 0,
height: r.top.clamp(0, availH),
child: IgnorePointer(child: Container(color: maskColor)),
),
Positioned(
left: 0,
top: r.bottom,
right: 0,
bottom: 0,
child: IgnorePointer(child: Container(color: maskColor)),
),
Positioned(
left: 0,
top: r.top,
width: r.left.clamp(0, availW),
height: r.height,
child: IgnorePointer(child: Container(color: maskColor)),
),
Positioned(
left: r.right,
top: r.top,
right: 0,
height: r.height,
child: IgnorePointer(child: Container(color: maskColor)),
),
];
}
List<Widget> _buildCornerHandles(Rect r) {
return [
_cornerHandle(r.topLeft, _Corner.topLeft),
_cornerHandle(r.topRight, _Corner.topRight),
_cornerHandle(r.bottomLeft, _Corner.bottomLeft),
_cornerHandle(r.bottomRight, _Corner.bottomRight),
];
}
Widget _cornerHandle(Offset position, _Corner corner) {
const hitSize = 44.0;
return Positioned(
left: position.dx - hitSize / 2,
top: position.dy - hitSize / 2,
width: hitSize,
height: hitSize,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: (d) => _updateCorner(corner, d.delta),
child: Center(
child: CustomPaint(
size: const Size(28, 28),
painter: _CornerBracketPainter(corner: corner),
),
),
),
);
}
Widget _buildProcessingSheet() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(28),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 55,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(height: 28),
Container(
height: 70,
width: 70,
decoration: const BoxDecoration(
shape: BoxShape.circle,
),
child: Center(
child: SizedBox(
width: 70,
height: 70,
child: Lottie.asset(
'assets/lotties/ai_loader.json',
height: 140,
),
),
),
),
const SizedBox(height: 24),
const Text(
"Searching Similar Products",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Text(
"Analyzing image, detecting text\nand finding the closest matches...",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
height: 1.5,
),
),
const SizedBox(height: 28),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: const LinearProgressIndicator(
minHeight: 8,
backgroundColor: Color(0xffECECEC),
color: Colors.green,
),
),
const SizedBox(height: 14),
Text(
"This usually takes a few seconds",
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 12,
),
),
],
),
);
}
}
/// Draws a white L-shaped bracket oriented for the given corner, matching
/// the Google Lens style crop handles.
class _CornerBracketPainter extends CustomPainter {
final _Corner corner;
_CornerBracketPainter({required this.corner});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white
..strokeWidth = 3
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final path = Path();
switch (corner) {
case _Corner.topLeft:
path.moveTo(0, size.height);
path.lineTo(0, 0);
path.lineTo(size.width, 0);
break;
case _Corner.topRight:
path.moveTo(0, 0);
path.lineTo(size.width, 0);
path.lineTo(size.width, size.height);
break;
case _Corner.bottomLeft:
path.moveTo(0, 0);
path.lineTo(0, size.height);
path.lineTo(size.width, size.height);
break;
case _Corner.bottomRight:
path.moveTo(size.width, 0);
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
break;
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _CornerBracketPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
// Adjust these import paths to match your project structure.
import '../../../../constants/color_constants.dart';
import '../../../../widgets/text_widget.dart';
import 'ai_processing_screen.dart';
// TODO: replace with your actual search/results screen import + widget.
// import '../search/search_result_screen.dart';
class VoiceAssistantScreen extends StatefulWidget {
const VoiceAssistantScreen({super.key});
@override
State<VoiceAssistantScreen> createState() => _VoiceAssistantScreenState();
}
class _VoiceAssistantScreenState extends State<VoiceAssistantScreen>
with SingleTickerProviderStateMixin {
final stt.SpeechToText _speech = stt.SpeechToText();
bool _speechAvailable = false;
bool isListening = true;
String recognizedText = "Tap the mic and start speaking";
late final AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_initSpeech();
}
@override
void dispose() {
controller.dispose();
_speech.stop();
super.dispose();
}
// ---------------- SPEECH SETUP ----------------
Future<void> _initSpeech() async {
_speechAvailable = await _speech.initialize(
onStatus: (status) {
// Fires when recognition stops on its own (silence / done)
if (status == 'notListening' || status == 'done') {
if (isListening) {
setState(() => isListening = false);
_handleFinalResult();
}
}
},
onError: (error) {
setState(() => isListening = false);
},
);
setState(() {});
}
Future<void> _toggleListening() async {
if (!_speechAvailable) return;
if (isListening) {
await _speech.stop();
setState(() => isListening = false);
_handleFinalResult();
return;
}
setState(() {
isListening = true;
recognizedText = "Listening...";
});
await _speech.listen(
onResult: (result) {
setState(() {
recognizedText = result.recognizedWords;
});
},
);
}
/// Called once listening stops (either the user tapped stop, or the
/// recognizer auto-stopped after silence). Navigates forward with
/// whatever was captured, as long as it isn't empty.
void _handleFinalResult() {
final query = recognizedText.trim();
final isPlaceholder = query.isEmpty ||
query == "Listening..." ||
query == "Tap the mic and start speaking";
if (isPlaceholder) return;
// TODO: swap this for your real navigation, e.g.:
// Navigator.pushReplacement(
// context,
// MaterialPageRoute(
// builder: (_) => SearchResultScreen(query: query),
// ),
// );
//
// If this screen was opened for a result (e.g. from a search bar),
// you can instead just pop with the value:
// Navigator.pop(context, query);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AIAssistantScreen(),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.secondaryColor,
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildHeader(context),
const SizedBox(height: 4),
_buildStatusChip(),
const SizedBox(height: 26),
_buildMicStage(),
const SizedBox(height: 26),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: ReusableTextWidget(
text: recognizedText,
fontSize: 16,
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
color: ColorConstants.blackColor,
),
),
_buildPremiumMicButton(),
const SizedBox(height: 22),
],
),
),
);
}
// ---------------- HEADER ----------------
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 0),
child: Row(
children: [
InkWell(
onTap: () => Navigator.maybePop(context),
borderRadius: BorderRadius.circular(30),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: ColorConstants.lightGreyBg,
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 16,
color: ColorConstants.darkGreyColor,
),
),
),
const Expanded(
child: Center(
child: ReusableTextWidget(
text: "Voice Assistant",
fontSize: 18,
fontWeight: FontWeight.w700,
color: ColorConstants.blackColor,
),
),
),
const SizedBox(width: 40),
],
),
);
}
Widget _buildStatusChip() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: ColorConstants.primaryColor1,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 8,
width: 8,
decoration: const BoxDecoration(
color: ColorConstants.primaryColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
ReusableTextWidget(
text: isListening ? "Listening" : "Paused",
fontSize: 12,
fontWeight: FontWeight.w600,
color: ColorConstants.primaryColor,
),
],
),
);
}
// ---------------- MIC / LOTTIE ----------------
Widget _buildMicStage() {
return SizedBox(
height: 270,
width: 270,
child: Center(
child: Lottie.asset(
"assets/lotties/ai.json",
height: 250,
width: 250,
repeat: true,
animate: isListening,
),
),
);
}
// ---------------- PREMIUM MIC BUTTON ----------------
Widget _buildPremiumMicButton() {
return Column(
children: [
GestureDetector(
onTap: _toggleListening,
child: AnimatedBuilder(
animation: controller,
builder: (context, child) {
final ringOpacity =
isListening ? 0.18 + (controller.value * 0.18) : 0.0;
return Container(
height: 92,
width: 92,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
ColorConstants.primaryColor1.withOpacity(ringOpacity),
),
child: Center(child: child),
);
},
child: Container(
height: 68,
width: 68,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [ColorConstants.primaryColor, Color(0xFF8C3EAE)],
),
boxShadow: [
BoxShadow(
color: ColorConstants.primaryColor.withOpacity(0.4),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Icon(
isListening ? Icons.mic_rounded : Icons.mic_off_rounded,
color: ColorConstants.secondaryColor,
size: 28,
),
),
),
),
const SizedBox(height: 12),
ReusableTextWidget(
text: !_speechAvailable
? "Speech recognition unavailable"
: isListening
? "Tap to stop listening"
: "Tap to speak",
fontSize: 13,
fontWeight: FontWeight.w500,
color: ColorConstants.lightGrey,
),
],
);
}
}
/// Temporary placeholder so the file compiles and navigation is visible.
/// Delete this and point `_handleFinalResult` at your real screen.
class _PlaceholderResultScreen extends StatelessWidget {
final String query;
const _PlaceholderResultScreen({required this.query});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Search Results")),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'You said: "$query"\n\nReplace _PlaceholderResultScreen with your real search/results page.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
),
),
);
}
}

View File

@@ -0,0 +1,744 @@
// delivery_tracking_page.dart
//
// SETUP:
// 1. pubspec.yaml -> add:
// google_maps_flutter: ^2.9.0
// url_launcher: ^6.3.0 (for calling the delivery partner)
//
// 2. Android -> android/app/src/main/AndroidManifest.xml, inside <application>:
// <meta-data
// android:name="com.google.android.geo.API_KEY"
// android:value="xxx" />
//
// 3. iOS -> ios/Runner/AppDelegate.swift, inside application(didFinishLaunchingWithOptions):
// GMSServices.provideAPIKey("xxx")
//
// Replace "xxx" above with your real Google Maps API key in BOTH places.
import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import '../../../widgets/text_widget.dart';
/// Draws a small circular badge marker at runtime (no external icon assets
/// needed) — a colored circle with an icon inside, similar to Swiggy's
/// bike / restaurant / home pins.
Future<BitmapDescriptor> _drawBadgeMarker({
required IconData icon,
required Color background,
Color iconColor = Colors.white,
double size = 60,
}) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..color = background;
final radius = size / 2;
canvas.drawCircle(Offset(radius, radius), radius, Paint()..color = Colors.white);
canvas.drawCircle(Offset(radius, radius), radius - 6, paint);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text = TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: size * 0.5,
fontFamily: icon.fontFamily,
package: icon.fontPackage,
color: iconColor,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(radius - textPainter.width / 2, radius - textPainter.height / 2),
);
final picture = recorder.endRecording();
final img = await picture.toImage(size.toInt(), size.toInt());
final bytes = await img.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
}
/// Your Google Maps API key (also enable it for "Directions API").
const String kGoogleApiKey = "AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q";
/// Custom map style JSON — a clean, low-saturation grey theme.
const String kSwiggyLikeMapStyle = '''
[
{"elementType": "geometry", "stylers": [{"color": "#f5f5f3"}]},
{"elementType": "labels.icon", "stylers": [{"visibility": "off"}]},
{"elementType": "labels.text.fill", "stylers": [{"color": "#8a8a8a"}]},
{"elementType": "labels.text.stroke", "stylers": [{"color": "#f5f5f3"}]},
{"featureType": "administrative", "elementType": "geometry", "stylers": [{"visibility": "off"}]},
{"featureType": "administrative.land_parcel", "stylers": [{"visibility": "off"}]},
{"featureType": "administrative.neighborhood", "stylers": [{"visibility": "off"}]},
{"featureType": "poi", "stylers": [{"visibility": "off"}]},
{"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#e5e8e0"}]},
{"featureType": "road", "elementType": "geometry", "stylers": [{"color": "#ffffff"}]},
{"featureType": "road", "elementType": "geometry.stroke", "stylers": [{"color": "#e3e3e3"}]},
{"featureType": "road.arterial", "elementType": "labels", "stylers": [{"visibility": "off"}]},
{"featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#f2c9a0"}]},
{"featureType": "road.local", "elementType": "labels", "stylers": [{"visibility": "off"}]},
{"featureType": "transit", "stylers": [{"visibility": "off"}]},
{"featureType": "water", "elementType": "geometry", "stylers": [{"color": "#c9d6df"}]}
]
''';
class DeliveryTrackingPage extends StatefulWidget {
const DeliveryTrackingPage({super.key});
@override
State<DeliveryTrackingPage> createState() => _DeliveryTrackingPageState();
}
class _DeliveryTrackingPageState extends State<DeliveryTrackingPage> {
GoogleMapController? _mapController;
String _partnerName = 'Majid Khan';
double _partnerRating = 4.8;
int _partnerOrders = 1240;
// Sample coordinates - replace with your real pickup / drop / rider coords
final LatLng _pickup = const LatLng(28.6450, 77.3550); // restaurant
final LatLng _drop = const LatLng(28.6395, 77.3480); // customer
LatLng _riderPosition = const LatLng(28.6440, 77.3520);
final Set<Marker> _markers = {};
final Set<Polyline> _polylines = {};
List<LatLng> _routePoints = [];
Timer? _riderTimer;
int _routeIndex = 0;
int _etaMinutes = 2;
BitmapDescriptor? _pickupIcon;
BitmapDescriptor? _dropIcon;
BitmapDescriptor? _riderIcon;
@override
void initState() {
super.initState();
_loadIcons();
}
Future<void> _loadIcons() async {
_pickupIcon = await _drawBadgeMarker(
icon: Icons.storefront,
background: const Color(0xFFFF6A00),
);
_dropIcon = await _drawBadgeMarker(
icon: Icons.home,
background: const Color(0xFF7A1F5C),
);
_riderIcon = await _drawBadgeMarker(
icon: Icons.two_wheeler,
background: const Color(0xFF662582),
size: 60,
);
_setupMarkers();
_fetchRoute();
}
@override
void dispose() {
_riderTimer?.cancel();
super.dispose();
}
void _setupMarkers() {
_markers
..clear()
..addAll([
Marker(
markerId: const MarkerId('pickup'),
position: _pickup,
icon: _pickupIcon ??
BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueOrange),
infoWindow: const InfoWindow(title: 'Wrapperz'),
),
Marker(
markerId: const MarkerId('drop'),
position: _drop,
icon: _dropIcon ??
BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueViolet),
infoWindow: const InfoWindow(title: 'Friends and Family'),
),
Marker(
markerId: const MarkerId('rider'),
position: _riderPosition,
icon: _riderIcon ??
BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
infoWindow: const InfoWindow(title: 'MAJID - delivery partner'),
),
]);
}
/// Fetches a real road-following route from Google Directions API and
/// draws it as a polyline.
Future<void> _fetchRoute() async {
try {
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/directions/json'
'?origin=${_pickup.latitude},${_pickup.longitude}'
'&destination=${_drop.latitude},${_drop.longitude}'
'&mode=driving'
'&key=$kGoogleApiKey',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final points = data['routes'][0]['overview_polyline']['points'];
_routePoints = _decodePolyline(points);
} else {
_routePoints = _fallbackRoute();
}
} else {
_routePoints = _fallbackRoute();
}
} catch (_) {
_routePoints = _fallbackRoute();
}
setState(() {
_polylines
..clear()
..add(
Polyline(
polylineId: const PolylineId('route'),
points: _routePoints,
color: const Color(0xFF662582),
width: 5,
startCap: Cap.roundCap,
endCap: Cap.roundCap,
jointType: JointType.round,
),
);
});
_startRiderAnimation();
}
/// Simple zig-zag fallback so the UI still shows a route without a live key.
List<LatLng> _fallbackRoute() {
return [
_pickup,
LatLng(_pickup.latitude - 0.0025, _pickup.longitude - 0.0015),
LatLng(_pickup.latitude - 0.0035, _pickup.longitude - 0.0060),
LatLng(_pickup.latitude - 0.0060, _pickup.longitude - 0.0075),
_drop,
];
}
List<LatLng> _decodePolyline(String encoded) {
List<LatLng> points = [];
int index = 0, len = encoded.length;
int lat = 0, lng = 0;
while (index < len) {
int b, shift = 0, result = 0;
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
int dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
lat += dlat;
shift = 0;
result = 0;
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
int dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
lng += dlng;
points.add(LatLng(lat / 1E5, lng / 1E5));
}
return points;
}
/// Moves the rider marker along the route to simulate live tracking.
void _startRiderAnimation() {
if (_routePoints.isEmpty) return;
_riderTimer?.cancel();
_riderTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
if (_routeIndex >= _routePoints.length - 1) {
timer.cancel();
return;
}
_routeIndex++;
_riderPosition = _routePoints[_routeIndex];
_setupMarkers();
setState(() {});
_mapController?.animateCamera(CameraUpdate.newLatLng(_riderPosition));
});
}
Future<void> _callPartner() async {
final uri = Uri(scheme: 'tel', path: '+910000000000');
if (await canLaunchUrl(uri)) await launchUrl(uri);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: SizedBox(),
title: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ReusableTextWidget(
text: 'Out for delivery',
fontSize: 17,
fontWeight: FontWeight.w700,
color: Colors.white,
),
const SizedBox(height: 2),
ReusableTextWidget(
text: '11:38 AM • 1 items',
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white
),
],
),
centerTitle: true,
backgroundColor: const Color(0xFF662582),
),
backgroundColor: Colors.white,
body: Stack(
children: [
// ---------------- MAP ----------------
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: _pickup,
zoom: 14.5,
),
markers: _markers,
polylines: _polylines,
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
compassEnabled: false,
mapToolbarEnabled: false,
buildingsEnabled: false,
indoorViewEnabled: false,
trafficEnabled: false,
style: kSwiggyLikeMapStyle,
onMapCreated: (controller) => _mapController = controller,
),
),
// -------------- TOP BAR --------------
// SafeArea(
// child: Padding(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: [
// _circleIconButton(Icons.arrow_back, () {
// Navigator.maybePop(context);
// }),
// Column(
// crossAxisAlignment: CrossAxisAlignment.center,
// children: [
// ReusableTextWidget(
// text: 'Out for delivery',
// fontSize: 17,
// fontWeight: FontWeight.w700,
// color: Colors.grey.shade900,
// ),
// const SizedBox(height: 2),
// ReusableTextWidget(
// text: '11:38 AM • 1 items',
// fontSize: 12,
// fontWeight: FontWeight.w500,
// color: Colors.grey.shade600,
// ),
// ],
// ),
// const SizedBox(width: 40),
// ],
// ),
// ),
// ),
// -------------- BOTTOM SHEET --------------
DraggableScrollableSheet(
initialChildSize: 0.42,
minChildSize: 0.42,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
child: ListView(
controller: scrollController,
padding: EdgeInsets.zero,
children: [
// Out for delivery row
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: 'Out for delivery',
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.grey.shade800,
),
const SizedBox(height: 4),
ReusableTextWidget(
text: 'MAJID is on the way to deliver your order',
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF7A1F5C),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
ReusableTextWidget(
text: '$_etaMinutes',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
ReusableTextWidget(
text: 'mins',
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
),
],
),
),
],
),
),
// Delivery partner info card
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Stack(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Colors.grey.shade100,
backgroundImage: const NetworkImage(
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSmbomkQ5Aqtd848F_UVR86eY1wrq5tuNJRjmRXAGgXsqJtlL5H6EF8Efws&s=10'),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.green.shade500,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: _partnerName,
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.grey.shade800,
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.star_rounded,
size: 16, color: Colors.amber.shade700),
const SizedBox(width: 4),
ReusableTextWidget(
text: '$_partnerRating',
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
const SizedBox(width: 10),
ReusableTextWidget(
text: '$_partnerOrders orders',
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
],
),
],
),
),
InkWell(
onTap: _callPartner,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFCE4EC),
),
child: const Icon(Icons.call,
color: Colors.redAccent, size: 18),
),
),
],
),
const SizedBox(height: 14),
// Action buttons row
],
),
),
),
Container(height: 8, color: const Color(0xFFF5F5F5)),
// Product / order items card
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(Icons.receipt_long, size: 20, color: Colors.grey.shade700),
const SizedBox(width: 8),
ReusableTextWidget(
text: 'Order ID: # 123456789',
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Items list
_buildBillItem('Chicken Biryani', 1, 250),
const SizedBox(height: 10),
_buildBillItem('Mutton Biryani', 1, 350),
const SizedBox(height: 10),
const SizedBox(height: 4),
const Divider(height: 1),
const SizedBox(height: 12),
// Price breakdown
_buildPriceRow('Subtotal', 600),
const SizedBox(height: 6),
_buildPriceRow('Delivery Fee', 40),
const SizedBox(height: 6),
_buildPriceRow('Tax (5%)', 30),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Total
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ReusableTextWidget(
text: 'Total',
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.grey.shade900,
),
ReusableTextWidget(
text: '₹670',
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.grey.shade900,
),
],
),
],
),
),
),
],
),
);
},
),
],
),
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required Color color,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
ReusableTextWidget(
text: label,
fontSize: 13,
fontWeight: FontWeight.w600,
color: color,
),
],
),
),
);
}
Widget _buildBillItem(String name, int qty, double price) {
final total = qty * price;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ReusableTextWidget(
text: '${qty}x $name',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
ReusableTextWidget(
text: '${price.toStringAsFixed(0)}',
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
const SizedBox(width: 12),
ReusableTextWidget(
text: '${total.toStringAsFixed(0)}',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
],
);
}
Widget _buildPriceRow(String label, double amount) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ReusableTextWidget(
text: label,
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
ReusableTextWidget(
text: '${amount.toStringAsFixed(0)}',
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
],
);
}
Widget _circleIconButton(IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 6),
],
),
child: Icon(icon, color: Colors.black87, size: 20),
),
);
}
}

View File

@@ -1,8 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:io';
import 'package:alp_animated_splashscreen/alp_animated_splashscreen.dart';
import 'package:app_links/app_links.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
@@ -10,11 +9,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:in_app_update/in_app_update.dart';
import 'package:nearledaily/constants/color_constants.dart';
import 'package:nearledaily/view/authentication/app_update_view.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:new_version_plus/new_version_plus.dart';
@@ -26,35 +27,27 @@ import 'controllers/dashboard_controller/dashboard_controller.dart';
import 'controllers/intro_controller/intro_screen_controller.dart';
import 'controllers/order_controller/create_order_controller.dart';
import 'controllers/tenant/get_tenant.dart';
import 'controllers/tenant_controller /tenant_list.dart';
import 'helper/firebase_options.dart';
import 'controllers/tenant_list/tenant_controller.dart';
import 'core/services/ExitWrapper.dart';
import 'features/dashboard/presentation/screens/ai_processing_screen.dart';
import 'service/bindings.dart';
import 'service/device_info/device_info.dart';
import 'view/home_view.dart';
import 'view/splash_view/splash_view.dart';
// -------------------------
// FIREBASE BACKGROUND HANDLER
// -------------------------
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await Firebase.initializeApp();
const AndroidInitializationSettings androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
await flutterLocalNotificationsPlugin.initialize(
const InitializationSettings(android: androidSettings),
);
final androidPlugin = flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.createNotificationChannel(
const AndroidNotificationChannel(
'nearle_channel',
@@ -67,81 +60,56 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final title = data['title'] as String?;
final body = data['body'] as String?;
if (title != null && title.isNotEmpty) {
await flutterLocalNotificationsPlugin.show(
999,
title,
body?.isNotEmpty == true ? body : null,
NotificationDetails(
android: AndroidNotificationDetails(
'nearle_channel',
'Nearle Notifications',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
largeIcon: const DrawableResourceAndroidBitmap('nearle_logo.jpeg'),
),
if (title == null || title.isEmpty) return;
await flutterLocalNotificationsPlugin.show(
999,
title,
body?.isNotEmpty == true ? body : null,
NotificationDetails(
android: AndroidNotificationDetails(
'nearle_channel',
'Nearle Notifications',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
largeIcon: const DrawableResourceAndroidBitmap('nearle_logo.jpeg'),
),
);
}
),
);
}
// -------------------------
// LOCAL NOTIFICATIONS SETUP
// -------------------------
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> _setupLocalNotifications() async {
// Android settings
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// iOS/macOS settings
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestSoundPermission: true,
requestBadgePermission: true,
requestAlertPermission: true,
);
// Combine all platforms
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
macOS: initializationSettingsIOS,
);
const InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
// Android-only notification channel
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'nearle_channel',
'Nearle Notifications',
description: 'High priority notifications',
importance: Importance.max,
playSound: true,
enableVibration: true,
);
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'nearle_channel',
'Nearle Notifications',
description: 'High priority notifications',
importance: Importance.max,
playSound: true,
enableVibration: true,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
// -------------------------
// ROUTE OBSERVER
// -------------------------
final RouteObserver<ModalRoute<void>> routeObserver =
RouteObserver<ModalRoute<void>>();
// -------------------------
// APP VERSION CHECK
// -------------------------
// PRINT CURRENT + STORE VERSION (unchanged)
Future<void> _printAndCheckAppVersions() async {
try {
final PackageInfo packageInfo = await PackageInfo.fromPlatform();
@@ -159,7 +127,10 @@ Future<void> _printAndCheckAppVersions() async {
await prefs.setString('currentAppVersion', currentVersion);
if (defaultTargetPlatform == TargetPlatform.android) {
final newVersion = NewVersionPlus(androidId: "com.nearle.gear");
final newVersion = NewVersionPlus(
androidId: "com.nearle.gear",
);
final status = await newVersion.getVersionStatus();
if (status != null) {
print("PLAY STORE VERSION: ${status.storeVersion}");
@@ -179,7 +150,7 @@ Future<void> _printAndCheckAppVersions() async {
}
}
// Returns true if update available
// FIXED: Now correctly detects real update from Play Store
Future<bool> _checkForAppUpdate() async {
if (defaultTargetPlatform != TargetPlatform.android) return false;
@@ -190,24 +161,18 @@ Future<bool> _checkForAppUpdate() async {
if (status != null && status.canUpdate) {
print("UPDATE DETECTED! Redirecting to AppUpdateView");
print("Current: ${status.localVersion} → Store: ${status.storeVersion}");
return true;
return true; // This will now go to AppUpdateView
}
} catch (e) {
print("Version check failed: $e");
print("Version check failed (continuing anyway): $e");
}
return false;
}
// -------------------------
// HANDLE NOTIFICATION TAP
// -------------------------
void _handleMessage(RemoteMessage message) {
print("Notification tapped: ${message.data}");
}
// -------------------------
// ANIMATED SPLASH
// -------------------------
class AnimatedSplashWithNavigation extends StatefulWidget {
final Widget nextScreen;
const AnimatedSplashWithNavigation({super.key, required this.nextScreen});
@@ -237,162 +202,166 @@ class _AnimatedSplashWithNavigationState
backgroundcolor: Colors.white,
foregroundcolor: ColorConstants.primaryColor,
brandnamecolor: ColorConstants.primaryColor,
// companyname: 'Nearle Daily',
);
}
}
// -------------------------
// MAIN ENTRY POINT
// -------------------------
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Firebase initialize with options (iOS/web safe)
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FlutterError.onError = (details) => FlutterError.dumpErrorToConsole(details);
await _printAndCheckAppVersions();
await _setupLocalNotifications();
await runZonedGuarded(() async {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(statusBarColor: Colors.grey[200]),
);
final prefs = await SharedPreferences.getInstance();
prefs.setBool("firstTime", true);
await Firebase.initializeApp();
// -------------------------
// NOTIFICATION PERMISSIONS & FCM TOKEN
// -------------------------
if (!kIsWeb) {
if (Platform.isIOS) {
NotificationSettings settings =
await _printAndCheckAppVersions();
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(statusBarColor: Colors.grey[200]),
);
await _setupLocalNotifications();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) _handleMessage(initialMessage);
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
if (!kIsWeb) {
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
print('User granted permission');
// Listen for token refresh (APNs ready)
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
print('FCM Token (iOS APNs ready): $newToken');
await prefs.setString('fcmToken', newToken);
});
// Optional: small delay initial attempt
Future.delayed(const Duration(seconds: 2), () async {
String? token = await FirebaseMessaging.instance.getToken();
print('FCM Token initial attempt (iOS): $token');
if (token != null) await prefs.setString('fcmToken', token);
});
} else {
print('User declined notification permission');
}
} else {
// Android: get token immediately
String? token = await FirebaseMessaging.instance.getToken();
print('FCM Token (Android): $token');
if (token != null) await prefs.setString('fcmToken', token);
}
}
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool("firstTime", true);
// Foreground notifications
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
final data = message.data;
final title = data['title'] ?? message.notification?.title ?? 'Nearle';
final body = data['body'] ?? message.notification?.body ?? '';
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
final data = message.data;
final title = data['title'] ?? message.notification?.title ?? 'Nearle';
final body = data['body'] ?? message.notification?.body ?? '';
final ByteData jpegData =
await rootBundle.load('assets/images/nearledaily.png');
final Uint8List jpegBytes = jpegData.buffer.asUint8List();
final ByteData jpegData =
await rootBundle.load('assets/images/nearledaily.png');
final Uint8List jpegBytes = jpegData.buffer.asUint8List();
await flutterLocalNotificationsPlugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'nearle_channel',
'Nearle Notifications',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
largeIcon: ByteArrayAndroidBitmap(jpegBytes),
await flutterLocalNotificationsPlugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'nearle_channel',
'Nearle Notifications',
importance: Importance.max,
priority: Priority.high,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
largeIcon: ByteArrayAndroidBitmap(jpegBytes),
),
),
iOS: const DarwinNotificationDetails(),
);
});
String? fcmToken = await FirebaseMessaging.instance.getToken();
await prefs.setString('fcmToken', fcmToken ?? '');
if (defaultTargetPlatform == TargetPlatform.android) {
final params = PlatformWebViewControllerCreationParams();
AndroidWebViewController(params);
}
ApiConstants.tenantCustomers = ApiConstants.tenantCustomerLive;
ApiConstants.orderedtenantCustomers = ApiConstants.orderedtenantCustomerLive;
ApiConstants.login = ApiConstants.loginLive;
Get.put(TenantController(), permanent: true);
Get.lazyPut(() => AuthController(), fenix: true);
Get.lazyPut(() => DashboardController(), fenix: true);
Get.lazyPut(() => CartController(), fenix: true);
Get.lazyPut(() => BottomNavController(), fenix: true);
Get.lazyPut(() => OrderedTenantController(), fenix: true);
Get.lazyPut(() => OrderController(), fenix: true);
Get.lazyPut(() => FaqController(), fenix: true);
Get.lazyPut(() => IntroScreenController(), fenix: true);
DeviceInfo deviceInfo = DeviceInfo();
await deviceInfo.getDeviceInfo();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
final int? customerId = prefs.getInt('customerId');
final String? contactNo = prefs.getString('contactno');
bool updateAvailable = await _checkForAppUpdate(); // Now works correctly!
Widget nextScreen;
if (updateAvailable) {
nextScreen = const AppUpdateView();
} else if (customerId != null && contactNo != null && contactNo.isNotEmpty) {
nextScreen = BottomNavigation();
} else {
nextScreen = const SplashScreenView();
}
// --- Deep link check, overrides nextScreen if a valid link exists ---
try {
final initialUri = await AppLinks().getInitialLink();
debugPrint('DEEPLINK initial: $initialUri');
if (initialUri != null &&
initialUri.scheme == 'nearle' &&
initialUri.host == 'order') {
final item = initialUri.queryParameters['item'];
final budget = initialUri.queryParameters['budget'];
nextScreen = AIAssistantScreen();
}
} catch (e) {
debugPrint('DEEPLINK initial error: $e');
}
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.white, // or transparent
statusBarIconBrightness: Brightness.dark, // Android
statusBarBrightness: Brightness.light, // iOS
),
);
});
// Android WebView Controller
if (defaultTargetPlatform == TargetPlatform.android) {
final params = PlatformWebViewControllerCreationParams();
AndroidWebViewController(params);
}
// API constants
ApiConstants.tenantCustomers = ApiConstants.tenantCustomerLive;
ApiConstants.orderedtenantCustomers = ApiConstants.orderedtenantCustomerLive;
ApiConstants.login = ApiConstants.loginLive;
// Controllers
Get.put(TenantController(), permanent: true);
Get.lazyPut(() => AuthController(), fenix: true);
Get.lazyPut(() => DashboardController(), fenix: true);
Get.lazyPut(() => CartController(), fenix: true);
Get.lazyPut(() => BottomNavController(), fenix: true);
Get.lazyPut(() => OrderedTenantController(), fenix: true);
Get.lazyPut(() => OrderController(), fenix: true);
Get.lazyPut(() => FaqController(), fenix: true);
Get.lazyPut(() => IntroScreenController(), fenix: true);
DeviceInfo deviceInfo = DeviceInfo();
await deviceInfo.getDeviceInfo();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
final int? customerId = prefs.getInt('customerId');
final String? contactNo = prefs.getString('contactno');
bool updateAvailable = await _checkForAppUpdate();
Widget nextScreen;
if (updateAvailable) {
nextScreen = const AppUpdateView();
} else if (customerId != null && contactNo != null && contactNo.isNotEmpty) {
nextScreen = BottomNavigation();
} else {
nextScreen = const SplashScreenView();
}
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
);
runApp(MyApp(startScreen: nextScreen));
runApp(MyApp(startScreen: nextScreen));
AppLinks().uriLinkStream.listen((uri) {
debugPrint('DEEPLINK stream: $uri');
if (uri.scheme == 'nearle' && uri.host == 'order') {
final item = uri.queryParameters['item'];
final budget = uri.queryParameters['budget'];
Get.to(() => AIAssistantScreen());
}
});
}, (error, stack) => print('Error: $error'));
}
// -------------------------
// MAIN APP
// -------------------------
class MyApp extends StatelessWidget {
final Widget startScreen;
const MyApp({super.key, required this.startScreen});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
@@ -404,6 +373,7 @@ class MyApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
initialBinding: GlobalBinding(),
home: AnimatedSplashWithNavigation(nextScreen: startScreen),
);
}

View File

@@ -0,0 +1,24 @@
class GroceryItem {
final int id;
final String name;
final String quantity;
final String emoji;
bool inCart;
GroceryItem({
required this.id,
required this.name,
required this.quantity,
required this.emoji,
this.inCart = false,
});
factory GroceryItem.fromJson(int id, Map<String, dynamic> json) {
return GroceryItem(
id: id,
name: json['name'] ?? '',
quantity: json['quantity'] ?? '',
emoji: json['emoji'] ?? '🛒',
);
}
}

View File

@@ -0,0 +1,69 @@
// ─── Data Model ───────────────────────────────────────────────────────────────
import 'dart:ui';
class GroceryItem {
final int id;
final String name;
final String quantity;
final String emoji;
bool inCart;
GroceryItem({
required this.id,
required this.name,
required this.quantity,
required this.emoji,
this.inCart = false,
});
factory GroceryItem.fromJson(int id, Map<String, dynamic> json) {
return GroceryItem(
id: id,
name: json['name']?.toString() ?? '',
quantity: json['quantity']?.toString() ?? '',
emoji: json['emoji']?.toString() ?? '🛒',
);
}
}
class RecommendedItem {
final String title;
final String subtitle;
final String image;
final Color bgColor;
final Color accentColor;
RecommendedItem({
required this.title,
required this.subtitle,
required this.image,
required this.bgColor,
required this.accentColor,
});
}
class ProductItem {
final String name;
final String category;
final String price;
final String originalPrice;
final String image;
final double rating;
final int reviewCount;
final bool isFavorite;
ProductItem({
required this.name,
required this.category,
required this.price,
required this.originalPrice,
required this.image,
required this.rating,
required this.reviewCount,
this.isFavorite = false,
});
}

View File

@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class GroqService {
static const String _apiKey = 'YOUR_API_KEY';
static Future<String> fetchIngredients(String query) async {
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': 600,
'messages': [
{
'role': 'system',
'content':
'Return ONLY JSON array of grocery items...'
},
{'role': 'user', 'content': query}
],
}),
);
final data = jsonDecode(res.body);
return data['choices'][0]['message']['content'];
}
}
class AiSearchService {
static const String baseUrl =
'http://10.0.2.2:8000/find-ingredients';
Future<Map<String, dynamic>> search(String query) async {
final response = await http.post(
Uri.parse(baseUrl),
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
"user_input": query,
"customer_id": 6060,
"latitude": 11.0050728,
"longitude": 76.9508513
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw Exception("Failed to load results");
}
}

View File

@@ -0,0 +1,28 @@
import 'package:firebase_analytics/firebase_analytics.dart';
class AnalyticsService {
static final FirebaseAnalytics analytics =
FirebaseAnalytics.instance;
static Future<void> logScreenView(String screenName) async {
await analytics.logScreenView(
screenName: screenName,
);
}
static Future<void> logLogin(String method) async {
await analytics.logLogin(
loginMethod: method,
);
}
static Future<void> logEvent(
String name, {
Map<String, Object>? parameters,
}) async {
await analytics.logEvent(
name: name,
parameters: parameters,
);
}
}

View File

@@ -277,17 +277,8 @@ class _AccountPageState extends State<AccountPage> {
}
},
),
_divider(),
_tile(
icon: Icons.question_answer,
title: "Faq",
onTap: () => Get.to(
() => FaqView(),
// transition: Transition.fade, // or any transition you like
// duration: Duration(milliseconds: 400),
),
),
_divider(),
_tile(
icon: Icons.reorder,
@@ -318,15 +309,15 @@ class _AccountPageState extends State<AccountPage> {
onTap: controller.rateApp,
),
_divider(),
// _tile(
// icon: Icons.group_add,
// title: "Refer a Friend",
// onTap: () => Get.to(
// () => const ShowContactsScreen(),
// // transition: Transition.fade, // or any style you like
// // duration: Duration(milliseconds: 400),
// ),
// ),
_tile(
icon: Icons.group_add,
title: "Refer a Friend",
onTap: () => Get.to(
() => ReferEarnScreen(),
// transition: Transition.fade, // or any style you like
// duration: Duration(milliseconds: 400),
),
),
],
),
@@ -347,6 +338,17 @@ class _AccountPageState extends State<AccountPage> {
// duration: Duration(milliseconds: 400),
),
),
_divider(),
_tile(
icon: Icons.question_answer,
title: "Faq",
onTap: () => Get.to(
() => FaqView(),
// transition: Transition.fade, // or any transition you like
// duration: Duration(milliseconds: 400),
),
),
_divider(),
_tile(

View File

@@ -1,379 +1,396 @@
// import 'package:flutter/material.dart';
// import 'package:flutter_contacts/flutter_contacts.dart';
// import 'package:nearledaily/constants/color_constants.dart';
// import 'package:permission_handler/permission_handler.dart'
// as permission_handler;
// import 'package:url_launcher/url_launcher.dart';
// import 'package:flutter/services.dart';
//
// import '../../constants/font_constants.dart';
// import '../../widgets/text_widget.dart';
//
// class ShowContactsScreen extends StatefulWidget {
// const ShowContactsScreen({super.key});
//
// @override
// State<ShowContactsScreen> createState() => _ShowContactsScreenState();
// }
//
// class _ShowContactsScreenState extends State<ShowContactsScreen>
// with WidgetsBindingObserver {
// List<Contact> _contacts = [];
// bool _loading = false;
// bool _permissionDenied = false;
//
// /// 🔹 ADDED
// bool _showDisclaimer = true;
//
// @override
// void initState() {
// super.initState();
// WidgetsBinding.instance.addObserver(this);
// _loadContacts();
// }
//
// @override
// void dispose() {
// WidgetsBinding.instance.removeObserver(this);
// super.dispose();
// }
//
// Future<void> _loadContacts() async {
// setState(() {
// _loading = true;
// _permissionDenied = false;
// });
//
// final bool granted = await FlutterContacts.requestPermission();
//
// if (!granted) {
// setState(() {
// _loading = false;
// _permissionDenied = true;
// });
// return;
// }
//
// try {
// final List<Contact> contacts = await FlutterContacts.getContacts(
// withProperties: true,
// withPhoto: true,
// );
//
// setState(() {
// _contacts = contacts
// .where((c) => c.phones.isNotEmpty)
// .toList()
// ..sort((a, b) => a.displayName.compareTo(b.displayName));
// _loading = false;
// });
// } catch (e) {
// setState(() {
// _loading = false;
// });
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: ReusableTextWidget(
// text: "Error loading contacts: $e",
// fontSize: 14,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.white,
// ),
// ),
// );
// }
// }
//
// Widget _buildAvatar(Contact contact) {
// if (contact.photo != null && contact.photo!.isNotEmpty) {
// return CircleAvatar(
// backgroundImage: MemoryImage(contact.photo!),
// );
// } else {
// String initials = "";
// final names = contact.displayName.split(" ");
// if (names.isNotEmpty) initials += names[0][0];
// if (names.length > 1) initials += names[1][0];
// return CircleAvatar(
// backgroundColor: Colors.primaries[
// contact.displayName.hashCode % Colors.primaries.length],
// child: ReusableTextWidget(
// text: initials.toUpperCase(),
// fontSize: 16,
// fontWeight: FontWeight.bold,
// fontFamily: FontConstants.fontFamily,
// color: Colors.white,
// ),
// );
// }
// }
//
// Future<void> _openWhatsApp(Contact contact) async {
// if (contact.phones.isEmpty) return;
//
// String phoneNumber =
// contact.phones.first.number.replaceAll(RegExp(r'\D'), '');
// final Uri url = Uri.parse("https://wa.me/$phoneNumber");
//
// if (await canLaunchUrl(url)) {
// await launchUrl(url, mode: LaunchMode.externalApplication);
// } else {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: ReusableTextWidget(
// text: "Could not open WhatsApp",
// fontSize: 14,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.white,
// ),
// ),
// );
// }
// }
//
// Future<void> _inviteWhatsApp(Contact contact) async {
// if (contact.phones.isEmpty) return;
//
// String phoneNumber =
// contact.phones.first.number.replaceAll(RegExp(r'\D'), '');
//
// final String message = Uri.encodeComponent(
// "Hey! Join me on Nearle Daily 🚀");
//
// final Uri url = Uri.parse("https://wa.me/$phoneNumber?text=$message");
//
// if (await canLaunchUrl(url)) {
// await launchUrl(url, mode: LaunchMode.externalApplication);
// } else {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: ReusableTextWidget(
// text: "Could not open WhatsApp",
// fontSize: 14,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.white,
// ),
// ),
// );
// }
// }
//
// @override
// Widget build(BuildContext context) {
// return AnnotatedRegion<SystemUiOverlayStyle>(
// value: const SystemUiOverlayStyle(
// statusBarColor: Colors.white, // White background
// statusBarIconBrightness: Brightness.dark, // Dark icons
// statusBarBrightness: Brightness.light, // iOS
// ),
// child: Scaffold(
// backgroundColor: Colors.white,
// appBar: AppBar(
// backgroundColor: Colors.white,
// surfaceTintColor: Colors.transparent,
// scrolledUnderElevation: 0,
// titleSpacing: -5,
// animateColor: false,
// elevation: 0,
// title: ReusableTextWidget(
// text: "Refer a friend",
// fontSize: 20,
// fontWeight: FontWeight.w600,
// fontFamily: FontConstants.fontFamily,
// color: Colors.black,
// ),
// iconTheme: const IconThemeData(color: Colors.black),
// ),
// body: Padding(
// padding: const EdgeInsets.only(left: 12.0, right: 12, bottom: 12),
// child: Column(
// children: [
// /// 🔹 MODIFIED DISCLAIMER ONLY
// if (_showDisclaimer)
// Stack(
// children: [
// Padding(
// padding: const EdgeInsets.only(top: 12.0),
// child: Container(
// width: double.infinity,
// padding: const EdgeInsets.all(14),
// margin: const EdgeInsets.only(bottom: 16),
// decoration: BoxDecoration(
// color: ColorConstants.primaryColor.withOpacity(0.08),
// borderRadius: BorderRadius.circular(12),
// ),
// child: const ReusableTextWidget(
// text:
// "We access contacts only to let you share\nor recommend to friends. Nothing is stored.",
// fontSize: 13,
// fontWeight: FontWeight.w500,
// fontFamily: FontConstants.fontFamily,
// color: Colors.black87,
// ),
// ),
// ),
// Positioned(
// top: 6,
// right: -3,
// child: IconButton(
// icon: const Icon(Icons.close, size: 18),
// onPressed: () {
// setState(() {
// _showDisclaimer = false;
// });
// },
// ),
// ),
// ],
// ),
//
// if (_loading)
// const Expanded(
// child: Center(child: CircularProgressIndicator()),
// ),
//
// if (_permissionDenied)
// Expanded(
// child: Center(
// child: Container(
// margin: const EdgeInsets.symmetric(horizontal: 24),
// padding: const EdgeInsets.all(24),
// decoration: BoxDecoration(
// color: Colors.red.withOpacity(0.05),
// borderRadius: BorderRadius.circular(20),
// border: Border.all(
// color: Colors.red.withOpacity(0.2),
// ),
// ),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// Container(
// padding: const EdgeInsets.all(18),
// decoration: BoxDecoration(
// color: Colors.red.withOpacity(0.12),
// shape: BoxShape.circle,
// ),
// child: const Icon(
// Icons.info_outline,
// color: Colors.red,
// size: 48,
// ),
// ),
// const SizedBox(height: 20),
// const ReusableTextWidget(
// text: "Contacts Access Needed",
// fontSize: 18,
// fontWeight: FontWeight.w600,
// fontFamily: FontConstants.fontFamily,
// color: Colors.black,
// ),
// const SizedBox(height: 8),
// const ReusableTextWidget(
// text:
// "Allow contacts permission to view\nand invite your friends easily.",
// fontSize: 14,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.black54,
// textAlign: TextAlign.center,
// ),
// const SizedBox(height: 24),
// SizedBox(
// width: double.infinity,
// child: ElevatedButton(
// onPressed: permission_handler.openAppSettings,
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.red,
// elevation: 0,
// padding: const EdgeInsets.symmetric(vertical: 14),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(12),
// ),
// ),
// child: const ReusableTextWidget(
// text: "Open Settings",
// fontSize: 15,
// fontWeight: FontWeight.w600,
// fontFamily: FontConstants.fontFamily,
// color: Colors.white,
// ),
// ),
// ),
// ],
// ),
// ),
// ),
// ),
//
// if (_contacts.isNotEmpty && !_loading && !_permissionDenied)
// Expanded(
// child: RefreshIndicator(
// onRefresh: _loadContacts,
// child: ListView.builder(
// itemCount: _contacts.length,
// itemBuilder: (context, index) {
// final contact = _contacts[index];
// final phones =
// contact.phones.map((p) => p.number).toList();
// final subtitle = phones.length > 1
// ? phones.sublist(0, 2).join(", ")
// : phones.first;
//
// return ListTile(
// leading: _buildAvatar(contact),
// title: ReusableTextWidget(
// text: contact.displayName.isEmpty
// ? "No Name"
// : contact.displayName,
// fontSize: 14,
// fontWeight: FontWeight.w600,
// fontFamily: FontConstants.fontFamily,
// color: Colors.black,
// ),
// subtitle: ReusableTextWidget(
// text: subtitle,
// fontSize: 13,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.grey,
// ),
// trailing: TextButton(
// onPressed: () => _inviteWhatsApp(contact),
// child: const ReusableTextWidget(
// text: "Invite",
// fontSize: 14,
// fontWeight: FontWeight.bold,
// fontFamily: FontConstants.fontFamily,
// color: Colors.green,
// ),
// ),
// onTap: () => _openWhatsApp(contact),
// );
// },
// ),
// ),
// ),
//
// if (_contacts.isEmpty && !_loading && !_permissionDenied)
// const Expanded(
// child: Center(
// child: ReusableTextWidget(
// text: "No contacts found with phone numbers",
// fontSize: 16,
// fontWeight: FontWeight.w400,
// fontFamily: FontConstants.fontFamily,
// color: Colors.grey,
// ),
// ),
// ),
// ],
// ),
// ),
// ),
// );
// }
// }
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../constants/color_constants.dart';
import '../../constants/font_constants.dart';
import '../../widgets/text_widget.dart';
// ─────────────────────────────────────────────────────────────────────────────
// ReferEarnScreen (StatefulWidget)
// ─────────────────────────────────────────────────────────────────────────────
class ReferEarnScreen extends StatefulWidget {
const ReferEarnScreen({super.key});
@override
State<ReferEarnScreen> createState() => _ReferEarnScreenState();
}
class _ReferEarnScreenState extends State<ReferEarnScreen> {
bool _codeCopied = false;
static const String _referralCode = 'ANBU123';
static const int _totalCoins = 1250;
// ── helpers ────────────────────────────────────────────────────────────────
void _copyCode() async {
await Clipboard.setData(const ClipboardData(text: _referralCode));
setState(() => _codeCopied = true);
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _codeCopied = false);
}
// ── build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
bottom: true,
child: Scaffold(
backgroundColor: Color(0xFFF6F6F6),
appBar: AppBar(
surfaceTintColor: Colors.transparent,
// 🔥 Prevent color overlay when scrolled
scrolledUnderElevation: 0,
animateColor: false, // ✨ prevent color change on scroll
elevation: 0,
backgroundColor: Colors.white,
leading: const BackButton(color: Color(0xFF1A1A2E)),
title: ReusableTextWidget(
text: 'Refer & Earn',
color: const Color(0xFF1A1A2E),
fontFamily: FontConstants.fontFamily,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTotalCoinsBanner(),
const SizedBox(height: 16),
_buildReferralCodeCard(),
const SizedBox(height: 20),
Center(
child: ReusableTextWidget(
text: 'How It Works',
color: const Color(0xFF1A1A2E),
fontFamily: FontConstants.fontFamily,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 16),
_buildHowItWorksRow(),
const SizedBox(height: 24),
],
),
),
),
);
}
// ── Total Coins Banner ─────────────────────────────────────────────────────
Widget _buildTotalCoinsBanner() {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
ColorConstants.primaryColor.withOpacity(0.85),
ColorConstants.primaryColor,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: 'Total Coins',
color: Colors.white,
fontFamily: FontConstants.fontFamily,
fontSize: 13,
fontWeight: FontWeight.w800,
),
const SizedBox(height: 4),
ReusableTextWidget(
text: '$_totalCoins Coins',
color: Colors.white,
fontFamily: FontConstants.fontFamily,
fontSize: 28,
fontWeight: FontWeight.w800,
),
const SizedBox(height: 4),
ReusableTextWidget(
text: 'Earn more by referring friends',
color: Colors.white60,
fontFamily: FontConstants.fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
),
],
),
),
Container(
width: 80,
height: 70,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white.withOpacity(0.15),
),
child: const Center(
child: Text('🪙', style: TextStyle(fontSize: 36)),
),
),
],
),
const SizedBox(height: 16),
],
),
);
}
// ── Referral Code Card ─────────────────────────────────────────────────────
Widget _buildReferralCodeCard() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: 'Your Referral Code',
color: Colors.black.withOpacity(0.7),
fontFamily: FontConstants.fontFamily,
fontSize: 14,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black.withOpacity(0.7),
width: 1.5),
borderRadius: BorderRadius.circular(10),
),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ReusableTextWidget(
text: _referralCode,
color: Colors.black.withOpacity(0.7),
fontFamily: FontConstants.fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
),
GestureDetector(
onTap: _copyCode,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _codeCopied
? Row(
key: const ValueKey('copied'),
children: [
const Icon(Icons.check_circle,
size: 18, color: Color(0xFF2ECC71)),
const SizedBox(width: 4),
ReusableTextWidget(
text: 'Copied!',
color: const Color(0xFF2ECC71),
fontFamily: FontConstants.fontFamily,
fontSize: 14,
fontWeight: FontWeight.w600,
),
],
)
: Row(
key: const ValueKey('copy'),
children: [
Icon(Icons.copy,
size: 18,
color: ColorConstants.primaryColor),
const SizedBox(width: 4),
ReusableTextWidget(
text: 'Copy',
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
fontSize: 14,
fontWeight: FontWeight.w600,
),
],
),
),
),
],
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.share_rounded, color: Colors.white),
label: ReusableTextWidget(
text: 'Invite Friends & Earn',
color: Colors.white,
fontFamily: FontConstants.fontFamily,
fontSize: 15,
fontWeight: FontWeight.w700,
),
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
),
),
const SizedBox(height: 10),
Center(
child: ReusableTextWidget(
text: 'Share your code or link with friends',
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
// ── How It Works ───────────────────────────────────────────────────────────
Widget _buildHowItWorksRow() {
final steps = [
{
'step': 1,
'icon': Icons.share_rounded,
'label': 'Share',
'color': ColorConstants.primaryColor,
},
{
'step': 2,
'icon': Icons.download_rounded,
'label': 'Install',
'color': const Color(0xFF3498DB),
},
{
'step': 3,
'icon': Icons.how_to_reg_rounded,
'label': 'Register',
'color': const Color(0xFF2ECC71),
},
{
'step': 4,
'icon': Icons.card_giftcard_rounded,
'label': 'Reward',
'color': const Color(0xFFE67E22),
},
];
return Row(
children: steps.map((s) {
final color = s['color'] as Color;
return Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: color.withOpacity(0.12),
shape: BoxShape.circle,
),
child: Icon(
s['icon'] as IconData,
color: color,
size: 20,
),
),
Positioned(
top: -3,
left: -3,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${s['step']}',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
const SizedBox(height: 8),
ReusableTextWidget(
text: s['label'] as String,
color: const Color(0xFF555555),
fontFamily: FontConstants.fontFamily,
fontSize: 9,
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -13,7 +14,8 @@ import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../../constants/color_constants.dart';
import '../../constants/font_constants.dart';
import '../../controllers/tenant_controller /tenant_list.dart';
import '../../controllers/tenant_list/tenant_controller.dart';
import '../../service/firebase_analytics/analytics_service.dart';
import '../../widgets/text_widget.dart';
import '../home_view.dart';
@@ -114,6 +116,19 @@ class _CustomerCreateViewState extends State<CustomerCreateView> {
await prefs.setString('customerDoorNo', details['doorno'] ?? '');
debugPrint("✅ Customer info saved to SharedPreferences.");
await FirebaseAnalytics.instance.setUserId(
id: customerIdStr,
);
await AnalyticsService.logEvent(
'signup_success',
parameters: {
'customer_id': int.tryParse(customerIdStr) ?? 0,
'customer_name': details['firstname'] ?? '',
'user_type': 'new_user',
},
);
}
tenantController.loadTenants();
@@ -131,11 +146,7 @@ class _CustomerCreateViewState extends State<CustomerCreateView> {
fontSize: 15,
);
} else {
// ❌ Handle failure message from API
debugPrint("❌ API returned failure: $message");
@@ -147,8 +158,6 @@ class _CustomerCreateViewState extends State<CustomerCreateView> {
textColor: Colors.white,
fontSize: 15,
);
}
} catch (e, stacktrace) {
debugPrint(" Something went wrong");
@@ -161,17 +170,11 @@ class _CustomerCreateViewState extends State<CustomerCreateView> {
textColor: Colors.white,
fontSize: 15,
);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final width = size.width;
final height = size.height;

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../controllers/authentication/auth_controller.dart';
import '../../service/firebase_analytics/analytics_service.dart';
import '../authentication/verification_view.dart';
class Login_view extends StatelessWidget {
@@ -319,8 +320,13 @@ class Login_view extends StatelessWidget {
elevation: 0,
),
onPressed: canProceed
? () =>
authController.signIn(context, phone)
? () async {
await AnalyticsService.logEvent(
'login_continue_clicked',
);
authController.signIn(context, phone);
}
: null,
child: authController.isLoading.value
? const SizedBox(

View File

@@ -347,13 +347,39 @@ class _CartPageState extends State<CartPage> {
const Padding(
padding: EdgeInsets.all(6.0),
child: Text(
'No slots available for today.\nPlease try changing dates to schedule orders.',
'Your cart is empty.\nBrowse our products to get started.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 24),
SizedBox(
width: 200,
height: 46,
child: ElevatedButton(
onPressed: () {
// Switch to the Stores tab (index 1) via the shared GetX controller
Get.find<BottomNavController>().currentIndex.value = 1;
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: const Text(
'Browse Products',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
)

View File

@@ -6,6 +6,7 @@ import '../../constants/font_constants.dart';
import '../../controllers/cart_controller/cart.dart';
import '../../controllers/order_controller/create_order_controller.dart';
import '../../modules/orders/create_order.dart';
import '../../service/firebase_analytics/analytics_service.dart';
import '../../widgets/text_widget.dart';
import '../orders/order_succes.dart';
@@ -99,12 +100,25 @@ class _OrderCountdownPageState extends State<OrderCountdownPage>
if (!widget.orderCtrl.isLoading.value) {
await AnalyticsService.logEvent(
'order_placed',
parameters: {
'customer_id': widget.order.customerid ?? 0,
'tenant_id': widget.order.tenantid ?? 0,
'item_count': widget.order.items?.length ?? 0,
'payment_type': widget.order.paymenttype ?? 0,
},
);
Get.offAll(() => OrderSuccessView());
widget.cartCtrl.clearCart();
await widget.cartCtrl.notifyAdmin(
title: 'Nearle Deals - New Order',
body: 'A new order has been placed successfully by ${widget.customerName}!',
);
print('jeee');
print(widget.order.toJson());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:animated_text_kit/animated_text_kit.dart';
import 'package:animations/animations.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/cupertino.dart';
@@ -27,8 +28,12 @@ import '../../controllers/cart_controller/cart.dart';
import '../../controllers/dashboard_controller/category.dart';
import '../../controllers/dashboard_controller/dashboard_controller.dart';
import '../../controllers/product/product_controller.dart';
import '../../controllers/tenant_controller /tenant_list.dart';
import '../../controllers/tenant_list/tenant_controller.dart';
import '../../domain/repository/authentication/auth_repository.dart';
import '../../features/Products/product_card.dart';
import '../../modules/search_model/search_model.dart';
import '../../service/firebase_analytics/analytics_service.dart';
import '../../widgets/fav_button.dart';
import '../../widgets/tenantcategory.dart';
import '../../widgets/text_widget.dart';
import '../account/demo.dart';
@@ -37,8 +42,10 @@ import '../cart/cart_view.dart';
import '../home_view.dart';
import '../product/category_products.dart';
import '../product/product_view.dart';
import '../product/products_list.dart' hide ProductItem;
import '../product/tenant_products.dart';
import '../qr_scaner/qr_scaner.dart';
import 'ai_search.dart';
class DashboardPage extends StatefulWidget {
const DashboardPage({super.key});
@@ -72,6 +79,87 @@ class _DashboardPageState extends State<DashboardPage> {
static const _kProfile = 'cached_profile';
final recommendations = [
RecommendedItem(
title: 'Cheapest Pizza\nNearby',
subtitle: 'From ₹149',
image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS9b5A0dGeZ4PR6cdpuvDyoMa3lO7JBRyMo8Q&s',
bgColor: Color(0xFFFFF3EC),
accentColor: Color(0xFFFF6B2C),
),
RecommendedItem(
title: 'Grocery Basket\n₹320 Less',
subtitle: 'Save more on your order',
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=200&fit=crop',
bgColor: Color(0xFFF0FAF0),
accentColor: Color(0xFF22A45D),
),
RecommendedItem(
title: 'Birthday\nComing Up!',
subtitle: 'Let Nearle plan it',
image: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=200&fit=crop',
bgColor: Color(0xFFFDF0F8),
accentColor: Color(0xFFD63384),
),
];
final products = [
ProductItem(
name: 'Chicken Biryani',
category: 'Food · HotPot',
price: '₹189',
originalPrice: '₹240',
image: 'https://images.unsplash.com/photo-1563379091339-03b21ab4a4f8?w=300&fit=crop',
rating: 4.8,
reviewCount: 320,
),
ProductItem(
name: 'Fresh Veggie Basket',
category: 'Grocery · BigBasket',
price: '₹299',
originalPrice: '₹420',
image: 'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=300&fit=crop',
rating: 4.5,
reviewCount: 180,
),
ProductItem(
name: 'Margherita Pizza',
category: 'Food · Dominos',
price: '₹149',
originalPrice: '₹199',
image: 'https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=300&fit=crop',
rating: 4.6,
reviewCount: 512,
),
ProductItem(
name: 'Birthday Cake',
category: 'Bakery · CakeZone',
price: '₹549',
originalPrice: '₹699',
image: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=300&fit=crop',
rating: 4.9,
reviewCount: 94,
),
ProductItem(
name: 'Mango Smoothie',
category: 'Drinks · Juice Bar',
price: '₹89',
originalPrice: '₹120',
image: 'https://images.unsplash.com/photo-1623065422902-30a2d299bbe4?w=300&fit=crop',
rating: 4.4,
reviewCount: 67,
),
ProductItem(
name: 'Paneer Butter Masala',
category: 'Food · Behrouz',
price: '₹220',
originalPrice: '₹280',
image: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc7?w=300&fit=crop',
rating: 4.7,
reviewCount: 210,
),
];
bool status = true;
bool _showBackToTop = false;
RxBool showMiniCart = false.obs;
@@ -84,177 +172,6 @@ class _DashboardPageState extends State<DashboardPage> {
Icons.shopping_cart_rounded,
];
void _openCategoryBottomSheet(
BuildContext context,
List subCategories,
item,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
return SafeArea(
top: false,
child: ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(20)),
child: Container(
height: MediaQuery.of(context).size.height * 0.85,
color: Colors.white,
child: Column(
children: [
const SizedBox(height: 8),
// drag handle (smaller)
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 10),
// Title (not oversized)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
"Category",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 10),
Expanded(
child: subCategories.isEmpty
? const Center(
child: Text("No products available"),
)
: GridView.builder(
padding:
const EdgeInsets.fromLTRB(12, 8, 12, 16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.78,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: subCategories.length,
itemBuilder: (context, index) {
final product = subCategories[index];
return InkWell(
borderRadius:
BorderRadius.circular(14),
onTap: () {
Navigator.pop(context);
Get.to(
() => SubCategoryProductsScreen(
tenantId: item.tenantid!,
locationId: item.locationid!,
categoryId: item.categoryid!,
tenantName: item.tenantname!,
locationname:
item.locationname!,
tenantLocation: item.suburb!,
tenantImage:
item.tenantimage!,
tenantloc:
item.locationid!,
subCategoryName:
product.subcatname
.toString(),
),
);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(14),
border: Border.all(
color: Colors.black12,
width: 0.25,
),
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.04),
blurRadius: 5,
),
],
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const SizedBox(height: 10),
ClipOval(
child: Image.network(
product.image ?? '',
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) =>
const Icon(
Icons
.image_not_supported),
),
),
Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 6),
child: ReusableTextWidget(
text:
product.subcatname ??
'',
color: Colors.black
.withOpacity(0.7),
fontFamily:
FontConstants
.fontFamily,
fontSize: 11.5,
fontWeight:
FontWeight.w600,
textAlign:
TextAlign.center,
maxLines: 1,
overflow: TextOverflow
.ellipsis,
),
),
const SizedBox(height: 10),
],
),
),
);
},
),
),
],
),
),
),
);
},
);
}
@override
@@ -659,7 +576,7 @@ class _DashboardPageState extends State<DashboardPage> {
borderRadius: BorderRadius.circular(14),
onTap: () => Get.to(() => const SearchScreen()),
child: Container(
height: 48,
height: 55,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
@@ -710,6 +627,11 @@ class _DashboardPageState extends State<DashboardPage> {
],
),
),
IconButton(onPressed: () => Get.to(() => const SearchPage()), icon: const Icon(Icons.mic, color: Colors.black87, size: 22)),
IconButton(onPressed: () => Get.to(() => const SearchPage()), icon: const Icon(Icons.camera_alt_outlined, color: Colors.black87, size: 22)),
],
),
),
@@ -811,12 +733,226 @@ class _DashboardPageState extends State<DashboardPage> {
);
}),
const SliverToBoxAdapter(
child: SizedBox(height: 12),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Recommended for You",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black.withOpacity(0.7),
),
),
TextButton(
onPressed: () {},
child: const Text("See all"),
),
],
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 110,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: recommendations.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final item = recommendations[index];
return Container(
width: 160,
decoration: BoxDecoration(
color: item.bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Stack(
children: [
// Title + Subtitle
Positioned(
top: 14,
left: 14,
right: 14,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.black.withOpacity(0.7),
height: 1.25,
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: item.accentColor,
),
),
],
),
),
// Network image bottom-right
Positioned(
bottom: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomRight: Radius.circular(20),
),
child: CachedNetworkImage(
imageUrl: item.image,
width: 80,
height: 80,
fit: BoxFit.contain,
placeholder: (context, url) => const SizedBox(
width: 80,
height: 80,
),
errorWidget: (context, url, error) => SizedBox(
width: 80,
height: 80,
child: Icon(
Icons.image_not_supported_rounded,
color: item.accentColor.withOpacity(0.3),
size: 28,
),
),
),
),
),
// Arrow button bottom-right corner
Positioned(
bottom: 10,
right: 10,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.chevron_right_rounded,
size: 16,
color: item.accentColor,
),
),
),
],
),
);
},
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 12),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Fresh picks for you",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black.withOpacity(0.7),
),
),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context,) => const ProductsList()));
},
child: const Text("See all"),
),
],
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 12),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 270,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ProductCard(item: products[index]),
childCount: products.length,
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 12),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Near by Stores",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black.withOpacity(0.7),
),
),
TextButton(
onPressed: () {},
child: const Text("See all"),
),
],
),
),
),
@@ -898,7 +1034,7 @@ class _DashboardPageState extends State<DashboardPage> {
(context, index) {
final item = tenantController.tenants[index];
return _ZoomOnTap(
onTap: () {
onTap: () async {
Get.to(() => ProductsScreen(
tenantId: item.tenantid!,
locationId: item.locationid!,
@@ -910,6 +1046,16 @@ class _DashboardPageState extends State<DashboardPage> {
tenantloc:item.locationid!,
subCategoryName: "",
));
await AnalyticsService.logEvent(
'store_viewed',
parameters: {
'store_id': item.tenantid!,
'store_name': item.tenantname!,
'locatiom' : item.locationname!,
},
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12, top: 12),
@@ -1063,7 +1209,7 @@ class _DashboardPageState extends State<DashboardPage> {
maxLines: 1,
),
IconButton(
onPressed: () {
onPressed: () async {
// _openCategoryBottomSheet(
// context,
// item.subcategories ?? [],
@@ -1082,6 +1228,15 @@ class _DashboardPageState extends State<DashboardPage> {
subCategoryName: "",
));
await AnalyticsService.logEvent(
'store_viewed',
parameters: {
'store_id': item.tenantid!,
'store_name': item.tenantname!,
'locatiom' : item.locationname!,
},
);
},
icon: Icon(Icons.arrow_circle_right_outlined,
color: Colors.black.withOpacity(0.6),
@@ -1113,7 +1268,7 @@ class _DashboardPageState extends State<DashboardPage> {
final product = item.subcategories![index];
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
onTap: () async {
// Get.to(() => SubCategoryProductsScreen(
// tenantId: item.tenantid!,
@@ -1139,6 +1294,15 @@ class _DashboardPageState extends State<DashboardPage> {
subCategoryName: "",
));
await AnalyticsService.logEvent(
'store_viewed',
parameters: {
'store_id': item.tenantid!,
'store_name': item.tenantname!,
'locatiom' : item.locationname!,
},
);
},
child: Container(
margin:
@@ -1247,11 +1411,6 @@ class _DashboardPageState extends State<DashboardPage> {
),
),
),
Obx(() {
if (cartController.cartItems.isEmpty) return const SizedBox();
final tenant = cartController.currentTenant.value;
@@ -1374,45 +1533,12 @@ class _DashboardPageState extends State<DashboardPage> {
),
);
}),
],),
),
);
}
Widget _chip(String emoji, String label) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Color(0xFFF8F4FF),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Color(0xFFE0D4FF), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: TextStyle(fontSize: 13)),
SizedBox(width: 5),
Text(
label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: Color(0xFF333333)),
),
],
),
);
}
@@ -1628,23 +1754,6 @@ class _DashboardPageState extends State<DashboardPage> {
);
}
Widget _buildBannerShimmer() {
return SliverToBoxAdapter(
child: Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
Widget _buildListShimmer(BuildContext context) {
return SliverList(
@@ -1834,4 +1943,5 @@ class _ZoomOnTapState extends State<_ZoomOnTap> {
),
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,210 @@ class TenantApiService {
}
}
// ─────────────────────────────────────────────
// SHIMMER WIDGET
// ─────────────────────────────────────────────
class _ShimmerBox extends StatefulWidget {
final double width;
final double height;
final double borderRadius;
const _ShimmerBox({
required this.width,
required this.height,
this.borderRadius = 8,
});
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
_animation = Tween<double>(begin: -1.5, end: 1.5).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (_, __) => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
gradient: LinearGradient(
begin: Alignment(_animation.value - 1, 0),
end: Alignment(_animation.value, 0),
colors: const [
Color(0xFFE8E8E8),
Color(0xFFF5F5F5),
Color(0xFFE8E8E8),
],
),
),
),
);
}
}
// Shimmer skeleton that mimics the actual screen layout
class _StoreOverviewShimmer extends StatelessWidget {
const _StoreOverviewShimmer();
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top bar
Row(
children: [
_ShimmerBox(width: 40, height: 40, borderRadius: 20),
],
),
const SizedBox(height: 16),
// Store card shimmer
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShimmerBox(width: 200, height: 22, borderRadius: 6),
const SizedBox(height: 10),
_ShimmerBox(width: double.infinity, height: 13, borderRadius: 4),
const SizedBox(height: 6),
_ShimmerBox(width: 160, height: 13, borderRadius: 4),
const SizedBox(height: 14),
Row(
children: [
_ShimmerBox(width: 40, height: 40, borderRadius: 20),
const SizedBox(width: 12),
_ShimmerBox(width: 40, height: 40, borderRadius: 20),
],
),
const Divider(height: 24, thickness: 0.5),
Row(
children: [
_ShimmerBox(width: 20, height: 20, borderRadius: 4),
const SizedBox(width: 12),
_ShimmerBox(width: 100, height: 13, borderRadius: 4),
],
),
const Divider(height: 24, thickness: 0.5),
Row(
children: [
_ShimmerBox(width: 20, height: 20, borderRadius: 4),
const SizedBox(width: 12),
_ShimmerBox(width: 150, height: 13, borderRadius: 4),
],
),
],
),
),
const SizedBox(height: 12),
// Bad experience card shimmer
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
_ShimmerBox(width: 36, height: 36, borderRadius: 18),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShimmerBox(width: 180, height: 13, borderRadius: 4),
const SizedBox(height: 6),
_ShimmerBox(width: 140, height: 11, borderRadius: 4),
],
),
],
),
),
const SizedBox(height: 12),
// Legal card shimmer
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(4, (i) => Padding(
padding: EdgeInsets.only(bottom: i < 3 ? 16.0 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShimmerBox(width: 80, height: 11, borderRadius: 4),
const SizedBox(height: 5),
_ShimmerBox(width: 160, height: 13, borderRadius: 4),
],
),
)),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────
// SCREEN
// ─────────────────────────────────────────────
@@ -285,9 +489,16 @@ class _StoreOverviewScreenState extends State<StoreOverviewScreen> {
child: FutureBuilder<TenantDetails>(
future: _future,
builder: (context, snap) {
// ── SHIMMER while loading ──────────────
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
return Column(
children: [
Expanded(child: const _StoreOverviewShimmer()),
_bottomButton(),
],
);
}
if (snap.hasError) {
return Center(
child: Column(

View File

@@ -0,0 +1,560 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// ─────────────────────────────────────────────
// CONFIG — change IP to your Mac's local IP
// Run: ipconfig getifaddr en0
// ─────────────────────────────────────────────
const String kBaseUrl = 'http://192.168.1.XXX:8000';
const int kCustomerId = 6060;
const double kLatitude = 11.0168;
const double kLongitude = 76.9558;
// ─────────────────────────────────────────────
// API SERVICE
// ─────────────────────────────────────────────
class AgentService {
static Future<Map<String, dynamic>> findIngredients(String userInput) async {
final res = await http.post(
Uri.parse('$kBaseUrl/find-ingredients'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'user_input': userInput,
'customer_id': kCustomerId,
'latitude': kLatitude,
'longitude': kLongitude,
}),
);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> chat(
String message,
List conversationHistory,
) async {
final res = await http.post(
Uri.parse('$kBaseUrl/chat'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'message': message,
'customer_id': kCustomerId,
'latitude': kLatitude,
'longitude': kLongitude,
'conversation_history': conversationHistory,
}),
);
return jsonDecode(res.body);
}
static Future<Map<String, dynamic>> clarify(
String originalInput,
String clarification,
) async {
final res = await http.post(
Uri.parse('$kBaseUrl/clarify'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'original_input': originalInput,
'clarification': clarification,
'customer_id': kCustomerId,
'latitude': kLatitude,
'longitude': kLongitude,
}),
);
return jsonDecode(res.body);
}
}
// ─────────────────────────────────────────────
// SEARCH SCREEN
// ─────────────────────────────────────────────
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _isLoading = false;
String? _agentMessage;
String? _clarificationQuestion;
String? _lastInput;
List _ingredients = [];
List _stores = [];
// Quick suggestion chips
final List<String> _suggestions = [
'🍓 Strawberry',
'🍗 Biryani',
'🤒 Fever medicine',
'🍳 Cook sambar',
'🥛 Milk',
'😴 Bored snacks',
];
// ── Search via /find-ingredients ──────────────────────────
Future<void> _search(String input) async {
if (input.trim().isEmpty) return;
FocusScope.of(context).unfocus();
setState(() {
_isLoading = true;
_agentMessage = null;
_clarificationQuestion = null;
_stores = [];
_ingredients = [];
_lastInput = input;
});
try {
final result = await AgentService.findIngredients(input);
if (result['status'] == 'needs_clarification') {
setState(() {
_clarificationQuestion = result['question'];
_isLoading = false;
});
return;
}
setState(() {
_agentMessage = result['agent_message'];
_ingredients = result['ingredients_needed'] ?? [];
_stores = result['stores'] ?? [];
_isLoading = false;
});
} catch (e) {
setState(() {
_agentMessage = 'Something went wrong. Please try again.';
_isLoading = false;
});
}
}
// ── Answer agent clarification ────────────────────────────
Future<void> _answerClarification(String answer) async {
setState(() {
_clarificationQuestion = null;
_isLoading = true;
});
try {
final result = await AgentService.clarify(_lastInput ?? '', answer);
setState(() {
_agentMessage = result['agent_message'];
_ingredients = result['ingredients_needed'] ?? [];
_stores = result['stores'] ?? [];
_isLoading = false;
});
} catch (e) {
setState(() {
_agentMessage = 'Something went wrong.';
_isLoading = false;
});
}
}
// ─────────────────────────────────────────────────────────
// BUILD
// ─────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text(
'Nearle',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
centerTitle: false,
),
body: Column(
children: [
// ── Search Bar ──────────────────────────────────
Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
focusNode: _focusNode,
onSubmitted: _search,
decoration: InputDecoration(
hintText: 'Search or ask anything...',
hintStyle: TextStyle(color: Colors.grey[400]),
prefixIcon: const Icon(Icons.search, color: Color(0xFF6C63FF)),
filled: true,
fillColor: const Color(0xFFF0EFFF),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: () => _search(_searchController.text),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.arrow_forward, color: Colors.white, size: 20),
),
),
],
),
),
// ── Suggestion Chips ────────────────────────────
if (_stores.isEmpty && !_isLoading && _clarificationQuestion == null)
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _suggestions.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
return GestureDetector(
onTap: () {
final text = _suggestions[i].replaceAll(RegExp(r'[^\w\s]'), '').trim();
_searchController.text = text;
_search(text);
},
child: Chip(
label: Text(
_suggestions[i],
style: const TextStyle(fontSize: 13),
),
backgroundColor: Colors.white,
side: const BorderSide(color: Color(0xFFE0E0E0)),
),
);
},
),
),
const SizedBox(height: 8),
// ── Body ────────────────────────────────────────
Expanded(
child: _isLoading
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Color(0xFF6C63FF)),
SizedBox(height: 16),
Text('Anna is finding the best deals...', style: TextStyle(color: Colors.grey)),
],
),
)
: _clarificationQuestion != null
? _buildClarification()
: _stores.isEmpty
? _buildEmpty()
: _buildResults(),
),
],
),
);
}
// ── Clarification UI ──────────────────────────────────────
Widget _buildClarification() {
final answerController = TextEditingController();
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFEDE9FF),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Text('🤔', style: TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Expanded(
child: Text(
_clarificationQuestion!,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
),
],
),
),
const SizedBox(height: 16),
TextField(
controller: answerController,
decoration: InputDecoration(
hintText: 'Type your answer...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
onSubmitted: _answerClarification,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _answerClarification(answerController.text),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Send', style: TextStyle(color: Colors.white, fontSize: 16)),
),
),
],
),
);
}
// ── Empty State ───────────────────────────────────────────
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🛒', style: TextStyle(fontSize: 60)),
const SizedBox(height: 16),
Text(
_agentMessage ?? 'Search for anything\nfrom nearby stores',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
);
}
// ── Results ───────────────────────────────────────────────
Widget _buildResults() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Agent message
if (_agentMessage != null)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFEDE9FF),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(child: Text(_agentMessage!, style: const TextStyle(fontSize: 14))),
],
),
),
// Store cards
..._stores.map((storeData) => _StoreCard(storeData: storeData)),
],
);
}
}
// ─────────────────────────────────────────────
// STORE CARD WIDGET
// ─────────────────────────────────────────────
class _StoreCard extends StatelessWidget {
final Map storeData;
const _StoreCard({required this.storeData});
@override
Widget build(BuildContext context) {
final store = storeData['store'] as Map;
final products = storeData['matched_products'] as List;
final savings = store['total_savings'] ?? 0;
final total = store['total_discounted_price'] ?? 0;
final hasOffers = store['has_offers'] == true;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Store header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFFF0EFFF),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.store, color: Color(0xFF6C63FF), size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store['tenantname'] ?? '',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
Text(
store['locationname'] ?? '',
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
],
),
),
if (hasOffers)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Save ₹${savings.toStringAsFixed(0)}',
style: const TextStyle(color: Color(0xFF2E7D32), fontWeight: FontWeight.bold, fontSize: 12),
),
),
],
),
),
const Divider(height: 1),
// Products
...products.map((m) => _ProductRow(matched: m)),
const Divider(height: 1),
// Total + Order button
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Total', style: TextStyle(color: Colors.grey, fontSize: 12)),
Text(
'${total.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Color(0xFF1A1A1A)),
),
],
),
const Spacer(),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('Order Now', style: TextStyle(color: Colors.white)),
),
],
),
),
],
),
);
}
}
// ─────────────────────────────────────────────
// PRODUCT ROW WIDGET
// ─────────────────────────────────────────────
class _ProductRow extends StatelessWidget {
final Map matched;
const _ProductRow({required this.matched});
@override
Widget build(BuildContext context) {
final product = matched['product'] as Map;
final emoji = matched['emoji'] ?? '🛒';
final name = product['productname'] ?? matched['ingredient'] ?? '';
final price = product['discounted_price'] ?? 0;
final original = product['original_price'] ?? 0;
final hasOffer = product['has_offer'] == true;
final discount = product['discount_percent'] ?? 0;
final quantity = matched['quantity'] ?? '';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Text(emoji, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
Text(quantity, style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${price.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFF1A1A1A)),
),
if (hasOffer)
Row(
children: [
Text(
'${original.toStringAsFixed(0)}',
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 4),
Text(
'$discount% off',
style: const TextStyle(color: Color(0xFF2E7D32), fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
],
),
],
),
);
}
}

View File

@@ -7,6 +7,8 @@ import 'package:lottie/lottie.dart';
import 'package:nearledaily/view/qr_scaner/qr_scaner.dart';
import '../constants/font_constants.dart';
import '../controllers/cart_controller/cart.dart';
import '../features/Store/presentation/screens/store_view.dart';
import '../features/dashboard/presentation/screens/dash_view.dart';
import '../widgets/text_widget.dart';
import 'account/account_view.dart';
import 'cart/cart_view.dart';
@@ -22,8 +24,9 @@ const Color _kInactive = Color(0xFFCBA8E4);
// ─── Screens ──────────────────────────────────────────────────────────────────
final List<Widget> _screens = [
DashboardPage(),
const OrdersByStoreScreen(showBackArrow: false),
HomeScreen(),
StoreViewPage(),
// const OrdersByStoreScreen(showBackArrow: false),
QrScannerPage(),
CartPage(),
AccountPage(),
@@ -131,18 +134,29 @@ class BottomNavigation extends StatelessWidget {
);
}
return Scaffold(
backgroundColor: Colors.white,
extendBody: true,
bottomNavigationBar: Obx(
() => _BottomNavBar(
currentIndex: controller.currentIndex.value,
cartController: cartController,
onTap: (i) => controller.currentIndex.value = i,
return PopScope(
canPop: controller.currentIndex.value == 0,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// Not on Home tab -> go back to Home instead of exiting/popping
if (controller.currentIndex.value != 0) {
controller.currentIndex.value = 0;
}
},
child: Scaffold(
backgroundColor: Colors.white,
extendBody: true,
bottomNavigationBar: Obx(
() => _BottomNavBar(
currentIndex: controller.currentIndex.value,
cartController: cartController,
onTap: (i) => controller.currentIndex.value = i,
),
),
body: Obx(
() => _screens[controller.currentIndex.value],
),
),
body: Obx(
() => _screens[controller.currentIndex.value],
),
);
});
@@ -240,8 +254,8 @@ class _GlassPill extends StatelessWidget {
onTap: () => onTap(0),
),
_NavItem(
icon: Icons.receipt_long_rounded,
label: 'Order',
icon: Icons.storefront,
label: 'Stores',
isActive: currentIndex == 1,
onTap: () => onTap(1),
),
@@ -440,4 +454,4 @@ class _CartNavItem extends StatelessWidget {
),
);
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:nearledaily/constants/color_constants.dart';
import '../../../constants/font_constants.dart';
import '../../controllers/cart_controller/cart.dart';
import '../../controllers/dashboard_controller/dashboard_controller.dart';
import '../../features/orders/presentation/order_tracking.dart';
import '../../widgets/text_widget.dart';
import '../home_view.dart';
@@ -233,7 +234,44 @@ class _OrderSuccessViewState extends State<OrderSuccessView>
SizedBox(height: size.height * 0.015),
// Secondary CTA — Track Order
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton(
onPressed: () {
Get.to(() => DeliveryTrackingPage());
},
style: OutlinedButton.styleFrom(
foregroundColor: ColorConstants.primaryColor,
side: BorderSide(
color: ColorConstants.primaryColor,
width: 1.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_shipping_outlined, size: 20),
const SizedBox(width: 8),
ReusableTextWidget(
text: 'Track Order',
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
fontSize: 16,
fontWeight: FontWeight.w600,
textAlign: TextAlign.center,
),
],
),
),
),
SizedBox(height: size.height * 0.015),
],
),
),

View File

@@ -7,6 +7,7 @@ import '../../constants/asset_constants.dart';
import '../../constants/color_constants.dart';
import '../../constants/font_constants.dart';
import '../../controllers/tenant/get_tenant.dart'; // OrderedTenantController
import '../../features/orders/presentation/order_tracking.dart';
import '../../widgets/text_widget.dart';
import 'my_orders.dart'; // OrderDatum
@@ -450,149 +451,126 @@ class _OrdersByStoreScreenState extends State<OrdersByStoreScreen>
height: 5,
),
Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () async {
final uri = Uri(scheme: 'tel', path: order.pickupcontactno!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReusableTextWidget(
text: 'Contact :',
color: ColorConstants.blackColor.withOpacity(0.67),
fontWeight: FontWeight.w600,
fontSize: 13,
fontFamily: FontConstants.fontFamily,
// Track Button
Expanded(
child: Container(
height: 44,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: const LinearGradient(
colors: [Color(0xFF662582), Color(0xFF7A2E9C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.phone_rounded,
size: 14,
color: ColorConstants.primaryColor,
boxShadow: [
BoxShadow(
color: const Color(0xFF662582).withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
HapticFeedback.mediumImpact();
Get.to(() => const DeliveryTrackingPage());
},
child: const Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on_rounded, color: Colors.white, size: 18),
SizedBox(width: 6),
Text(
'Track',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: 0.2,
),
),
],
),
const SizedBox(width: 5),
ReusableTextWidget(
text: order.pickupcontactno ?? "No Contact",
color: ColorConstants.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 13,
fontFamily: FontConstants.fontFamily,
),
],
),
),
],
),
),
),
ElevatedButton(
style: ElevatedButton
.styleFrom(
padding:
const EdgeInsets
.symmetric(
horizontal: 12,
vertical: 5),
shape:
RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(
8),
// View Details Button
Expanded(
child: Container(
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: primaryColor, width: 1.4),
color: Colors.white,
),
backgroundColor:
primaryColor,
),
onPressed: () {
// ✨ Haptic feedback on button press
HapticFeedback.mediumImpact();
// ✨ Smooth page transition
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context,
animation,
secondaryAnimation) =>
OrderDetailsPage(
orderId: order
.orderid ??
'Unknown',
storeName: tenantName,
storeLocation: order
.tenantsuburb ??
'Unknown',
tax: order
.totaltaxamount ??
0,
gstno: order.gstno ?? "",
fee: order
.deliverycharge ??
0,
items: order
.orderdetails
?.map((item) =>
{
'name':
item.productname ?? 'Unknown',
'quantity':
item.orderqty ?? 0,
'productSumPrice':
item.productsumprice ?? 0.0,
'price':
item.price ?? 0.0,
'discountamount':
item.price ?? 0.0,
'image':
item.productimage ?? '',
})
.toList() ??
[],
),
transitionsBuilder:
(context,
animation,
secondaryAnimation,
child) {
return FadeTransition(
opacity: animation,
child:
SlideTransition(
position:
Tween<Offset>(
begin:
const Offset(
0.05, 0),
end:
Offset.zero,
).animate(
animation),
child: child,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
HapticFeedback.mediumImpact();
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
OrderDetailsPage(
orderId: order.orderid ?? 'Unknown',
storeName: tenantName,
storeLocation: order.tenantsuburb ?? 'Unknown',
tax: order.totaltaxamount ?? 0,
gstno: order.gstno ?? "",
fee: order.deliverycharge ?? 0,
items: order.orderdetails
?.map((item) => {
'name': item.productname ?? 'Unknown',
'quantity': item.orderqty ?? 0,
'productSumPrice': item.productsumprice ?? 0.0,
'price': item.price ?? 0.0,
'discountamount': item.price ?? 0.0,
'image': item.productimage ?? '',
})
.toList() ??
[],
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
transitionDuration: const Duration(milliseconds: 300),
),
);
},
transitionDuration:
const Duration(
milliseconds:
300),
child: Center(
child: Text(
'View Details',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: primaryColor,
letterSpacing: 0.2,
),
),
),
),
);
},
child: const Text(
"View Details",
style: TextStyle(
fontSize: 12,
color: Colors.white),
),
),
),
],

View File

@@ -0,0 +1,886 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:nearledaily/view/product/products_list.dart';
// ─── Entry point ───────────────────────────────────────────────────────────────
void showProductSheet(BuildContext context, ProductItem item) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withOpacity(0.4),
builder: (_) => _ProductSheet(item: item),
);
}
// ─── Sheet widget ──────────────────────────────────────────────────────────────
class _ProductSheet extends StatefulWidget {
final ProductItem item;
const _ProductSheet({required this.item});
@override
State<_ProductSheet> createState() => _ProductSheetState();
}
class _ProductSheetState extends State<_ProductSheet> {
final DraggableScrollableController _drag = DraggableScrollableController();
static const double _minSize = 0.54;
static const double _maxSize = 1.0;
int _qty = 1;
bool _isFav = false;
bool _inCart = false;
double _expansion = 0.0;
static const Color _purple = Color(0xFF662582);
static const Color _purpleLight = Color(0xFFF0EBFF);
static const Color _dark = Color(0xFF111111);
static const Color _surface = Color(0xFFF8F8F8);
static const Color _muted = Color(0xFF888888);
@override
void initState() {
super.initState();
_drag.addListener(_onDrag);
}
void _onDrag() {
if (!_drag.isAttached) return;
final t = ((_drag.size - _minSize) / (_maxSize - _minSize)).clamp(0.0, 1.0);
if (t > 0.88) {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);
} else {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
}
setState(() => _expansion = t);
}
@override
void dispose() {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
_drag.removeListener(_onDrag);
_drag.dispose();
super.dispose();
}
int get _discount {
final orig = double.tryParse(
widget.item.originalPrice.replaceAll(RegExp(r'[^\d.]'), '')) ?? 0;
final cur = double.tryParse(
widget.item.price.replaceAll(RegExp(r'[^\d.]'), '')) ?? 0;
return orig > 0 ? ((orig - cur) / orig * 100).round() : 0;
}
String get _savedAmount {
final orig = double.tryParse(
widget.item.originalPrice.replaceAll(RegExp(r'[^\d.]'), '')) ?? 0;
final cur = double.tryParse(
widget.item.price.replaceAll(RegExp(r'[^\d.]'), '')) ?? 0;
final saved = orig - cur;
return saved > 0 ? '${saved.toStringAsFixed(0)}' : '';
}
void _snapToFull() => _drag.animateTo(_maxSize,
duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic);
void _snapToPeek() => _drag.animateTo(_minSize,
duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic);
@override
Widget build(BuildContext context) {
final topPad = MediaQuery.of(context).padding.top;
final botPad = MediaQuery.of(context).padding.bottom;
final e = _expansion;
final cornerRadius = lerpDouble(24.0, 0.0, e)!;
final imageHeight = lerpDouble(300.0, topPad + 340.0, e)!;
final discount = _discount;
final saved = _savedAmount;
return DraggableScrollableSheet(
controller: _drag,
initialChildSize: _minSize,
minChildSize: _minSize,
maxChildSize: _maxSize,
snap: true,
snapSizes: const [_minSize, _maxSize],
builder: (context, scrollCtrl) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.vertical(top: Radius.circular(cornerRadius)),
),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
controller: scrollCtrl,
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Hero ──
_buildHero(imageHeight, cornerRadius, topPad, e),
// ── Drag handle (peek) ──
if (e < 0.2)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFDDD8F0),
borderRadius: BorderRadius.circular(2),
),
),
),
),
// ── Body ──
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tag chip
_tagChip(widget.item.tag),
const SizedBox(height: 12),
// Product name
Text(
widget.item.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _dark,
letterSpacing: -0.4,
height: 1.2,
),
),
const SizedBox(height: 4),
// Category / store
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Color(0xFF4CAF50),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
widget.item.category,
style: const TextStyle(
fontSize: 12,
color: _muted,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 18),
// Price
_buildPriceRow(discount, saved),
const SizedBox(height: 6),
if (saved.isNotEmpty)
Text(
'You save $saved · Free delivery on this order',
style: const TextStyle(fontSize: 12, color: _muted),
),
const SizedBox(height: 18),
// Rating
_buildRatingRow(),
// Swipe hint
if (e < 0.12) ...[
const SizedBox(height: 20),
_swipeHint(),
],
// Expanded content
if (e > 0.12) ...[
const SizedBox(height: 24),
// Highlights
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: e > 0.4 ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Why you\'ll love it'),
const SizedBox(height: 10),
_buildHighlights(),
const SizedBox(height: 24),
],
),
),
// About (derived from name + category)
AnimatedOpacity(
duration: const Duration(milliseconds: 350),
opacity: e > 0.52 ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('About'),
const SizedBox(height: 10),
Text(
'A premium quality ${widget.item.name} carefully '
'curated for you. Sourced from trusted partners at '
'${widget.item.category}. Every order is freshness-'
'guaranteed with no compromise on quality.',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF555555),
height: 1.8,
),
),
const SizedBox(height: 24),
],
),
),
// Reviews
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: e > 0.62 ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDivider(),
const SizedBox(height: 20),
_buildReviewsHeader(),
const SizedBox(height: 12),
_buildReviewCard(
initials: 'PR',
name: 'Priya R.',
date: '2 days ago',
stars: 5,
text:
'Absolutely love it! Super fresh and delivered on time. Will definitely order again.',
),
const SizedBox(height: 10),
_buildReviewCard(
initials: 'AK',
name: 'Arjun K.',
date: '1 week ago',
stars: 5,
text:
'Great quality and packaging. Exactly as described. Fast delivery too!',
),
const SizedBox(height: 24),
],
),
),
// Quantity
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: e > 0.70 ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDivider(),
const SizedBox(height: 20),
_buildQuantityRow(),
const SizedBox(height: 8),
],
),
),
],
],
),
),
SizedBox(height: botPad + 90),
],
),
),
),
// ── CTA bar ──
_buildCtaBar(botPad),
],
),
);
},
);
}
// ── Hero ────────────────────────────────────────────────────────────────────
Widget _buildHero(
double imageHeight, double cornerRadius, double topPad, double e) {
final item = widget.item;
final discount = _discount;
return Stack(
children: [
// Product image
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(cornerRadius),
topRight: Radius.circular(cornerRadius),
),
child: Container(
height: imageHeight,
width: double.infinity,
color: item.bgColor,
child: item.imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.imageUrl,
fit: BoxFit.cover,
placeholder: (_, __) => const Center(
child: CircularProgressIndicator(
strokeWidth: 2, color: _purple),
),
errorWidget: (_, __, ___) => Image.network(
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQfoIZCZOkJq4Yg00gVdyXr49xxOJsFjSjABE_SAzOiDQ&s=10',
fit: BoxFit.cover,
),
)
: const SizedBox(),
),
),
// Back / close button (full screen)
// Positioned(
// top: lerpDouble(-60, topPad + 12, e)!,
// left: 14,
// child: AnimatedOpacity(
// opacity: e > 0.7 ? 1.0 : 0.0,
// duration: const Duration(milliseconds: 150),
// child: _circleButton(
// icon: Icons.arrow_back_ios_new_rounded,
// onTap: () {
// HapticFeedback.lightImpact();
// if (_drag.isAttached && _drag.size > _minSize + 0.05) {
// _snapToPeek();
// } else {
// Navigator.pop(context);
// }
// },
// ),
// ),
// ),
// Share + search (full screen)
// Discount badge
if (discount > 0)
Positioned(
bottom: 48,
left: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: _dark,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$discount% OFF',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
),
],
);
}
// ── Tag chip ────────────────────────────────────────────────────────────────
Widget _tagChip(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _purpleLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _purple,
),
),
);
}
// ── Price ───────────────────────────────────────────────────────────────────
Widget _buildPriceRow(int discount, String saved) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.item.price,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: _dark,
letterSpacing: -0.5,
),
),
const SizedBox(width: 10),
// Text(
// widget.item.originalPrice,
// style: const TextStyle(
// fontSize: 14,
// color: Color(0xFFBBBBBB),
// decoration: TextDecoration.lineThrough,
// decorationColor: Color(0xFFBBBBBB),
// ),
// ),
if (discount > 0) ...[
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _dark,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$discount% OFF',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
],
],
);
}
// ── Rating ──────────────────────────────────────────────────────────────────
Widget _buildRatingRow() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFF0F0F0)),
bottom: BorderSide(color: Color(0xFFF0F0F0)),
),
),
child: Row(
children: [
Row(
children: List.generate(
5,
(i) => Icon(
i < widget.item.rating.floor()
? Icons.star_rounded
: Icons.star_border_rounded,
size: 16,
color: Colors.yellow.shade700,
),
),
),
const SizedBox(width: 8),
Text(
widget.item.rating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w700, color: _dark),
),
const SizedBox(width: 6),
// reviewCount is int — convert directly
Text(
'· ${widget.item.reviewCount} ratings',
style: const TextStyle(fontSize: 12, color: _muted),
),
const Spacer(),
],
),
);
}
// ── Highlights ──────────────────────────────────────────────────────────────
Widget _buildHighlights() {
final tiles = [
_HighlightData(
icon: Icons.bolt_rounded,
label: 'Fast\nDelivery',
sub: '2045 min',
iconColor: _purple,
),
_HighlightData(
icon: Icons.verified_rounded,
label: 'Quality\nVerified',
sub: 'Trusted partners',
iconColor: const Color(0xFF1B5E20),
),
_HighlightData(
icon: Icons.refresh_rounded,
label: 'Easy\nReturns',
sub: 'Hassle-free',
iconColor: const Color(0xFFB45309),
),
_HighlightData(
icon: Icons.lock_outline_rounded,
label: 'Secure\nPay',
sub: '100% safe',
iconColor: const Color(0xFF1565C0),
),
];
return Row(
children: List.generate(tiles.length, (index) {
final t = tiles[index];
return Expanded(
child: Container(
margin: EdgeInsets.only(right: index < tiles.length - 1 ? 8 : 0),
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
Icon(t.icon, color: t.iconColor, size: 20),
const SizedBox(height: 6),
Text(
t.label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: t.iconColor,
height: 1.3,
),
),
const SizedBox(height: 3),
Text(
t.sub,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 9, color: _muted, height: 1.3),
),
],
),
),
);
}),
);
}
// ── Reviews ─────────────────────────────────────────────────────────────────
Widget _buildReviewsHeader() {
return Row(
children: [
const Text(
'Customer reviews',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w700, color: _dark),
),
const Spacer(),
// reviewCount is int
],
);
}
Widget _buildReviewCard({
required String initials,
required String name,
required String date,
required int stars,
required String text,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 15,
backgroundColor: _purpleLight,
child: Text(
initials,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _purple),
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _dark)),
Row(
children: List.generate(
stars,
(_) => const Icon(Icons.star_rounded,
size: 11, color: _dark),
),
),
],
),
const Spacer(),
Text(date,
style: const TextStyle(fontSize: 11, color: _muted)),
],
),
const SizedBox(height: 8),
Text(
text,
style: const TextStyle(
fontSize: 12, color: Color(0xFF666666), height: 1.6),
),
],
),
);
}
// ── Quantity ─────────────────────────────────────────────────────────────────
Widget _buildQuantityRow() {
return Row(
children: [
_sectionLabel('Quantity'),
const SizedBox(width: 16),
_qtyButton(
icon: Icons.remove_rounded,
onTap: () {
if (_qty > 1) {
HapticFeedback.lightImpact();
setState(() => _qty--);
}
},
filled: false,
),
const SizedBox(width: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Text(
'$_qty',
key: ValueKey(_qty),
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: _dark),
),
),
const SizedBox(width: 8),
_qtyButton(
icon: Icons.add_rounded,
onTap: () {
HapticFeedback.lightImpact();
setState(() => _qty++);
},
filled: true,
),
],
);
}
// ── CTA bar ──────────────────────────────────────────────────────────────────
Widget _buildCtaBar(double botPad) {
return Container(
padding: EdgeInsets.fromLTRB(20, 14, 20, botPad + 16),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFF0F0F0))),
),
child: Row(
children: [
// Wishlist
GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
setState(() => _isFav = !_isFav);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 52,
height: 52,
decoration: BoxDecoration(
color: _isFav ? const Color(0xFFFFF5F5) : Colors.white,
border: Border.all(
color: _isFav
? const Color(0xFFFFD0D0)
: const Color(0xFFE8E8E8),
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
_isFav
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: _isFav ? const Color(0xFFE24B4A) : _dark,
size: 22,
),
),
),
const SizedBox(width: 10),
// Add to bag
Expanded(
child: GestureDetector(
onTap: () {
HapticFeedback.mediumImpact();
setState(() => _inCart = true);
_snapToFull();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: 52,
decoration: BoxDecoration(
color: _inCart ? const Color(0xFF2E2E2E) : _dark,
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Text(
_inCart ? 'Added ✓' : 'Add to cart',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: -0.2,
),
),
),
),
),
),
const SizedBox(width: 10),
],
),
);
}
// ── Swipe hint ───────────────────────────────────────────────────────────────
Widget _swipeHint() {
return GestureDetector(
onTap: _snapToFull,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: _purple.withOpacity(0.2)),
borderRadius: BorderRadius.circular(14),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Swipe up for full details',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w600, color: _purple),
),
SizedBox(width: 6),
Icon(Icons.keyboard_arrow_up_rounded, color: _purple, size: 18),
],
),
),
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
Widget _buildDivider() =>
Container(height: 1, color: const Color(0xFFF0F0F0));
Widget _sectionLabel(String label) => Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _muted,
letterSpacing: 1.0,
),
);
Widget _circleButton({required IconData icon, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.85),
shape: BoxShape.circle,
),
child: Icon(icon, size: 18, color: _dark),
),
);
}
Widget _qtyButton(
{required IconData icon,
required VoidCallback onTap,
required bool filled}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: filled ? _dark : Colors.white,
border:
Border.all(color: filled ? _dark : const Color(0xFFE8E8E8)),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: filled ? Colors.white : _dark),
),
);
}
}
// ─── Internal data class ────────────────────────────────────────────────────────
class _HighlightData {
final IconData icon;
final String label;
final String sub;
final Color iconColor;
const _HighlightData({
required this.icon,
required this.label,
required this.sub,
required this.iconColor,
});
}

View File

@@ -3,14 +3,11 @@ import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:get/get.dart';
import 'package:photo_view/photo_view.dart';
import 'package:readmore/readmore.dart';
import '../../constants/color_constants.dart';
import '../../constants/font_constants.dart';
import '../../controllers/cart_controller/cart.dart';
import '../../controllers/product/variant_controller.dart';
import '../../domain/provider/varient/varient_pro.dart';
import '../../modules/product/product.dart';
import '../../widgets/text_widget.dart';
class ProductViewPage extends StatefulWidget {
final Product product;

View File

@@ -0,0 +1,878 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:nearledaily/view/product/product_details.dart';
import 'package:nearledaily/view/product/refill_home_screen.dart';
import 'package:wheel_chooser/wheel_chooser.dart';
import '../../widgets/fav_button.dart';
// ─────────────────────────────────────────────
// MODEL
// ─────────────────────────────────────────────
class ProductItem {
final String name;
final String category;
final String price;
final String originalPrice;
final String imageUrl;
final Color bgColor;
final double rating;
final int reviewCount;
final String tag;
ProductItem({
required this.name,
required this.category,
required this.price,
required this.originalPrice,
required this.imageUrl,
required this.bgColor,
required this.rating,
required this.reviewCount,
required this.tag,
});
}
// ─────────────────────────────────────────────
// SAMPLE DATA
// ─────────────────────────────────────────────
final allProducts = [
ProductItem(
name: 'Margherita Pizza',
category: 'Dominos · 20 min',
price: '₹149',
originalPrice: '₹199',
imageUrl:
'https://images.unsplash.com/photo-1513104890138-7c749659a591?w=500',
bgColor: const Color(0xFFFFF3E0),
rating: 4.6,
reviewCount: 512,
tag: 'Food',
),
ProductItem(
name: 'Fresh Veggie Basket',
category: 'BigBasket · 45 min',
price: '₹299',
originalPrice: '₹420',
imageUrl:
'https://images.unsplash.com/photo-1542838132-92c53300491e?w=500',
bgColor: const Color(0xFFE8F5E9),
rating: 4.5,
reviewCount: 180,
tag: 'Grocery',
),
ProductItem(
name: 'Margherita Pizza',
category: 'Dominos · 20 min',
price: '₹149',
originalPrice: '₹199',
imageUrl:
'https://images.unsplash.com/photo-1513104890138-7c749659a591?w=500',
bgColor: const Color(0xFFFFF3E0),
rating: 4.6,
reviewCount: 512,
tag: 'Food',
),
ProductItem(
name: 'Chicken Biryani',
category: 'HotPot · 30 min',
price: '₹189',
originalPrice: '₹240',
imageUrl:
'https://images.unsplash.com/photo-1631515243349-e0cb75fb8d3a?w=500',
bgColor: const Color(0xFFFBE9E7),
rating: 4.8,
reviewCount: 320,
tag: 'Food',
),
ProductItem(
name: 'Margherita Pizza',
category: 'Dominos · 20 min',
price: '₹149',
originalPrice: '₹199',
imageUrl:
'https://images.unsplash.com/photo-1513104890138-7c749659a591?w=500',
bgColor: const Color(0xFFFFF3E0),
rating: 4.6,
reviewCount: 512,
tag: 'Food',
),
ProductItem(
name: 'Birthday Cake',
category: 'CakeZone · 60 min',
price: '₹549',
originalPrice: '₹699',
imageUrl:
'https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=500',
bgColor: const Color(0xFFFCE4EC),
rating: 4.9,
reviewCount: 94,
tag: 'Bakery',
),
ProductItem(
name: 'Margherita Pizza',
category: 'Dominos · 20 min',
price: '₹149',
originalPrice: '₹199',
imageUrl:
'https://images.unsplash.com/photo-1513104890138-7c749659a591?w=500',
bgColor: const Color(0xFFFFF3E0),
rating: 4.6,
reviewCount: 512,
tag: 'Food',
),
];
// ─────────────────────────────────────────────
// FILTER CHIPS DATA
// ─────────────────────────────────────────────
const filterTabs = ['All', 'Food', 'Grocery', 'Bakery', 'Drinks'];
// ─────────────────────────────────────────────
// PRODUCTS PAGE
// ─────────────────────────────────────────────
class ProductsList extends StatefulWidget {
const ProductsList({super.key});
@override
State<ProductsList> createState() => _ProductsListState();
}
class _ProductsListState extends State<ProductsList> {
String _selectedFilter = 'All';
String _sortBy = 'Popular';
List<ProductItem> get _filtered => _selectedFilter == 'All'
? allProducts
: allProducts.where((p) => p.tag == _selectedFilter).toList();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F7FA),
body: CustomScrollView(
slivers: [
// ── App Bar ──
SliverAppBar(
backgroundColor: Colors.white,
elevation: 0,
pinned: true,
title: const Text(
'Products',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
centerTitle: false,
leading: const BackButton(color: Color(0xFF1A1A1A)),
actions: [
IconButton(
icon: const Icon(Icons.search_rounded,
color: Color(0xFF1A1A1A)),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const AutoRefillDemo()));
},
),
IconButton(
icon: const Icon(Icons.tune_rounded,
color: Color(0xFF1A1A1A)),
onPressed: () => _showSortSheet(context),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(
height: 1,
color: const Color(0xFFEEEEEE),
),
),
),
// ── Filter chips ──
SliverPersistentHeader(
pinned: true,
delegate: _FilterHeaderDelegate(
selectedFilter: _selectedFilter,
sortBy: _sortBy,
onFilterChanged: (f) => setState(() => _selectedFilter = f),
onSortTap: () => _showSortSheet(context),
),
),
// ── Product grid ──
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.72,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _ProductCard(item: _filtered[index]),
childCount: _filtered.length,
),
),
),
],
),
);
}
void _showSortSheet(BuildContext context) {
final options = ['Popular', 'Price: Low to High', 'Price: High to Low', 'Rating', 'Discount'];
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text(
'Sort by',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 12),
...options.map((opt) => InkWell(
onTap: () {
setState(() => _sortBy = opt);
Navigator.pop(context);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(
child: Text(
opt,
style: TextStyle(
fontSize: 14,
fontWeight: _sortBy == opt
? FontWeight.w700
: FontWeight.w400,
color: _sortBy == opt
? const Color(0xFF7C3AED)
: const Color(0xFF1A1A1A),
),
),
),
if (_sortBy == opt)
const Icon(Icons.check_circle_rounded,
color: Color(0xFF7C3AED), size: 20),
],
),
),
)),
],
),
),
);
}
}
// ─────────────────────────────────────────────
// STICKY FILTER HEADER DELEGATE
// ─────────────────────────────────────────────
class _FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
final String selectedFilter;
final String sortBy;
final ValueChanged<String> onFilterChanged;
final VoidCallback onSortTap;
_FilterHeaderDelegate({
required this.selectedFilter,
required this.sortBy,
required this.onFilterChanged,
required this.onSortTap,
});
@override
double get minExtent => 56;
@override
double get maxExtent => 56;
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
child: Column(
children: [
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
scrollDirection: Axis.horizontal,
itemCount: filterTabs.length + 1, // +1 for sort chip
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
// Last item = Sort chip
if (index == filterTabs.length) {
return GestureDetector(
onTap: onSortTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF3F0FF),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFFD4C5FF), width: 1),
),
child: Row(
children: [
const Icon(Icons.swap_vert_rounded,
size: 15, color: Color(0xFF662582)),
const SizedBox(width: 4),
Text(
sortBy == 'Popular' ? 'Sort' : sortBy,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: Color(0xFF662582),
),
),
],
),
),
);
}
final tab = filterTabs[index];
final isSelected = selectedFilter == tab;
return GestureDetector(
onTap: () => onFilterChanged(tab),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF662582)
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? const Color(0xFF662582)
: const Color(0xFFE0E0E0),
width: 1,
),
),
child: Text(
tab,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: isSelected
? Colors.white
: const Color(0xFF757575),
),
),
),
);
},
),
),
Container(height: 1, color: const Color(0xFFEEEEEE)),
],
),
);
}
@override
bool shouldRebuild(_FilterHeaderDelegate old) =>
old.selectedFilter != selectedFilter || old.sortBy != sortBy;
}
// ─────────────────────────────────────────────
// PRODUCT CARD
// ─────────────────────────────────────────────
class _ProductCard extends StatefulWidget {
final ProductItem item;
const _ProductCard({required this.item});
@override
State<_ProductCard> createState() => _ProductCardState();
}
class _ProductCardState extends State<_ProductCard> {
bool _isFav = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
int selectedIndex = 2;
final original = double.tryParse(
item.originalPrice.replaceAll(RegExp(r'[^\d.]'), '')) ??
0;
final current =
double.tryParse(item.price.replaceAll(RegExp(r'[^\d.]'), '')) ?? 0;
final discount =
original > 0 ? ((original - current) / original * 100).round() : 0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Image area ──
Stack(
children: [
// Image or emoji fallback
GestureDetector(
onTap: () => showProductSheet(context, item),
child: SizedBox(
height: 140,
width: double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
child: CachedNetworkImage(
imageUrl: item.imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => _fallback(item),
),
),
),
),
// Discount badge — top left
if (discount > 0)
Positioned(
top: 10,
left: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF662582),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$discount% OFF',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
),
// Favourite — top right
Positioned(
top: 8,
right: 8,
child: FavButton(
size: 32,
initialValue: _isFav,
onChanged: (val) => setState(() => _isFav = val),
),
),
// Add button — bottom right ON image
Positioned(
bottom: 10,
right: 10,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
height: 420,
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
Container(
width: 50,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(100),
),
),
const SizedBox(height: 20),
const Text(
"Choose Variant",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF662582),
),
),
const SizedBox(height: 3),
Text(
"Scroll to select your size",
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 13,
),
),
const SizedBox(height: 20),
Expanded(
child: PageView.builder(
controller: PageController(
viewportFraction: 0.30,
initialPage: selectedIndex,
),
onPageChanged: (index) {
setModalState(() {
selectedIndex = index;
});
},
itemCount: 5,
itemBuilder: (context, index) {
final items = [
{
"name": "Small",
"price": "₹149",
"image":
"https://images.unsplash.com/photo-1513104890138-7c749659a591?w=500",
},
{
"name": "Medium",
"price": "₹199",
"image":
"https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=500",
},
{
"name": "Large",
"price": "₹249",
"image":
"https://images.unsplash.com/photo-1514326640560-7d063ef2aed5?w=500",
},
{
"name": "XL",
"price": "₹299",
"image":
"https://images.unsplash.com/photo-1594007654729-407eedc4be65?w=500",
},
{
"name": "Party",
"price": "₹399",
"image":
"https://images.unsplash.com/photo-1514326640560-7d063ef2aed5?w=500",
},
];
final isSelected = selectedIndex == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
transform: Matrix4.identity()
..scale(isSelected ? 1.15 : 1.15),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isSelected ? 100 : 85,
height: isSelected ? 100 : 85,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? const Color(0xFF662582)
: Colors.transparent,
width: 3,
),
),
child: ClipOval(
child: Image.network(
items[index]["image"]!,
fit: BoxFit.fill,
),
),
),
],
),
const SizedBox(height: 15),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: TextStyle(
fontSize: isSelected ? 18 : 14,
fontWeight: FontWeight.bold,
color: isSelected
? Colors.black
: Colors.grey.shade600,
),
child: Text(items[index]["name"]!),
),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: TextStyle(
color: const Color(0xFF662582),
fontWeight: FontWeight.w700,
fontSize: isSelected ? 16 : 12,
),
child: Text(items[index]["price"]!),
),
const SizedBox(height: 10),
],
),
);
},
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF662582),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
onPressed: () {},
child: const Text(
"Confirm Selection",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
],
),
);
},
);
},
);
},
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.add_rounded,
color: Color(0xFF662582),
size: 20,
),
),
),
),
],
),
// ── Info area ──
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category
Text(
item.category,
style: const TextStyle(
fontSize: 10.5,
color: Color(0xFF662582),
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
// Name
Text(
item.name,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
// Rating
Row(
children: [
const Icon(Icons.star_rounded,
size: 13, color: Color(0xFFFFC107)),
const SizedBox(width: 3),
Text(
'${item.rating}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 3),
Text(
'(${item.reviewCount})',
style: const TextStyle(
fontSize: 10.5,
color: Color(0xFF9E9E9E),
),
),
],
),
const SizedBox(height: 8),
// Price
Row(
children: [
Text(
item.price,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(width: 6),
if (discount > 0)
Text(
item.originalPrice,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF9E9E9E),
decoration: TextDecoration.lineThrough,
),
),
],
),
],
),
),
],
),
);
}
Widget _fallback(ProductItem item) {
return Container(
height: 140,
width: double.infinity,
color: item.bgColor,
child: const Center(
child: Icon(
Icons.image_not_supported,
size: 40,
color: Colors.grey,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import 'package:lottie/lottie.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../controllers/tenant/create_tenant.dart';
import '../../controllers/tenant_controller /tenant_list.dart';
import '../../controllers/tenant_list/tenant_controller.dart';
import '../home_view.dart';
class QrScannerPage extends StatefulWidget {

118
lib/widgets/fav_button.dart Normal file
View File

@@ -0,0 +1,118 @@
import 'dart:math';
import 'package:flutter/material.dart';
class FavButton extends StatefulWidget {
final bool initialValue;
final ValueChanged<bool>? onChanged;
final double size;
const FavButton({
super.key,
this.initialValue = false,
this.onChanged,
this.size = 40,
});
@override
State<FavButton> createState() => _FavButtonState();
}
class _FavButtonState extends State<FavButton>
with SingleTickerProviderStateMixin {
late bool _isFav;
late AnimationController _ctrl;
late Animation<double> _scale;
late Animation<double> _particleProgress;
@override
void initState() {
super.initState();
_isFav = widget.initialValue;
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
);
_scale = TweenSequence([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.4)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 1.4, end: 1.0)
.chain(CurveTween(curve: Curves.elasticOut)),
weight: 60,
),
]).animate(_ctrl);
_particleProgress =
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _toggle() {
setState(() => _isFav = !_isFav);
widget.onChanged?.call(_isFav);
if (_isFav) _ctrl.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _toggle,
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, child) => CustomPaint(
painter: _isFav ? _BurstPainter(_particleProgress.value) : null,
child: Transform.scale(
scale: _isFav ? _scale.value : 1.0,
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _isFav
? Colors.red.withOpacity(0.1)
: Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
_isFav
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: widget.size * 0.5,
color: _isFav ? Colors.redAccent : Colors.grey,
),
),
),
),
),
);
}
}
class _BurstPainter extends CustomPainter {
final double progress;
_BurstPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
if (progress == 0) return;
final center = Offset(size.width / 2, size.height / 2);
final paint = Paint()
..color = Colors.redAccent.withOpacity(1 - progress);
const count = 7;
for (int i = 0; i < count; i++) {
final angle = (i / count) * 2 * pi;
final dist = progress * 24;
final pos = center + Offset(dist * cos(angle), dist * sin(angle));
canvas.drawCircle(pos, 3 * (1 - progress * 0.5), paint);
}
}
@override
bool shouldRepaint(_BurstPainter old) => old.progress != progress;
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../modules/search/grocery_item.dart';
class GroceryCard extends StatelessWidget {
final GroceryItem item;
final VoidCallback onTap;
const GroceryCard({
super.key,
required this.item,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(item.emoji, style: const TextStyle(fontSize: 30)),
Text(item.name),
Text(item.quantity),
Text(item.inCart ? "Added" : "Add"),
],
),
),
);
}
}
class StoreCard extends StatelessWidget {
final dynamic storeData;
const StoreCard({
super.key,
required this.storeData,
});
@override
Widget build(BuildContext context) {
final store = storeData["store"];
return Container(
margin: const EdgeInsets.only(
bottom: 15,
),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
store["name"],
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(store["address"]),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
"${store["total_discounted_price"]}",
style: const TextStyle(
fontSize: 24,
color: Colors.green,
fontWeight:
FontWeight.bold,
),
),
),
ElevatedButton(
onPressed: () {},
child: const Text(
"Select Store",
),
),
],
),
Text(
"Save ₹${store["total_savings"]}",
style: const TextStyle(
color: Colors.green,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,383 @@
import 'package:flutter/material.dart';
import '../modules/search/grocery_item.dart';
// ─── Grocery Card ─────────────────────────────────────────────────────────────
class GroceryCard extends StatelessWidget {
final GroceryItem item;
final VoidCallback onToggle;
const GroceryCard({super.key, required this.item, required this.onToggle});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: item.inCart ? const Color(0xFFF8FDF9) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: item.inCart ? const Color(0xFF1A6B3C) : const Color(0xFFE2E8E0),
width: 1.5,
),
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(item.emoji, style: const TextStyle(fontSize: 36)),
const SizedBox(height: 8),
Text(
item.name,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F1C),
height: 1.3,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E8),
borderRadius: BorderRadius.circular(20),
),
child: Text(
item.quantity,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFFF97316),
),
),
),
const SizedBox(height: 10),
GestureDetector(
onTap: onToggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: item.inCart
? const Color(0xFF1A6B3C)
: const Color(0xFFE8F5EE),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item.inCart ? '✓ Added' : '+ Add to cart',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: item.inCart ? Colors.white : const Color(0xFF1A6B3C),
),
),
),
),
],
),
),
if (item.inCart)
Positioned(
top: 8,
right: 8,
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Color(0xFF1A6B3C),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 12),
),
),
],
),
);
}
}
// ─── Skeleton Card ────────────────────────────────────────────────────────────
class SkeletonCard extends StatefulWidget {
const SkeletonCard({super.key});
@override
State<SkeletonCard> createState() => _SkeletonCardState();
}
class _SkeletonCardState extends State<SkeletonCard>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 900))
..repeat(reverse: true);
_anim = Tween<double>(begin: 0.4, end: 1.0).animate(_ctrl);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _anim,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8E0), width: 1.5),
),
padding: const EdgeInsets.fromLTRB(12, 14, 12, 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: Color(0xFFE8EDE9),
shape: BoxShape.circle,
),
),
const SizedBox(height: 10),
_skLine(0.8),
const SizedBox(height: 6),
_skLine(0.6),
const SizedBox(height: 10),
Container(
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFE8EDE9),
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
);
}
Widget _skLine(double widthFraction) {
return FractionallySizedBox(
widthFactor: widthFraction,
child: Container(
height: 12,
decoration: BoxDecoration(
color: const Color(0xFFE8EDE9),
borderRadius: BorderRadius.circular(6),
),
),
);
}
}
// ─── Chip ─────────────────────────────────────────────────────────────────────
class SearchChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool muted;
const SearchChip({
super.key,
required this.label,
required this.onTap,
this.muted = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: muted ? const Color(0xFFEDF2F0) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: muted ? Colors.transparent : const Color(0xFFE2E8E0),
width: 1.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: muted ? 12 : 13,
color: muted ? const Color(0xFF6B7A72) : const Color(0xFF1A1F1C),
),
),
),
);
}
}
// ─── Cart Bottom Sheet ────────────────────────────────────────────────────────
class CartSheet extends StatelessWidget {
final List<GroceryItem> items;
final void Function(GroceryItem) onRemove;
const CartSheet({super.key, required this.items, required this.onRemove});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE2E8E0),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
Row(
children: [
const Text(
'Your Cart',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1F1C)),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFEDF2F0),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${items.length} items',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7A72)),
),
),
],
),
const SizedBox(height: 16),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.45,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Text(item.emoji, style: const TextStyle(fontSize: 28)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F1C))),
const SizedBox(height: 2),
Text(item.quantity,
style: const TextStyle(
fontSize: 12,
color: Color(0xFFF97316),
fontWeight: FontWeight.w500)),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline_rounded,
color: Color(0xFFE24B4A), size: 20),
onPressed: () => onRemove(item),
),
],
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('🛒 Order placed successfully!'),
behavior: SnackBarBehavior.floating,
backgroundColor: Color(0xFF1A6B3C),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A6B3C),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
textStyle:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
child: const Text('Place Order'),
),
),
],
),
);
}
}
// ─── Section Label ────────────────────────────────────────────────────────────
class SectionLabel extends StatelessWidget {
final String label;
const SectionLabel(this.label, {super.key});
@override
Widget build(BuildContext context) {
return Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7A72),
letterSpacing: 0.8,
),
);
}
}