Files
daily_mobileapp_customer/lib/view/dashboard_view/ai_search.dart
2026-07-23 15:36:11 +05:30

1625 lines
58 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:lottie/lottie.dart';
import '../../modules/orders/create_order.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Shimmer widget
// ─────────────────────────────────────────────────────────────────────────────
class _ShimmerBox extends StatefulWidget {
final double width;
final double height;
final BorderRadius borderRadius;
const _ShimmerBox({
required this.width,
required this.height,
this.borderRadius = const BorderRadius.all(Radius.circular(8)),
});
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
_anim = Tween<double>(begin: -1.5, end: 2.5).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment(_anim.value - 1, 0),
end: Alignment(_anim.value, 0),
colors: const [
Color(0xFFE8E8E8),
Color(0xFFF5F5F5),
Color(0xFFE8E8E8),
],
),
),
);
},
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SearchPage
// ─────────────────────────────────────────────────────────────────────────────
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final TextEditingController searchController = TextEditingController();
bool isLoading = false;
Map<String, dynamic>? responseData;
String? errorMessage;
String? clarificationQuestion; // ← NEW: holds question when needs_clarification
File? selectedImage;
List<String> clarificationOptions = [];
Future<void> searchIngredients() async {
final query = searchController.text.trim();
if (query.isEmpty) return;
setState(() {
isLoading = true;
errorMessage = null;
responseData = null;
clarificationQuestion = null; // reset on every new search
});
try {
final response = await http
.post(
Uri.parse('http://127.0.0.1:8000/find-ingredients'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
"user_input": query,
"customer_id": 6060,
"latitude": 11.0050728,
"longitude": 76.9508513,
}),
)
.timeout(const Duration(seconds: 15));
if (!mounted) return;
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final status = decoded['status']?.toString();
if (status == 'needs_clarification') {
setState(() {
clarificationQuestion = decoded['question']?.toString();
clarificationOptions =
(decoded['options'] as List?)
?.map((e) => e.toString())
.toList() ??
[];
});
} else {
setState(() {
responseData = decoded;
clarificationOptions = [];
});
}
} else {
setState(() {
errorMessage = 'Unexpected response format.';
});
}
} else {
setState(() {
errorMessage = 'Server error (${response.statusCode}). Try again.';
});
debugPrint("API Error: ${response.body}");
}
} catch (e) {
if (!mounted) return;
setState(() {
errorMessage = 'Could not connect. Check your network and try again.';
});
debugPrint("Error: $e");
} finally {
if (mounted) setState(() => isLoading = false);
}
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
Future<void> pickImage() async {
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (image != null) {
setState(() {
selectedImage = File(image.path);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: 0,
elevation: 0,
backgroundColor: Colors.white,
leading: Row(
children: [
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
),
const Text(
"Search",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
leadingWidth: 300,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Search bar ──
Column(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 20,
spreadRadius: 1,
offset: const Offset(0, 6),
),
],
border: Border.all(color: Colors.grey.shade300, width: 0.5),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: searchController,
decoration: const InputDecoration(
hintText: "Search ingredients...",
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
onSubmitted: (_) => searchIngredients(),
),
),
IconButton(
onPressed: () {},
icon: const Icon(Icons.mic),
),
IconButton(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text("Camera"),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text("Gallery"),
onTap: () {
Navigator.pop(context);
pickImage();
},
),
],
),
);
},
icon: const Icon(Icons.camera_alt_outlined),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: isLoading
? SizedBox(
width: 50,
height: 50,
child: Lottie.asset('assets/lotties/ai_loader.json'),
)
: ElevatedButton(
onPressed: searchIngredients,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF662582),
elevation: 0,
shape: const CircleBorder(),
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
child: const Icon(Icons.search, color: Colors.white),
),
),
],
),
),
if (clarificationQuestion != null) ...[
const SizedBox(height: 12),
Text(
clarificationQuestion!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
],
if (clarificationOptions.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: clarificationOptions.map((option) {
return ActionChip(
label: Text(option),
onPressed: () {
searchController.text = option;
// Call API again with selected option
searchIngredients();
},
);
}).toList(),
),
],
),
const SizedBox(height: 20),
// ── Results area ──
Expanded(
child: isLoading
? _buildShimmer()
: errorMessage != null
? _buildError()
: clarificationQuestion != null // ← NEW: check clarification first
? _buildClarification() // ← NEW: show agent question
: responseData == null
? const Center(child: Text("Search for ingredients"))
: _buildResults(),
),
],
),
),
);
}
// ── Shimmer skeleton ──
Widget _buildShimmer() {
return ListView(
physics: const NeverScrollableScrollPhysics(),
children: [
const _ShimmerBox(width: 160, height: 22),
const SizedBox(height: 12),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, __) => const _ShimmerBox(
width: 130,
height: 150,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
),
const SizedBox(height: 24),
const _ShimmerBox(width: 140, height: 22),
const SizedBox(height: 12),
SizedBox(
height: 176,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, __) => const _ShimmerBox(
width: 250,
height: 176,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
),
],
);
}
// ── Error state ──
Widget _buildError() {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, size: 48, color: Colors.grey),
const SizedBox(height: 12),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: searchIngredients,
child: const Text('Retry'),
),
],
),
),
);
}
// ── NEW: Clarification card (only shown when status == needs_clarification) ──
Widget _buildClarification() {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF3E5F5),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF662582).withOpacity(0.25),
width: 1,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF662582).withOpacity(0.06),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
child: Column(
children: [
// AI avatar icon
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFF662582).withOpacity(0.12),
shape: BoxShape.circle,
),
child: const Icon(
Icons.auto_awesome_rounded,
size: 26,
color: Color(0xFF662582),
),
),
const SizedBox(height: 14),
// "AI Assistant" label
const Text(
'AI Assistant',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF662582),
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
// The actual question from the agent
Text(
clarificationQuestion ?? 'Can you clarify your request?',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
),
const SizedBox(height: 12),
// Hint
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.6),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.keyboard_rounded,
size: 13, color: Color(0xFF757575)),
SizedBox(width: 5),
Text(
'Type your answer above and search again',
style: TextStyle(
fontSize: 11, color: Color(0xFF757575)),
),
],
),
),
],
),
),
],
),
),
);
}
// ── Normal results ──
Widget _buildResults() {
final ingredients = (responseData!['ingredients_needed'] as List?) ?? [];
return ListView(
children: [
const Text(
"Recommended",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 12),
if (ingredients.isEmpty)
const Text('No ingredients found.',
style: TextStyle(color: Colors.grey))
else
SizedBox(
height: 150,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: ingredients.length,
itemBuilder: (context, index) {
final item =
ingredients[index] as Map<String, dynamic>? ?? {};
return Container(
width: 130,
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: Colors.grey.shade300, width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
(item['emoji'] as String?)?.isNotEmpty == true
? item['emoji'] as String
: '🥬',
style: const TextStyle(fontSize: 35),
),
const SizedBox(height: 8),
Text(
item['name']?.toString() ?? '',
textAlign: TextAlign.center,
maxLines: 2,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
item['quantity']?.toString() ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold),
),
],
),
);
},
),
),
const SizedBox(height: 24),
CompareStoresSection(
responseData: responseData,
onStoreSelected: (storeData) {
debugPrint(
'Selected: ${(storeData['store'] as Map?)?['name']}');
},
),
const SizedBox(height: 24),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// StoreCompareCard
// ─────────────────────────────────────────────────────────────────────────────
class StoreCompareCard extends StatelessWidget {
final String storeName;
final String location;
final double rating;
final int reviewCount;
final String discountLabel;
final double originalPrice;
final double discountedPrice;
final double savings;
final int matchCount;
final int totalIngredients;
final String? imageUrl;
final bool isBestValue;
final bool isSelected;
final VoidCallback? onSelectStore;
const StoreCompareCard({
super.key,
required this.storeName,
required this.location,
required this.rating,
required this.reviewCount,
required this.discountLabel,
required this.originalPrice,
required this.discountedPrice,
required this.savings,
required this.matchCount,
required this.totalIngredients,
this.imageUrl,
this.isBestValue = false,
this.isSelected = false,
this.onSelectStore,
});
@override
Widget build(BuildContext context) {
return Container(
width: 250,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSelected ? const Color(0xFF2E7D32) : Colors.grey.shade300,
width: 1,
),
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: imageUrl != null && imageUrl!.isNotEmpty
? Image.network(
imageUrl!,
width: 40,
height: 45,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_placeholderIcon(),
)
: _placeholderIcon(),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
storeName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
height: 1.2,
),
),
const SizedBox(height: 4),
if (rating > 0)
Row(
children: [
const Icon(Icons.star_rounded,
size: 11, color: Color(0xFFFFA000)),
const SizedBox(width: 2),
Flexible(
child: Text(
'$rating (${_formatCount(reviewCount)})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF616161)),
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
const Icon(Icons.location_on_outlined,
size: 11, color: Color(0xFF616161)),
const SizedBox(width: 2),
Flexible(
child: Text(
location,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 8,
color: Color(0xFF616161)),
),
),
],
),
if (discountLabel.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9),
borderRadius: BorderRadius.circular(6),
),
child: Text(
discountLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 7,
fontWeight: FontWeight.w700,
color: Color(0xFF2E7D32),
),
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
'${_fmt(originalPrice)}',
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
decoration: TextDecoration
.lineThrough,
decorationColor: Colors.grey,
),
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
'${_fmt(discountedPrice)}',
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF2E7D32),
),
),
),
],
),
const SizedBox(height: 2),
Text(
'You save ₹${_fmt(savings)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF2E7D32)),
),
],
),
),
Column(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Text(
'$matchCount / $totalIngredients',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const Text('items',
style: TextStyle(
fontSize: 7,
color: Color(0xFF757575))),
const Text('available',
style: TextStyle(
fontSize: 7,
color: Color(0xFF757575))),
],
),
],
),
],
),
),
],
),
const SizedBox(height: 7),
Align(
alignment: Alignment.center,
child: InkWell(
onTap: onSelectStore,
borderRadius: BorderRadius.circular(2),
highlightColor: Colors.transparent,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF2E7D32)
: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSelected
? const Color(0xFF2E7D32)
: Colors.grey.shade300,
width: 1,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: Text(
isSelected ? 'Selected ✓' : 'Select Store',
key: ValueKey(isSelected),
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected
? Colors.white
: const Color(0xFF2E7D32),
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
),
if (isBestValue)
Positioned(
top: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: const BoxDecoration(
color: Color(0xFF2E7D32),
borderRadius: BorderRadius.only(
topRight: Radius.circular(14),
bottomLeft: Radius.circular(10),
),
),
child: const Text(
'Best Value',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
Widget _placeholderIcon() => Container(
width: 45,
height: 50,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.store_rounded,
size: 20, color: Colors.grey.shade400),
);
String _fmt(double v) => v == v.truncateToDouble()
? v.toInt().toString()
: v.toStringAsFixed(2);
String _formatCount(int count) =>
count >= 1000 ? '${(count / 1000).toStringAsFixed(1)}k' : '$count';
}
// ─────────────────────────────────────────────────────────────────────────────
// CompareStoresSection
// ─────────────────────────────────────────────────────────────────────────────
class CompareStoresSection extends StatefulWidget {
final Map<String, dynamic>? responseData;
final void Function(Map<String, dynamic> storeData)? onStoreSelected;
const CompareStoresSection({
super.key,
required this.responseData,
this.onStoreSelected,
});
@override
State<CompareStoresSection> createState() => _CompareStoresSectionState();
}
class _CompareStoresSectionState extends State<CompareStoresSection> {
int _selectedIndex = 0;
List<dynamic> get _stores =>
(widget.responseData?['stores'] as List?) ?? [];
String _discountLabel(
Map<String, dynamic> store, Map<String, dynamic> storeData) {
final label = store['discount_label'] ?? storeData['discount_label'];
if (label != null && label.toString().isNotEmpty) {
return label.toString();
}
final orig = _toDouble(
store['total_original_price'] ?? storeData['total_original_price']);
final disc = _toDouble(store['total_discounted_price'] ??
storeData['total_discounted_price']);
if (orig > 0 && disc < orig) {
final pct = ((orig - disc) / orig * 100).toStringAsFixed(1);
return '$pct% OFF';
}
return '';
}
double _toDouble(dynamic v) =>
v == null ? 0.0 : double.tryParse(v.toString()) ?? 0.0;
int _toInt(dynamic v) =>
v == null ? 0 : int.tryParse(v.toString()) ?? 0;
@override
Widget build(BuildContext context) {
if (_stores.isEmpty) return const SizedBox.shrink();
final selectedStore = _stores[_selectedIndex];
final matchedProducts =
(selectedStore['matched_products'] as List?) ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Compare stores",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A)),
),
Text(
'${_stores.length} stores found',
style:
const TextStyle(fontSize: 13, color: Color(0xFF757575)),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 163,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _stores.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final raw = _stores[index];
if (raw is! Map<String, dynamic>) {
return const SizedBox.shrink();
}
final storeData = raw;
final store =
(storeData['store'] as Map<String, dynamic>?) ?? {};
final isBestValue = storeData['is_best_value'] == true ||
(storeData['is_best_value'] == null && index == 0);
return StoreCompareCard(
storeName:
store['tenantname']?.toString().isNotEmpty == true
? store['tenantname'].toString()
: 'Unknown Store',
location: store['locationname']?.toString() ?? '',
rating: _toDouble(store['rating']),
reviewCount: _toInt(store['review_count']),
discountLabel: _discountLabel(store, storeData),
originalPrice: _toDouble(store['total_original_price'] ??
storeData['total_original_price']),
discountedPrice: _toDouble(
store['total_discounted_price'] ??
storeData['total_discounted_price']),
savings: _toDouble(store['total_savings'] ??
storeData['total_savings']),
matchCount: _toInt(storeData['match_count']),
totalIngredients: _toInt(storeData['total_ingredients']),
imageUrl: store['image_url']?.toString(),
isBestValue: isBestValue,
isSelected: _selectedIndex == index,
onSelectStore: () {
setState(() => _selectedIndex = index);
widget.onStoreSelected?.call(storeData);
},
);
},
),
),
if (matchedProducts.isNotEmpty) ...[
const SizedBox(height: 24),
_SelectedStoreProducts(
key: ValueKey(_selectedIndex),
matchedProducts: matchedProducts,
),
],
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _SelectedStoreProducts
// ─────────────────────────────────────────────────────────────────────────────
class _SelectedStoreProducts extends StatefulWidget {
final List<dynamic> matchedProducts;
const _SelectedStoreProducts({
super.key,
required this.matchedProducts,
});
@override
State<_SelectedStoreProducts> createState() =>
_SelectedStoreProductsState();
}
class _SelectedStoreProductsState extends State<_SelectedStoreProducts> {
final Map<int, int> _cartQuantities = {};
bool _isPlacingOrder = false;
@override
void initState() {
super.initState();
_initCart();
}
void _initCart() {
for (final item in widget.matchedProducts) {
if (item is! Map<String, dynamic>) continue;
final product = (item['product'] as Map<String, dynamic>?) ?? {};
final productId = product['productid'] as int?;
if (productId != null) {
_cartQuantities[productId] = 1;
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Products",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A)),
),
const SizedBox(height: 12),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.matchedProducts.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = widget.matchedProducts[index];
if (item is! Map<String, dynamic>) {
return const SizedBox.shrink();
}
final product =
(item['product'] as Map<String, dynamic>?) ?? {};
final productId = product['productid'] as int? ?? index;
final qty = _cartQuantities[productId] ?? 0;
final imageUrl = product['productimage']?.toString() ?? '';
final name = product['productname']?.toString() ??
item['ingredient']?.toString() ??
'';
final unit = product['productunit']?.toString() ?? '';
final originalPrice = double.tryParse(
product['original_price']?.toString() ?? '') ??
0.0;
final discountedPrice = double.tryParse(
product['discounted_price']?.toString() ?? '') ??
0.0;
final discountPct = double.tryParse(
product['discount_percent']?.toString() ?? '') ??
0.0;
final hasOffer = product['has_offer'] == true;
final emoji = item['emoji']?.toString() ?? '';
final quantity = item['quantity']?.toString() ?? '';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: Colors.grey.shade200, width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: imageUrl.isNotEmpty
? Image.network(
imageUrl,
width: 64,
height: 64,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_productPlaceholder(emoji),
)
: _productPlaceholder(emoji),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
'$quantity · $unit',
style: const TextStyle(
fontSize: 11, color: Color(0xFF757575)),
),
const SizedBox(height: 6),
Row(
children: [
if (hasOffer &&
originalPrice > discountedPrice) ...[
Text(
'${_fmt(originalPrice)}',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
decoration: TextDecoration.lineThrough,
decorationColor: Colors.grey,
),
),
const SizedBox(width: 6),
],
Text(
'${_fmt(discountedPrice)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: Color(0xFF2E7D32),
),
),
if (hasOffer && discountPct > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${discountPct.toStringAsFixed(1)}% OFF',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFF2E7D32),
),
),
),
],
],
),
],
),
),
const SizedBox(width: 8),
qty == 0
? OutlinedButton(
onPressed: () => setState(
() => _cartQuantities[productId] = 1),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF662582),
side: const BorderSide(
color: Color(0xFF662582), width: 1.5),
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text(
'Add',
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w700),
),
)
: Container(
decoration: BoxDecoration(
color: const Color(0xFF662582),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_stepperBtn(
icon: Icons.remove,
onTap: () => setState(() {
if (qty <= 1) {
_cartQuantities.remove(productId);
} else {
_cartQuantities[productId] = qty - 1;
}
}),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Text(
'$qty',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
_stepperBtn(
icon: Icons.add,
onTap: () => setState(() =>
_cartQuantities[productId] = qty + 1),
),
],
),
),
],
),
);
},
),
const SizedBox(height: 20),
_buildOrderSummary(),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: (_cartQuantities.isEmpty || _isPlacingOrder)
? null
: _placeOrder,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF662582),
disabledBackgroundColor: Colors.grey.shade300,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: _isPlacingOrder
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Place Order',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700),
),
),
),
const SizedBox(height: 8),
],
);
}
Widget _buildOrderSummary() {
double total = 0;
int itemCount = 0;
for (final item in widget.matchedProducts) {
if (item is! Map<String, dynamic>) continue;
final product = (item['product'] as Map<String, dynamic>?) ?? {};
final productId = product['productid'] as int?;
final qty =
productId != null ? (_cartQuantities[productId] ?? 0) : 0;
if (qty > 0) {
final price = double.tryParse(
product['discounted_price']?.toString() ?? '') ??
0.0;
total += price * qty;
itemCount += qty;
}
}
if (itemCount == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFE8F5E9),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$itemCount item${itemCount > 1 ? 's' : ''} in cart',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF2E7D32)),
),
Text(
'${_fmt(total)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF2E7D32)),
),
],
),
);
}
Future<void> _placeOrder() async {
if (_cartQuantities.isEmpty || _isPlacingOrder) return;
setState(() => _isPlacingOrder = true);
try {
final firstItem =
widget.matchedProducts.first as Map<String, dynamic>;
final firstProduct =
(firstItem['product'] as Map<String, dynamic>?) ?? {};
final tenantid = firstProduct['tenantid'] as int? ?? 0;
final categoryid = firstProduct['categoryid'] as int? ?? 0;
final subcategoryid = firstProduct['subcategoryid'] as int? ?? 0;
final items = <OrderItem>[];
double total = 0;
for (final item in widget.matchedProducts) {
if (item is! Map<String, dynamic>) continue;
final product = (item['product'] as Map<String, dynamic>?) ?? {};
final productId = product['productid'] as int?;
if (productId == null) continue;
final qty = _cartQuantities[productId] ?? 0;
if (qty <= 0) continue;
final cost = (product['productcost'] as num?)?.toDouble() ?? 0;
final discountedPrice = double.tryParse(
product['discounted_price']?.toString() ?? '') ??
0.0;
final tax = (product['taxamount'] as num?)?.toDouble() ?? 0;
final discount =
double.tryParse(product['you_save']?.toString() ?? '') ?? 0.0;
final lineTotal = discountedPrice * qty;
total += lineTotal;
items.add(
OrderItem(
productid: productId,
productname: product['productname']?.toString() ?? '',
productdescription:
product['productdesc']?.toString() ?? '',
orderqty: qty,
price: cost,
unitid: int.tryParse(
product['unitvalue']?.toString() ?? '') ??
1,
unitname: product['productunit']?.toString() ?? 'unit',
productsumprice: lineTotal,
tax: tax,
discount: discount,
tenantfee: 0,
),
);
}
if (items.isEmpty) return;
final now = DateTime.now();
final orderDateStr = _fmtNow(now);
final deliveryDateStr =
'${_fmtDate(now)} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:00';
final order = CreateOrder(
applocationid: 1,
applocation: '',
tenantid: tenantid,
partnerid: 60,
locationid: 1135,
categoryid: categoryid,
subcategoryid: subcategoryid,
moduleid: 2,
configid: 1,
orderdate: orderDateStr,
deliverydate: deliveryDateStr,
orderstatus: 'created',
deliverycharge: 0,
customerid: 6060,
pickupcustomer: 'Ragul Stores',
pickupcontactno: '7402223869',
pickupaddress:
'412-419, R.S. Puram,Coimbatore,Tamil Nadu ,India,641002.',
pickuplocationid: 1135,
pickupcity: 'Coimbatore',
deliverycustomer: 'Test User',
deliverycontactno: '9999999999',
deliveryaddress: 'Test delivery address',
deliverylocationid: 0,
deliverylat: '11.0050728',
deliverylong: '76.9508513',
paymenttype: 42,
items: items,
);
final response = await http
.post(
Uri.parse(
'https://queue.workolik.com/live/api/v1/mob/orders/createorder'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(order.toJson()),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200 || response.statusCode == 201) {
debugPrint('Order placed successfully: ${response.body}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Order placed successfully!')),
);
}
} else {
debugPrint(
'Order failed: ${response.statusCode} ${response.body}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Order failed (${response.statusCode}).')),
);
}
}
} catch (e) {
debugPrint('Order error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not place order. Try again.')),
);
}
} finally {
if (mounted) setState(() => _isPlacingOrder = false);
}
}
String _fmtNow(DateTime dt) =>
'${_fmtDate(dt)} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
String _fmtDate(DateTime dt) =>
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
Widget _stepperBtn(
{required IconData icon, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Icon(icon, color: Colors.white, size: 16),
),
);
}
Widget _productPlaceholder(String emoji) => Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
emoji.isNotEmpty ? emoji : '🛒',
style: const TextStyle(fontSize: 28),
),
),
);
String _fmt(double v) => v == v.truncateToDouble()
? v.toInt().toString()
: v.toStringAsFixed(2);
}