Update project
This commit is contained in:
316
lib/features/Products/product_card.dart
Normal file
316
lib/features/Products/product_card.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../modules/search_model/search_model.dart';
|
||||
import '../../widgets/fav_button.dart';
|
||||
|
||||
class ProductCard extends StatefulWidget {
|
||||
final ProductItem item;
|
||||
final VoidCallback? onAddTap;
|
||||
final VoidCallback? onIncrementTap;
|
||||
final VoidCallback? onDecrementTap;
|
||||
final int quantity; // 0 = show "Add", >0 = show stepper
|
||||
|
||||
const ProductCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.onAddTap,
|
||||
this.onIncrementTap,
|
||||
this.onDecrementTap,
|
||||
this.quantity = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProductCard> createState() => _ProductCardState();
|
||||
}
|
||||
|
||||
class _ProductCardState extends State<ProductCard> {
|
||||
bool _isFav = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFav = widget.item.isFavorite;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
|
||||
final original = double.tryParse(
|
||||
item.originalPrice.replaceAll(RegExp(r'[^\d.]'), ''),
|
||||
) ??
|
||||
0;
|
||||
final current = double.tryParse(
|
||||
item.price.replaceAll(RegExp(r'[^\d.]'), ''),
|
||||
) ??
|
||||
0;
|
||||
final discount = original > 0
|
||||
? ((original - current) / original * 100).round()
|
||||
: 0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// ── Image area ──
|
||||
Stack(
|
||||
children: [
|
||||
|
||||
// Product image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.image,
|
||||
height: 100,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 140,
|
||||
color: const Color(0xFFF3F0FF),
|
||||
child: Image.network('https://i0.wp.com/www.chitrasfoodbook.com/wp-content/uploads/2014/08/vegetables-for-salad.jpg?w=1200&ssl=1'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Discount badge — top left
|
||||
if (discount > 0)
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF662582),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$discount% OFF',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Favourite button — top right
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: FavButton(
|
||||
size: 32,
|
||||
initialValue: _isFav,
|
||||
onChanged: (val) => setState(() => _isFav = val),
|
||||
),
|
||||
),
|
||||
|
||||
// Add / Stepper button — bottom right ON image
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.quantity <= 0
|
||||
? GestureDetector(
|
||||
key: const ValueKey('add_button'), // important!
|
||||
onTap: widget.onAddTap,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_rounded,
|
||||
color: Color(0xFF662582),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
key: const ValueKey('counter_row'), // important!
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: widget.onDecrementTap,
|
||||
child: const Icon(
|
||||
Icons.remove_rounded,
|
||||
color: Color(0xFF662582),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${widget.quantity}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: widget.onIncrementTap,
|
||||
child: const Icon(
|
||||
Icons.add_rounded,
|
||||
color: Color(0xFF662582),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
// ── Info area ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// Category
|
||||
Text(
|
||||
item.category,
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Color(0xFF662582),
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 3),
|
||||
|
||||
// Product name
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Rating row
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star_rounded,
|
||||
size: 13,
|
||||
color: Color(0xFFFFC107),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${item.rating}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'(${item.reviewCount})',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Price row — no button here anymore
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.price,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// if (discount > 0)
|
||||
// Text(
|
||||
// item.originalPrice,
|
||||
// style: const TextStyle(
|
||||
// fontSize: 11,
|
||||
// color: Color(0xFF9E9E9E),
|
||||
// decoration: TextDecoration.lineThrough,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/features/Store/presentation/controller/store_controller.dart
Normal file
120
lib/features/Store/presentation/controller/store_controller.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// Manages all scroll, cart-bar visibility, back-to-top, and
|
||||
/// network-retry logic for StoreViewPage.
|
||||
class StoreViewController extends GetxController {
|
||||
// ── Scroll ──────────────────────────────────────────────
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
double scrollOffset = 0.0;
|
||||
bool showBackToTop = false;
|
||||
bool hideCartBar = false;
|
||||
|
||||
Timer? _cartTimer;
|
||||
|
||||
// ── Mini-cart ────────────────────────────────────────────
|
||||
RxBool showMiniCart = false.obs;
|
||||
|
||||
// ── Search ───────────────────────────────────────────────
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
|
||||
// ── Misc ─────────────────────────────────────────────────
|
||||
var selectedCategoryId = 0.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
scrollController.removeListener(_onScroll);
|
||||
scrollController.dispose();
|
||||
searchController.dispose();
|
||||
_cartTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ── Scroll handler (used by the ScrollController listener) ──
|
||||
void _onScroll() {
|
||||
scrollOffset = scrollController.offset;
|
||||
showBackToTop = scrollController.offset > 300;
|
||||
update(['scroll']);
|
||||
}
|
||||
|
||||
// ── Notification-based handler (NestedScrollView body) ──────
|
||||
bool handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
notification.metrics.axis == Axis.vertical) {
|
||||
final double pixels = notification.metrics.pixels;
|
||||
final bool isScrollingUp = pixels < scrollOffset;
|
||||
|
||||
scrollOffset = pixels;
|
||||
|
||||
if (isScrollingUp && pixels > 300) {
|
||||
showBackToTop = true;
|
||||
} else if (pixels <= 300 || !isScrollingUp) {
|
||||
showBackToTop = false;
|
||||
}
|
||||
|
||||
// Hide cart bar immediately on scroll
|
||||
if (!hideCartBar) {
|
||||
hideCartBar = true;
|
||||
showMiniCart.value = false;
|
||||
update(['cartBar']);
|
||||
}
|
||||
|
||||
// Re-show cart bar after scroll stops
|
||||
_cartTimer?.cancel();
|
||||
_cartTimer = Timer(const Duration(milliseconds: 250), () {
|
||||
if (hideCartBar) {
|
||||
hideCartBar = false;
|
||||
update(['cartBar']);
|
||||
}
|
||||
});
|
||||
|
||||
update(['scroll']);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Background colour based on scroll position ───────────────
|
||||
Color backgroundColorFor(List<Color> colors) {
|
||||
if (colors.isEmpty) return Colors.white;
|
||||
return colors[(scrollOffset ~/ 300) % colors.length];
|
||||
}
|
||||
|
||||
// ── Scroll to top ─────────────────────────────────────────────
|
||||
void scrollToTop() {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Network retry ─────────────────────────────────────────────
|
||||
Future<void> retryNetworkCall(Future<void> Function() onSuccess) async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
|
||||
if (connectivityResult == ConnectivityResult.mobile ||
|
||||
connectivityResult == ConnectivityResult.wifi) {
|
||||
debugPrint('✅ Internet available, retrying...');
|
||||
await onSuccess();
|
||||
} else {
|
||||
debugPrint('❌ Still no internet connection');
|
||||
Get.snackbar(
|
||||
'No Internet',
|
||||
'Please check your connection and try again.',
|
||||
backgroundColor: Colors.grey[200],
|
||||
colorText: Colors.black,
|
||||
snackPosition: SnackPosition.TOP,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
454
lib/features/Store/presentation/screens/store_view.dart
Normal file
454
lib/features/Store/presentation/screens/store_view.dart
Normal file
@@ -0,0 +1,454 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart';
|
||||
|
||||
import '../../../../constants/color_constants.dart';
|
||||
import '../../../../constants/font_constants.dart';
|
||||
import '../../../../controllers/account_controller/profile.dart';
|
||||
import '../../../../controllers/cart_controller/cart.dart';
|
||||
import '../../../../controllers/dashboard_controller/dashboard_controller.dart';
|
||||
import '../../../../controllers/tenant_list/tenant_controller.dart';
|
||||
import '../../../../service/firebase_analytics/analytics_service.dart';
|
||||
import '../../../../view/dashboard_view/tenant_profile.dart';
|
||||
import '../../../../view/product/tenant_products.dart';
|
||||
import '../../../../widgets/text_widget.dart';
|
||||
import '../controller/store_controller.dart';
|
||||
import '../widgets/store_widgets.dart' hide ZoomIconButton;
|
||||
|
||||
|
||||
class StoreViewPage extends StatefulWidget {
|
||||
const StoreViewPage({super.key});
|
||||
|
||||
@override
|
||||
State<StoreViewPage> createState() => _StoreViewPageState();
|
||||
}
|
||||
|
||||
class _StoreViewPageState extends State<StoreViewPage> {
|
||||
// ── Controllers ──────────────────────────────────────────────
|
||||
final StoreViewController storeCtrl = Get.put(StoreViewController());
|
||||
final TenantController tenantCtrl = Get.put(TenantController());
|
||||
final CartController cartCtrl = Get.put(CartController());
|
||||
|
||||
bool status = true;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.white,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
body: GetBuilder<StoreViewController>(
|
||||
id: 'scroll',
|
||||
builder: (ctrl) {
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
color: ctrl.backgroundColorFor(ColorConstants.bgColors),
|
||||
child: NestedScrollViewPlus(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
controller: ctrl.scrollController,
|
||||
headerSliverBuilder: (context, _) => [
|
||||
CupertinoSliverRefreshControl(
|
||||
onRefresh: () async {
|
||||
await tenantCtrl.loadTenants();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
},
|
||||
),
|
||||
],
|
||||
body: NotificationListener<ScrollNotification>(
|
||||
onNotification: ctrl.handleScrollNotification,
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 12)),
|
||||
|
||||
// ── Section header ──────────────────────────────
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: ReusableTextWidget(
|
||||
text: "Near by Stores",
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Store list ──────────────────────────────────
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 0),
|
||||
sliver: Obx(() {
|
||||
// No internet
|
||||
if (!tenantCtrl.isConnected.value) {
|
||||
return SliverToBoxAdapter(
|
||||
child: NoInternetWidget(
|
||||
onRetry: () => ctrl.retryNetworkCall(
|
||||
() async => await tenantCtrl.loadTenants(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (tenantCtrl.isLoading.value) {
|
||||
return const StoreListShimmer();
|
||||
}
|
||||
|
||||
// Empty
|
||||
if (tenantCtrl.tenants.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: NoStoresFound(),
|
||||
);
|
||||
}
|
||||
|
||||
// Store list
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final item = tenantCtrl.tenants[index];
|
||||
return ZoomOnTap(
|
||||
onTap: () => _openStore(item),
|
||||
child: _StoreCard(
|
||||
item: item,
|
||||
status: status,
|
||||
onInfoTap: () =>
|
||||
Get.to(StoreOverviewScreen()),
|
||||
onCategoryArrowTap: () =>
|
||||
_openStore(item),
|
||||
onSubcategoryTap: (_) =>
|
||||
_openStore(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: tenantCtrl.tenants.length,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 220)),
|
||||
SliverToBoxAdapter(
|
||||
child: Image.asset(
|
||||
'assets/images/nearle_copyrights.png'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
void _openStore(dynamic item) async {
|
||||
Get.to(() => ProductsScreen(
|
||||
tenantId: item.tenantid!,
|
||||
locationId: item.locationid!,
|
||||
categoryId: item.categoryid!,
|
||||
tenantName: item.tenantname!,
|
||||
locationname: item.locationname!,
|
||||
tenantLocation: item.suburb!,
|
||||
tenantImage: item.tenantimage!,
|
||||
tenantloc: item.locationid!,
|
||||
subCategoryName: '',
|
||||
));
|
||||
|
||||
await AnalyticsService.logEvent(
|
||||
'store_viewed',
|
||||
parameters: {
|
||||
'store_id': item.tenantid!,
|
||||
'store_name': item.tenantname!,
|
||||
'locatiom': item.locationname!,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Private store card widget (lives here because it needs item data)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
class _StoreCard extends StatelessWidget {
|
||||
final dynamic item;
|
||||
final bool status;
|
||||
final VoidCallback onInfoTap;
|
||||
final VoidCallback onCategoryArrowTap;
|
||||
final void Function(dynamic subcategory) onSubcategoryTap;
|
||||
|
||||
const _StoreCard({
|
||||
required this.item,
|
||||
required this.status,
|
||||
required this.onInfoTap,
|
||||
required this.onCategoryArrowTap,
|
||||
required this.onSubcategoryTap,
|
||||
});
|
||||
|
||||
double _rs(BuildContext context, double size) =>
|
||||
size * (MediaQuery.of(context).size.width / 390);
|
||||
|
||||
double _rh(BuildContext context, double size) =>
|
||||
size * (MediaQuery.of(context).size.height / 844);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12, top: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE1E5EA), width: 0.55),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.10),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header row ──────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: item.tenantname!,
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: _rs(context, 23),
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on,
|
||||
color: Colors.grey, size: _rs(context, 11)),
|
||||
ReusableTextWidget(
|
||||
text: item.locationname!,
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: _rs(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
ZoomIconButton(
|
||||
onTap: onInfoTap,
|
||||
icon: Icons.info_outline,
|
||||
size: 25,
|
||||
color: Colors.black87,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Body: closed or open ─────────────────────────────
|
||||
status == false
|
||||
? const ClosedStoreWidget()
|
||||
: Column(
|
||||
children: [
|
||||
// Banner
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
height: _rh(context, 150),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: (item.tenantbanner != null &&
|
||||
item.tenantbanner!.isNotEmpty)
|
||||
? Image.network(
|
||||
item.tenantbanner!,
|
||||
fit: BoxFit.fill,
|
||||
errorBuilder: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
size: _rs(context, 40),
|
||||
color: Colors.white),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
'https://img.freepik.com/free-psd/healthy-eating-lifestyle-banner-template_23-2149087275.jpg',
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Category row
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: 'Category',
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: _rs(context, 15),
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onCategoryArrowTap,
|
||||
icon: Icon(
|
||||
Icons.arrow_circle_right_outlined,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
size: _rs(context, 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Subcategories
|
||||
SizedBox(
|
||||
height: _rh(context, 160),
|
||||
child: (item.subcategories != null &&
|
||||
item.subcategories!.isNotEmpty)
|
||||
? ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: item.subcategories!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = item.subcategories![index];
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => onSubcategoryTap(product),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 14, right: 12, top: 2),
|
||||
height: _rh(context, 180),
|
||||
width: _rs(context, 120),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.black12, width: 0.20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
Colors.black.withOpacity(0.10),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 7,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SizedBox(height: _rh(context, 12)),
|
||||
ClipOval(
|
||||
child: Image.network(
|
||||
product.image ?? '',
|
||||
width: _rs(context, 80),
|
||||
height: _rs(context, 80),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey,
|
||||
size: _rs(context, 30),
|
||||
),
|
||||
loadingBuilder:
|
||||
(_, child, progress) {
|
||||
if (progress == null)
|
||||
return child;
|
||||
return const Center(
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8),
|
||||
child: Center(
|
||||
child: ReusableTextWidget(
|
||||
text: product.subcatname!,
|
||||
color: Colors.black
|
||||
.withOpacity(0.7),
|
||||
fontFamily:
|
||||
FontConstants.fontFamily,
|
||||
fontSize: _rs(context, 12),
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: _rh(context, 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No products available',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
425
lib/features/Store/presentation/widgets/store_widgets.dart
Normal file
425
lib/features/Store/presentation/widgets/store_widgets.dart
Normal file
@@ -0,0 +1,425 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../../constants/color_constants.dart';
|
||||
import '../../../../constants/font_constants.dart';
|
||||
import '../../../../widgets/text_widget.dart';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Zoom-on-tap wrapper
|
||||
// ─────────────────────────────────────────────
|
||||
class ZoomOnTap extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ZoomOnTap({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ZoomOnTap> createState() => _ZoomOnTapState();
|
||||
}
|
||||
|
||||
class _ZoomOnTapState extends State<ZoomOnTap> {
|
||||
double _scale = 1.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => setState(() => _scale = 0.96),
|
||||
onTapUp: (_) {
|
||||
setState(() => _scale = 1.0);
|
||||
widget.onTap();
|
||||
},
|
||||
onTapCancel: () => setState(() => _scale = 1.0),
|
||||
child: AnimatedScale(
|
||||
scale: _scale,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Zoom icon button
|
||||
// ─────────────────────────────────────────────
|
||||
class ZoomIconButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const ZoomIconButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.icon,
|
||||
this.size = 24,
|
||||
this.color = Colors.black87,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ZoomIconButton> createState() => _ZoomIconButtonState();
|
||||
}
|
||||
|
||||
class _ZoomIconButtonState extends State<ZoomIconButton> {
|
||||
double _scale = 1.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => setState(() => _scale = 0.85),
|
||||
onTapUp: (_) {
|
||||
setState(() => _scale = 1.0);
|
||||
widget.onTap();
|
||||
},
|
||||
onTapCancel: () => setState(() => _scale = 1.0),
|
||||
child: AnimatedScale(
|
||||
scale: _scale,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
child: Icon(widget.icon, size: widget.size, color: widget.color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Search bar
|
||||
// ─────────────────────────────────────────────
|
||||
class StoreSearchBar extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const StoreSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hint = 'Search',
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onTap: onTap,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// No internet widget
|
||||
// ─────────────────────────────────────────────
|
||||
class NoInternetWidget extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const NoInternetWidget({super.key, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Lottie.asset(
|
||||
'assets/lotties/no_internet.json',
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ReusableTextWidget(
|
||||
text: 'No Internet Connection',
|
||||
color: Colors.grey[700]!,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: ColorConstants.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// No stores found
|
||||
// ─────────────────────────────────────────────
|
||||
class NoStoresFound extends StatelessWidget {
|
||||
const NoStoresFound({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Lottie.asset('assets/lotties/QR Code scan on phone.json'),
|
||||
const SizedBox(height: 10),
|
||||
ReusableTextWidget(
|
||||
text: 'We don\'t deliver to this location yet. Try browsing nearby stores instead.',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
fontSize: 14,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────
|
||||
// Closed store UI
|
||||
// ─────────────────────────────────────────────
|
||||
class ClosedStoreWidget extends StatelessWidget {
|
||||
const ClosedStoreWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Center(
|
||||
child: Lottie.asset(
|
||||
'assets/lotties/shop.json',
|
||||
height: 140,
|
||||
repeat: true,
|
||||
animate: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Store is currently closed',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Please come back later',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// List shimmer (loading placeholder)
|
||||
// ─────────────────────────────────────────────
|
||||
class StoreListShimmer extends StatelessWidget {
|
||||
const StoreListShimmer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey.shade300,
|
||||
highlightColor: Colors.grey.shade100,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 16,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 14,
|
||||
width: MediaQuery.of(context).size.width * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: 5,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Grid shimmer (subcategory loading placeholder)
|
||||
// ─────────────────────────────────────────────
|
||||
class SubcategoryGridShimmer extends StatelessWidget {
|
||||
const SubcategoryGridShimmer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
double maxCrossAxisExtent = 250;
|
||||
if (screenWidth > 1200) {
|
||||
maxCrossAxisExtent = 300;
|
||||
} else if (screenWidth > 800) {
|
||||
maxCrossAxisExtent = 280;
|
||||
}
|
||||
|
||||
return SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, left: 10, right: 10),
|
||||
child: Container(
|
||||
height: 140,
|
||||
width: 140,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10, right: 10),
|
||||
child: Container(height: 20, width: 100, color: Colors.white),
|
||||
),
|
||||
Container(
|
||||
height: 35,
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: 6,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: 230,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1934
lib/features/category/prestation/screens/category.dart
Normal file
1934
lib/features/category/prestation/screens/category.dart
Normal file
File diff suppressed because it is too large
Load Diff
243
lib/features/dashboard/data/grocery_product_model.dart
Normal file
243
lib/features/dashboard/data/grocery_product_model.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
// Models for:
|
||||
// GET /live/api/v1/mob/products/getproductsbysubcategory
|
||||
// ?categoryid=&tenantid=&locationid=
|
||||
//
|
||||
|
||||
// Mirrors the response shape exactly (field names/casing kept as-is, e.g.
|
||||
// the API's inconsistent "Subcategoryname" on the product object vs.
|
||||
// "subcategoryname" on the section object).
|
||||
|
||||
class GroceryProductsResponse {
|
||||
final int code;
|
||||
final GroceryData data;
|
||||
final String message;
|
||||
final bool status;
|
||||
|
||||
GroceryProductsResponse({
|
||||
required this.code,
|
||||
required this.data,
|
||||
required this.message,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory GroceryProductsResponse.fromJson(Map<String, dynamic> json) {
|
||||
return GroceryProductsResponse(
|
||||
code: _asInt(json['code']),
|
||||
data: GroceryData.fromJson(
|
||||
(json['data'] as Map<String, dynamic>?) ?? const {}),
|
||||
message: (json['message'] as String?) ?? '',
|
||||
status: (json['status'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroceryData {
|
||||
final String address;
|
||||
final String city;
|
||||
final List<GrocerySubcategory> details;
|
||||
final String licenseno;
|
||||
final String locationname;
|
||||
final String pickuplat;
|
||||
final int pickuplocationid;
|
||||
final String pickuplong;
|
||||
final String postcode;
|
||||
final String primarycontact;
|
||||
final String primaryemail;
|
||||
final String suburb;
|
||||
final String tenantname;
|
||||
|
||||
GroceryData({
|
||||
required this.address,
|
||||
required this.city,
|
||||
required this.details,
|
||||
required this.licenseno,
|
||||
required this.locationname,
|
||||
required this.pickuplat,
|
||||
required this.pickuplocationid,
|
||||
required this.pickuplong,
|
||||
required this.postcode,
|
||||
required this.primarycontact,
|
||||
required this.primaryemail,
|
||||
required this.suburb,
|
||||
required this.tenantname,
|
||||
});
|
||||
|
||||
factory GroceryData.fromJson(Map<String, dynamic> json) {
|
||||
return GroceryData(
|
||||
address: (json['address'] as String?) ?? '',
|
||||
city: (json['city'] as String?) ?? '',
|
||||
details: ((json['details'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => GrocerySubcategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
licenseno: (json['licenseno'] as String?) ?? '',
|
||||
locationname: (json['locationname'] as String?) ?? '',
|
||||
pickuplat: (json['pickuplat'] as String?) ?? '',
|
||||
pickuplocationid: _asInt(json['pickuplocationid']),
|
||||
pickuplong: (json['pickuplong'] as String?) ?? '',
|
||||
postcode: (json['postcode'] as String?) ?? '',
|
||||
primarycontact: (json['primarycontact'] as String?) ?? '',
|
||||
primaryemail: (json['primaryemail'] as String?) ?? '',
|
||||
suburb: (json['suburb'] as String?) ?? '',
|
||||
tenantname: (json['tenantname'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in `details`, e.g. "Vegetables & Fruits", "Dairy, Deli & Egg".
|
||||
class GrocerySubcategory {
|
||||
final int subcategoryId;
|
||||
final String subcategoryName;
|
||||
final String image;
|
||||
final List<GroceryProduct> products;
|
||||
|
||||
GrocerySubcategory({
|
||||
required this.subcategoryId,
|
||||
required this.subcategoryName,
|
||||
required this.image,
|
||||
required this.products,
|
||||
});
|
||||
|
||||
factory GrocerySubcategory.fromJson(Map<String, dynamic> json) {
|
||||
return GrocerySubcategory(
|
||||
subcategoryId: _asInt(json['subcategoryid']),
|
||||
subcategoryName: (json['subcategoryname'] as String?) ?? 'Other',
|
||||
image: (json['image'] as String?) ?? '',
|
||||
products: ((json['products'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => GroceryProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroceryProduct {
|
||||
final int productId;
|
||||
final int applocationId;
|
||||
final int productLocationId;
|
||||
final int tenantId;
|
||||
final int categoryId;
|
||||
final String categoryName;
|
||||
final int subcategoryId;
|
||||
// Note: the API returns this capitalized on the product object.
|
||||
final String subcategoryNameOnProduct;
|
||||
final int discountId;
|
||||
final num discountValue;
|
||||
final String productName;
|
||||
final String productDesc;
|
||||
final String? productSku;
|
||||
final String? productBrand;
|
||||
final String productUnit;
|
||||
final String unitValue;
|
||||
final String? topPicks;
|
||||
final num productCost;
|
||||
final num? taxAmount;
|
||||
final num? taxPercent;
|
||||
final num productTax;
|
||||
final int productStock;
|
||||
final int productCombo;
|
||||
final int variants;
|
||||
final int quantity;
|
||||
final num? retailPrice;
|
||||
final num? diffPrice;
|
||||
final num? diffPercent;
|
||||
final int approve;
|
||||
final String productStatus;
|
||||
final String? productImage;
|
||||
|
||||
GroceryProduct({
|
||||
required this.productId,
|
||||
required this.applocationId,
|
||||
required this.productLocationId,
|
||||
required this.tenantId,
|
||||
required this.categoryId,
|
||||
required this.categoryName,
|
||||
required this.subcategoryId,
|
||||
required this.subcategoryNameOnProduct,
|
||||
required this.discountId,
|
||||
required this.discountValue,
|
||||
required this.productName,
|
||||
required this.productDesc,
|
||||
this.productSku,
|
||||
this.productBrand,
|
||||
required this.productUnit,
|
||||
required this.unitValue,
|
||||
this.topPicks,
|
||||
required this.productCost,
|
||||
this.taxAmount,
|
||||
this.taxPercent,
|
||||
required this.productTax,
|
||||
required this.productStock,
|
||||
required this.productCombo,
|
||||
required this.variants,
|
||||
required this.quantity,
|
||||
this.retailPrice,
|
||||
this.diffPrice,
|
||||
this.diffPercent,
|
||||
required this.approve,
|
||||
required this.productStatus,
|
||||
this.productImage,
|
||||
});
|
||||
|
||||
/// Selling price shown to the customer: retail price if set, else cost.
|
||||
num get displayPrice => retailPrice ?? productCost;
|
||||
|
||||
/// Struck-through "was" price — only meaningful when the API sent a
|
||||
/// positive diffprice (an actual discount amount).
|
||||
num get displayOriginalPrice =>
|
||||
(diffPrice != null && diffPrice! > 0) ? displayPrice + diffPrice! : displayPrice;
|
||||
|
||||
bool get isInStock => productStatus.toLowerCase() == 'available';
|
||||
|
||||
bool get isActive => productStatus.toLowerCase() != 'inactive';
|
||||
|
||||
factory GroceryProduct.fromJson(Map<String, dynamic> json) {
|
||||
return GroceryProduct(
|
||||
productId: _asInt(json['productid']),
|
||||
applocationId: _asInt(json['applocationid']),
|
||||
productLocationId: _asInt(json['productlocationid']),
|
||||
tenantId: _asInt(json['tenantid']),
|
||||
categoryId: _asInt(json['categoryid']),
|
||||
categoryName: (json['categoryname'] as String?) ?? '',
|
||||
subcategoryId: _asInt(json['subcategoryid']),
|
||||
subcategoryNameOnProduct: (json['Subcategoryname'] as String?) ?? '',
|
||||
discountId: _asInt(json['discountid']),
|
||||
discountValue: _asNum(json['discountvalue']),
|
||||
productName: (json['productname'] as String?) ?? 'Product',
|
||||
productDesc: (json['productdesc'] as String?) ?? '',
|
||||
productSku: json['productsku'] as String?,
|
||||
productBrand: json['productbrand'] as String?,
|
||||
productUnit: (json['productunit'] as String?) ?? '',
|
||||
unitValue: (json['unitvalue'] as String?) ?? '',
|
||||
topPicks: json['toppicks'] as String?,
|
||||
productCost: _asNum(json['productcost']),
|
||||
taxAmount: json['taxamount'] == null ? null : _asNum(json['taxamount']),
|
||||
taxPercent:
|
||||
json['taxpercent'] == null ? null : _asNum(json['taxpercent']),
|
||||
productTax: _asNum(json['producttax']),
|
||||
productStock: _asInt(json['productstock']),
|
||||
productCombo: _asInt(json['productcombo']),
|
||||
variants: _asInt(json['variants']),
|
||||
quantity: _asInt(json['quantity']),
|
||||
retailPrice:
|
||||
json['retailprice'] == null ? null : _asNum(json['retailprice']),
|
||||
diffPrice: json['diffprice'] == null ? null : _asNum(json['diffprice']),
|
||||
diffPercent:
|
||||
json['diffpercent'] == null ? null : _asNum(json['diffpercent']),
|
||||
approve: _asInt(json['approve']),
|
||||
productStatus: (json['productstatus'] as String?) ?? '',
|
||||
productImage: json['productimage'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _asInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v.toString()) ?? 0;
|
||||
}
|
||||
|
||||
num _asNum(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v;
|
||||
return num.tryParse(v.toString()) ?? 0;
|
||||
}
|
||||
89
lib/features/dashboard/domain/repo/grocery_repository.dart
Normal file
89
lib/features/dashboard/domain/repo/grocery_repository.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// TODO: point this at wherever your real `Product` model (the one used by
|
||||
// CartController.addToCart) actually lives.
|
||||
import '../../../../modules/product/product.dart';
|
||||
|
||||
/// One subcategory row returned by the API, e.g. "Vegetables & Fruits",
|
||||
/// holding the real `Product` model straight from the API — the same type
|
||||
/// CartController.addToCart expects, so no adapter/mapping is needed to
|
||||
/// add a grocery item to the cart.
|
||||
class GrocerySubcategorySection {
|
||||
final int subcategoryId;
|
||||
final String subcategoryName;
|
||||
final String image;
|
||||
final List<Product> products;
|
||||
|
||||
GrocerySubcategorySection({
|
||||
required this.subcategoryId,
|
||||
required this.subcategoryName,
|
||||
required this.image,
|
||||
required this.products,
|
||||
});
|
||||
}
|
||||
|
||||
class GroceryRepository {
|
||||
static const String _baseUrl =
|
||||
'https://fiesta.nearle.app/live/api/v1/mob/products/getproductsbysubcategory';
|
||||
|
||||
/// Fetches grocery products for the given category/tenant/location and
|
||||
/// returns them grouped by subcategory, in the order the API returns them.
|
||||
///
|
||||
/// Throws an [Exception] with a readable message on failure so the caller
|
||||
/// can show a retry UI.
|
||||
Future<List<GrocerySubcategorySection>> fetchGroceryProducts({
|
||||
required int categoryId,
|
||||
required int tenantId,
|
||||
required int locationId,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$_baseUrl?categoryid=$categoryId&tenantid=$tenantId&locationid=$locationId',
|
||||
);
|
||||
|
||||
late final http.Response response;
|
||||
try {
|
||||
print('Fetching grocery products from API...');
|
||||
print(uri);
|
||||
response = await http.get(uri).timeout(const Duration(seconds: 15));
|
||||
} catch (_) {
|
||||
throw Exception('Could not reach the server. Check your connection.');
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
'Failed to load grocery products (HTTP ${response.statusCode})');
|
||||
}
|
||||
|
||||
late final Map<String, dynamic> body;
|
||||
try {
|
||||
body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('Received an unexpected response from the server.');
|
||||
}
|
||||
|
||||
if (body['status'] != true) {
|
||||
throw Exception(
|
||||
(body['message'] as String?) ?? 'Failed to load grocery products');
|
||||
}
|
||||
|
||||
final List<dynamic> details =
|
||||
(body['data'] as Map<String, dynamic>?)?['details'] as List<dynamic>? ??
|
||||
[];
|
||||
|
||||
return details.map((rawSection) {
|
||||
final section = rawSection as Map<String, dynamic>;
|
||||
final rawProducts = (section['products'] as List<dynamic>?) ?? [];
|
||||
|
||||
return GrocerySubcategorySection(
|
||||
subcategoryId: (section['subcategoryid'] as num?)?.toInt() ?? 0,
|
||||
subcategoryName: (section['subcategoryname'] as String?) ?? 'Other',
|
||||
image: (section['image'] as String?) ?? 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRNKer1bKTGDnr5Um463cjhRTpKstbPxh5DxA2hrAr1egSWr7VK42aLkiI&s=10',
|
||||
// Uses your existing Product.fromJson, so tenantid/productid/etc.
|
||||
// needed by addToCart are preserved straight from the API.
|
||||
products: Product.fromJsonList(rawProducts),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
526
lib/features/dashboard/presentation/screens/camera_search.dart
Normal file
526
lib/features/dashboard/presentation/screens/camera_search.dart
Normal file
@@ -0,0 +1,526 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../modules/search_model/search_model.dart';
|
||||
import '../../../Products/product_card.dart';
|
||||
import 'edit_object.dart';
|
||||
|
||||
|
||||
|
||||
/// Full-screen camera-based visual product search, styled after
|
||||
/// Google Lens / Amazon-style "search by image" camera views.
|
||||
///
|
||||
/// Capturing or picking a photo now hands off to [CropConfirmScreen],
|
||||
/// which shows a draggable crop box (pre-filled from on-device object
|
||||
/// detection) so the user can pick exactly which product to search for.
|
||||
class LensSearchScreen extends StatefulWidget {
|
||||
const LensSearchScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LensSearchScreen> createState() => _LensSearchScreenState();
|
||||
}
|
||||
|
||||
class _LensSearchScreenState extends State<LensSearchScreen>
|
||||
with WidgetsBindingObserver {
|
||||
CameraController? _controller;
|
||||
List<CameraDescription> _cameras = [];
|
||||
bool _flashOn = false;
|
||||
bool _isInitializing = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_setupCamera();
|
||||
}
|
||||
|
||||
Future<void> _setupCamera() async {
|
||||
try {
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'No camera found on this device.';
|
||||
_isInitializing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final backCamera = _cameras.firstWhere(
|
||||
(cam) => cam.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => _cameras.first,
|
||||
);
|
||||
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_controller = controller;
|
||||
_isInitializing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Could not start camera: $e';
|
||||
_isInitializing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final controller = _controller;
|
||||
if (controller == null || !controller.value.isInitialized) return;
|
||||
|
||||
if (state == AppLifecycleState.inactive) {
|
||||
controller.dispose();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_setupCamera();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggleFlash() async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
final next = !_flashOn;
|
||||
try {
|
||||
await controller.setFlashMode(next ? FlashMode.torch : FlashMode.off);
|
||||
setState(() => _flashOn = next);
|
||||
} catch (_) {
|
||||
// Some devices/emulators don't support torch mode; ignore silently.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _capturePhoto() async {
|
||||
final controller = _controller;
|
||||
if (controller == null || !controller.value.isInitialized) return;
|
||||
if (controller.value.isTakingPicture) return;
|
||||
|
||||
try {
|
||||
final file = await controller.takePicture();
|
||||
if (!mounted) return;
|
||||
_goToCropConfirm(file.path);
|
||||
} catch (e) {
|
||||
_showSnack('Failed to capture photo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) return;
|
||||
if (!mounted) return;
|
||||
_goToCropConfirm(picked.path);
|
||||
}
|
||||
|
||||
void _goToCropConfirm(String imagePath) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CropConfirmScreen(imagePath: imagePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraPreview(),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 140,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTopBar(),
|
||||
const Spacer(),
|
||||
_buildHintPill(),
|
||||
const SizedBox(height: 24),
|
||||
_buildBottomBar(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCameraPreview() {
|
||||
if (_isInitializing) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
if (_error != null || _controller == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error ?? 'Camera unavailable',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.previewSize?.height ?? 1,
|
||||
height: _controller!.value.previewSize?.width ?? 1,
|
||||
child: CameraPreview(_controller!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: _LensWordmark()),
|
||||
_CircleIconButton(
|
||||
icon: _flashOn ? Icons.flash_on : Icons.flash_on_outlined,
|
||||
onTap: _toggleFlash,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_CircleIconButton(
|
||||
icon: Icons.help_outline,
|
||||
onTap: () =>
|
||||
_showSnack('Point the camera at a product to search for it.'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHintPill() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.55),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Text(
|
||||
'Take a photo to search products',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_CircleIconButton(
|
||||
icon: Icons.add_photo_alternate_outlined,
|
||||
onTap: _pickFromGallery,
|
||||
size: 48,
|
||||
),
|
||||
_ShutterButton(onTap: _capturePhoto),
|
||||
_CircleIconButton(
|
||||
icon: Icons.qr_code_scanner,
|
||||
onTap: () => _showSnack('Barcode scan mode coming soon.'),
|
||||
size: 48,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LensWordmark extends StatelessWidget {
|
||||
const _LensWordmark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'Nearle',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'ai',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 2),
|
||||
Icon(Icons.auto_awesome, color: Colors.white, size: 14),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircleIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
|
||||
const _CircleIconButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.size = 36,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Icon(icon, color: Colors.white, size: size * 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShutterButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ShutterButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.white54, width: 4),
|
||||
),
|
||||
child: const Icon(Icons.search, color: Colors.black87, size: 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Results screen shown after crop confirmation.
|
||||
///
|
||||
/// [extractedText] is whatever on-device OCR (ML Kit Text Recognition)
|
||||
/// found inside the user's cropped selection — e.g. a brand name like
|
||||
/// "OPPO", model numbers, or other label text. It's shown in a dedicated
|
||||
/// card with a "Copy" button so the user can grab it (paste into notes,
|
||||
/// a search bar, a print dialog, wherever).
|
||||
///
|
||||
/// Replace `_fetchMatches` with a real call to your product-search API.
|
||||
class SearchResultsScreen extends StatelessWidget {
|
||||
final String imagePath;
|
||||
final bool wasDetected;
|
||||
final String extractedText;
|
||||
|
||||
const SearchResultsScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.wasDetected = false,
|
||||
this.extractedText = '',
|
||||
});
|
||||
|
||||
Future<void> _copyText(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: extractedText));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Text copied to clipboard')),
|
||||
);
|
||||
}
|
||||
|
||||
/// Placeholder matches until this is wired to a real product-search API.
|
||||
/// Swap this out for the actual API response mapped into [ProductItem]s.
|
||||
/// NOTE: adjust field names here if they don't match your ProductItem
|
||||
/// constructor exactly (image, category, name, rating, reviewCount,
|
||||
/// price, originalPrice, isFavorite are assumed based on ProductCard).
|
||||
List<ProductItem> _mockMatches() {
|
||||
return List.generate(10, (index) {
|
||||
final price = 15 + index * 7.5;
|
||||
final original = price + 10;
|
||||
return ProductItem(
|
||||
image: 'https://picsum.photos/seed/product$index/300/300',
|
||||
category: 'Electronics',
|
||||
name: 'Matched product ${index + 1}',
|
||||
rating: 4.5,
|
||||
reviewCount: 20 + index,
|
||||
price: '\$${price.toStringAsFixed(2)}',
|
||||
originalPrice: '\$${original.toStringAsFixed(2)}',
|
||||
isFavorite: false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasText = extractedText.trim().isNotEmpty;
|
||||
final matches = _mockMatches();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
animateColor: false,
|
||||
title: const Text('Search results')),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(imagePath),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: Colors.grey.shade300,
|
||||
child: const Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Showing matches for your cropped selection',
|
||||
style: TextStyle(fontSize: 14, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (hasText) _buildDetectedTextCard(context),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: matches.length,
|
||||
itemBuilder: (context, index) => ProductCard(item: matches[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetectedTextCard(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.text_snippet_outlined,
|
||||
size: 18, color: Colors.grey.shade700),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Detected text',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => _copyText(context),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy'),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SelectableText(
|
||||
extractedText,
|
||||
style: const TextStyle(fontSize: 14, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1690
lib/features/dashboard/presentation/screens/daily_plan.dart
Normal file
1690
lib/features/dashboard/presentation/screens/daily_plan.dart
Normal file
File diff suppressed because it is too large
Load Diff
1551
lib/features/dashboard/presentation/screens/dash_view.dart
Normal file
1551
lib/features/dashboard/presentation/screens/dash_view.dart
Normal file
File diff suppressed because it is too large
Load Diff
597
lib/features/dashboard/presentation/screens/edit_object.dart
Normal file
597
lib/features/dashboard/presentation/screens/edit_object.dart
Normal file
@@ -0,0 +1,597 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mlkit_object_detection/google_mlkit_object_detection.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../../core/services/vector_service.dart';
|
||||
import 'camera_search.dart';
|
||||
|
||||
/// Interactive crop-confirmation screen shown after a photo is captured
|
||||
/// or picked. Mirrors the Google Lens / Amazon "search by image" pattern:
|
||||
/// 1. The photo is shown with a draggable crop rectangle, pre-filled
|
||||
/// with an ML Kit object-detection guess once it's ready.
|
||||
/// 2. The user can drag the four corner handles (or the box itself) to
|
||||
/// adjust which region gets searched.
|
||||
/// 3. Tapping the confirm button crops the image, runs on-device OCR
|
||||
/// on the cropped region to pull out any product text (brand,
|
||||
/// model, etc.), sends both the image and text to the backend to
|
||||
/// get vector embeddings, then shows an animated "Finding similar
|
||||
/// products" sheet before navigating to the results screen.
|
||||
class CropConfirmScreen extends StatefulWidget {
|
||||
final String imagePath;
|
||||
|
||||
const CropConfirmScreen({super.key, required this.imagePath});
|
||||
|
||||
@override
|
||||
State<CropConfirmScreen> createState() => _CropConfirmScreenState();
|
||||
}
|
||||
|
||||
enum _Corner { topLeft, topRight, bottomLeft, bottomRight }
|
||||
|
||||
class _CropConfirmScreenState extends State<CropConfirmScreen> {
|
||||
final ObjectDetector _objectDetector = ObjectDetector(
|
||||
options: ObjectDetectorOptions(
|
||||
mode: DetectionMode.single,
|
||||
classifyObjects: false,
|
||||
multipleObjects: true,
|
||||
),
|
||||
);
|
||||
|
||||
final TextRecognizer _textRecognizer = TextRecognizer();
|
||||
|
||||
// Original decoded image dimensions (pixels).
|
||||
double? _imgWidth;
|
||||
double? _imgHeight;
|
||||
|
||||
// Crop rectangle in *original image pixel* coordinates.
|
||||
Rect? _cropRect;
|
||||
|
||||
bool _isDetecting = true;
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Updated every build so gesture callbacks can convert screen <-> image
|
||||
// coordinates using the current layout.
|
||||
double _scale = 1;
|
||||
double _offsetX = 0;
|
||||
double _offsetY = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_objectDetector.close();
|
||||
_textRecognizer.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final bytes = await File(widget.imagePath).readAsBytes();
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded == null || !mounted) return;
|
||||
|
||||
final w = decoded.width.toDouble();
|
||||
final h = decoded.height.toDouble();
|
||||
|
||||
// Fallback: centered rect covering 80% of the image, used until (or
|
||||
// unless) object detection finds something.
|
||||
const inset = 0.1;
|
||||
final fallback = Rect.fromLTRB(
|
||||
w * inset,
|
||||
h * inset,
|
||||
w * (1 - inset),
|
||||
h * (1 - inset),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_imgWidth = w;
|
||||
_imgHeight = h;
|
||||
_cropRect = fallback;
|
||||
});
|
||||
|
||||
try {
|
||||
final inputImage = InputImage.fromFilePath(widget.imagePath);
|
||||
final objects = await _objectDetector.processImage(inputImage);
|
||||
if (!mounted || objects.isEmpty) {
|
||||
setState(() => _isDetecting = false);
|
||||
return;
|
||||
}
|
||||
objects.sort((a, b) {
|
||||
final areaA = a.boundingBox.width * a.boundingBox.height;
|
||||
final areaB = b.boundingBox.width * b.boundingBox.height;
|
||||
return areaB.compareTo(areaA);
|
||||
});
|
||||
final box = objects.first.boundingBox;
|
||||
setState(() {
|
||||
_cropRect = Rect.fromLTRB(
|
||||
box.left.clamp(0, w),
|
||||
box.top.clamp(0, h),
|
||||
box.right.clamp(0, w),
|
||||
box.bottom.clamp(0, h),
|
||||
);
|
||||
_isDetecting = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isDetecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
double get _minCropSize {
|
||||
if (_imgWidth == null || _imgHeight == null) return 40;
|
||||
return math.min(_imgWidth!, _imgHeight!) * 0.12;
|
||||
}
|
||||
|
||||
void _updateCorner(_Corner corner, Offset screenDelta) {
|
||||
if (_cropRect == null) return;
|
||||
final dx = screenDelta.dx / _scale;
|
||||
final dy = screenDelta.dy / _scale;
|
||||
final w = _imgWidth!;
|
||||
final h = _imgHeight!;
|
||||
final min = _minCropSize;
|
||||
|
||||
Rect r = _cropRect!;
|
||||
switch (corner) {
|
||||
case _Corner.topLeft:
|
||||
final newLeft = (r.left + dx).clamp(0.0, r.right - min);
|
||||
final newTop = (r.top + dy).clamp(0.0, r.bottom - min);
|
||||
r = Rect.fromLTRB(newLeft, newTop, r.right, r.bottom);
|
||||
break;
|
||||
case _Corner.topRight:
|
||||
final newRight = (r.right + dx).clamp(r.left + min, w);
|
||||
final newTop = (r.top + dy).clamp(0.0, r.bottom - min);
|
||||
r = Rect.fromLTRB(r.left, newTop, newRight, r.bottom);
|
||||
break;
|
||||
case _Corner.bottomLeft:
|
||||
final newLeft = (r.left + dx).clamp(0.0, r.right - min);
|
||||
final newBottom = (r.bottom + dy).clamp(r.top + min, h);
|
||||
r = Rect.fromLTRB(newLeft, r.top, r.right, newBottom);
|
||||
break;
|
||||
case _Corner.bottomRight:
|
||||
final newRight = (r.right + dx).clamp(r.left + min, w);
|
||||
final newBottom = (r.bottom + dy).clamp(r.top + min, h);
|
||||
r = Rect.fromLTRB(r.left, r.top, newRight, newBottom);
|
||||
break;
|
||||
}
|
||||
setState(() => _cropRect = r);
|
||||
}
|
||||
|
||||
void _moveRect(Offset screenDelta) {
|
||||
if (_cropRect == null) return;
|
||||
final dx = screenDelta.dx / _scale;
|
||||
final dy = screenDelta.dy / _scale;
|
||||
final w = _imgWidth!;
|
||||
final h = _imgHeight!;
|
||||
Rect r = _cropRect!.shift(Offset(dx, dy));
|
||||
|
||||
// Clamp so the rect stays fully inside the image bounds.
|
||||
if (r.left < 0) r = r.shift(Offset(-r.left, 0));
|
||||
if (r.top < 0) r = r.shift(Offset(0, -r.top));
|
||||
if (r.right > w) r = r.shift(Offset(w - r.right, 0));
|
||||
if (r.bottom > h) r = r.shift(Offset(0, h - r.bottom));
|
||||
setState(() => _cropRect = r);
|
||||
}
|
||||
|
||||
Future<void> _confirmCrop() async {
|
||||
if (_cropRect == null || _isProcessing) return;
|
||||
setState(() => _isProcessing = true);
|
||||
|
||||
try {
|
||||
final bytes = await File(widget.imagePath).readAsBytes();
|
||||
final original = img.decodeImage(bytes);
|
||||
if (original == null) throw Exception('Could not decode image');
|
||||
|
||||
final r = _cropRect!;
|
||||
final x = r.left.toInt().clamp(0, original.width - 1);
|
||||
final y = r.top.toInt().clamp(0, original.height - 1);
|
||||
final cw = r.width.toInt().clamp(1, original.width - x);
|
||||
final ch = r.height.toInt().clamp(1, original.height - y);
|
||||
|
||||
final cropped = img.copyCrop(original, x: x, y: y, width: cw, height: ch);
|
||||
final croppedBytes = img.encodeJpg(cropped, quality: 92);
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final croppedPath =
|
||||
'${dir.path}/cropped_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
await File(croppedPath).writeAsBytes(croppedBytes);
|
||||
|
||||
// Run on-device text recognition on the cropped region only, so we
|
||||
// pick up whatever brand/model/label text is inside the user's box.
|
||||
final extractedText = await _recognizeText(croppedPath);
|
||||
|
||||
// Send the cropped image + extracted text to the backend, which
|
||||
// converts each into a vector embedding and returns both.
|
||||
VectorEmbeddingResult? embeddings;
|
||||
try {
|
||||
embeddings = await VectorService.getEmbeddings(
|
||||
imagePath: croppedPath,
|
||||
labelText: extractedText,
|
||||
);
|
||||
debugPrint(
|
||||
'Image vector (${embeddings.imageVector.length} dims): ${embeddings.imageVector}');
|
||||
debugPrint(
|
||||
'Text vector (${embeddings.textVector.length} dims): ${embeddings.textVector}');
|
||||
} catch (e) {
|
||||
debugPrint('Vector embedding failed: $e');
|
||||
// decide whether to continue to results without vectors, or bail out
|
||||
}
|
||||
|
||||
// Give the "Finding similar products" sheet a moment to be visible
|
||||
// before navigating — this is where a real search API call would
|
||||
// happen instead of a fixed delay.
|
||||
await Future.delayed(const Duration(milliseconds: 1400));
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SearchResultsScreen(
|
||||
imagePath: croppedPath,
|
||||
wasDetected: true,
|
||||
extractedText: extractedText,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isProcessing = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not crop photo: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs ML Kit text recognition on [path] and returns the recognized
|
||||
/// text (empty string if nothing was found or recognition failed).
|
||||
Future<String> _recognizeText(String path) async {
|
||||
try {
|
||||
final inputImage = InputImage.fromFilePath(path);
|
||||
final result = await _textRecognizer.processImage(inputImage);
|
||||
return result.text.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF15141B),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTopBar(),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _buildImageArea()),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isProcessing) _buildHintPill(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: _isProcessing
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
backgroundColor: Colors.white,
|
||||
onPressed: _confirmCrop,
|
||||
child: const Icon(Icons.check, color: Colors.black87),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
bottomSheet: _isProcessing ? _buildProcessingSheet() : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'Nearle',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'ai',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 2),
|
||||
Icon(Icons.auto_awesome, color: Colors.white, size: 14),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 48), // balances the back button
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHintPill() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(
|
||||
_isDetecting ? 'Detecting product…' : 'Drag corners to adjust',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageArea() {
|
||||
if (_imgWidth == null || _imgHeight == null || _cropRect == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availW = constraints.maxWidth;
|
||||
final availH = constraints.maxHeight;
|
||||
_scale = math.min(availW / _imgWidth!, availH / _imgHeight!);
|
||||
final dispW = _imgWidth! * _scale;
|
||||
final dispH = _imgHeight! * _scale;
|
||||
_offsetX = (availW - dispW) / 2;
|
||||
_offsetY = (availH - dispH) / 2;
|
||||
|
||||
final r = _cropRect!;
|
||||
final screenRect = Rect.fromLTRB(
|
||||
_offsetX + r.left * _scale,
|
||||
_offsetY + r.top * _scale,
|
||||
_offsetX + r.right * _scale,
|
||||
_offsetY + r.bottom * _scale,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: _offsetX,
|
||||
top: _offsetY,
|
||||
width: dispW,
|
||||
height: dispH,
|
||||
child: Image.file(File(widget.imagePath), fit: BoxFit.fill),
|
||||
),
|
||||
..._buildDarkMask(screenRect, availW, availH),
|
||||
Positioned.fromRect(
|
||||
rect: screenRect,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanUpdate: (d) => _moveRect(d.delta),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._buildCornerHandles(screenRect),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDarkMask(Rect r, double availW, double availH) {
|
||||
const maskColor = Colors.black54;
|
||||
return [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
height: r.top.clamp(0, availH),
|
||||
child: IgnorePointer(child: Container(color: maskColor)),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: r.bottom,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(child: Container(color: maskColor)),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: r.top,
|
||||
width: r.left.clamp(0, availW),
|
||||
height: r.height,
|
||||
child: IgnorePointer(child: Container(color: maskColor)),
|
||||
),
|
||||
Positioned(
|
||||
left: r.right,
|
||||
top: r.top,
|
||||
right: 0,
|
||||
height: r.height,
|
||||
child: IgnorePointer(child: Container(color: maskColor)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildCornerHandles(Rect r) {
|
||||
return [
|
||||
_cornerHandle(r.topLeft, _Corner.topLeft),
|
||||
_cornerHandle(r.topRight, _Corner.topRight),
|
||||
_cornerHandle(r.bottomLeft, _Corner.bottomLeft),
|
||||
_cornerHandle(r.bottomRight, _Corner.bottomRight),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _cornerHandle(Offset position, _Corner corner) {
|
||||
const hitSize = 44.0;
|
||||
return Positioned(
|
||||
left: position.dx - hitSize / 2,
|
||||
top: position.dy - hitSize / 2,
|
||||
width: hitSize,
|
||||
height: hitSize,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanUpdate: (d) => _updateCorner(corner, d.delta),
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(28, 28),
|
||||
painter: _CornerBracketPainter(corner: corner),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessingSheet() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 55,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Container(
|
||||
height: 70,
|
||||
width: 70,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 70,
|
||||
height: 70,
|
||||
child: Lottie.asset(
|
||||
'assets/lotties/ai_loader.json',
|
||||
height: 140,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text(
|
||||
"Searching Similar Products",
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Text(
|
||||
"Analyzing image, detecting text\nand finding the closest matches...",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: const LinearProgressIndicator(
|
||||
minHeight: 8,
|
||||
backgroundColor: Color(0xffECECEC),
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
Text(
|
||||
"This usually takes a few seconds",
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a white L-shaped bracket oriented for the given corner, matching
|
||||
/// the Google Lens style crop handles.
|
||||
class _CornerBracketPainter extends CustomPainter {
|
||||
final _Corner corner;
|
||||
|
||||
_CornerBracketPainter({required this.corner});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final path = Path();
|
||||
switch (corner) {
|
||||
case _Corner.topLeft:
|
||||
path.moveTo(0, size.height);
|
||||
path.lineTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
break;
|
||||
case _Corner.topRight:
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
break;
|
||||
case _Corner.bottomLeft:
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
break;
|
||||
case _Corner.bottomRight:
|
||||
path.moveTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(0, size.height);
|
||||
break;
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CornerBracketPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart' as stt;
|
||||
|
||||
// Adjust these import paths to match your project structure.
|
||||
import '../../../../constants/color_constants.dart';
|
||||
import '../../../../widgets/text_widget.dart';
|
||||
import 'ai_processing_screen.dart';
|
||||
|
||||
// TODO: replace with your actual search/results screen import + widget.
|
||||
// import '../search/search_result_screen.dart';
|
||||
|
||||
class VoiceAssistantScreen extends StatefulWidget {
|
||||
const VoiceAssistantScreen({super.key});
|
||||
|
||||
@override
|
||||
State<VoiceAssistantScreen> createState() => _VoiceAssistantScreenState();
|
||||
}
|
||||
|
||||
class _VoiceAssistantScreenState extends State<VoiceAssistantScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final stt.SpeechToText _speech = stt.SpeechToText();
|
||||
|
||||
bool _speechAvailable = false;
|
||||
bool isListening = true;
|
||||
String recognizedText = "Tap the mic and start speaking";
|
||||
|
||||
late final AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_initSpeech();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
_speech.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---------------- SPEECH SETUP ----------------
|
||||
|
||||
Future<void> _initSpeech() async {
|
||||
_speechAvailable = await _speech.initialize(
|
||||
onStatus: (status) {
|
||||
// Fires when recognition stops on its own (silence / done)
|
||||
if (status == 'notListening' || status == 'done') {
|
||||
if (isListening) {
|
||||
setState(() => isListening = false);
|
||||
_handleFinalResult();
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
setState(() => isListening = false);
|
||||
},
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (!_speechAvailable) return;
|
||||
|
||||
if (isListening) {
|
||||
await _speech.stop();
|
||||
setState(() => isListening = false);
|
||||
_handleFinalResult();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isListening = true;
|
||||
recognizedText = "Listening...";
|
||||
});
|
||||
|
||||
await _speech.listen(
|
||||
onResult: (result) {
|
||||
setState(() {
|
||||
recognizedText = result.recognizedWords;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called once listening stops (either the user tapped stop, or the
|
||||
/// recognizer auto-stopped after silence). Navigates forward with
|
||||
/// whatever was captured, as long as it isn't empty.
|
||||
void _handleFinalResult() {
|
||||
final query = recognizedText.trim();
|
||||
|
||||
final isPlaceholder = query.isEmpty ||
|
||||
query == "Listening..." ||
|
||||
query == "Tap the mic and start speaking";
|
||||
|
||||
if (isPlaceholder) return;
|
||||
|
||||
// TODO: swap this for your real navigation, e.g.:
|
||||
// Navigator.pushReplacement(
|
||||
// context,
|
||||
// MaterialPageRoute(
|
||||
// builder: (_) => SearchResultScreen(query: query),
|
||||
// ),
|
||||
// );
|
||||
//
|
||||
// If this screen was opened for a result (e.g. from a search bar),
|
||||
// you can instead just pop with the value:
|
||||
// Navigator.pop(context, query);
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AIAssistantScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.secondaryColor,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 4),
|
||||
_buildStatusChip(),
|
||||
const SizedBox(height: 26),
|
||||
_buildMicStage(),
|
||||
const SizedBox(height: 26),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: ReusableTextWidget(
|
||||
text: recognizedText,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
textAlign: TextAlign.center,
|
||||
color: ColorConstants.blackColor,
|
||||
),
|
||||
),
|
||||
|
||||
_buildPremiumMicButton(),
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- HEADER ----------------
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => Navigator.maybePop(context),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.lightGreyBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 16,
|
||||
color: ColorConstants.darkGreyColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: ReusableTextWidget(
|
||||
text: "Voice Assistant",
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.blackColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primaryColor1,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 8,
|
||||
width: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: ColorConstants.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ReusableTextWidget(
|
||||
text: isListening ? "Listening" : "Paused",
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- MIC / LOTTIE ----------------
|
||||
|
||||
Widget _buildMicStage() {
|
||||
return SizedBox(
|
||||
height: 270,
|
||||
width: 270,
|
||||
child: Center(
|
||||
child: Lottie.asset(
|
||||
"assets/lotties/ai.json",
|
||||
height: 250,
|
||||
width: 250,
|
||||
repeat: true,
|
||||
animate: isListening,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- PREMIUM MIC BUTTON ----------------
|
||||
|
||||
Widget _buildPremiumMicButton() {
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _toggleListening,
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
final ringOpacity =
|
||||
isListening ? 0.18 + (controller.value * 0.18) : 0.0;
|
||||
return Container(
|
||||
height: 92,
|
||||
width: 92,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color:
|
||||
ColorConstants.primaryColor1.withOpacity(ringOpacity),
|
||||
),
|
||||
child: Center(child: child),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 68,
|
||||
width: 68,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [ColorConstants.primaryColor, Color(0xFF8C3EAE)],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorConstants.primaryColor.withOpacity(0.4),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
isListening ? Icons.mic_rounded : Icons.mic_off_rounded,
|
||||
color: ColorConstants.secondaryColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ReusableTextWidget(
|
||||
text: !_speechAvailable
|
||||
? "Speech recognition unavailable"
|
||||
: isListening
|
||||
? "Tap to stop listening"
|
||||
: "Tap to speak",
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.lightGrey,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary placeholder so the file compiles and navigation is visible.
|
||||
/// Delete this and point `_handleFinalResult` at your real screen.
|
||||
class _PlaceholderResultScreen extends StatelessWidget {
|
||||
final String query;
|
||||
|
||||
const _PlaceholderResultScreen({required this.query});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Search Results")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'You said: "$query"\n\nReplace _PlaceholderResultScreen with your real search/results page.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
744
lib/features/orders/presentation/order_tracking.dart
Normal file
744
lib/features/orders/presentation/order_tracking.dart
Normal file
@@ -0,0 +1,744 @@
|
||||
// delivery_tracking_page.dart
|
||||
//
|
||||
// SETUP:
|
||||
// 1. pubspec.yaml -> add:
|
||||
// google_maps_flutter: ^2.9.0
|
||||
// url_launcher: ^6.3.0 (for calling the delivery partner)
|
||||
//
|
||||
// 2. Android -> android/app/src/main/AndroidManifest.xml, inside <application>:
|
||||
// <meta-data
|
||||
// android:name="com.google.android.geo.API_KEY"
|
||||
// android:value="xxx" />
|
||||
//
|
||||
// 3. iOS -> ios/Runner/AppDelegate.swift, inside application(didFinishLaunchingWithOptions):
|
||||
// GMSServices.provideAPIKey("xxx")
|
||||
//
|
||||
// Replace "xxx" above with your real Google Maps API key in BOTH places.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../widgets/text_widget.dart';
|
||||
|
||||
/// Draws a small circular badge marker at runtime (no external icon assets
|
||||
/// needed) — a colored circle with an icon inside, similar to Swiggy's
|
||||
/// bike / restaurant / home pins.
|
||||
Future<BitmapDescriptor> _drawBadgeMarker({
|
||||
required IconData icon,
|
||||
required Color background,
|
||||
Color iconColor = Colors.white,
|
||||
double size = 60,
|
||||
}) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
final paint = Paint()..color = background;
|
||||
final radius = size / 2;
|
||||
|
||||
canvas.drawCircle(Offset(radius, radius), radius, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(Offset(radius, radius), radius - 6, paint);
|
||||
|
||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||
textPainter.text = TextSpan(
|
||||
text: String.fromCharCode(icon.codePoint),
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.5,
|
||||
fontFamily: icon.fontFamily,
|
||||
package: icon.fontPackage,
|
||||
color: iconColor,
|
||||
),
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(radius - textPainter.width / 2, radius - textPainter.height / 2),
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final img = await picture.toImage(size.toInt(), size.toInt());
|
||||
final bytes = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
/// Your Google Maps API key (also enable it for "Directions API").
|
||||
const String kGoogleApiKey = "AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q";
|
||||
|
||||
/// Custom map style JSON — a clean, low-saturation grey theme.
|
||||
const String kSwiggyLikeMapStyle = '''
|
||||
[
|
||||
{"elementType": "geometry", "stylers": [{"color": "#f5f5f3"}]},
|
||||
{"elementType": "labels.icon", "stylers": [{"visibility": "off"}]},
|
||||
{"elementType": "labels.text.fill", "stylers": [{"color": "#8a8a8a"}]},
|
||||
{"elementType": "labels.text.stroke", "stylers": [{"color": "#f5f5f3"}]},
|
||||
{"featureType": "administrative", "elementType": "geometry", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "administrative.land_parcel", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "administrative.neighborhood", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "poi", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#e5e8e0"}]},
|
||||
{"featureType": "road", "elementType": "geometry", "stylers": [{"color": "#ffffff"}]},
|
||||
{"featureType": "road", "elementType": "geometry.stroke", "stylers": [{"color": "#e3e3e3"}]},
|
||||
{"featureType": "road.arterial", "elementType": "labels", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#f2c9a0"}]},
|
||||
{"featureType": "road.local", "elementType": "labels", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "transit", "stylers": [{"visibility": "off"}]},
|
||||
{"featureType": "water", "elementType": "geometry", "stylers": [{"color": "#c9d6df"}]}
|
||||
]
|
||||
''';
|
||||
|
||||
class DeliveryTrackingPage extends StatefulWidget {
|
||||
const DeliveryTrackingPage({super.key});
|
||||
|
||||
@override
|
||||
State<DeliveryTrackingPage> createState() => _DeliveryTrackingPageState();
|
||||
}
|
||||
|
||||
class _DeliveryTrackingPageState extends State<DeliveryTrackingPage> {
|
||||
GoogleMapController? _mapController;
|
||||
|
||||
String _partnerName = 'Majid Khan';
|
||||
double _partnerRating = 4.8;
|
||||
int _partnerOrders = 1240;
|
||||
|
||||
// Sample coordinates - replace with your real pickup / drop / rider coords
|
||||
final LatLng _pickup = const LatLng(28.6450, 77.3550); // restaurant
|
||||
final LatLng _drop = const LatLng(28.6395, 77.3480); // customer
|
||||
LatLng _riderPosition = const LatLng(28.6440, 77.3520);
|
||||
|
||||
final Set<Marker> _markers = {};
|
||||
final Set<Polyline> _polylines = {};
|
||||
List<LatLng> _routePoints = [];
|
||||
|
||||
Timer? _riderTimer;
|
||||
int _routeIndex = 0;
|
||||
int _etaMinutes = 2;
|
||||
|
||||
BitmapDescriptor? _pickupIcon;
|
||||
BitmapDescriptor? _dropIcon;
|
||||
BitmapDescriptor? _riderIcon;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIcons();
|
||||
}
|
||||
|
||||
Future<void> _loadIcons() async {
|
||||
_pickupIcon = await _drawBadgeMarker(
|
||||
icon: Icons.storefront,
|
||||
background: const Color(0xFFFF6A00),
|
||||
);
|
||||
_dropIcon = await _drawBadgeMarker(
|
||||
icon: Icons.home,
|
||||
background: const Color(0xFF7A1F5C),
|
||||
);
|
||||
_riderIcon = await _drawBadgeMarker(
|
||||
icon: Icons.two_wheeler,
|
||||
background: const Color(0xFF662582),
|
||||
size: 60,
|
||||
);
|
||||
_setupMarkers();
|
||||
_fetchRoute();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_riderTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setupMarkers() {
|
||||
_markers
|
||||
..clear()
|
||||
..addAll([
|
||||
Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: _pickup,
|
||||
icon: _pickupIcon ??
|
||||
BitmapDescriptor.defaultMarkerWithHue(
|
||||
BitmapDescriptor.hueOrange),
|
||||
infoWindow: const InfoWindow(title: 'Wrapperz'),
|
||||
),
|
||||
Marker(
|
||||
markerId: const MarkerId('drop'),
|
||||
position: _drop,
|
||||
icon: _dropIcon ??
|
||||
BitmapDescriptor.defaultMarkerWithHue(
|
||||
BitmapDescriptor.hueViolet),
|
||||
infoWindow: const InfoWindow(title: 'Friends and Family'),
|
||||
),
|
||||
Marker(
|
||||
markerId: const MarkerId('rider'),
|
||||
position: _riderPosition,
|
||||
icon: _riderIcon ??
|
||||
BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
|
||||
infoWindow: const InfoWindow(title: 'MAJID - delivery partner'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Fetches a real road-following route from Google Directions API and
|
||||
/// draws it as a polyline.
|
||||
Future<void> _fetchRoute() async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'https://maps.googleapis.com/maps/api/directions/json'
|
||||
'?origin=${_pickup.latitude},${_pickup.longitude}'
|
||||
'&destination=${_drop.latitude},${_drop.longitude}'
|
||||
'&mode=driving'
|
||||
'&key=$kGoogleApiKey',
|
||||
);
|
||||
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
if (data['status'] == 'OK') {
|
||||
final points = data['routes'][0]['overview_polyline']['points'];
|
||||
_routePoints = _decodePolyline(points);
|
||||
} else {
|
||||
_routePoints = _fallbackRoute();
|
||||
}
|
||||
} else {
|
||||
_routePoints = _fallbackRoute();
|
||||
}
|
||||
} catch (_) {
|
||||
_routePoints = _fallbackRoute();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_polylines
|
||||
..clear()
|
||||
..add(
|
||||
Polyline(
|
||||
polylineId: const PolylineId('route'),
|
||||
points: _routePoints,
|
||||
color: const Color(0xFF662582),
|
||||
width: 5,
|
||||
startCap: Cap.roundCap,
|
||||
endCap: Cap.roundCap,
|
||||
jointType: JointType.round,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
_startRiderAnimation();
|
||||
}
|
||||
|
||||
/// Simple zig-zag fallback so the UI still shows a route without a live key.
|
||||
List<LatLng> _fallbackRoute() {
|
||||
return [
|
||||
_pickup,
|
||||
LatLng(_pickup.latitude - 0.0025, _pickup.longitude - 0.0015),
|
||||
LatLng(_pickup.latitude - 0.0035, _pickup.longitude - 0.0060),
|
||||
LatLng(_pickup.latitude - 0.0060, _pickup.longitude - 0.0075),
|
||||
_drop,
|
||||
];
|
||||
}
|
||||
|
||||
List<LatLng> _decodePolyline(String encoded) {
|
||||
List<LatLng> points = [];
|
||||
int index = 0, len = encoded.length;
|
||||
int lat = 0, lng = 0;
|
||||
|
||||
while (index < len) {
|
||||
int b, shift = 0, result = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
int dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
int dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
lng += dlng;
|
||||
|
||||
points.add(LatLng(lat / 1E5, lng / 1E5));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/// Moves the rider marker along the route to simulate live tracking.
|
||||
void _startRiderAnimation() {
|
||||
if (_routePoints.isEmpty) return;
|
||||
_riderTimer?.cancel();
|
||||
_riderTimer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||
if (_routeIndex >= _routePoints.length - 1) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
_routeIndex++;
|
||||
_riderPosition = _routePoints[_routeIndex];
|
||||
_setupMarkers();
|
||||
setState(() {});
|
||||
_mapController?.animateCamera(CameraUpdate.newLatLng(_riderPosition));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _callPartner() async {
|
||||
final uri = Uri(scheme: 'tel', path: '+910000000000');
|
||||
if (await canLaunchUrl(uri)) await launchUrl(uri);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: SizedBox(),
|
||||
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: 'Out for delivery',
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ReusableTextWidget(
|
||||
text: '11:38 AM • 1 items',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: const Color(0xFF662582),
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
children: [
|
||||
// ---------------- MAP ----------------
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _pickup,
|
||||
zoom: 14.5,
|
||||
),
|
||||
markers: _markers,
|
||||
polylines: _polylines,
|
||||
myLocationButtonEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
buildingsEnabled: false,
|
||||
indoorViewEnabled: false,
|
||||
trafficEnabled: false,
|
||||
style: kSwiggyLikeMapStyle,
|
||||
onMapCreated: (controller) => _mapController = controller,
|
||||
),
|
||||
),
|
||||
|
||||
// -------------- TOP BAR --------------
|
||||
|
||||
|
||||
// SafeArea(
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
// child: Row(
|
||||
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
// children: [
|
||||
// _circleIconButton(Icons.arrow_back, () {
|
||||
// Navigator.maybePop(context);
|
||||
// }),
|
||||
// Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||
// children: [
|
||||
// ReusableTextWidget(
|
||||
// text: 'Out for delivery',
|
||||
// fontSize: 17,
|
||||
// fontWeight: FontWeight.w700,
|
||||
// color: Colors.grey.shade900,
|
||||
// ),
|
||||
// const SizedBox(height: 2),
|
||||
// ReusableTextWidget(
|
||||
// text: '11:38 AM • 1 items',
|
||||
// fontSize: 12,
|
||||
// fontWeight: FontWeight.w500,
|
||||
// color: Colors.grey.shade600,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// const SizedBox(width: 40),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// -------------- BOTTOM SHEET --------------
|
||||
DraggableScrollableSheet(
|
||||
initialChildSize: 0.42,
|
||||
minChildSize: 0.42,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
// Out for delivery row
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: 'Out for delivery',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ReusableTextWidget(
|
||||
text: 'MAJID is on the way to deliver your order',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF7A1F5C),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: '$_etaMinutes',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
ReusableTextWidget(
|
||||
text: 'mins',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Delivery partner info card
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.03),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
backgroundImage: const NetworkImage(
|
||||
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSmbomkQ5Aqtd848F_UVR86eY1wrq5tuNJRjmRXAGgXsqJtlL5H6EF8Efws&s=10'),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade500,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: _partnerName,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.star_rounded,
|
||||
size: 16, color: Colors.amber.shade700),
|
||||
const SizedBox(width: 4),
|
||||
ReusableTextWidget(
|
||||
text: '$_partnerRating',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ReusableTextWidget(
|
||||
text: '• $_partnerOrders orders',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _callPartner,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFFCE4EC),
|
||||
),
|
||||
child: const Icon(Icons.call,
|
||||
color: Colors.redAccent, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Action buttons row
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
Container(height: 8, color: const Color(0xFFF5F5F5)),
|
||||
|
||||
// Product / order items card
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.receipt_long, size: 20, color: Colors.grey.shade700),
|
||||
const SizedBox(width: 8),
|
||||
ReusableTextWidget(
|
||||
text: 'Order ID: # 123456789',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Items list
|
||||
_buildBillItem('Chicken Biryani', 1, 250),
|
||||
const SizedBox(height: 10),
|
||||
_buildBillItem('Mutton Biryani', 1, 350),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Price breakdown
|
||||
_buildPriceRow('Subtotal', 600),
|
||||
const SizedBox(height: 6),
|
||||
_buildPriceRow('Delivery Fee', 40),
|
||||
const SizedBox(height: 6),
|
||||
_buildPriceRow('Tax (5%)', 30),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Total
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: 'Total',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey.shade900,
|
||||
),
|
||||
ReusableTextWidget(
|
||||
text: '₹670',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey.shade900,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
ReusableTextWidget(
|
||||
text: label,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBillItem(String name, int qty, double price) {
|
||||
final total = qty * price;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ReusableTextWidget(
|
||||
text: '${qty}x $name',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
ReusableTextWidget(
|
||||
text: '₹${price.toStringAsFixed(0)}',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ReusableTextWidget(
|
||||
text: '₹${total.toStringAsFixed(0)}',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceRow(String label, double amount) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ReusableTextWidget(
|
||||
text: label,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
ReusableTextWidget(
|
||||
text: '₹${amount.toStringAsFixed(0)}',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _circleIconButton(IconData icon, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, color: Colors.black87, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user