Update project
This commit is contained in:
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