diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fbcd549..eabaa09 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ prosesScan({ + required String host, + required String base64File, + }) async { + final service = "http://${host}/one-api/scan-ktp/Scanktp/proses_scan"; + final resp = await post(param:{ + "base64File":base64File + }, service: service); + + if(resp['status'] == "OK"){ + return "Sukses Upload File"; + }else{ + resp['message']; + } + + return resp['message']; + } } diff --git a/lib/screen/scan/scan_screen.dart b/lib/screen/scan/scan_screen.dart index 7aaa5c0..6382654 100644 --- a/lib/screen/scan/scan_screen.dart +++ b/lib/screen/scan/scan_screen.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:camera/camera.dart'; @@ -8,6 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as img; import '../../app/constant.dart'; +import '../../provider/current_user_provider.dart'; +import '../../widget/customsnackbarwidget.dart'; +import 'upload_scan_provider.dart'; class ScanScreen extends HookConsumerWidget { const ScanScreen({super.key}); @@ -23,7 +27,9 @@ class ScanScreen extends HookConsumerWidget { final capturedImage = useState(null); final croppedImage = useState(null); final isLoading = useState(false); - final isModalOpen = useState(false); + final isLoadingUpload = useState(false); + final currentUser = ref.watch(currentUserProvider); + final host = currentUser?.host ?? ""; useEffect(() { Future initializeCamera() async { @@ -86,122 +92,6 @@ class ScanScreen extends HookConsumerWidget { return rotatedFile; } - void showResultModal(BuildContext context) { - isModalOpen.value = true; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - enableDrag: false, - backgroundColor: Colors.white, - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header dengan tombol close - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Hasil Foto", - style: Constant.cardText(context: context).copyWith( - fontWeight: FontWeight.bold, - fontSize: 18, - color: Constant.textBlack, - ), - ), - IconButton( - icon: const Icon(Icons.close, color: Colors.black), - onPressed: () { - isModalOpen.value = false; - Navigator.of(context).pop(); - }, - ), - ], - ), - const SizedBox(height: 16), - - // Gambar hasil foto - if (croppedImage.value != null) - Container( - width: double.infinity, - height: 300, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - croppedImage.value!, - fit: BoxFit.contain, - ), - ), - ), - - const SizedBox(height: 16), - - // Tombol Hapus dan Upload - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Tombol Hapus - ElevatedButton.icon( - onPressed: () { - capturedImage.value = null; - croppedImage.value = null; - isModalOpen.value = false; - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.delete, - size: 17, color: Colors.white), - label: Text( - 'Hapus', - style: Constant.cardText(context: context).copyWith( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Constant.textRed, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 8, - ), - ), - - // Tombol Upload - ElevatedButton.icon( - onPressed: () { - print('upload baru'); - }, - icon: const Icon(Icons.upload, - size: 17, color: Colors.white), - label: Text( - 'Upload', - style: Constant.cardText(context: context).copyWith( - color: Colors.white, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 8, - ), - ), - ], - ), - ], - ), - ); - }, - ).whenComplete(() => isModalOpen.value = false); - } - Future captureAndCropImage() async { try { isLoading.value = true; @@ -218,7 +108,13 @@ class ScanScreen extends HookConsumerWidget { capturedImage.value = image; croppedImage.value = rotatedImage; - showResultModal(context); + // post ke BE + Uint8List bytes = await croppedImage.value!.readAsBytes(); + String base64String = base64Encode(bytes); + ref.read(uploadScanProvider.notifier).uploadScan( + host: host, + base64File: base64String, + ); } catch (e) { print("Error capturing image: $e"); } finally { @@ -226,7 +122,66 @@ class ScanScreen extends HookConsumerWidget { } } + // proses upload + ref.listen(uploadScanProvider, (prev, next) { + if (next is UploadScanStateLoading) { + isLoadingUpload.value = true; + } else if (next is UploadScanStateError) { + isLoadingUpload.value = false; + // errorMessage.value = next.message; + debugPrint(next.message); + snackbarWidget( + context, + next.message, + snackbarType.error, + Duration(seconds: 3), + ); + } else if (next is UploadScanStateDone) { + isLoadingUpload.value = false; + snackbarWidget( + context, + next.pesan, + snackbarType.success, + Duration(seconds: 3), + ); + } + }); + + // loading proses upload useEffect + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isLoadingUpload.value == true) { + snackbarWidget( + context, + 'Sedang Upload Foto...', + snackbarType.warning, + Duration(seconds: 3), + ); + } + }); + return () {}; + }, [isLoadingUpload.value]); + + // loading proses image useEffect + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isLoading.value == true) { + snackbarWidget( + context, + 'Sedang Proses Gambar...', + snackbarType.warning, + Duration(seconds: 3), + ); + } + }); + return () {}; + }, [isLoading.value]); + return Scaffold( + appBar: AppBar( + title: Text('Posisi Foto Landscape'), + automaticallyImplyLeading: false, + ), backgroundColor: Colors.black.withOpacity(0.5), body: Stack( children: [ @@ -269,6 +224,8 @@ class ScanScreen extends HookConsumerWidget { children: [ ElevatedButton.icon( onPressed: () { + capturedImage.value = null; + croppedImage.value = null; Navigator.of(context).pop(); }, icon: Icon( diff --git a/lib/screen/scan/scan_screen2.txt b/lib/screen/scan/scan_screen2.txt new file mode 100644 index 0000000..4fd05a3 --- /dev/null +++ b/lib/screen/scan/scan_screen2.txt @@ -0,0 +1,386 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as img; + +import '../../app/constant.dart'; +import '../../provider/current_user_provider.dart'; +import '../../widget/customsnackbarwidget.dart'; +import 'upload_scan_provider.dart'; + +class ScanScreen extends HookConsumerWidget { + const ScanScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [ + SystemUiOverlay.bottom, + ]); + + final cameraController = useState(null); + final initializeControllerFuture = useState?>(null); + final capturedImage = useState(null); + final croppedImage = useState(null); + final isLoading = useState(false); + final isModalOpen = useState(false); + final currentUser = ref.watch(currentUserProvider); + final host = currentUser?.host ?? ""; + + useEffect(() { + Future initializeCamera() async { + final cameras = await availableCameras(); + cameraController.value = + CameraController(cameras[0], ResolutionPreset.max); + initializeControllerFuture.value = cameraController.value!.initialize(); + await initializeControllerFuture.value; + await cameraController.value!.setFlashMode(FlashMode.off); + } + + initializeCamera(); + return null; + }, []); + + Future rotateAndCropImage(File imageFile) async { + Uint8List imageBytes = await imageFile.readAsBytes(); + img.Image? original = img.decodeImage(imageBytes); + if (original == null) return imageFile; + + img.Image rotated = img.bakeOrientation(original); + + int width = rotated.width; + int height = rotated.height; + bool isPortrait = height > width; + + int cropWidth, cropHeight; + + if (isPortrait) { + cropHeight = (height * 0.7).toInt(); + cropWidth = (cropHeight ~/ 1.59).toInt(); + } else { + cropWidth = (width * 0.7).toInt(); + cropHeight = (cropWidth ~/ 1.59).toInt(); + } + + int left = ((width - cropWidth) ~/ 2).toInt(); + int top = ((height - cropHeight) ~/ 2).toInt(); + + img.Image cropped = img.copyCrop(rotated, + x: left, y: top, width: cropWidth, height: cropHeight); + + File croppedFile = File('${imageFile.path}_cropped.jpg'); + await croppedFile.writeAsBytes(img.encodeJpg(cropped)); + + return croppedFile; + } + + Future rotateImage(File imageFile) async { + Uint8List bytes = await imageFile.readAsBytes(); + img.Image? image = img.decodeImage(bytes); + + if (image == null) return imageFile; + + img.Image rotated = img.copyRotate(image, angle: -90); + + File rotatedFile = File('${imageFile.path}_rotated.jpg'); + await rotatedFile.writeAsBytes(img.encodeJpg(rotated)); + + return rotatedFile; + } + + void showResultModal(BuildContext context) { + isModalOpen.value = true; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + enableDrag: false, + backgroundColor: Colors.white, + builder: (context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan tombol close + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Hasil Foto", + style: Constant.cardText(context: context).copyWith( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Constant.textBlack, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.black), + onPressed: () { + isModalOpen.value = false; + Navigator.of(context).pop(); + }, + ), + ], + ), + const SizedBox(height: 16), + + // Gambar hasil foto + if (croppedImage.value != null) + Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + croppedImage.value!, + fit: BoxFit.contain, + ), + ), + ), + + const SizedBox(height: 16), + + // Tombol Hapus dan Upload + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Tombol Hapus + ElevatedButton.icon( + onPressed: () { + capturedImage.value = null; + croppedImage.value = null; + isModalOpen.value = false; + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.delete, + size: 17, color: Colors.white), + label: Text( + 'Hapus', + style: Constant.cardText(context: context).copyWith( + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Constant.textRed, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 8, + ), + ), + + // Tombol Upload + ElevatedButton.icon( + onPressed: () async { + // print('upload baru'); + Uint8List bytes = + await croppedImage.value!.readAsBytes(); + String base64String = base64Encode(bytes); + ref.read(uploadScanProvider.notifier).uploadScan( + host: host, + base64File: base64String, + ); + }, + icon: const Icon(Icons.upload, + size: 17, color: Colors.white), + label: Text( + 'Upload', + style: Constant.cardText(context: context).copyWith( + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 8, + ), + ), + ], + ), + ], + ), + ); + }, + ).whenComplete(() => isModalOpen.value = false); + } + + Future captureAndCropImage() async { + try { + isLoading.value = true; + await initializeControllerFuture.value; + + // Ambil gambar dari kamera + final image = await cameraController.value!.takePicture(); + File cropped = await rotateAndCropImage(File(image.path)); + + // Rotate gambar setelah cropping + File rotatedImage = await rotateImage(cropped); + + // Simpan hasil yang sudah di-crop dan di-rotate + capturedImage.value = image; + croppedImage.value = rotatedImage; + + showResultModal(context); + } catch (e) { + print("Error capturing image: $e"); + } finally { + isLoading.value = false; + } + } + + // proses upload + ref.listen(uploadScanProvider, (prev, next) { + if (next is UploadScanStateLoading) { + isLoading.value = true; + } else if (next is UploadScanStateError) { + isLoading.value = false; + // errorMessage.value = next.message; + debugPrint(next.message); + snackbarWidget( + context, + next.message, + snackbarType.error, + Duration(seconds: 3), + ); + } else if (next is UploadScanStateDone) { + isLoading.value = false; + } + }); + + return Scaffold( + backgroundColor: Colors.black.withOpacity(0.5), + body: Stack( + children: [ + if (cameraController.value != null) + FutureBuilder( + future: initializeControllerFuture.value, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return CameraPreview(cameraController.value!); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), + // bingkai foto + Positioned.fill( + child: CustomPaint( + painter: OverlayPainter(), + ), + ), + // loading + if (isLoading.value) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.5), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], + ), + bottomNavigationBar: BottomAppBar( + height: Constant.getActualYPhone( + context: context, + y: 100, + ), + shape: const CircularNotchedRectangle(), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.arrow_back, + size: 17, + color: Constant.textWhite, + ), + label: Text( + 'Kembali', + style: Constant.cardText(context: context).copyWith( + color: Constant.textWhite, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Constant.textCardGrey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 8, + shadowColor: Constant.bgButton.withOpacity(0.24), + ), + ), + const Spacer(), + // scan + ElevatedButton.icon( + onPressed: captureAndCropImage, + icon: Icon( + Icons.camera, + size: 17, + color: Constant.textWhite, + ), + label: Text( + 'Foto', + style: Constant.cardText(context: context).copyWith( + color: Constant.textWhite, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Constant.bgButton, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 8, + shadowColor: Constant.bgButton.withOpacity(0.24), + ), + ), + ], + ), + ), + ); + } +} + +class OverlayPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.black.withOpacity(0.6) + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = Colors.yellow + ..strokeWidth = 4 + ..style = PaintingStyle.stroke; + + double boxHeight = size.height * 0.7; + double boxWidth = boxHeight / 1.59; + + double left = (size.width - boxWidth) / 2; + double top = (size.height - boxHeight) / 2; + + Path path = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRect(Rect.fromLTWH(left, top, boxWidth, boxHeight)) + ..fillType = PathFillType.evenOdd; + + canvas.drawPath(path, paint); + canvas.drawRect(Rect.fromLTWH(left, top, boxWidth, boxHeight), borderPaint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/screen/scan/upload_scan_provider.dart b/lib/screen/scan/upload_scan_provider.dart new file mode 100644 index 0000000..c1e12da --- /dev/null +++ b/lib/screen/scan/upload_scan_provider.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scanktpflutter/repository/scan_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../app/constant.dart'; +import '../../model/auth_model.dart'; +import '../../provider/current_user_provider.dart'; +import '../../provider/dio_provider.dart'; +import '../../repository/auth_repository.dart'; +import '../../repository/base_repository.dart'; + +// 3. state provider +final uploadScanProvider = StateNotifierProvider( + (ref) => UploadScanNotifier(ref: ref)); + +// 2. notifier +class UploadScanNotifier extends StateNotifier { + final Ref ref; + UploadScanNotifier({required this.ref}) : super(UploadScanStateInit()); + void uploadScan({ + required String host, + required String base64File, + }) async { + try { + state = UploadScanStateLoading(); + final resp = await ScanRepository( + dio: ref.read(dioProvider), + ).prosesScan( + host: host, + base64File: base64File, + ); + + // print(resp); + state = UploadScanStateDone(pesan: resp); + } catch (e) { + if (e is BaseRepositoryException) { + state = UploadScanStateError(message: e.message); + } else { + state = UploadScanStateError(message: e.toString()); + } + } + } +} + +// 1. state +abstract class UploadScanState extends Equatable { + final DateTime date; + const UploadScanState(this.date); + @override + List get props => [date]; +} + +class UploadScanStateInit extends UploadScanState { + UploadScanStateInit() : super(DateTime.now()); +} + +class UploadScanStateLoading extends UploadScanState { + UploadScanStateLoading() : super(DateTime.now()); +} + +class UploadScanStateError extends UploadScanState { + final String message; + UploadScanStateError({ + required this.message, + }) : super(DateTime.now()); +} + +class UploadScanStateDone extends UploadScanState { + final String pesan; + UploadScanStateDone({ + required this.pesan, + }) : super(DateTime.now()); +}