diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f87af7d..a4f0ac2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> _CameraScreenState(); + State createState() => _CameraScreenState(); } class _CameraScreenState extends State { CameraController? controller; List? cameras; + final YoloDetectionService _detectionService = YoloDetectionService(); + final TTSService _tts = TTSService(); + bool isRecording = false; + bool isDetecting = false; + String statusMessage = "Tekan tombol rekam untuk mulai deteksi"; + List detectedObjects = const []; + Size? detectionFrameSize; + Timer? _detectionTimer; + String _lastSpokenMessage = ""; + DateTime? _lastSpokenAt; @override void initState() { @@ -19,49 +39,198 @@ class _CameraScreenState extends State { } Future initCamera() async { - cameras = await availableCameras(); - controller = CameraController( - cameras![0], - ResolutionPreset.high, - ); + try { + cameras = await availableCameras(); - await controller!.initialize(); - setState(() {}); + 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"); + } } - // 🔴 START RECORD Future startRecording() async { - if (!controller!.value.isRecordingVideo) { - await controller!.startVideoRecording(); + 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; + + if (!mounted) return; + setState(() { + isRecording = false; + isDetecting = false; + statusMessage = "Deteksi dihentikan"; + detectedObjects = const []; + detectionFrameSize = null; + }); + await _tts.speak("Deteksi dihentikan"); + } + + 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(() { - isRecording = true; + 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; + }); + } } } - // ⏹ STOP RECORD - Future stopRecording() async { - if (controller!.value.isRecordingVideo) { - await controller!.stopVideoRecording(); - setState(() { - isRecording = 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; + } + + 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 _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(); controller?.dispose(); super.dispose(); } Widget buildCameraPreview() { - final previewSize = controller?.value.previewSize; + final Size? previewSize = controller?.value.previewSize; if (previewSize == null) { return CameraPreview(controller!); } - // Kamera biasanya memberikan rasio landscape, ubah ke portrait 9:16 final double previewAspectRatio = previewSize.height / previewSize.width; return Center( @@ -88,19 +257,46 @@ class _CameraScreenState extends State { Widget build(BuildContext context) { if (controller == null || !controller!.value.isInitialized) { return Scaffold( - body: Center(child: CircularProgressIndicator()), + 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 Scaffold( body: Stack( children: [ - // 📷 PREVIEW KAMERA (FULL SCREEN) Positioned.fill( child: buildCameraPreview(), ), - - // 🔙 TOMBOL KEMBALI (KANAN BAWAH) + 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, @@ -112,12 +308,10 @@ class _CameraScreenState extends State { onPressed: () { Navigator.pop(context); }, - child: Icon(Icons.arrow_back), + child: const Icon(Icons.arrow_back), ), ), ), - - // 🔘 TOMBOL RECORD (TENGAH BAWAH) Positioned( bottom: 30, left: MediaQuery.of(context).size.width / 2 - 35, @@ -153,4 +347,243 @@ class _CameraScreenState extends State { ), ); } -} \ No newline at end of file +} + +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, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/services/api_config.dart b/lib/services/api_config.dart index 85bf63b..fc0bb8b 100644 --- a/lib/services/api_config.dart +++ b/lib/services/api_config.dart @@ -5,7 +5,7 @@ class ApiConfig { static const bool useAndroidEmulator = false; // Ganti IP ini dengan IP laptop/PC Anda saat menggunakan HP fisik (ADB). - static const String deviceHost = "192.168.0.31:8000"; + static const String deviceHost = "192.168.18.14:8000"; static String get baseUrl { if (Platform.isAndroid && useAndroidEmulator) { diff --git a/lib/services/yolo_detection_service.dart b/lib/services/yolo_detection_service.dart new file mode 100644 index 0000000..39c9137 --- /dev/null +++ b/lib/services/yolo_detection_service.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'api_config.dart'; + +class YoloDetection { + const YoloDetection({ + required this.name, + required this.confidence, + required this.xmin, + required this.ymin, + required this.xmax, + required this.ymax, + }); + + final String name; + final double confidence; + final int xmin; + final int ymin; + final int xmax; + final int ymax; + + factory YoloDetection.fromJson(Map json) { + return YoloDetection( + name: json["name"]?.toString() ?? "unknown", + confidence: (json["confidence"] as num?)?.toDouble() ?? 0, + xmin: (json["xmin"] as num?)?.toInt() ?? 0, + ymin: (json["ymin"] as num?)?.toInt() ?? 0, + xmax: (json["xmax"] as num?)?.toInt() ?? 0, + ymax: (json["ymax"] as num?)?.toInt() ?? 0, + ); + } +} + +class YoloDetectionResponse { + const YoloDetectionResponse({ + required this.status, + required this.message, + required this.objects, + }); + + final String status; + final String message; + final List objects; + + bool get isSuccess => status == "success"; + + factory YoloDetectionResponse.fromJson(Map json) { + final Object? rawObjects = json["objects"]; + final List objects = rawObjects is List + ? rawObjects + .whereType() + .map((object) => YoloDetection.fromJson( + Map.from(object), + )) + .toList() + : const []; + + return YoloDetectionResponse( + status: json["status"]?.toString() ?? "error", + message: json["message"]?.toString() ?? "Respons server tidak valid", + objects: objects, + ); + } + + factory YoloDetectionResponse.error(String message) { + return YoloDetectionResponse( + status: "error", + message: message, + objects: const [], + ); + } +} + +class YoloDetectionService { + static const Duration _timeout = Duration(seconds: 30); + + Future detectFrame(File imageFile) async { + final Uri url = Uri.parse("${ApiConfig.baseUrl}/detect-frame"); + + try { + final http.MultipartRequest request = http.MultipartRequest("POST", url); + request.files.add( + await http.MultipartFile.fromPath("file", imageFile.path), + ); + + final http.StreamedResponse streamedResponse = + await request.send().timeout(_timeout); + final http.Response response = + await http.Response.fromStream(streamedResponse); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return YoloDetectionResponse.error( + "Server mengembalikan status ${response.statusCode}", + ); + } + + final Object? decoded = jsonDecode(response.body); + if (decoded is! Map) { + return YoloDetectionResponse.error("Format respons server tidak valid"); + } + + return YoloDetectionResponse.fromJson(decoded); + } on SocketException { + return YoloDetectionResponse.error( + "Gagal terhubung ke server. Pastikan API YOLOv5 sudah berjalan.", + ); + } on TimeoutException { + return YoloDetectionResponse.error("Koneksi ke YOLOv5 terlalu lama"); + } on FormatException { + return YoloDetectionResponse.error("Respons server bukan JSON valid"); + } on http.ClientException { + return YoloDetectionResponse.error("Koneksi ke server terputus"); + } catch (error) { + return YoloDetectionResponse.error("Gagal deteksi objek: $error"); + } + } +}