import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/tts_service.dart'; import '../services/vibration_service.dart'; import '../services/yolo_detection_service.dart'; import 'login_screen.dart'; class CameraScreen extends StatefulWidget { const CameraScreen({super.key}); @override State createState() => _CameraScreenState(); } class _CameraScreenState extends State { CameraController? controller; List? cameras; final YoloDetectionService _detectionService = YoloDetectionService(); final TTSService _tts = TTSService(); final VibrationService _vibrationService = VibrationService(); bool isRecording = false; bool isDetecting = false; String statusMessage = "Tekan tombol rekam untuk mulai deteksi"; List detectedObjects = const []; Size? detectionFrameSize; Timer? _detectionTimer; Timer? _vibrationTimer; bool _isLoggingOut = false; String _lastSpokenMessage = ""; DateTime? _lastSpokenAt; @override void initState() { super.initState(); initCamera(); } Future initCamera() async { try { cameras = await availableCameras(); if (cameras == null || cameras!.isEmpty) { setState(() { statusMessage = "Kamera tidak ditemukan"; }); await _tts.speak(statusMessage); return; } controller = CameraController( cameras![0], ResolutionPreset.high, enableAudio: false, ); await controller!.initialize(); if (!mounted) return; setState(() {}); } catch (error) { if (!mounted) return; setState(() { statusMessage = "Gagal membuka kamera: $error"; }); await _tts.speak("Gagal membuka kamera"); } } Future startRecording() async { final CameraController? cameraController = controller; if (cameraController == null || !cameraController.value.isInitialized || isRecording) { return; } setState(() { isRecording = true; statusMessage = "Deteksi dimulai"; detectedObjects = const []; detectionFrameSize = null; }); await _tts.speak("Deteksi dimulai"); await _detectCurrentFrame(); _detectionTimer = Timer.periodic(const Duration(seconds: 3), (_) { if (mounted && isRecording && !isDetecting) { _detectCurrentFrame(); } }); } Future stopRecording() async { _detectionTimer?.cancel(); _detectionTimer = null; _vibrationTimer?.cancel(); _vibrationTimer = null; await _vibrationService.stop(); if (!mounted) return; setState(() { isRecording = false; isDetecting = false; statusMessage = "Deteksi dihentikan"; detectedObjects = const []; detectionFrameSize = null; }); await _tts.speak("Deteksi dihentikan"); } Future logout() async { if (_isLoggingOut) { return; } _isLoggingOut = true; if (isRecording || isDetecting || _detectionTimer != null || _vibrationTimer != null) { await stopRecording(); } else { _detectionTimer?.cancel(); _detectionTimer = null; _vibrationTimer?.cancel(); _vibrationTimer = null; await _vibrationService.stop(); } final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.remove("auth_token"); if (!mounted) { return; } Navigator.pushAndRemoveUntil( context, MaterialPageRoute( builder: (_) => const LoginScreen(), ), (route) => false, ); } Future _detectCurrentFrame() async { final CameraController? cameraController = controller; if (cameraController == null || !cameraController.value.isInitialized || cameraController.value.isTakingPicture || isDetecting) { return; } setState(() { isDetecting = true; statusMessage = "Mengambil frame kamera..."; }); File? capturedFile; try { final XFile frame = await cameraController.takePicture(); capturedFile = File(frame.path); final Size capturedImageSize = await _readImageSize(capturedFile); if (!mounted || !isRecording) return; setState(() { statusMessage = "Mengirim frame ke YOLOv5..."; }); final YoloDetectionResponse response = await _detectionService.detectFrame(capturedFile); if (!mounted || !isRecording) return; setState(() { detectedObjects = response.objects; detectionFrameSize = capturedImageSize; statusMessage = response.message; }); await _announceDetection(response); } catch (error) { if (!mounted || !isRecording) return; const String message = "Gagal memproses frame kamera"; setState(() { statusMessage = "$message: $error"; }); await _speakOnce(message); } finally { if (capturedFile != null && await capturedFile.exists()) { try { await capturedFile.delete(); } catch (_) { // File sementara bisa saja sudah dibersihkan oleh sistem. } } if (mounted) { setState(() { isDetecting = false; }); } } } Future _readImageSize(File imageFile) async { final Uint8List bytes = await imageFile.readAsBytes(); final ui.Codec codec = await ui.instantiateImageCodec(bytes); final ui.FrameInfo frameInfo = await codec.getNextFrame(); return Size( frameInfo.image.width.toDouble(), frameInfo.image.height.toDouble(), ); } Future _announceDetection(YoloDetectionResponse response) async { if (!response.isSuccess) { await _speakOnce(response.message); return; } if (response.objects.isEmpty) { await _speakOnce("Tidak ada objek terdeteksi"); return; } await _vibrateForSevenSeconds(); final Map objectCounts = {}; for (final YoloDetection object in response.objects) { objectCounts[object.name] = (objectCounts[object.name] ?? 0) + 1; } final String objectSummary = objectCounts.entries .map( (entry) => entry.value > 1 ? "${entry.value} ${entry.key}" : entry.key, ) .join(", "); await _speakOnce("Terdeteksi $objectSummary"); } Future _vibrateForSevenSeconds() async { _vibrationTimer?.cancel(); const Duration vibrationDuration = Duration(seconds: 7); await _vibrationService.vibrateFor(vibrationDuration); _vibrationTimer = Timer(vibrationDuration, () { _vibrationTimer = null; }); } Future _speakOnce(String message) async { final DateTime now = DateTime.now(); final bool canRepeat = _lastSpokenAt == null || now.difference(_lastSpokenAt!) > const Duration(seconds: 5); if (message == _lastSpokenMessage && !canRepeat) { return; } _lastSpokenMessage = message; _lastSpokenAt = now; await _tts.speak(message); } @override void dispose() { _detectionTimer?.cancel(); _vibrationTimer?.cancel(); unawaited(_vibrationService.stop()); controller?.dispose(); super.dispose(); } Widget buildCameraPreview() { final Size? previewSize = controller?.value.previewSize; if (previewSize == null) { return CameraPreview(controller!); } final double previewAspectRatio = previewSize.height / previewSize.width; return Center( child: AspectRatio( aspectRatio: 9 / 16, child: ClipRect( child: OverflowBox( alignment: Alignment.center, child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: previewSize.width, height: previewSize.width / previewAspectRatio, child: CameraPreview(controller!), ), ), ), ), ), ); } @override Widget build(BuildContext context) { if (controller == null || !controller!.value.isInitialized) { return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (!didPop) { await logout(); } }, child: Scaffold( body: Center( child: statusMessage == "Tekan tombol rekam untuk mulai deteksi" ? const CircularProgressIndicator() : Padding( padding: const EdgeInsets.all(24), child: Text( statusMessage, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16), ), ), ), ), ); } return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (!didPop) { await logout(); } }, child: Scaffold( body: Stack( children: [ Positioned.fill( child: buildCameraPreview(), ), Positioned.fill( child: IgnorePointer( child: _BoundingBoxOverlay( imageSize: detectionFrameSize, objects: detectedObjects, ), ), ), Positioned( top: 48, left: 16, right: 16, child: _DetectionStatusPanel( isRecording: isRecording, isDetecting: isDetecting, statusMessage: statusMessage, objects: detectedObjects, ), ), Positioned( bottom: 40, right: 30, child: Semantics( label: "Kembali", button: true, child: FloatingActionButton( backgroundColor: Colors.black, onPressed: logout, child: const Icon(Icons.arrow_back), ), ), ), Positioned( bottom: 30, left: MediaQuery.of(context).size.width / 2 - 35, child: Semantics( label: isRecording ? "Stop rekam" : "Mulai rekam", button: true, child: GestureDetector( onTap: () { if (isRecording) { stopRecording(); } else { startRecording(); } }, child: Container( width: 70, height: 70, decoration: BoxDecoration( shape: BoxShape.circle, color: isRecording ? Colors.red : Colors.white, border: Border.all(color: Colors.grey, width: 4), ), child: Icon( isRecording ? Icons.stop : Icons.circle, color: isRecording ? Colors.white : Colors.red, size: 30, ), ), ), ), ), ], ), ), ); } } class _BoundingBoxOverlay extends StatelessWidget { const _BoundingBoxOverlay({ required this.imageSize, required this.objects, }); final Size? imageSize; final List objects; @override Widget build(BuildContext context) { if (imageSize == null || objects.isEmpty) { return const SizedBox.shrink(); } return CustomPaint( painter: _BoundingBoxPainter( imageSize: imageSize!, objects: objects, ), ); } } class _BoundingBoxPainter extends CustomPainter { _BoundingBoxPainter({ required this.imageSize, required this.objects, }); final Size imageSize; final List objects; static const double _previewAspectRatio = 9 / 16; static const List _boxColors = [ Color(0xFF00E676), Color(0xFFFFD600), Color(0xFF40C4FF), Color(0xFFFF4081), Color(0xFFFF9100), ]; @override void paint(Canvas canvas, Size size) { if (imageSize.width <= 0 || imageSize.height <= 0) { return; } final Rect previewRect = _centeredAspectRect(size, _previewAspectRatio); final double scale = _coverScale(imageSize, previewRect.size); final Size scaledImageSize = Size( imageSize.width * scale, imageSize.height * scale, ); final Offset imageOffset = Offset( previewRect.left + (previewRect.width - scaledImageSize.width) / 2, previewRect.top + (previewRect.height - scaledImageSize.height) / 2, ); canvas.save(); canvas.clipRect(previewRect); for (int index = 0; index < objects.length; index++) { final YoloDetection object = objects[index]; final Color color = _boxColors[index % _boxColors.length]; final Rect box = Rect.fromLTRB( imageOffset.dx + object.xmin * scale, imageOffset.dy + object.ymin * scale, imageOffset.dx + object.xmax * scale, imageOffset.dy + object.ymax * scale, ); if (box.width <= 0 || box.height <= 0) { continue; } _drawBox(canvas, previewRect, box, color, object); } canvas.restore(); } void _drawBox( Canvas canvas, Rect previewRect, Rect box, Color color, YoloDetection object, ) { final Paint shadowPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 5 ..color = Colors.black.withOpacity(0.55); final Paint boxPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..color = color; canvas.drawRect(box, shadowPaint); canvas.drawRect(box, boxPaint); final String label = "${object.name} ${(object.confidence * 100).round()}%"; final TextPainter textPainter = TextPainter( text: TextSpan( text: label, style: const TextStyle( color: Colors.black, fontSize: 13, fontWeight: FontWeight.w700, ), ), maxLines: 1, textDirection: TextDirection.ltr, )..layout(maxWidth: previewRect.width - 16); final double labelWidth = textPainter.width + 12; final double labelHeight = textPainter.height + 8; double labelLeft = box.left; double labelTop = box.top - labelHeight; if (labelTop < previewRect.top) { labelTop = box.top; } if (labelLeft + labelWidth > previewRect.right) { labelLeft = previewRect.right - labelWidth; } labelLeft = labelLeft.clamp(previewRect.left, previewRect.right - labelWidth).toDouble(); final RRect labelBackground = RRect.fromRectAndRadius( Rect.fromLTWH(labelLeft, labelTop, labelWidth, labelHeight), const Radius.circular(6), ); canvas.drawRRect( labelBackground, Paint()..color = color.withOpacity(0.92), ); textPainter.paint( canvas, Offset(labelLeft + 6, labelTop + 4), ); } Rect _centeredAspectRect(Size containerSize, double aspectRatio) { final double containerRatio = containerSize.width / containerSize.height; if (containerRatio > aspectRatio) { final double width = containerSize.height * aspectRatio; final double left = (containerSize.width - width) / 2; return Rect.fromLTWH(left, 0, width, containerSize.height); } final double height = containerSize.width / aspectRatio; final double top = (containerSize.height - height) / 2; return Rect.fromLTWH(0, top, containerSize.width, height); } double _coverScale(Size source, Size destination) { final double scaleX = destination.width / source.width; final double scaleY = destination.height / source.height; return scaleX > scaleY ? scaleX : scaleY; } @override bool shouldRepaint(covariant _BoundingBoxPainter oldDelegate) { return oldDelegate.imageSize != imageSize || oldDelegate.objects != objects; } } class _DetectionStatusPanel extends StatelessWidget { const _DetectionStatusPanel({ required this.isRecording, required this.isDetecting, required this.statusMessage, required this.objects, }); final bool isRecording; final bool isDetecting; final String statusMessage; final List objects; @override Widget build(BuildContext context) { final List visibleObjects = objects.take(4).toList(); return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.black.withOpacity(0.72), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( isRecording ? Icons.radio_button_checked : Icons.videocam, color: isRecording ? Colors.redAccent : Colors.white, size: 20, ), const SizedBox(width: 8), Expanded( child: Text( isDetecting ? "YOLOv5 sedang berjalan..." : statusMessage, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ], ), if (visibleObjects.isNotEmpty) ...[ const SizedBox(height: 10), for (final YoloDetection object in visibleObjects) Padding( padding: const EdgeInsets.only(top: 4), child: Text( "${object.name} - ${(object.confidence * 100).round()}%", style: const TextStyle( color: Colors.white, fontSize: 13, ), ), ), ], ], ), ); } }