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