Update project
This commit is contained in:
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user