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

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),
),
),
),
);
}
}