Update project

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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