Update project

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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