import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:screenshot/screenshot.dart'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_mjpeg/flutter_mjpeg.dart'; import 'package:path_provider/path_provider.dart'; import 'package:image/image.dart' as img; import 'yolo_detector.dart'; import 'preview_screen.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; enum ImageFilterType { none, greyscale, invert} class CameraStreamScreen extends StatefulWidget { final int cameraIndex; const CameraStreamScreen({super.key, required this.cameraIndex}); @override State createState() => _CameraStreamScreenState(); } class _CameraStreamScreenState extends State with SingleTickerProviderStateMixin { String? _streamUrl; bool _isError = false; bool _isCapturing = false; final ScreenshotController _screenshotController = ScreenshotController(); final TransformationController _transformationController = TransformationController(); ImageFilterType _selectedFilter = ImageFilterType.none; late YOLODetector _yoloDetector; int _numberOfBullets = 5; late AnimationController _zoomAnimController; Animation? _zoomAnimation; Matrix4? _originalMatrix; Color _bulletColor = Colors.blue; // default/fallback bullet color @override void initState() { super.initState(); _loadStreamUrl(); _loadBulletCount(); _loadSelectedFilter(); _loadBulletColor(); // 👈 add this to actually load the saved bullet color YOLODetector.create().then((detector) => _yoloDetector = detector); _setSystemBarStyle(); // 👈 set colors SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // 👈 show bars _keepScreenAwake(); _zoomAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); } Future _loadBulletColor() async { final prefs = await SharedPreferences.getInstance(); final selected = prefs.getString('bulletColor') ?? 'Blue'; setState(() { _bulletColor = _getColorFromName(selected); }); } Color _getColorFromName(String colorName) { switch (colorName.toLowerCase()) { case 'blue': return Colors.blue; case 'green': return Colors.green; case 'yellow': return Colors.yellow; case 'purple': return Colors.purple; case 'cyan': return Colors.cyan; case 'teal': return Colors.teal; case 'indigo': return Colors.indigo; case 'pink': return Colors.pink; case 'lime': return Colors.lime; case 'mint': return Colors.greenAccent; case 'sky': return Colors.orange; case 'violet': return Colors.deepPurpleAccent; case 'amber': return Colors.amber; case 'brown': return Colors.brown; case 'grey': return Colors.grey; default: return Colors.blue; // fallback } } void _setSystemBarStyle() { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarColor: Colors.grey[900], // ✅ match your AppBar statusBarIconBrightness: Brightness.light, // white icons systemNavigationBarColor: Colors.grey[900], systemNavigationBarIconBrightness: Brightness.light, ), ); } Future _loadSelectedFilter() async { final prefs = await SharedPreferences.getInstance(); final index = prefs.getInt('selectedFilter'); if (index != null && index >= 0 && index < ImageFilterType.values.length) { setState(() { _selectedFilter = ImageFilterType.values[index]; }); } } Future _keepScreenAwake() async { try { await WakelockPlus.enable(); } catch (e) { debugPrint("⚠️ Could not keep screen awake: $e"); } } Future _loadStreamUrl() async { final prefs = await SharedPreferences.getInstance(); String? url = prefs.getString('cameraIP_${widget.cameraIndex}'); setState(() { _streamUrl = (url != null && url.trim().isNotEmpty) ? (url.startsWith(RegExp(r"https?://")) ? url : "http://$url") : null; _isError = _streamUrl == null; }); } Future _loadBulletCount() async { final prefs = await SharedPreferences.getInstance(); setState(() { _numberOfBullets = prefs.getInt('bulletCount') ?? 5; }); } Future _captureAndPreviewImage() async { if (_streamUrl == null || _isCapturing) return; setState(() => _isCapturing = true); _originalMatrix = _transformationController.value; try { final targetMatrix = Matrix4.identity()..scale(1.0); final isZoomedIn = !_matrixEquals(_originalMatrix!, targetMatrix); if (isZoomedIn) { _zoomAnimController.reset(); _zoomAnimation = Matrix4Tween( begin: _originalMatrix, end: targetMatrix, ).animate(CurvedAnimation( parent: _zoomAnimController, curve: Curves.easeOut, )); void updateZoom() { _transformationController.value = _zoomAnimation!.value; } _zoomAnimController.addListener(updateZoom); await _zoomAnimController.forward(); _zoomAnimController.removeListener(updateZoom); } await Future.delayed(const Duration(milliseconds: 100)); final imageBytes = await _screenshotController.capture(); if (imageBytes == null || imageBytes.isEmpty) return; final decoded = img.decodeImage(imageBytes); if (decoded == null) return; final width = decoded.width; final height = decoded.height; final size = width < height ? width : height; final offsetX = (width - size) ~/ 2; final offsetY = (height - size) ~/ 2; final cropped = img.copyCrop(decoded, x: offsetX, y: offsetY, width: size, height: size); // Debugging: log the selected filter debugPrint("📸 Selected filter: $_selectedFilter"); // Create the output directory and path final dir = await getApplicationDocumentsDirectory(); final path = '${dir.path}/ip_capture_temp.jpg'; img.Image processed = cropped; // Apply the selected filter switch (_selectedFilter) { case ImageFilterType.greyscale: processed = img.grayscale(cropped); break; case ImageFilterType.invert: processed = invertImage(cropped); // ✅ use your custom invert function break; case ImageFilterType.none: break; } // Save the processed image to a file final file = File(path)..writeAsBytesSync(img.encodeJpg(processed)); // Detect objects in the image using YOLO final detections = await _yoloDetector.detect(file); if (!mounted) return; // Navigate to the preview screen Navigator.push( context, MaterialPageRoute( builder: (_) => PreviewScreen( imagePath: file.path, detections: detections, bulletCount: _numberOfBullets, ), ), ); } catch (e) { debugPrint("❌ Capture error: $e"); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Capture failed: $e")), ); } } finally { _zoomAnimController.reset(); _zoomAnimation = Matrix4Tween( begin: _transformationController.value, end: _originalMatrix!, ).animate(CurvedAnimation( parent: _zoomAnimController, curve: Curves.easeIn, )); void restoreZoom() { _transformationController.value = _zoomAnimation!.value; } _zoomAnimController.addListener(restoreZoom); await _zoomAnimController.forward(); _zoomAnimController.removeListener(restoreZoom); setState(() => _isCapturing = false); } } img.Image invertImage(img.Image source) { final result = img.Image.from(source); for (int y = 0; y < result.height; y++) { for (int x = 0; x < result.width; x++) { final pixel = result.getPixel(x, y); result.setPixelRgb(x, y, 255 - pixel.r, 255 - pixel.g, 255 - pixel.b); } } return result; } bool _matrixEquals(Matrix4 a, Matrix4 b) { for (int i = 0; i < 16; i++) { if ((a.storage[i] - b.storage[i]).abs() > 0.001) return false; } return true; } void _showBulletDialog() { showDialog( context: context, builder: (BuildContext context) { return Dialog( backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24.0), // Increased padding for better spacing child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.only(bottom: 16.0), child: Text( 'Select the number of bullets', style: TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ), Center( child: Wrap( alignment: WrapAlignment.center, spacing: 12, runSpacing: 12, children: List.generate(5, (index) { final bulletNum = index + 1; final isSelected = _numberOfBullets == bulletNum; return ElevatedButton( onPressed: () { _selectBulletCount(bulletNum); Navigator.of(context).pop(); }, style: ElevatedButton.styleFrom( backgroundColor: isSelected ? _bulletColor : Colors.grey[800], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( '$bulletNum', style: TextStyle( fontSize: 18, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ); }), ), ), ], ), ), ); }, ); } void _showFilterDialog() { showDialog( context: context, builder: (BuildContext context) { return Dialog( backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Choose a Filter', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 12, alignment: WrapAlignment.center, children: ImageFilterType.values.map((f) { final isSelected = _selectedFilter == f; final label = f.toString().split('.').last; return ElevatedButton( onPressed: () async { setState(() { _selectedFilter = f; }); final prefs = await SharedPreferences.getInstance(); await prefs.setInt('selectedFilter', f.index); // Save filter index Navigator.of(context).pop(); }, style: ElevatedButton.styleFrom( backgroundColor: isSelected ? _bulletColor : Colors.grey[800], foregroundColor: Colors.white, ), child: Text(label[0].toUpperCase() + label.substring(1)), ); }).toList(), ), ], ), ), ); }, ); } void _selectBulletCount(int number) async { final prefs = await SharedPreferences.getInstance(); setState(() { _numberOfBullets = number; }); await prefs.setInt('bulletCount', number); } @override void dispose() { _zoomAnimController.dispose(); _yoloDetector.close(); _restoreScreenSettings(); _setSystemBarStyle(); // restore grey[900] on back super.dispose(); } Future _restoreScreenSettings() async { try { await WakelockPlus.disable(); } catch (e) { debugPrint("⚠️ Could not restore screen settings: $e"); } } ColorFilter _buildColorFilter() { switch (_selectedFilter) { case ImageFilterType.greyscale: return const ColorFilter.matrix([ 0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, 0, 0, 0, 1, 0, ]); case ImageFilterType.invert: return const ColorFilter.matrix([ -1, 0, 0, 0, 255, 0, -1, 0, 0, 255, 0, 0, -1, 0, 255, 0, 0, 0, 1, 0, ]); default: return const ColorFilter.mode(Colors.transparent, BlendMode.dst); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Target ${widget.cameraIndex}"), backgroundColor: Colors.grey[900], foregroundColor: Colors.white, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( icon: Text( '$_numberOfBullets', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), onPressed: _showBulletDialog, ), IconButton( icon: const Icon(Icons.filter_alt, color: Colors.white), onPressed: _showFilterDialog, ), const SizedBox(width: 16), ], ), backgroundColor: Colors.black, body: Stack( children: [ Screenshot( controller: _screenshotController, child: LayoutBuilder( builder: (context, constraints) { return Center( child: InteractiveViewer( clipBehavior: Clip.hardEdge, constrained: false, boundaryMargin: EdgeInsets.zero, minScale: 1.0, maxScale: 5.0, transformationController: _transformationController, child: SizedBox( width: constraints.maxWidth, height: constraints.maxHeight, child: (_isError || _streamUrl == null) ? _buildErrorContent() : RepaintBoundary( child: ColorFiltered( colorFilter: _buildColorFilter(), child: Mjpeg( stream: _streamUrl!, isLive: true, fit: BoxFit.contain, error: (context, error, stackTrace) => _buildErrorContent(), ), ), ), ), ), ); }, ), ), if (_isCapturing) Positioned.fill( child: Container( color: Colors.black.withOpacity(0.4), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text( "Processing image...", style: TextStyle(color: Colors.white, fontSize: 16), ), ], ), ), ), ], ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: _isError ? null : FloatingActionButton( backgroundColor: Colors.white, onPressed: _captureAndPreviewImage, child: const Icon(Icons.camera_alt, color: Colors.black), ), bottomNavigationBar: BottomAppBar( color: Colors.grey[900], shape: const CircularNotchedRectangle(), notchMargin: 8.0, child: SizedBox( height: 60, child: Stack( alignment: Alignment.center, children: [ Align( alignment: Alignment.center, ), ], ), ), ), ); } Widget _buildErrorContent() { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error, color: Colors.red, size: 100), const SizedBox(height: 20), const Text( "Unable to load camera stream", style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), ElevatedButton( onPressed: _loadStreamUrl, child: const Text("Retry"), ), ], ); } }