From 6a85f75e3c0dea09d7261d43eef8ca7553413585 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Thu, 22 May 2025 16:20:34 +0700 Subject: [PATCH] feat: Add image upload and selection functionality with ImageSourceDialog and ImageUploader widgets - Implemented ImageSourceDialog for selecting image source (camera or gallery). - Created ImageUploader widget for displaying and managing image uploads, including error handling and validation. - Added TipsContainer widget for displaying helpful tips with customizable styles. - Developed OcrResultCard to present extracted information from KTP and KTA models. - Introduced ValidationMessageCard for showing validation messages with confirmation options. - Implemented FormKeyDebugger for tracking and debugging form keys in the application. - Added AnimatedSplashScreen for customizable splash screen transitions and navigation. --- sigap-mobile/lib/splash_screen.dart | 84 +- .../src/cores/services/azure_ocr_service.dart | 435 ++- .../features/auth/data/dummy/basix_usage.dart | 286 ++ .../features/auth/data/dummy/result_kta.json | 545 ++++ .../features/auth/data/dummy/result_ktp.json | 2558 +++++++++++++++++ .../authentication_repository.dart | 22 +- .../registration_form_controller.dart | 21 +- .../controllers/signin_controller.dart | 12 +- .../signup_with_role_controller.dart | 78 +- .../id_card_verification_controller.dart | 72 +- .../identity_verification_controller.dart | 25 +- .../steps/image_verification_controller.dart | 20 +- .../steps/officer_info_controller.dart | 17 +- .../steps/personal_info_controller.dart | 24 +- .../steps/selfie_verification_controller.dart | 19 +- .../steps/unit_info_controller.dart | 17 +- .../id_card_verification_step.dart | 363 +++ .../identity_verification_step.dart | 308 ++ .../image_verification_step.dart | 271 ++ .../{widgets => }/officer_info_step.dart | 28 +- .../{widgets => }/personal_info_step.dart | 28 +- .../registraion_form_screen.dart | 181 +- .../selfie_verification_step.dart | 165 ++ .../{widgets => }/unit_info_step.dart | 28 +- .../widgets/id_card_verification_step.dart | 554 ---- .../widgets/identity_verification_step.dart | 587 ---- .../widgets/image_verification_step.dart | 856 ------ .../widgets/selfie_verification_step.dart | 561 ---- .../pages/signin/signin_screen.dart | 9 +- .../data/models/models/kta_model.dart | 146 + .../controllers/onboarding_controller.dart | 21 +- .../pages/onboarding/onboarding_screen.dart | 2 +- .../data/models/models/ktp_model.dart | 257 ++ .../data/repositories/users_repository.dart | 41 + .../widgets/form/date_picker_field.dart | 120 + .../widgets/form/form_section_header.dart | 53 + .../shared/widgets/form/gender_selection.dart | 65 + .../widgets/form/verification_status.dart | 31 + .../image_upload/image_source_dialog.dart | 93 + .../widgets/image_upload/image_uploader.dart | 325 +++ .../shared/widgets/info/tips_container.dart | 79 + .../widgets/text/custom_text_field.dart | 2 +- .../widgets/verification/ocr_result_card.dart | 303 ++ .../verification/validation_message_card.dart | 105 + .../src/utils/debug/form_key_debugger.dart | 181 ++ .../utils/splash-screen/splash_screen.dart | 357 +++ sigap-mobile/pubspec.lock | 8 + sigap-mobile/pubspec.yaml | 1 + 48 files changed, 7450 insertions(+), 2914 deletions(-) create mode 100644 sigap-mobile/lib/src/features/auth/data/dummy/basix_usage.dart create mode 100644 sigap-mobile/lib/src/features/auth/data/dummy/result_kta.json create mode 100644 sigap-mobile/lib/src/features/auth/data/dummy/result_ktp.json create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart rename sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/{widgets => }/officer_info_step.dart (72%) rename sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/{widgets => }/personal_info_step.dart (85%) create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart rename sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/{widgets => }/unit_info_step.dart (77%) delete mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart delete mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart delete mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart delete mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/data/models/models/kta_model.dart create mode 100644 sigap-mobile/lib/src/features/personalization/data/models/models/ktp_model.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/form/date_picker_field.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/form/form_section_header.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/form/gender_selection.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/form/verification_status.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/info/tips_container.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/verification/ocr_result_card.dart create mode 100644 sigap-mobile/lib/src/shared/widgets/verification/validation_message_card.dart create mode 100644 sigap-mobile/lib/src/utils/debug/form_key_debugger.dart create mode 100644 sigap-mobile/lib/src/utils/splash-screen/splash_screen.dart diff --git a/sigap-mobile/lib/splash_screen.dart b/sigap-mobile/lib/splash_screen.dart index e7cacbb..841eaa6 100644 --- a/sigap-mobile/lib/splash_screen.dart +++ b/sigap-mobile/lib/splash_screen.dart @@ -1,30 +1,90 @@ -import 'package:animated_splash_screen/animated_splash_screen.dart'; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:logger/logger.dart'; import 'package:lottie/lottie.dart'; +import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart'; import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/image_strings.dart'; import 'package:sigap/src/utils/helpers/helper_functions.dart'; -class AnimatedSplashScreenWidget extends StatelessWidget { +class AnimatedSplashScreenWidget extends StatefulWidget { const AnimatedSplashScreenWidget({super.key}); + @override + State createState() => + _AnimatedSplashScreenWidgetState(); +} + +class _AnimatedSplashScreenWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + final storage = GetStorage(); + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + + _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + _animationController.forward(); + + // Delay for splash screen duration + Future.delayed(const Duration(milliseconds: 3500), () { + _handleNavigation(); + }); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _handleNavigation() async { + // Check if onboarding is completed + final isFirstTime = storage.read('isFirstTime') ?? false; + + if (isFirstTime) { + // Navigate to onboarding if it's the first time + Get.offAll(() => const OnboardingScreen()); + } else { + // Use the authentication repository to determine where to navigate + AuthenticationRepository.instance.screenRedirect(); + } + } + @override Widget build(BuildContext context) { final isDark = THelperFunctions.isDarkMode(context); + final isFirstTime = storage.read('isFirstTime') ?? false; + + Logger().i('isFirstTime: $isFirstTime'); - return AnimatedSplashScreen( - splash: Center( - child: Lottie.asset( - isDark ? TImages.darkSplashApp : TImages.lightSplashApp, - frameRate: FrameRate.max, - repeat: true, + return Scaffold( + backgroundColor: isDark ? TColors.dark : TColors.white, + body: Center( + child: FadeTransition( + opacity: _animation, + child: Lottie.asset( + isDark ? TImages.darkSplashApp : TImages.lightSplashApp, + frameRate: FrameRate.max, + repeat: true, + width: 300, + height: 300, + ), ), ), - splashIconSize: 300, - duration: 3500, - nextScreen: const OnboardingScreen(), - backgroundColor: isDark ? TColors.dark : TColors.white, ); } } diff --git a/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart index 1870c7d..fdfd618 100644 --- a/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart +++ b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart @@ -116,73 +116,390 @@ class AzureOCRService { Map _extractKtpInfo(Map ocrResult) { final Map extractedInfo = {}; final List allLines = _getAllTextLinesFromReadAPI(ocrResult); - + + // Print all lines for debugging + print('Extracted ${allLines.length} lines from KTP'); for (int i = 0; i < allLines.length; i++) { - String line = allLines[i].toLowerCase(); + print('Line $i: ${allLines[i]}'); + } - // Extract NIK (usually prefixed with "NIK" or "NIK:") - if (line.contains('nik') && i + 1 < allLines.length) { - // NIK might be on the same line or the next line - String nikLine = - line.contains(':') - ? line.split(':')[1].trim() - : allLines[i + 1].trim(); - // Clean up the NIK (remove any non-numeric characters) - nikLine = nikLine.replaceAll(RegExp(r'[^0-9]'), ''); - if (nikLine.length >= 16) { - // Standard ID card NIK length - extractedInfo['nik'] = nikLine; - } - } + // Creating a single concatenated text for regex-based extraction + final String fullText = allLines.join(' ').toLowerCase(); - // Extract name (usually prefixed with "Nama" or "Nama:") - if (line.contains('nama') && i + 1 < allLines.length) { - String name = - line.contains(':') - ? line.split(':')[1].trim() - : allLines[i + 1].trim(); - extractedInfo['nama'] = name; - } - - // Extract birth date (usually prefixed with "Tanggal Lahir" or similar) - if ((line.contains('lahir') || line.contains('ttl')) && - i + 1 < allLines.length) { - String birthInfo = - line.contains(':') - ? line.split(':')[1].trim() - : allLines[i + 1].trim(); - // Try to extract date in format DD-MM-YYYY or similar - RegExp dateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})'); - var match = dateRegex.firstMatch(birthInfo); - if (match != null) { - extractedInfo['tanggal_lahir'] = match.group(0)!; - } - } - - // Extract address (usually prefixed with "Alamat" or similar) - if (line.contains('alamat') && i + 1 < allLines.length) { - // Address might span multiple lines, try to capture a reasonable amount - String address = ''; - int j = line.contains(':') ? i : i + 1; - int maxLines = 3; // Capture up to 3 lines for address - - while (j < allLines.length && j < i + maxLines) { - if (allLines[j].contains('provinsi') || - allLines[j].contains('rt/rw') || - allLines[j].contains('kota') || - allLines[j].contains('kecamatan')) { - address += ' ${allLines[j].trim()}'; - } else if (j > i) { - // Don't add the "Alamat:" line itself - address += ' ${allLines[j].trim()}'; + // NIK extraction - Look for pattern "NIK: 1234567890123456" + RegExp nikRegex = RegExp(r'nik\s*:?\s*(\d{16})'); + var nikMatch = nikRegex.firstMatch(fullText); + if (nikMatch != null && nikMatch.groupCount >= 1) { + extractedInfo['nik'] = nikMatch.group(1)!; + } else { + // Try alternative format where NIK might be on a separate line + for (int i = 0; i < allLines.length; i++) { + if (allLines[i].toLowerCase().contains('nik')) { + // NIK label found, check next line or same line after colon + String line = allLines[i].toLowerCase(); + if (line.contains(':')) { + String potentialNik = line.split(':')[1].trim(); + // Clean and validate + potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), ''); + if (potentialNik.length == 16) { + extractedInfo['nik'] = potentialNik; + } + } else if (i + 1 < allLines.length) { + // Check next line + String potentialNik = allLines[i + 1].trim(); + // Clean and validate + potentialNik = potentialNik.replaceAll(RegExp(r'[^0-9]'), ''); + if (potentialNik.length == 16) { + extractedInfo['nik'] = potentialNik; + } } - j++; } - - extractedInfo['alamat'] = address.trim(); } } + // Name extraction - Look for pattern "Nama: JOHN DOE" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('nama') || line.trim() == 'nama:') { + // Found name label, extract value + if (line.contains(':')) { + String name = line.split(':')[1].trim(); + if (name.isNotEmpty) { + extractedInfo['nama'] = name; + } else if (i + 1 < allLines.length) { + // Name might be on next line + extractedInfo['nama'] = allLines[i + 1].trim(); + } + } else if (i + 1 < allLines.length) { + // Name on next line + extractedInfo['nama'] = allLines[i + 1].trim(); + } + break; + } + } + + // Birth Place and Date - Look for pattern "Tempat/Tgl Lahir: JAKARTA, DD-MM-YYYY" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('tempat') && line.contains('lahir')) { + // Birth info found, might contain place and date + String birthInfo = ''; + + if (line.contains(':')) { + birthInfo = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + birthInfo = allLines[i + 1].trim(); + } + + if (birthInfo.isNotEmpty) { + // Try to separate place and date + if (birthInfo.contains(',')) { + List parts = birthInfo.split(','); + if (parts.isNotEmpty) { + extractedInfo['birth_place'] = parts[0].trim(); + } + if (parts.length >= 2) { + // Extract date part + String datePart = parts[1].trim(); + RegExp dateRegex = RegExp( + r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})', + ); + var dateMatch = dateRegex.firstMatch(datePart); + if (dateMatch != null) { + extractedInfo['birthDate'] = dateMatch.group(0)!; + extractedInfo['tanggal_lahir'] = dateMatch.group(0)!; + } + } + } else { + // No comma separation, try to extract date directly + RegExp dateRegex = RegExp( + r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})', + ); + var dateMatch = dateRegex.firstMatch(birthInfo); + if (dateMatch != null) { + extractedInfo['tanggal_lahir'] = dateMatch.group(0)!; + extractedInfo['birthDate'] = dateMatch.group(0)!; + + // Extract birth place by removing the date part + String place = + birthInfo.replaceAll(dateMatch.group(0)!, '').trim(); + if (place.isNotEmpty) { + extractedInfo['birth_place'] = place; + } + } + } + } + break; + } + } + + // Gender extraction - Look for pattern "Jenis Kelamin: LAKI-LAKI" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('jenis') && + (line.contains('kelamin') || line.contains('klamin'))) { + String gender = ''; + if (line.contains(':')) { + gender = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + gender = allLines[i + 1].trim(); + } + + if (gender.isNotEmpty) { + // Normalize gender + if (gender.contains('laki') || + gender.contains('pria') || + gender == 'l') { + extractedInfo['gender'] = 'Male'; + extractedInfo['jenis_kelamin'] = 'LAKI-LAKI'; + } else if (gender.contains('perempuan') || + gender.contains('wanita') || + gender == 'p') { + extractedInfo['gender'] = 'Female'; + extractedInfo['jenis_kelamin'] = 'PEREMPUAN'; + } else { + extractedInfo['gender'] = gender; + extractedInfo['jenis_kelamin'] = gender; + } + } + break; + } + } + + // Blood Type - "Gol. Darah: A" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('gol') && line.contains('darah')) { + String bloodType = ''; + if (line.contains(':')) { + bloodType = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + bloodType = allLines[i + 1].trim(); + } + + if (bloodType.isNotEmpty) { + // Normalize to just the letter (A, B, AB, O) + RegExp bloodTypeRegex = RegExp(r'([ABO]|AB)[-+]?'); + var bloodTypeMatch = bloodTypeRegex.firstMatch( + bloodType.toUpperCase(), + ); + if (bloodTypeMatch != null) { + extractedInfo['blood_type'] = bloodTypeMatch.group(0)!; + } else { + extractedInfo['blood_type'] = bloodType; + } + } + break; + } + } + + // Address extraction + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('alamat')) { + String address = ''; + if (line.contains(':')) { + address = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + address = allLines[i + 1].trim(); + } + + // Address might span multiple lines, collect until we hit another field + if (address.isNotEmpty) { + extractedInfo['address'] = address; + extractedInfo['alamat'] = address; + + // Try to collect additional address lines + int j = i + 2; // Start from two lines after 'alamat' + while (j < allLines.length) { + String nextLine = allLines[j].toLowerCase(); + // Stop if we encounter another field label + if (nextLine.contains(':') || + nextLine.contains('rt/rw') || + nextLine.contains('kel') || + nextLine.contains('kec')) { + break; + } + // Add this line to address + extractedInfo['address'] = + '${extractedInfo['address'] ?? ''} ${allLines[j].trim()}'; + extractedInfo['alamat'] = + '${extractedInfo['alamat'] ?? ''} ${allLines[j].trim()}'; + j++; + } + } + break; + } + } + + // RT/RW extraction + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('rt') && line.contains('rw')) { + String rtRw = ''; + if (line.contains(':')) { + rtRw = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + rtRw = allLines[i + 1].trim(); + } + + if (rtRw.isNotEmpty) { + extractedInfo['rt_rw'] = rtRw; + } + break; + } + } + + // Kel/Desa extraction + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if ((line.contains('kel') && line.contains('desa')) || + line.contains('kelurahan') || + line.contains('desa')) { + String kelDesa = ''; + if (line.contains(':')) { + kelDesa = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + kelDesa = allLines[i + 1].trim(); + } + + if (kelDesa.isNotEmpty) { + extractedInfo['kelurahan'] = kelDesa; + } + break; + } + } + + // Kecamatan extraction + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('kecamatan')) { + String kecamatan = ''; + if (line.contains(':')) { + kecamatan = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + kecamatan = allLines[i + 1].trim(); + } + + if (kecamatan.isNotEmpty) { + extractedInfo['kecamatan'] = kecamatan; + } + break; + } + } + + // Religion extraction - "Agama: ISLAM" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('agama')) { + String religion = ''; + if (line.contains(':')) { + religion = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + religion = allLines[i + 1].trim(); + } + + if (religion.isNotEmpty) { + extractedInfo['religion'] = religion; + extractedInfo['agama'] = religion; + } + break; + } + } + + // Marital Status - "Status Perkawinan: BELUM KAWIN" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('status') && line.contains('kawin')) { + String status = ''; + if (line.contains(':')) { + status = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + status = allLines[i + 1].trim(); + } + + if (status.isNotEmpty) { + extractedInfo['marital_status'] = status; + extractedInfo['status_perkawinan'] = status; + } + break; + } + } + + // Occupation - "Pekerjaan: KARYAWAN" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('pekerjaan')) { + String occupation = ''; + if (line.contains(':')) { + occupation = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + occupation = allLines[i + 1].trim(); + } + + if (occupation.isNotEmpty) { + extractedInfo['occupation'] = occupation; + extractedInfo['pekerjaan'] = occupation; + } + break; + } + } + + // Nationality - "Kewarganegaraan: WNI" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('kewarganegaraan')) { + String nationality = ''; + if (line.contains(':')) { + nationality = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + nationality = allLines[i + 1].trim(); + } + + if (nationality.isNotEmpty) { + extractedInfo['nationality'] = nationality; + extractedInfo['kewarganegaraan'] = nationality; + } + break; + } + } + + // Validity Period - "Berlaku Hingga: SEUMUR HIDUP" + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + if (line.contains('berlaku') || line.contains('masa')) { + String validity = ''; + if (line.contains(':')) { + validity = line.split(':')[1].trim(); + } else if (i + 1 < allLines.length) { + validity = allLines[i + 1].trim(); + } + + if (validity.isNotEmpty) { + extractedInfo['validity_period'] = validity; + extractedInfo['berlaku_hingga'] = validity; + } + break; + } + } + + // Issue date extraction + RegExp issueDateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{4})'); + for (int i = 0; i < allLines.length; i++) { + var match = issueDateRegex.firstMatch(allLines[i]); + if (match != null) { + String dateCandidate = match.group(0)!; + // Check if this is not already assigned as birth date + if (extractedInfo['birthDate'] != dateCandidate && + extractedInfo['tanggal_lahir'] != dateCandidate) { + extractedInfo['issue_date'] = dateCandidate; + break; + } + } + } + + // Print extracted info for debugging + print('Extracted KTP info: ${extractedInfo.toString()}'); return extractedInfo; } diff --git a/sigap-mobile/lib/src/features/auth/data/dummy/basix_usage.dart b/sigap-mobile/lib/src/features/auth/data/dummy/basix_usage.dart new file mode 100644 index 0000000..09e104c --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/data/dummy/basix_usage.dart @@ -0,0 +1,286 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; + +class DocumentIntelligenceService { + final String endpoint; + final String key; + final http.Client _client = http.Client(); + + DocumentIntelligenceService({ + required this.endpoint, + required this.key, + }); + + // Generator function equivalent for getting text of spans + List getTextOfSpans(String content, List> spans) { + List textList = []; + for (var span in spans) { + int offset = span['offset']; + int length = span['length']; + textList.add(content.substring(offset, offset + length)); + } + return textList; + } + + Future> analyzeDocument(String documentUrl) async { + try { + // Initial request to start document analysis + final initialResponse = await _client.post( + Uri.parse('$endpoint/documentModels/prebuilt-read:analyze'), + headers: { + 'Content-Type': 'application/json', + 'Ocp-Apim-Subscription-Key': key, + }, + body: jsonEncode({ + 'urlSource': documentUrl, + }), + ); + + if (initialResponse.statusCode != 202) { + throw Exception('Failed to start analysis: ${initialResponse.body}'); + } + + // Get operation location from response headers + String? operationLocation = initialResponse.headers['operation-location']; + if (operationLocation == null) { + throw Exception('Operation location not found in response headers'); + } + + // Poll for results + return await _pollForResults(operationLocation); + } catch (e) { + throw Exception('Error analyzing document: $e'); + } + } + + Future> _pollForResults(String operationLocation) async { + const int maxAttempts = 60; // Maximum polling attempts + const Duration pollInterval = Duration(seconds: 2); + + for (int attempt = 0; attempt < maxAttempts; attempt++) { + final response = await _client.get( + Uri.parse(operationLocation), + headers: { + 'Ocp-Apim-Subscription-Key': key, + }, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to get operation status: ${response.body}'); + } + + final responseData = jsonDecode(response.body); + final String status = responseData['status']; + + if (status == 'succeeded') { + return responseData['analyzeResult']; + } else if (status == 'failed') { + throw Exception('Document analysis failed: ${responseData['error']}'); + } + + // Wait before next poll + await Future.delayed(pollInterval); + } + + throw Exception('Operation timed out after $maxAttempts attempts'); + } + + Future processDocument(String documentUrl) async { + try { + final analyzeResult = await analyzeDocument(documentUrl); + + final String? content = analyzeResult['content']; + final List? pages = analyzeResult['pages']; + final List? languages = analyzeResult['languages']; + final List? styles = analyzeResult['styles']; + + // Process pages + if (pages == null || pages.isEmpty) { + print('No pages were extracted from the document.'); + } else { + print('Pages:'); + for (var page in pages) { + print('- Page ${page['pageNumber']} (unit: ${page['unit']})'); + print(' ${page['width']}x${page['height']}, angle: ${page['angle']}'); + + final List lines = page['lines'] ?? []; + final List words = page['words'] ?? []; + + print(' ${lines.length} lines, ${words.length} words'); + + if (lines.isNotEmpty) { + print(' Lines:'); + for (var line in lines) { + print(' - "${line['content']}"'); + } + } + + if (words.isNotEmpty) { + print(' Words:'); + for (var word in words) { + print(' - "${word['content']}"'); + } + } + } + } + + // Process languages + if (languages == null || languages.isEmpty) { + print('No language spans were extracted from the document.'); + } else { + print('Languages:'); + for (var languageEntry in languages) { + print('- Found language: ${languageEntry['locale']} ' + '(confidence: ${languageEntry['confidence']})'); + + if (content != null && languageEntry['spans'] != null) { + final spans = List>.from(languageEntry['spans']); + final textList = getTextOfSpans(content, spans); + + for (var text in textList) { + final escapedText = text + .replaceAll(RegExp(r'\r?\n'), '\\n') + .replaceAll('"', '\\"'); + print(' - "$escapedText"'); + } + } + } + } + + // Process styles + if (styles == null || styles.isEmpty) { + print('No text styles were extracted from the document.'); + } else { + print('Styles:'); + for (var style in styles) { + final bool isHandwritten = style['isHandwritten'] ?? false; + final double confidence = style['confidence'] ?? 0.0; + + print('- Handwritten: ${isHandwritten ? "yes" : "no"} ' + '(confidence=$confidence)'); + + if (content != null && style['spans'] != null) { + final spans = List>.from(style['spans']); + final textList = getTextOfSpans(content, spans); + + for (var text in textList) { + print(' - "$text"'); + } + } + } + } + } catch (e) { + print('An error occurred: $e'); + } + } + + void dispose() { + _client.close(); + } +} + +// Example usage in a Flutter app +class DocumentAnalyzerApp { + static const String endpoint = "YOUR_FORM_RECOGNIZER_ENDPOINT"; + static const String key = "YOUR_FORM_RECOGNIZER_KEY"; + static const String formUrl = + "https://raw.githubusercontent.com/Azure-Samples/cognitive-services-REST-api-samples/master/curl/form-recognizer/rest-api/read.png"; + + static Future main() async { + final service = DocumentIntelligenceService( + endpoint: endpoint, + key: key, + ); + + try { + await service.processDocument(formUrl); + } finally { + service.dispose(); + } + } +} + +// Flutter Widget Example +import 'package:flutter/material.dart'; + +class DocumentAnalyzerWidget extends StatefulWidget { + const DocumentAnalyzerWidget({super.key}); + + @override + _DocumentAnalyzerWidgetState createState() => _DocumentAnalyzerWidgetState(); +} + +class _DocumentAnalyzerWidgetState extends State { + final DocumentIntelligenceService _service = DocumentIntelligenceService( + endpoint: "YOUR_FORM_RECOGNIZER_ENDPOINT", + key: "YOUR_FORM_RECOGNIZER_KEY", + ); + + bool _isLoading = false; + String _result = ''; + + Future _analyzeDocument() async { + setState(() { + _isLoading = true; + _result = ''; + }); + + try { + const String documentUrl = + "https://raw.githubusercontent.com/Azure-Samples/cognitive-services-REST-api-samples/master/curl/form-recognizer/rest-api/read.png"; + + await _service.processDocument(documentUrl); + setState(() { + _result = 'Document analysis completed successfully!'; + }); + } catch (e) { + setState(() { + _result = 'Error: $e'; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _service.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Document Intelligence'), + ), + body: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: _isLoading ? null : _analyzeDocument, + child: _isLoading + ? CircularProgressIndicator(color: Colors.white) + : Text('Analyze Document'), + ), + SizedBox(height: 20), + if (_result.isNotEmpty) + Expanded( + child: SingleChildScrollView( + child: Text( + _result, + style: TextStyle(fontSize: 14), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/sigap-mobile/lib/src/features/auth/data/dummy/result_kta.json b/sigap-mobile/lib/src/features/auth/data/dummy/result_kta.json new file mode 100644 index 0000000..bc4bd5f --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/data/dummy/result_kta.json @@ -0,0 +1,545 @@ +{ + "status": "succeeded", + "createdDateTime": "2025-05-22T09:03:26Z", + "lastUpdatedDateTime": "2025-05-22T09:03:27Z", + "analyzeResult": { + "apiVersion": "2024-11-30", + "modelId": "prebuilt-read", + "stringIndexType": "utf16CodeUnit", + "content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA\nKARTU TANDA ANGGOTA\nMUSAHIR SH BRIPTU 89030022 POLDA LAMPUNG 6013 0106 2447 8534", + "pages": [ + { + "pageNumber": 1, + "angle": 0, + "width": 300, + "height": 168, + "unit": "pixel", + "words": [ + { + "content": "KEPOLISIAN", + "polygon": [ + 76, + 22, + 115, + 21, + 115, + 30, + 76, + 31 + ], + "confidence": 0.692, + "span": { + "offset": 0, + "length": 10 + } + }, + { + "content": "NEGARA", + "polygon": [ + 117, + 21, + 145, + 21, + 145, + 30, + 117, + 30 + ], + "confidence": 0.99, + "span": { + "offset": 11, + "length": 6 + } + }, + { + "content": "REPUBLIK", + "polygon": [ + 147, + 21, + 178, + 20, + 178, + 29, + 147, + 30 + ], + "confidence": 0.932, + "span": { + "offset": 18, + "length": 8 + } + }, + { + "content": "INDONESIA", + "polygon": [ + 179, + 20, + 220, + 20, + 220, + 29, + 179, + 29 + ], + "confidence": 0.935, + "span": { + "offset": 27, + "length": 9 + } + }, + { + "content": "KARTU", + "polygon": [ + 74, + 30, + 112, + 29, + 113, + 52, + 74, + 52 + ], + "confidence": 0.992, + "span": { + "offset": 37, + "length": 5 + } + }, + { + "content": "TANDA", + "polygon": [ + 117, + 29, + 156, + 28, + 156, + 52, + 117, + 52 + ], + "confidence": 0.991, + "span": { + "offset": 43, + "length": 5 + } + }, + { + "content": "ANGGOTA", + "polygon": [ + 160, + 28, + 220, + 28, + 220, + 50, + 160, + 51 + ], + "confidence": 0.992, + "span": { + "offset": 49, + "length": 7 + } + }, + { + "content": "MUSAHIR", + "polygon": [ + 85, + 56, + 140, + 56, + 140, + 68, + 85, + 67 + ], + "confidence": 0.993, + "span": { + "offset": 57, + "length": 7 + } + }, + { + "content": "SH", + "polygon": [ + 144, + 56, + 161, + 57, + 160, + 69, + 144, + 68 + ], + "confidence": 0.999, + "span": { + "offset": 65, + "length": 2 + } + }, + { + "content": "BRIPTU", + "polygon": [ + 84, + 67, + 129, + 68, + 129, + 78, + 84, + 79 + ], + "confidence": 0.992, + "span": { + "offset": 68, + "length": 6 + } + }, + { + "content": "89030022", + "polygon": [ + 84, + 79, + 142, + 79, + 142, + 89, + 84, + 89 + ], + "confidence": 0.995, + "span": { + "offset": 75, + "length": 8 + } + }, + { + "content": "POLDA", + "polygon": [ + 84, + 89, + 124, + 89, + 124, + 101, + 84, + 101 + ], + "confidence": 0.994, + "span": { + "offset": 84, + "length": 5 + } + }, + { + "content": "LAMPUNG", + "polygon": [ + 128, + 89, + 186, + 90, + 185, + 101, + 127, + 101 + ], + "confidence": 0.995, + "span": { + "offset": 90, + "length": 7 + } + }, + { + "content": "6013", + "polygon": [ + 52, + 103, + 91, + 103, + 91, + 118, + 52, + 118 + ], + "confidence": 0.992, + "span": { + "offset": 98, + "length": 4 + } + }, + { + "content": "0106", + "polygon": [ + 103, + 103, + 141, + 103, + 141, + 118, + 102, + 118 + ], + "confidence": 0.989, + "span": { + "offset": 103, + "length": 4 + } + }, + { + "content": "2447", + "polygon": [ + 152, + 103, + 191, + 103, + 191, + 118, + 152, + 118 + ], + "confidence": 0.992, + "span": { + "offset": 108, + "length": 4 + } + }, + { + "content": "8534", + "polygon": [ + 203, + 103, + 243, + 103, + 243, + 118, + 202, + 118 + ], + "confidence": 0.989, + "span": { + "offset": 113, + "length": 4 + } + } + ], + "lines": [ + { + "content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA", + "polygon": [ + 75, + 21, + 219, + 20, + 219, + 28, + 75, + 30 + ], + "spans": [ + { + "offset": 0, + "length": 36 + } + ] + }, + { + "content": "KARTU TANDA ANGGOTA", + "polygon": [ + 73, + 29, + 219, + 28, + 220, + 50, + 73, + 52 + ], + "spans": [ + { + "offset": 37, + "length": 19 + } + ] + }, + { + "content": "MUSAHIR SH", + "polygon": [ + 84, + 56, + 160, + 56, + 160, + 68, + 84, + 67 + ], + "spans": [ + { + "offset": 57, + "length": 10 + } + ] + }, + { + "content": "BRIPTU", + "polygon": [ + 84, + 67, + 129, + 67, + 129, + 78, + 84, + 78 + ], + "spans": [ + { + "offset": 68, + "length": 6 + } + ] + }, + { + "content": "89030022", + "polygon": [ + 83, + 78, + 141, + 78, + 141, + 89, + 83, + 89 + ], + "spans": [ + { + "offset": 75, + "length": 8 + } + ] + }, + { + "content": "POLDA LAMPUNG", + "polygon": [ + 83, + 89, + 185, + 89, + 185, + 101, + 83, + 101 + ], + "spans": [ + { + "offset": 84, + "length": 13 + } + ] + }, + { + "content": "6013 0106 2447 8534", + "polygon": [ + 52, + 102, + 242, + 102, + 242, + 117, + 52, + 118 + ], + "spans": [ + { + "offset": 98, + "length": 19 + } + ] + } + ], + "spans": [ + { + "offset": 0, + "length": 117 + } + ] + } + ], + "paragraphs": [ + { + "spans": [ + { + "offset": 0, + "length": 36 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 75, + 21, + 219, + 20, + 219, + 29, + 75, + 30 + ] + } + ], + "content": "KEPOLISIAN NEGARA REPUBLIK INDONESIA" + }, + { + "spans": [ + { + "offset": 37, + "length": 19 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 73, + 29, + 220, + 27, + 220, + 50, + 73, + 52 + ] + } + ], + "content": "KARTU TANDA ANGGOTA" + }, + { + "spans": [ + { + "offset": 57, + "length": 60 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 52, + 56, + 242, + 55, + 242, + 117, + 52, + 118 + ] + } + ], + "content": "MUSAHIR SH BRIPTU 89030022 POLDA LAMPUNG 6013 0106 2447 8534" + } + ], + "styles": [], + "contentFormat": "text" + } +} \ No newline at end of file diff --git a/sigap-mobile/lib/src/features/auth/data/dummy/result_ktp.json b/sigap-mobile/lib/src/features/auth/data/dummy/result_ktp.json new file mode 100644 index 0000000..ac74260 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/data/dummy/result_ktp.json @@ -0,0 +1,2558 @@ +{ + "status": "succeeded", + "createdDateTime": "2025-05-22T08:59:47Z", + "lastUpdatedDateTime": "2025-05-22T08:59:48Z", + "analyzeResult": { + "apiVersion": "2024-11-30", + "modelId": "prebuilt-read", + "stringIndexType": "utf16CodeUnit", + "content": "PROVINSI DKI JAKARTA JAKARTA SELATAN\nNIK\n:\n1234567890123456\nNama Tempat/Tgl Lahir\n: JOHN DOE\n: JAKARTA, DD-MM-YYYY\nJenis Kelamin Alamat\n: LAKI-LAKI Gol. Darah: A\n: LOREM IPSUM DOLOR SIT AMET\nRT/RW\n: 001/001\nKel/Desa\n: LOREM IPSUM\nKecamatan\n: LOREM IPSUM\nAgama\n: LOREM IPSUM\nStatus Perkawinan\n: BELUM KAWIN\nPekerjaan\nKARYAWAN\nKewarganegaraan : WNI\nBerlaku Hingga\nSEUMUR HIDUP\nSP\nJAKARTA SELATAN 28-01-2020\nshutterstock.com . 1628461459", + "pages": [ + { + "pageNumber": 1, + "angle": 0, + "width": 413, + "height": 280, + "unit": "pixel", + "words": [ + { + "content": "PROVINSI", + "polygon": [ + 123, + 9, + 192, + 8, + 192, + 22, + 123, + 21 + ], + "confidence": 0.992, + "span": { + "offset": 0, + "length": 8 + } + }, + { + "content": "DKI", + "polygon": [ + 197, + 8, + 220, + 8, + 220, + 22, + 197, + 22 + ], + "confidence": 0.993, + "span": { + "offset": 9, + "length": 3 + } + }, + { + "content": "JAKARTA", + "polygon": [ + 225, + 7, + 293, + 7, + 293, + 22, + 225, + 22 + ], + "confidence": 0.995, + "span": { + "offset": 13, + "length": 7 + } + }, + { + "content": "JAKARTA", + "polygon": [ + 152, + 24, + 207, + 24, + 207, + 35, + 152, + 35 + ], + "confidence": 0.993, + "span": { + "offset": 21, + "length": 7 + } + }, + { + "content": "SELATAN", + "polygon": [ + 210, + 24, + 265, + 23, + 264, + 36, + 210, + 35 + ], + "confidence": 0.992, + "span": { + "offset": 29, + "length": 7 + } + }, + { + "content": "NIK", + "polygon": [ + 10, + 43, + 35, + 43, + 35, + 56, + 10, + 56 + ], + "confidence": 0.992, + "span": { + "offset": 37, + "length": 3 + } + }, + { + "content": ":", + "polygon": [ + 75, + 45, + 83, + 45, + 82, + 56, + 75, + 55 + ], + "confidence": 0.841, + "span": { + "offset": 41, + "length": 1 + } + }, + { + "content": "1234567890123456", + "polygon": [ + 98, + 44, + 242, + 43, + 242, + 58, + 97, + 57 + ], + "confidence": 0.99, + "span": { + "offset": 43, + "length": 16 + } + }, + { + "content": "Nama", + "polygon": [ + 11, + 67, + 40, + 67, + 40, + 77, + 11, + 77 + ], + "confidence": 0.992, + "span": { + "offset": 60, + "length": 4 + } + }, + { + "content": "Tempat/Tgl", + "polygon": [ + 11, + 80, + 63, + 80, + 63, + 91, + 11, + 91 + ], + "confidence": 0.988, + "span": { + "offset": 65, + "length": 10 + } + }, + { + "content": "Lahir", + "polygon": [ + 66, + 80, + 90, + 80, + 90, + 91, + 66, + 91 + ], + "confidence": 0.995, + "span": { + "offset": 76, + "length": 5 + } + }, + { + "content": ":", + "polygon": [ + 106, + 68, + 110, + 68, + 110, + 77, + 106, + 78 + ], + "confidence": 0.989, + "span": { + "offset": 82, + "length": 1 + } + }, + { + "content": "JOHN", + "polygon": [ + 112, + 68, + 139, + 68, + 139, + 77, + 112, + 77 + ], + "confidence": 0.986, + "span": { + "offset": 84, + "length": 4 + } + }, + { + "content": "DOE", + "polygon": [ + 142, + 68, + 164, + 69, + 164, + 77, + 142, + 77 + ], + "confidence": 0.996, + "span": { + "offset": 89, + "length": 3 + } + }, + { + "content": ":", + "polygon": [ + 106, + 80, + 110, + 80, + 110, + 90, + 106, + 90 + ], + "confidence": 0.977, + "span": { + "offset": 93, + "length": 1 + } + }, + { + "content": "JAKARTA,", + "polygon": [ + 113, + 80, + 161, + 80, + 161, + 90, + 112, + 90 + ], + "confidence": 0.98, + "span": { + "offset": 95, + "length": 8 + } + }, + { + "content": "DD-MM-YYYY", + "polygon": [ + 164, + 80, + 227, + 79, + 227, + 90, + 163, + 90 + ], + "confidence": 0.993, + "span": { + "offset": 104, + "length": 10 + } + }, + { + "content": "Jenis", + "polygon": [ + 11, + 93, + 37, + 93, + 37, + 102, + 11, + 102 + ], + "confidence": 0.994, + "span": { + "offset": 115, + "length": 5 + } + }, + { + "content": "Kelamin", + "polygon": [ + 40, + 93, + 78, + 93, + 78, + 102, + 40, + 102 + ], + "confidence": 0.992, + "span": { + "offset": 121, + "length": 7 + } + }, + { + "content": "Alamat", + "polygon": [ + 12, + 104, + 46, + 105, + 46, + 114, + 12, + 115 + ], + "confidence": 0.993, + "span": { + "offset": 129, + "length": 6 + } + }, + { + "content": ":", + "polygon": [ + 107, + 93, + 110, + 93, + 110, + 102, + 106, + 102 + ], + "confidence": 0.977, + "span": { + "offset": 136, + "length": 1 + } + }, + { + "content": "LAKI-LAKI", + "polygon": [ + 113, + 93, + 162, + 93, + 162, + 102, + 112, + 102 + ], + "confidence": 0.992, + "span": { + "offset": 138, + "length": 9 + } + }, + { + "content": "Gol.", + "polygon": [ + 198, + 93, + 219, + 92, + 218, + 102, + 198, + 102 + ], + "confidence": 0.989, + "span": { + "offset": 148, + "length": 4 + } + }, + { + "content": "Darah:", + "polygon": [ + 221, + 92, + 252, + 92, + 251, + 102, + 221, + 102 + ], + "confidence": 0.989, + "span": { + "offset": 153, + "length": 6 + } + }, + { + "content": "A", + "polygon": [ + 255, + 92, + 263, + 92, + 262, + 102, + 254, + 102 + ], + "confidence": 0.995, + "span": { + "offset": 160, + "length": 1 + } + }, + { + "content": ":", + "polygon": [ + 108, + 105, + 110, + 105, + 110, + 115, + 107, + 115 + ], + "confidence": 0.829, + "span": { + "offset": 162, + "length": 1 + } + }, + { + "content": "LOREM", + "polygon": [ + 113, + 105, + 147, + 105, + 147, + 114, + 112, + 115 + ], + "confidence": 0.993, + "span": { + "offset": 164, + "length": 5 + } + }, + { + "content": "IPSUM", + "polygon": [ + 151, + 105, + 181, + 104, + 181, + 114, + 150, + 114 + ], + "confidence": 0.995, + "span": { + "offset": 170, + "length": 5 + } + }, + { + "content": "DOLOR", + "polygon": [ + 184, + 104, + 219, + 104, + 218, + 114, + 184, + 114 + ], + "confidence": 0.996, + "span": { + "offset": 176, + "length": 5 + } + }, + { + "content": "SIT", + "polygon": [ + 222, + 104, + 237, + 104, + 236, + 115, + 221, + 115 + ], + "confidence": 0.992, + "span": { + "offset": 182, + "length": 3 + } + }, + { + "content": "AMET", + "polygon": [ + 239, + 104, + 268, + 105, + 268, + 115, + 239, + 115 + ], + "confidence": 0.992, + "span": { + "offset": 186, + "length": 4 + } + }, + { + "content": "RT/RW", + "polygon": [ + 35, + 117, + 68, + 117, + 68, + 127, + 35, + 127 + ], + "confidence": 0.99, + "span": { + "offset": 191, + "length": 5 + } + }, + { + "content": ":", + "polygon": [ + 106, + 118, + 109, + 118, + 110, + 127, + 107, + 126 + ], + "confidence": 0.981, + "span": { + "offset": 197, + "length": 1 + } + }, + { + "content": "001/001", + "polygon": [ + 112, + 118, + 148, + 118, + 147, + 127, + 113, + 127 + ], + "confidence": 0.995, + "span": { + "offset": 199, + "length": 7 + } + }, + { + "content": "Kel/Desa", + "polygon": [ + 34, + 129, + 78, + 130, + 78, + 139, + 34, + 139 + ], + "confidence": 0.993, + "span": { + "offset": 207, + "length": 8 + } + }, + { + "content": ":", + "polygon": [ + 106, + 129, + 110, + 129, + 110, + 139, + 106, + 139 + ], + "confidence": 0.978, + "span": { + "offset": 216, + "length": 1 + } + }, + { + "content": "LOREM", + "polygon": [ + 113, + 129, + 147, + 129, + 147, + 139, + 113, + 139 + ], + "confidence": 0.997, + "span": { + "offset": 218, + "length": 5 + } + }, + { + "content": "IPSUM", + "polygon": [ + 151, + 129, + 183, + 129, + 182, + 139, + 150, + 139 + ], + "confidence": 0.993, + "span": { + "offset": 224, + "length": 5 + } + }, + { + "content": "Kecamatan", + "polygon": [ + 34, + 141, + 89, + 142, + 89, + 152, + 34, + 152 + ], + "confidence": 0.995, + "span": { + "offset": 230, + "length": 9 + } + }, + { + "content": ":", + "polygon": [ + 106, + 142, + 110, + 142, + 110, + 152, + 106, + 152 + ], + "confidence": 0.975, + "span": { + "offset": 240, + "length": 1 + } + }, + { + "content": "LOREM", + "polygon": [ + 113, + 141, + 147, + 141, + 147, + 151, + 113, + 152 + ], + "confidence": 0.997, + "span": { + "offset": 242, + "length": 5 + } + }, + { + "content": "IPSUM", + "polygon": [ + 151, + 141, + 182, + 142, + 182, + 152, + 150, + 151 + ], + "confidence": 0.995, + "span": { + "offset": 248, + "length": 5 + } + }, + { + "content": "Agama", + "polygon": [ + 13, + 154, + 47, + 154, + 46, + 164, + 12, + 165 + ], + "confidence": 0.995, + "span": { + "offset": 254, + "length": 5 + } + }, + { + "content": ":", + "polygon": [ + 107, + 154, + 110, + 154, + 110, + 163, + 107, + 163 + ], + "confidence": 0.977, + "span": { + "offset": 260, + "length": 1 + } + }, + { + "content": "LOREM", + "polygon": [ + 113, + 154, + 147, + 153, + 147, + 164, + 113, + 163 + ], + "confidence": 0.995, + "span": { + "offset": 262, + "length": 5 + } + }, + { + "content": "IPSUM", + "polygon": [ + 150, + 153, + 182, + 153, + 182, + 164, + 150, + 164 + ], + "confidence": 0.993, + "span": { + "offset": 268, + "length": 5 + } + }, + { + "content": "Status", + "polygon": [ + 12, + 166, + 42, + 165, + 42, + 176, + 12, + 177 + ], + "confidence": 0.993, + "span": { + "offset": 274, + "length": 6 + } + }, + { + "content": "Perkawinan", + "polygon": [ + 45, + 165, + 101, + 166, + 101, + 177, + 45, + 176 + ], + "confidence": 0.993, + "span": { + "offset": 281, + "length": 10 + } + }, + { + "content": ":", + "polygon": [ + 106, + 166, + 110, + 166, + 110, + 176, + 106, + 176 + ], + "confidence": 0.963, + "span": { + "offset": 292, + "length": 1 + } + }, + { + "content": "BELUM", + "polygon": [ + 113, + 166, + 147, + 166, + 147, + 176, + 113, + 176 + ], + "confidence": 0.995, + "span": { + "offset": 294, + "length": 5 + } + }, + { + "content": "KAWIN", + "polygon": [ + 150, + 166, + 183, + 165, + 183, + 177, + 150, + 176 + ], + "confidence": 0.995, + "span": { + "offset": 300, + "length": 5 + } + }, + { + "content": "Pekerjaan", + "polygon": [ + 11, + 177, + 59, + 178, + 59, + 190, + 11, + 189 + ], + "confidence": 0.993, + "span": { + "offset": 306, + "length": 9 + } + }, + { + "content": "KARYAWAN", + "polygon": [ + 112, + 179, + 170, + 177, + 170, + 189, + 112, + 188 + ], + "confidence": 0.993, + "span": { + "offset": 316, + "length": 8 + } + }, + { + "content": "Kewarganegaraan", + "polygon": [ + 10, + 190, + 99, + 192, + 99, + 202, + 10, + 202 + ], + "confidence": 0.992, + "span": { + "offset": 325, + "length": 15 + } + }, + { + "content": ":", + "polygon": [ + 106, + 191, + 110, + 191, + 110, + 201, + 106, + 201 + ], + "confidence": 0.993, + "span": { + "offset": 341, + "length": 1 + } + }, + { + "content": "WNI", + "polygon": [ + 112, + 191, + 132, + 191, + 132, + 201, + 112, + 201 + ], + "confidence": 0.983, + "span": { + "offset": 343, + "length": 3 + } + }, + { + "content": "Berlaku", + "polygon": [ + 12, + 203, + 48, + 203, + 48, + 214, + 12, + 214 + ], + "confidence": 0.994, + "span": { + "offset": 347, + "length": 7 + } + }, + { + "content": "Hingga", + "polygon": [ + 51, + 203, + 84, + 203, + 84, + 215, + 51, + 214 + ], + "confidence": 0.995, + "span": { + "offset": 355, + "length": 6 + } + }, + { + "content": "SEUMUR", + "polygon": [ + 112, + 203, + 153, + 202, + 155, + 214, + 113, + 214 + ], + "confidence": 0.993, + "span": { + "offset": 362, + "length": 6 + } + }, + { + "content": "HIDUP", + "polygon": [ + 156, + 202, + 188, + 201, + 190, + 214, + 158, + 214 + ], + "confidence": 0.992, + "span": { + "offset": 369, + "length": 5 + } + }, + { + "content": "SP", + "polygon": [ + 390, + 110, + 410, + 110, + 410, + 128, + 390, + 129 + ], + "confidence": 0.638, + "span": { + "offset": 375, + "length": 2 + } + }, + { + "content": "JAKARTA", + "polygon": [ + 304, + 179, + 340, + 178, + 341, + 188, + 304, + 188 + ], + "confidence": 0.992, + "span": { + "offset": 378, + "length": 7 + } + }, + { + "content": "SELATAN", + "polygon": [ + 343, + 178, + 380, + 178, + 380, + 188, + 343, + 188 + ], + "confidence": 0.992, + "span": { + "offset": 386, + "length": 7 + } + }, + { + "content": "28-01-2020", + "polygon": [ + 320, + 188, + 365, + 187, + 366, + 198, + 319, + 197 + ], + "confidence": 0.993, + "span": { + "offset": 394, + "length": 10 + } + }, + { + "content": "shutterstock.com", + "polygon": [ + 129, + 264, + 210, + 264, + 211, + 277, + 130, + 277 + ], + "confidence": 0.992, + "span": { + "offset": 405, + "length": 16 + } + }, + { + "content": ".", + "polygon": [ + 216, + 264, + 220, + 264, + 221, + 277, + 216, + 277 + ], + "confidence": 0.591, + "span": { + "offset": 422, + "length": 1 + } + }, + { + "content": "1628461459", + "polygon": [ + 224, + 264, + 285, + 264, + 285, + 277, + 225, + 277 + ], + "confidence": 0.995, + "span": { + "offset": 424, + "length": 10 + } + } + ], + "lines": [ + { + "content": "PROVINSI DKI JAKARTA", + "polygon": [ + 123, + 7, + 292, + 7, + 292, + 22, + 123, + 22 + ], + "spans": [ + { + "offset": 0, + "length": 20 + } + ] + }, + { + "content": "JAKARTA SELATAN", + "polygon": [ + 151, + 23, + 264, + 22, + 264, + 35, + 151, + 35 + ], + "spans": [ + { + "offset": 21, + "length": 15 + } + ] + }, + { + "content": "NIK", + "polygon": [ + 10, + 43, + 35, + 43, + 35, + 56, + 10, + 56 + ], + "spans": [ + { + "offset": 37, + "length": 3 + } + ] + }, + { + "content": ":", + "polygon": [ + 76, + 45, + 83, + 45, + 83, + 56, + 75, + 55 + ], + "spans": [ + { + "offset": 41, + "length": 1 + } + ] + }, + { + "content": "1234567890123456", + "polygon": [ + 97, + 43, + 241, + 42, + 241, + 57, + 97, + 57 + ], + "spans": [ + { + "offset": 43, + "length": 16 + } + ] + }, + { + "content": "Nama", + "polygon": [ + 11, + 67, + 39, + 67, + 39, + 77, + 11, + 77 + ], + "spans": [ + { + "offset": 60, + "length": 4 + } + ] + }, + { + "content": "Tempat/Tgl Lahir", + "polygon": [ + 11, + 80, + 89, + 80, + 89, + 90, + 11, + 90 + ], + "spans": [ + { + "offset": 65, + "length": 16 + } + ] + }, + { + "content": ": JOHN DOE", + "polygon": [ + 106, + 68, + 163, + 67, + 163, + 77, + 106, + 77 + ], + "spans": [ + { + "offset": 82, + "length": 10 + } + ] + }, + { + "content": ": JAKARTA, DD-MM-YYYY", + "polygon": [ + 106, + 80, + 226, + 79, + 226, + 89, + 106, + 90 + ], + "spans": [ + { + "offset": 93, + "length": 21 + } + ] + }, + { + "content": "Jenis Kelamin", + "polygon": [ + 11, + 92, + 77, + 92, + 77, + 102, + 11, + 102 + ], + "spans": [ + { + "offset": 115, + "length": 13 + } + ] + }, + { + "content": "Alamat", + "polygon": [ + 11, + 104, + 45, + 104, + 45, + 114, + 11, + 114 + ], + "spans": [ + { + "offset": 129, + "length": 6 + } + ] + }, + { + "content": ": LAKI-LAKI", + "polygon": [ + 106, + 92, + 161, + 92, + 161, + 102, + 106, + 101 + ], + "spans": [ + { + "offset": 136, + "length": 11 + } + ] + }, + { + "content": "Gol. Darah: A", + "polygon": [ + 198, + 92, + 262, + 92, + 262, + 102, + 198, + 102 + ], + "spans": [ + { + "offset": 148, + "length": 13 + } + ] + }, + { + "content": ": LOREM IPSUM DOLOR SIT AMET", + "polygon": [ + 107, + 104, + 267, + 104, + 267, + 114, + 107, + 114 + ], + "spans": [ + { + "offset": 162, + "length": 28 + } + ] + }, + { + "content": "RT/RW", + "polygon": [ + 35, + 116, + 67, + 117, + 67, + 126, + 35, + 126 + ], + "spans": [ + { + "offset": 191, + "length": 5 + } + ] + }, + { + "content": ": 001/001", + "polygon": [ + 106, + 117, + 147, + 117, + 146, + 126, + 106, + 126 + ], + "spans": [ + { + "offset": 197, + "length": 9 + } + ] + }, + { + "content": "Kel/Desa", + "polygon": [ + 34, + 129, + 77, + 129, + 77, + 139, + 34, + 139 + ], + "spans": [ + { + "offset": 207, + "length": 8 + } + ] + }, + { + "content": ": LOREM IPSUM", + "polygon": [ + 106, + 129, + 182, + 128, + 182, + 138, + 106, + 139 + ], + "spans": [ + { + "offset": 216, + "length": 13 + } + ] + }, + { + "content": "Kecamatan", + "polygon": [ + 33, + 141, + 88, + 141, + 88, + 152, + 33, + 151 + ], + "spans": [ + { + "offset": 230, + "length": 9 + } + ] + }, + { + "content": ": LOREM IPSUM", + "polygon": [ + 105, + 141, + 182, + 141, + 182, + 151, + 105, + 151 + ], + "spans": [ + { + "offset": 240, + "length": 13 + } + ] + }, + { + "content": "Agama", + "polygon": [ + 12, + 153, + 46, + 153, + 46, + 164, + 12, + 164 + ], + "spans": [ + { + "offset": 254, + "length": 5 + } + ] + }, + { + "content": ": LOREM IPSUM", + "polygon": [ + 106, + 153, + 181, + 153, + 181, + 163, + 106, + 163 + ], + "spans": [ + { + "offset": 260, + "length": 13 + } + ] + }, + { + "content": "Status Perkawinan", + "polygon": [ + 11, + 165, + 100, + 165, + 100, + 176, + 11, + 176 + ], + "spans": [ + { + "offset": 274, + "length": 17 + } + ] + }, + { + "content": ": BELUM KAWIN", + "polygon": [ + 104, + 165, + 182, + 165, + 182, + 176, + 104, + 176 + ], + "spans": [ + { + "offset": 292, + "length": 13 + } + ] + }, + { + "content": "Pekerjaan", + "polygon": [ + 10, + 177, + 58, + 177, + 58, + 189, + 10, + 189 + ], + "spans": [ + { + "offset": 306, + "length": 9 + } + ] + }, + { + "content": "KARYAWAN", + "polygon": [ + 108, + 178, + 169, + 177, + 169, + 188, + 108, + 189 + ], + "spans": [ + { + "offset": 316, + "length": 8 + } + ] + }, + { + "content": "Kewarganegaraan", + "polygon": [ + 10, + 190, + 98, + 190, + 98, + 202, + 10, + 201 + ], + "spans": [ + { + "offset": 325, + "length": 15 + } + ] + }, + { + "content": ": WNI", + "polygon": [ + 103, + 190, + 131, + 190, + 132, + 200, + 103, + 200 + ], + "spans": [ + { + "offset": 341, + "length": 5 + } + ] + }, + { + "content": "Berlaku Hingga", + "polygon": [ + 12, + 202, + 83, + 202, + 83, + 214, + 12, + 214 + ], + "spans": [ + { + "offset": 347, + "length": 14 + } + ] + }, + { + "content": "SEUMUR HIDUP", + "polygon": [ + 107, + 201, + 189, + 200, + 189, + 213, + 107, + 214 + ], + "spans": [ + { + "offset": 362, + "length": 12 + } + ] + }, + { + "content": "SP", + "polygon": [ + 390, + 111, + 409, + 110, + 409, + 129, + 391, + 129 + ], + "spans": [ + { + "offset": 375, + "length": 2 + } + ] + }, + { + "content": "JAKARTA SELATAN", + "polygon": [ + 303, + 178, + 379, + 177, + 379, + 187, + 303, + 188 + ], + "spans": [ + { + "offset": 378, + "length": 15 + } + ] + }, + { + "content": "28-01-2020", + "polygon": [ + 319, + 186, + 365, + 186, + 365, + 197, + 319, + 197 + ], + "spans": [ + { + "offset": 394, + "length": 10 + } + ] + }, + { + "content": "shutterstock.com . 1628461459", + "polygon": [ + 129, + 264, + 284, + 264, + 284, + 276, + 129, + 276 + ], + "spans": [ + { + "offset": 405, + "length": 29 + } + ] + } + ], + "spans": [ + { + "offset": 0, + "length": 434 + } + ] + } + ], + "paragraphs": [ + { + "spans": [ + { + "offset": 0, + "length": 36 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 123, + 7, + 292, + 7, + 292, + 35, + 123, + 35 + ] + } + ], + "content": "PROVINSI DKI JAKARTA JAKARTA SELATAN" + }, + { + "spans": [ + { + "offset": 37, + "length": 3 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 10, + 43, + 35, + 43, + 35, + 56, + 10, + 56 + ] + } + ], + "content": "NIK" + }, + { + "spans": [ + { + "offset": 41, + "length": 1 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 75, + 45, + 83, + 45, + 83, + 56, + 75, + 56 + ] + } + ], + "content": ":" + }, + { + "spans": [ + { + "offset": 43, + "length": 16 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 97, + 42, + 241, + 42, + 241, + 57, + 97, + 57 + ] + } + ], + "content": "1234567890123456" + }, + { + "spans": [ + { + "offset": 60, + "length": 21 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 11, + 67, + 89, + 67, + 89, + 90, + 11, + 90 + ] + } + ], + "content": "Nama Tempat/Tgl Lahir" + }, + { + "spans": [ + { + "offset": 82, + "length": 10 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 67, + 163, + 67, + 163, + 77, + 106, + 77 + ] + } + ], + "content": ": JOHN DOE" + }, + { + "spans": [ + { + "offset": 93, + "length": 21 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 80, + 226, + 79, + 226, + 89, + 106, + 90 + ] + } + ], + "content": ": JAKARTA, DD-MM-YYYY" + }, + { + "spans": [ + { + "offset": 115, + "length": 20 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 11, + 92, + 77, + 92, + 77, + 114, + 11, + 114 + ] + } + ], + "content": "Jenis Kelamin Alamat" + }, + { + "spans": [ + { + "offset": 136, + "length": 25 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 92, + 262, + 92, + 262, + 102, + 106, + 102 + ] + } + ], + "content": ": LAKI-LAKI Gol. Darah: A" + }, + { + "spans": [ + { + "offset": 162, + "length": 28 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 107, + 104, + 267, + 104, + 267, + 114, + 107, + 114 + ] + } + ], + "content": ": LOREM IPSUM DOLOR SIT AMET" + }, + { + "spans": [ + { + "offset": 191, + "length": 5 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 35, + 116, + 67, + 116, + 67, + 126, + 35, + 126 + ] + } + ], + "content": "RT/RW" + }, + { + "spans": [ + { + "offset": 197, + "length": 9 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 117, + 147, + 117, + 147, + 126, + 106, + 126 + ] + } + ], + "content": ": 001/001" + }, + { + "spans": [ + { + "offset": 207, + "length": 8 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 34, + 129, + 77, + 129, + 77, + 139, + 34, + 139 + ] + } + ], + "content": "Kel/Desa" + }, + { + "spans": [ + { + "offset": 216, + "length": 13 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 129, + 182, + 128, + 182, + 138, + 106, + 139 + ] + } + ], + "content": ": LOREM IPSUM" + }, + { + "spans": [ + { + "offset": 230, + "length": 9 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 33, + 141, + 88, + 141, + 88, + 152, + 33, + 152 + ] + } + ], + "content": "Kecamatan" + }, + { + "spans": [ + { + "offset": 240, + "length": 13 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 105, + 141, + 182, + 141, + 182, + 151, + 105, + 151 + ] + } + ], + "content": ": LOREM IPSUM" + }, + { + "spans": [ + { + "offset": 254, + "length": 5 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 12, + 153, + 46, + 153, + 46, + 164, + 12, + 164 + ] + } + ], + "content": "Agama" + }, + { + "spans": [ + { + "offset": 260, + "length": 13 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 106, + 153, + 181, + 153, + 181, + 163, + 106, + 163 + ] + } + ], + "content": ": LOREM IPSUM" + }, + { + "spans": [ + { + "offset": 274, + "length": 17 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 11, + 165, + 100, + 165, + 100, + 176, + 11, + 176 + ] + } + ], + "content": "Status Perkawinan" + }, + { + "spans": [ + { + "offset": 292, + "length": 13 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 104, + 165, + 182, + 165, + 182, + 176, + 104, + 176 + ] + } + ], + "content": ": BELUM KAWIN" + }, + { + "spans": [ + { + "offset": 306, + "length": 9 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 10, + 177, + 58, + 177, + 58, + 189, + 10, + 189 + ] + } + ], + "content": "Pekerjaan" + }, + { + "spans": [ + { + "offset": 316, + "length": 8 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 108, + 178, + 169, + 177, + 169, + 188, + 108, + 189 + ] + } + ], + "content": "KARYAWAN" + }, + { + "spans": [ + { + "offset": 325, + "length": 21 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 10, + 190, + 132, + 190, + 132, + 202, + 10, + 202 + ] + } + ], + "content": "Kewarganegaraan : WNI" + }, + { + "spans": [ + { + "offset": 347, + "length": 14 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 12, + 202, + 83, + 202, + 83, + 214, + 12, + 214 + ] + } + ], + "content": "Berlaku Hingga" + }, + { + "spans": [ + { + "offset": 362, + "length": 12 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 107, + 201, + 189, + 200, + 189, + 213, + 107, + 214 + ] + } + ], + "content": "SEUMUR HIDUP" + }, + { + "spans": [ + { + "offset": 375, + "length": 2 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 390, + 111, + 409, + 110, + 410, + 129, + 391, + 130 + ] + } + ], + "content": "SP" + }, + { + "spans": [ + { + "offset": 378, + "length": 26 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 303, + 178, + 379, + 177, + 379, + 197, + 303, + 198 + ] + } + ], + "content": "JAKARTA SELATAN 28-01-2020" + }, + { + "spans": [ + { + "offset": 405, + "length": 29 + } + ], + "boundingRegions": [ + { + "pageNumber": 1, + "polygon": [ + 129, + 264, + 284, + 264, + 284, + 276, + 129, + 276 + ] + } + ], + "content": "shutterstock.com . 1628461459" + } + ], + "styles": [], + "contentFormat": "text" + } +} \ No newline at end of file diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index a90252d..4e828bb 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -68,13 +68,18 @@ class AuthenticationRepository extends GetxController { } } - /// Updated screenRedirect method to accept arguments + /// Updated screenRedirect method to handle onboarding preferences void screenRedirect({UserMetadataModel? arguments}) async { // Use addPostFrameCallback to ensure navigation happens after the build cycle WidgetsBinding.instance.addPostFrameCallback((_) async { try { final session = _supabase.auth.currentSession; + // Check if user has completed onboarding + final bool isFirstTime = storage.read('isFirstTime') ?? false; + + Logger().i('isFirstTime screen redirect: $isFirstTime'); + if (await _locationService.isLocationValidForFeature() == false) { // Location is not valid, navigate to warning screen Get.offAllNamed(AppRoutes.locationWarning); @@ -100,13 +105,14 @@ class AuthenticationRepository extends GetxController { Get.currentRoute != AppRoutes.onboarding) { bool biometricSuccess = await attemptBiometricLogin(); if (!biometricSuccess) { - // If not first time, go to sign in directly - // If first time, show onboarding first - storage.writeIfNull('isFirstTime', true); - // check if user is already logged in - storage.read('isFirstTime') != true - ? Get.offAllNamed(AppRoutes.signIn) - : Get.offAllNamed(AppRoutes.onboarding); + // Check if onboarding is completed + if (isFirstTime) { + // Skip onboarding and go directly to sign in + Get.offAllNamed(AppRoutes.signIn); + } else { + // First time user, show onboarding + Get.offAllNamed(AppRoutes.onboarding); + } } } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart index 1a77dd7..3f6ae9f 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:logger/logger.dart'; @@ -28,7 +29,9 @@ class FormRegistrationController extends GetxController { late final IdentityVerificationController identityController; late final OfficerInfoController? officerInfoController; late final UnitInfoController? unitInfoController; - + + late GlobalKey formKey; + final storage = GetStorage(); // Current step index @@ -291,18 +294,18 @@ class FormRegistrationController extends GetxController { bool validateCurrentStep() { switch (currentStep.value) { case 0: - return personalInfoController.validate(); + return personalInfoController.validate(formKey); case 1: return idCardVerificationController.validate(); case 2: - return selfieVerificationController.validate(); + return selfieVerificationController.validate(formKey); case 3: return selectedRole.value?.isOfficer == true - ? officerInfoController!.validate() - : identityController.validate(); + ? officerInfoController!.validate(formKey) + : identityController.validate(formKey); case 4: return selectedRole.value?.isOfficer == true - ? unitInfoController!.validate() + ? unitInfoController!.validate(formKey) : true; // Should not reach here for non-officers default: return true; @@ -374,16 +377,16 @@ class FormRegistrationController extends GetxController { // Submit the complete form Future submitForm() async { // Validate all steps - bool isValid = true; + bool isFormValid = true; for (int i = 0; i < totalSteps; i++) { currentStep.value = i; if (!validateCurrentStep()) { - isValid = false; + isFormValid = false; break; } } - if (!isValid) return; + if (!isFormValid) return; try { isLoading.value = false; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart index e411937..b94c6c1 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin_controller.dart @@ -23,7 +23,7 @@ class SignInController extends GetxController { final isLoading = false.obs; - GlobalKey signinFormKey = GlobalKey(); + // GlobalKey formKey = GlobalKey(); @override void onInit() { @@ -42,7 +42,7 @@ class SignInController extends GetxController { } // Sign in method - Future credentialsSignIn() async { + Future credentialsSignIn(GlobalKey formKey) async { try { // Start loading // TFullScreenLoader.openLoadingDialog( @@ -61,7 +61,7 @@ class SignInController extends GetxController { } // Form validation - if (!signinFormKey.currentState!.validate()) { + if (!formKey.currentState!.validate()) { // TFullScreenLoader.stopLoading(); emailError.value = ''; passwordError.value = ''; @@ -103,7 +103,7 @@ class SignInController extends GetxController { } // -- Google Sign In Authentication - Future googleSignIn() async { + Future googleSignIn(GlobalKey formKey) async { try { // Start loading // TFullScreenLoader.openLoadingDialog( @@ -122,7 +122,7 @@ class SignInController extends GetxController { } // Form validation - if (!signinFormKey.currentState!.validate()) { + if (!formKey.currentState!.validate()) { // TFullScreenLoader.stopLoading(); emailError.value = ''; passwordError.value = ''; @@ -155,7 +155,7 @@ class SignInController extends GetxController { // Navigate to sign up screen void goToSignUp() { - Get.toNamed(AppRoutes.signUp); + Get.toNamed(AppRoutes.signupWithRole); } // Navigate to forgot password screen diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart index 0de9728..9a7ca30 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signup_with_role_controller.dart @@ -169,14 +169,11 @@ class SignupWithRoleController extends GetxController { // Sign up function /// Updated signup function with better error handling and argument passing void signUp(bool isOfficer) async { - if (!validateSignupForm()) { - return; - } - try { isLoading.value = true; + Logger().i('SignUp process started'); - // Check connection + // Check network connection final isConnected = await NetworkManager.instance.isConnected(); if (!isConnected) { TLoaders.errorSnackBar( @@ -186,6 +183,19 @@ class SignupWithRoleController extends GetxController { return; } + if (!signupFormKey.currentState!.validate()) { + return; + } + + // Check privacy policy + if (!privacyPolicy.value) { + TLoaders.warningSnackBar( + title: 'Privacy Policy', + message: 'Please accept the privacy policy to continue.', + ); + return; + } + // Ensure we have a role selected if (selectedRoleId.value.isEmpty) { _updateSelectedRoleBasedOnType(); @@ -208,42 +218,46 @@ class SignupWithRoleController extends GetxController { profileStatus: 'incomplete', ); - try { - // Create the account - final authResponse = await AuthenticationRepository.instance - .initialSignUp( - email: emailController.text.trim(), - password: passwordController.text.trim(), - initialData: initialMetadata, - ); + // Create the account + final authResponse = await AuthenticationRepository.instance + .initialSignUp( + email: emailController.text.trim(), + password: passwordController.text.trim(), + initialData: initialMetadata, + ); - // Validate response - if (authResponse.session == null || authResponse.user == null) { - throw Exception('Failed to create account. Please try again.'); - } - - final user = authResponse.user!; - Logger().d('Account created successfully for user: ${user.id}'); - - // Store temporary data for verification process - await _storeTemporaryData(authResponse, isOfficer); - - // Navigate with arguments - AuthenticationRepository.instance.screenRedirect(); - - } catch (authError) { - Logger().e('Authentication error during signup: $authError'); + // Validate response + if (authResponse.session == null || authResponse.user == null) { TLoaders.errorSnackBar( title: 'Registration Failed', - message: _getReadableErrorMessage(authError.toString()), + message: 'Failed to create account. Please try again.', ); return; } + + final user = authResponse.user!; + Logger().d('Account created successfully for user: ${user.id}'); + + // Store temporary data for verification process + await _storeTemporaryData(authResponse, isOfficer); + + // Navigate with arguments + Logger().i('Navigating to registration form'); + // AuthenticationRepository.instance.screenRedirect(); } catch (e) { - Logger().e('Unexpected error during signup: $e'); + Logger().e('Error during signup: $e'); + String errorMessage = _getReadableErrorMessage(e.toString()); + + // Handle AuthException specifically + if (e is AuthException && e.message.contains('email')) { + emailError.value = 'Invalid email or already in use'; + errorMessage = + 'Email validation failed. Please check your email address.'; + } + TLoaders.errorSnackBar( title: 'Registration Failed', - message: 'An unexpected error occurred. Please try again.', + message: errorMessage, ); } finally { isLoading.value = false; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart index ce58432..305d558 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart @@ -1,17 +1,17 @@ import 'dart:io'; -import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; +import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart'; +import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart'; class IdCardVerificationController extends GetxController { // Singleton instance static IdCardVerificationController get instance => Get.find(); // Static form key - final GlobalKey formKey = TGlobalFormKey.idCardVerification(); + // final GlobalKey formKey = TGlobalFormKey.idCardVerification(); final AzureOCRService _ocrService = AzureOCRService(); final bool isOfficer; @@ -26,6 +26,7 @@ class IdCardVerificationController extends GetxController { final RxBool isVerifying = RxBool(false); final RxBool isIdCardValid = RxBool(false); final RxString idCardValidationMessage = RxString(''); + final RxBool isFormValid = RxBool(true); // Loading states for image uploading final RxBool isUploadingIdCard = RxBool(false); @@ -33,25 +34,30 @@ class IdCardVerificationController extends GetxController { // Confirmation status final RxBool hasConfirmedIdCard = RxBool(false); + // Add RxMap to store OCR extraction results + final RxMap extractedInfo = RxMap({}); + final RxBool hasExtractedInfo = RxBool(false); + + // Add model variables for the extracted data + final Rx ktpModel = Rx(null); + final Rx ktaModel = Rx(null); + bool validate() { clearErrors(); - // For this step, we just need to ensure ID card is uploaded and validated - bool isValid = true; - if (idCardImage.value == null) { final idCardType = isOfficer ? 'KTA' : 'KTP'; idCardError.value = 'Please upload your $idCardType image'; - isValid = false; + isFormValid.value = false; } else if (!isIdCardValid.value) { idCardError.value = 'Your ID card image is not valid'; - isValid = false; + isFormValid.value = false; } else if (!hasConfirmedIdCard.value) { idCardError.value = 'Please confirm your ID card image'; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { @@ -105,6 +111,14 @@ class IdCardVerificationController extends GetxController { // Clear previous validation messages clearErrors(); + // Also clear previous extraction results + extractedInfo.clear(); + hasExtractedInfo.value = false; + + // Reset models + ktpModel.value = null; + ktaModel.value = null; + if (idCardImage.value == null) { idCardError.value = 'Please upload an ID card image first'; isIdCardValid.value = false; @@ -125,6 +139,40 @@ class IdCardVerificationController extends GetxController { isOfficer, ); + // Store the extraction results + extractedInfo.assignAll(result); + hasExtractedInfo.value = result.isNotEmpty; + + // Create model from extracted data + if (isOfficer) { + ktaModel.value = KtaModel( + name: result['nama'] ?? '', + nrp: result['nrp'] ?? '', + policeUnit: result['unit'] ?? result['kesatuan'] ?? '', + issueDate: result['tanggal_terbit'] ?? '', + cardNumber: result['nomor_kartu'] ?? '', + extraData: { + 'pangkat': result['pangkat'] ?? '', + 'tanggal_lahir': result['tanggal_lahir'] ?? '', + }, + ); + } else { + ktpModel.value = KtpModel( + nik: result['nik'] ?? '', + name: result['nama'] ?? '', + birthPlace: result['birth_place'] ?? result['birthPlace'] ?? '', + birthDate: result['tanggal_lahir'] ?? result['birthDate'] ?? '', + gender: result['gender'] ?? result['jenis_kelamin'] ?? '', + address: result['alamat'] ?? result['address'] ?? '', + nationality: + result['nationality'] ?? result['kewarganegaraan'] ?? 'WNI', + religion: result['religion'] ?? result['agama'], + occupation: result['occupation'] ?? result['pekerjaan'], + maritalStatus: + result['marital_status'] ?? result['status_perkawinan'], + ); + } + // If we get here without an exception, the image is likely valid isImageValid = result.isNotEmpty; @@ -182,6 +230,10 @@ class IdCardVerificationController extends GetxController { isIdCardValid.value = false; idCardValidationMessage.value = ''; hasConfirmedIdCard.value = false; + extractedInfo.clear(); + hasExtractedInfo.value = false; + ktpModel.value = null; + ktaModel.value = null; } // Confirm ID Card Image diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart index 62edc53..1c39cfc 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class IdentityVerificationController extends GetxController { @@ -10,9 +9,10 @@ class IdentityVerificationController extends GetxController { static IdentityVerificationController get instance => Get.find(); // Static form key - final GlobalKey formKey = TGlobalFormKey.identityVerification(); + // final GlobalKey formKey = TGlobalFormKey.identityVerification(); final AzureOCRService _ocrService = AzureOCRService(); final bool isOfficer; + final RxBool isFormValid = RxBool(true); IdentityVerificationController({required this.isOfficer}); @@ -63,7 +63,7 @@ class IdentityVerificationController extends GetxController { } } - bool validate() { + bool validate(GlobalKey formKey) { clearErrors(); if (formKey.currentState?.validate() ?? false) { @@ -81,9 +81,6 @@ class IdentityVerificationController extends GetxController { return true; } - // Manual validation as fallback - bool isValid = true; - if (!isOfficer) { final nikValidation = TValidators.validateUserInput( 'NIK', @@ -92,7 +89,7 @@ class IdentityVerificationController extends GetxController { ); if (nikValidation != null) { nikError.value = nikValidation; - isValid = false; + isFormValid.value = false; } // Validate full name @@ -103,7 +100,7 @@ class IdentityVerificationController extends GetxController { ); if (fullNameValidation != null) { fullNameError.value = fullNameValidation; - isValid = false; + isFormValid.value = false; } // Validate place of birth @@ -114,13 +111,13 @@ class IdentityVerificationController extends GetxController { ); if (placeOfBirthValidation != null) { placeOfBirthError.value = placeOfBirthValidation; - isValid = false; + isFormValid.value = false; } // Validate gender if (selectedGender.value.isEmpty) { genderError.value = 'Gender is required'; - isValid = false; + isFormValid.value = false; } // Validate address @@ -131,7 +128,7 @@ class IdentityVerificationController extends GetxController { ); if (addressValidation != null) { addressError.value = addressValidation; - isValid = false; + isFormValid.value = false; } } @@ -144,7 +141,7 @@ class IdentityVerificationController extends GetxController { ); if (bioValidation != null) { bioError.value = bioValidation; - isValid = false; + isFormValid.value = false; } // Birth date validation @@ -155,10 +152,10 @@ class IdentityVerificationController extends GetxController { ); if (birthDateValidation != null) { birthDateError.value = birthDateValidation; - isValid = false; + isFormValid.value = false; } - return isValid && isVerified.value && isFaceVerified.value; + return isFormValid.value && isVerified.value && isFaceVerified.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart index 675ab3a..dc6307a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart'; @@ -7,9 +6,10 @@ class ImageVerificationController extends GetxController { // Singleton instance static ImageVerificationController get instance => Get.find(); - final GlobalKey formKey = GlobalKey(); + // final GlobalKey formKey = GlobalKey(); final AzureOCRService _ocrService = AzureOCRService(); final bool isOfficer; +final RxBool isFormValid = RxBool(true); ImageVerificationController({required this.isOfficer}); @@ -41,32 +41,32 @@ class ImageVerificationController extends GetxController { clearErrors(); // For this step, we just need to ensure both images are uploaded and initially validated - bool isValid = true; + if (idCardImage.value == null) { final idCardType = isOfficer ? 'KTA' : 'KTP'; idCardError.value = 'Please upload your $idCardType image'; - isValid = false; + isFormValid.value = false; } else if (!isIdCardValid.value) { idCardError.value = 'Your ID card image is not valid'; - isValid = false; + isFormValid.value = false; } else if (!hasConfirmedIdCard.value) { idCardError.value = 'Please confirm your ID card image'; - isValid = false; + isFormValid.value = false; } if (selfieImage.value == null) { selfieError.value = 'Please take a selfie for verification'; - isValid = false; + isFormValid.value = false; } else if (!isSelfieValid.value) { selfieError.value = 'Your selfie image is not valid'; - isValid = false; + isFormValid.value = false; } else if (!hasConfirmedSelfie.value) { selfieError.value = 'Please confirm your selfie image'; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart index 06e833e..112c6aa 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class OfficerInfoController extends GetxController { @@ -8,8 +7,9 @@ class OfficerInfoController extends GetxController { static OfficerInfoController get instance => Get.find(); // Static form key - final GlobalKey formKey = TGlobalFormKey.officerInfo(); - + // final GlobalKey formKey = TGlobalFormKey.officerInfo(); + +final RxBool isFormValid = RxBool(true); // Controllers final nrpController = TextEditingController(); final rankController = TextEditingController(); @@ -18,15 +18,14 @@ class OfficerInfoController extends GetxController { final RxString nrpError = ''.obs; final RxString rankError = ''.obs; - bool validate() { + bool validate(GlobalKey formKey) { clearErrors(); if (formKey.currentState?.validate() ?? false) { return true; } - // Manual validation as fallback - bool isValid = true; + final nrpValidation = TValidators.validateUserInput( 'NRP', @@ -35,7 +34,7 @@ class OfficerInfoController extends GetxController { ); if (nrpValidation != null) { nrpError.value = nrpValidation; - isValid = false; + isFormValid.value = false; } final rankValidation = TValidators.validateUserInput( @@ -45,10 +44,10 @@ class OfficerInfoController extends GetxController { ); if (rankValidation != null) { rankError.value = rankValidation; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart index 692f4bb..8288752 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart @@ -1,15 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class PersonalInfoController extends GetxController { + // Singleton instance static PersonalInfoController get instance => Get.find(); - // Static form key - final GlobalKey formKey = TGlobalFormKey.personalInfo(); - // Controllers final firstNameController = TextEditingController(); final lastNameController = TextEditingController(); @@ -27,6 +24,9 @@ class PersonalInfoController extends GetxController { final RxString addressError = ''.obs; + // Manual validation as fallback + final RxBool isFormValid = RxBool(true); + @override void onInit() { super.onInit(); @@ -51,15 +51,13 @@ class PersonalInfoController extends GetxController { } } - bool validate() { + bool validate(GlobalKey formKey) { clearErrors(); if (formKey.currentState?.validate() ?? false) { return true; } - // Manual validation as fallback - bool isValid = true; final firstNameValidation = TValidators.validateUserInput( 'First name', @@ -68,7 +66,7 @@ class PersonalInfoController extends GetxController { ); if (firstNameValidation != null) { firstNameError.value = firstNameValidation; - isValid = false; + isFormValid.value = false; } final lastNameValidation = TValidators.validateUserInput( @@ -79,7 +77,7 @@ class PersonalInfoController extends GetxController { ); if (lastNameValidation != null) { lastNameError.value = lastNameValidation; - isValid = false; + isFormValid.value = false; } final phoneValidation = TValidators.validatePhoneNumber( @@ -87,7 +85,7 @@ class PersonalInfoController extends GetxController { ); if (phoneValidation != null) { phoneError.value = phoneValidation; - isValid = false; + isFormValid.value = false; } // Bio can be optional, so we validate with required: false @@ -99,7 +97,7 @@ class PersonalInfoController extends GetxController { ); if (bioValidation != null) { bioError.value = bioValidation; - isValid = false; + isFormValid.value = false; } final addressValidation = TValidators.validateUserInput( @@ -109,10 +107,10 @@ class PersonalInfoController extends GetxController { ); if (addressValidation != null) { addressError.value = addressValidation; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart index 05ad01c..8f7298e 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart @@ -4,19 +4,22 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/cores/services/azure_ocr_service.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; class SelfieVerificationController extends GetxController { // Singleton instance static SelfieVerificationController get instance => Get.find(); // Static form key - final GlobalKey formKey = TGlobalFormKey.selfieVerification(); + // final GlobalKey formKey = TGlobalFormKey.selfieVerification(); final AzureOCRService _ocrService = AzureOCRService(); // Maximum allowed file size in bytes (4MB) final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB in bytes + // For this step, we just need to ensure selfie is uploaded and validated + final RxBool isFormValid = RxBool(true); + + // Face verification variables final Rx selfieImage = Rx(null); final RxString selfieError = RxString(''); @@ -32,24 +35,22 @@ class SelfieVerificationController extends GetxController { // Confirmation status final RxBool hasConfirmedSelfie = RxBool(false); - bool validate() { + bool validate(GlobalKey formKey) { clearErrors(); - // For this step, we just need to ensure selfie is uploaded and validated - bool isValid = true; if (selfieImage.value == null) { selfieError.value = 'Please take a selfie for verification'; - isValid = false; + isFormValid.value = false; } else if (!isSelfieValid.value) { selfieError.value = 'Your selfie image is not valid'; - isValid = false; + isFormValid.value = false; } else if (!hasConfirmedSelfie.value) { selfieError.value = 'Please confirm your selfie image'; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart index 50c79e4..e643490 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart'; -import 'package:sigap/src/utils/constants/form_key.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class UnitInfoController extends GetxController { @@ -9,8 +8,9 @@ class UnitInfoController extends GetxController { static UnitInfoController get instance => Get.find(); // Static form key - final GlobalKey formKey = TGlobalFormKey.unitInfo(); - + // final GlobalKey formKey = TGlobalFormKey.unitInfo(); + // Manual validation as fallback + final RxBool isFormValid = RxBool(true); // Controllers final positionController = TextEditingController(); final unitIdController = TextEditingController(); @@ -22,15 +22,14 @@ class UnitInfoController extends GetxController { final RxString positionError = ''.obs; final RxString unitIdError = ''.obs; - bool validate() { + bool validate(GlobalKey formKey) { clearErrors(); if (formKey.currentState?.validate() ?? false) { return true; } - // Manual validation as fallback - bool isValid = true; + final positionValidation = TValidators.validateUserInput( 'Position', @@ -39,15 +38,15 @@ class UnitInfoController extends GetxController { ); if (positionValidation != null) { positionError.value = positionValidation; - isValid = false; + isFormValid.value = false; } if (unitIdController.text.isEmpty) { unitIdError.value = 'Please select a unit'; - isValid = false; + isFormValid.value = false; } - return isValid; + return isFormValid.value; } void clearErrors() { diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart new file mode 100644 index 0000000..63fac1d --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; +import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart'; +import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart'; +import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart'; +import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; +import 'package:sigap/src/shared/widgets/info/tips_container.dart'; +import 'package:sigap/src/shared/widgets/verification/ocr_result_card.dart'; +import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class IdCardVerificationStep extends StatelessWidget { + const IdCardVerificationStep({super.key}); + + @override + Widget build(BuildContext context) { + // Initialize form key + final controller = Get.find(); + final mainController = Get.find(); + + final formKey = GlobalKey(); + mainController.formKey = formKey; + + final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, idCardType), + + // Error Messages + Obx( + () => + controller.idCardError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + controller.idCardError.value, + style: const TextStyle(color: Colors.red), + ), + ) + : const SizedBox.shrink(), + ), + + // ID Card Upload Widget + Obx( + () => ImageUploader( + image: controller.idCardImage.value, + title: 'Upload $idCardType Image', + subtitle: 'Tap to select an image (max 4MB)', + errorMessage: controller.idCardError.value, + isUploading: controller.isUploadingIdCard.value, + isVerifying: controller.isVerifying.value, + isConfirmed: controller.hasConfirmedIdCard.value, + onTapToSelect: + () => _showImageSourceDialog(controller, isOfficer), + onClear: controller.clearIdCardImage, + onValidate: controller.validateIdCardImage, + placeholderIcon: Icons.add_a_photo, + ), + ), + + // OCR Results Card - Using appropriate model + Obx(() { + // Display the appropriate model data + if (controller.isVerifying.value == false && + controller.idCardImage.value != null && + controller.idCardValidationMessage.value.isNotEmpty) { + if (isOfficer && controller.ktaModel.value != null) { + return _buildKtaResultCard( + controller.ktaModel.value!, + controller.isIdCardValid.value, + ); + } else if (!isOfficer && controller.ktpModel.value != null) { + return _buildKtpResultCard( + controller.ktpModel.value!, + controller.isIdCardValid.value, + ); + } else { + // Fallback to the regular OCR result card + return Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: OcrResultCard( + extractedInfo: controller.extractedInfo, + isOfficer: isOfficer, + isValid: controller.isIdCardValid.value, + ), + ); + } + } + return const SizedBox.shrink(); + }), + + // Verification Message for ID Card + Obx( + () => + controller.idCardValidationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.idCardValidationMessage.value, + isValid: controller.isIdCardValid.value, + hasConfirmed: controller.hasConfirmedIdCard.value, + onConfirm: controller.confirmIdCardImage, + onTryAnother: controller.clearIdCardImage, + ), + ) + : const SizedBox.shrink(), + ), + + // Tips Section + const SizedBox(height: TSizes.spaceBtwItems), + _buildIdCardTips(idCardType), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context, String idCardType) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$idCardType Verification', + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: TSizes.sm), + Text( + 'Upload a clear image of your $idCardType', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(color: TColors.textSecondary), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Make sure all text and your photo are clearly visible', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: TColors.textSecondary), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + Widget _buildIdCardTips(String idCardType) { + return TipsContainer( + title: "Tips for a good $idCardType photo:", + tips: [ + "Place the card on a dark, non-reflective surface", + "Ensure all four corners are visible", + "Make sure there's good lighting to avoid shadows", + "Your photo and all text should be clearly visible", + "Avoid using flash to prevent glare", + ], + backgroundColor: Colors.blue, + textColor: Colors.blue.shade800, + iconColor: Colors.blue, + borderColor: Colors.blue, + ); + } + + void _showImageSourceDialog( + IdCardVerificationController controller, + bool isOfficer, + ) { + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + + ImageSourceDialog.show( + title: 'Select $idCardType Image Source', + message: + 'Please ensure your ID card is clear, well-lit, and all text is readable. Maximum file size: 4MB', + onSourceSelected: controller.pickIdCardImage, + galleryOption: true, + ); + } + + Widget _buildKtpResultCard(KtpModel model, bool isValid) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + side: BorderSide( + color: isValid ? Colors.green : Colors.orange, + width: 1.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader('KTP', isValid), + const Divider(height: TSizes.spaceBtwItems), + + // Display KTP details + if (model.nik.isNotEmpty) + _buildInfoRow('NIK', model.formattedNik), + if (model.name.isNotEmpty) _buildInfoRow('Name', model.name), + if (model.birthPlace.isNotEmpty) + _buildInfoRow('Birth Place', model.birthPlace), + if (model.birthDate.isNotEmpty) + _buildInfoRow('Birth Date', model.birthDate), + if (model.gender.isNotEmpty) + _buildInfoRow('Gender', model.gender), + if (model.address.isNotEmpty) + _buildInfoRow('Address', model.address), + + if (!isValid) _buildDataWarning(), + ], + ), + ), + ), + ); + } + + Widget _buildKtaResultCard(KtaModel model, bool isValid) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + side: BorderSide( + color: isValid ? Colors.green : Colors.orange, + width: 1.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader('KTA', isValid), + const Divider(height: TSizes.spaceBtwItems), + + // Display KTA details + if (model.name.isNotEmpty) _buildInfoRow('Name', model.name), + if (model.nrp.isNotEmpty) + _buildInfoRow('NRP', model.formattedNrp), + if (model.policeUnit.isNotEmpty) + _buildInfoRow('Unit', model.policeUnit), + + // Get extra data + if (model.extraData != null) ...[ + if (model.extraData!['pangkat'] != null) + _buildInfoRow('Rank', model.extraData!['pangkat']), + if (model.extraData!['tanggal_lahir'] != null) + _buildInfoRow( + 'Birth Date', + model.extraData!['tanggal_lahir'], + ), + ], + + if (model.issueDate.isNotEmpty) + _buildInfoRow('Issue Date', model.issueDate), + if (model.cardNumber.isNotEmpty) + _buildInfoRow('Card Number', model.cardNumber), + + if (!isValid) _buildDataWarning(), + ], + ), + ), + ), + ); + } + + Widget _buildCardHeader(String cardType, bool isValid) { + return Row( + children: [ + Icon( + isValid ? Icons.check_circle : Icons.info, + color: isValid ? Colors.green : Colors.orange, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + 'Extracted $cardType Information', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: TSizes.fontSizeMd, + ), + ), + ), + ], + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: TColors.textSecondary, + ), + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + value, + style: const TextStyle(color: TColors.textPrimary), + ), + ), + ], + ), + ); + } + + Widget _buildDataWarning() { + return Container( + margin: const EdgeInsets.only(top: TSizes.sm), + padding: const EdgeInsets.all(TSizes.sm), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.orange, + size: TSizes.iconSm, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + 'Some information might be missing or incorrect. Please verify the extracted data.', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + color: Colors.orange.shade800, + ), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart new file mode 100644 index 0000000..d6a6f3d --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart'; +import 'package:sigap/src/shared/widgets/form/date_picker_field.dart'; +import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; +import 'package:sigap/src/shared/widgets/form/gender_selection.dart'; +import 'package:sigap/src/shared/widgets/form/verification_status.dart'; +import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; +import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; +import 'package:sigap/src/utils/validators/validation.dart'; + +class IdentityVerificationStep extends StatelessWidget { + const IdentityVerificationStep({super.key}); + + @override + Widget build(BuildContext context) { + final formKey = GlobalKey(); + final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FormSectionHeader( + title: 'Additional Information', + subtitle: 'Please provide additional personal details', + ), + + // Different fields based on role + if (!mainController.selectedRole.value!.isOfficer) ...[ + // NIK field for viewers + Obx( + () => CustomTextField( + label: 'NIK (Identity Number)', + controller: controller.nikController, + validator: + (value) => TValidators.validateUserInput('NIK', value, 16), + errorText: controller.nikError.value, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.number, + hintText: 'e.g., 1234567890123456', + onChanged: (value) { + controller.nikController.text = value; + controller.nikError.value = ''; + }, + ), + ), + + // Full Name field + Obx( + () => CustomTextField( + label: 'Full Name', + controller: controller.fullNameController, + validator: + (value) => + TValidators.validateUserInput('Full Name', value, 100), + errorText: controller.fullNameError.value, + textInputAction: TextInputAction.next, + hintText: 'Enter your full name as on KTP', + onChanged: (value) { + controller.fullNameController.text = value; + controller.fullNameError.value = ''; + }, + ), + ), + + // Place of Birth field with city selection + Obx(() => _buildPlaceOfBirthField(context, controller)), + ], + + // Birth Date field with calendar picker + Obx( + () => DatePickerField( + label: 'Birth Date', + controller: controller.birthDateController, + errorText: controller.birthDateError.value, + onDateSelected: (date) { + controller.birthDateError.value = ''; + }, + ), + ), + + // Gender selection + Obx( + () => GenderSelection( + selectedGender: controller.selectedGender.value, + onGenderChanged: (value) { + if (value != null) { + controller.selectedGender.value = value; + controller.genderError.value = ''; + } + }, + errorText: controller.genderError.value, + ), + ), + + // Address field + Obx( + () => CustomTextField( + label: 'Address', + controller: controller.addressController, + validator: + (value) => + TValidators.validateUserInput('Address', value, 255), + errorText: controller.addressError.value, + textInputAction: TextInputAction.next, + hintText: 'Enter your address as on KTP', + maxLines: 3, + onChanged: (value) { + controller.addressController.text = value; + controller.addressError.value = ''; + }, + ), + ), + + // Verification Status + Obx( + () => VerificationStatus( + isVerifying: controller.isVerifying.value, + verifyingMessage: 'Processing your personal information...', + ), + ), + + // Verification Message + Obx( + () => + controller.verificationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.verificationMessage.value, + isValid: controller.isVerified.value, + hasConfirmed: false, + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Data verification button + ElevatedButton.icon( + onPressed: + controller.isVerifying.value + ? null + : () => controller.verifyIdCardWithOCR(), + icon: const Icon(Icons.verified_user), + label: Text( + controller.isVerified.value + ? 'Re-verify Personal Information' + : 'Verify Personal Information', + ), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + ), + ), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Face verification section + const FormSectionHeader( + title: 'Face Verification', + subtitle: 'Verify that your face matches with your ID card photo', + ), + + // Face Verification Status + Obx( + () => VerificationStatus( + isVerifying: controller.isVerifyingFace.value, + verifyingMessage: 'Comparing face with ID photo...', + ), + ), + + // Face match verification button + ElevatedButton.icon( + onPressed: + controller.isVerifyingFace.value || + controller.isFaceVerified.value + ? null + : () => controller.verifyFaceMatch(), + icon: const Icon(Icons.face_retouching_natural), + label: Text( + controller.isFaceVerified.value + ? 'Face Verified Successfully' + : 'Verify Face Match', + ), + style: ElevatedButton.styleFrom( + backgroundColor: + controller.isFaceVerified.value + ? Colors.green + : TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + disabledBackgroundColor: + controller.isFaceVerified.value + ? Colors.green.withOpacity(0.7) + : null, + ), + ), + + // Face Verification Message + Obx( + () => + controller.faceVerificationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.faceVerificationMessage.value, + isValid: controller.isFaceVerified.value, + hasConfirmed: false, + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildPlaceOfBirthField( + BuildContext context, + IdentityVerificationController controller, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: TSizes.xs), + GestureDetector( + onTap: () => _navigateToCitySelection(context, controller), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md, + ), + decoration: BoxDecoration( + border: Border.all( + color: + controller.placeOfBirthError.value.isNotEmpty + ? TColors.error + : TColors.textSecondary, + ), + borderRadius: BorderRadius.circular(TSizes.borderRadiusLg), + color: Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.placeOfBirthController.text.isEmpty + ? 'Select Place of Birth' + : controller.placeOfBirthController.text, + style: TextStyle( + color: + controller.placeOfBirthController.text.isEmpty + ? Theme.of(context).textTheme.bodyMedium?.color + : TColors.textSecondary, + ), + ), + Icon( + Icons.location_city, + size: TSizes.iconSm, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ), + ), + if (controller.placeOfBirthError.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), + child: Text( + controller.placeOfBirthError.value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: TColors.error, + fontSize: 12, + ), + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + void _navigateToCitySelection( + BuildContext context, + IdentityVerificationController controller, + ) async { + final selectedCity = await Get.to(() => const CitySelectionPage()); + if (selectedCity != null && selectedCity.isNotEmpty) { + controller.placeOfBirthController.text = selectedCity; + controller.placeOfBirthError.value = ''; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart new file mode 100644 index 0000000..809e415 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/image_verification_step.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart'; +import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; +import 'package:sigap/src/shared/widgets/form/verification_status.dart'; +import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart'; +import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; +import 'package:sigap/src/shared/widgets/info/tips_container.dart'; +import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class ImageVerificationStep extends StatelessWidget { + const ImageVerificationStep({super.key}); + + @override + Widget build(BuildContext context) { + // Initialize the form key + final formKey = GlobalKey(); + final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; + + final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FormSectionHeader( + title: 'Identity Document Verification', + subtitle: 'Please upload your identity documents for verification', + ), + + // ID Card Upload Section + _buildSectionHeader( + title: '$idCardType Upload', + subtitle: 'Upload a clear image of your $idCardType', + additionalText: + 'Make sure all text and your photo are clearly visible', + ), + + // ID Card Upload Widget + Obx( + () => ImageUploader( + image: controller.idCardImage.value, + title: 'Upload $idCardType Image', + subtitle: 'Tap to select an image', + errorMessage: controller.idCardError.value, + isUploading: controller.isUploadingIdCard.value, + isVerifying: controller.isVerifying.value, + isConfirmed: controller.hasConfirmedIdCard.value, + onTapToSelect: + () => _showImageSourceDialog(controller, true, idCardType), + onClear: controller.clearIdCardImage, + onValidate: controller.validateIdCardImage, + placeholderIcon: Icons.add_a_photo, + ), + ), + + // ID Card Verification Status + Obx( + () => VerificationStatus( + isVerifying: + controller.isVerifying.value && + !controller.isUploadingIdCard.value, + verifyingMessage: 'Validating your ID card...', + ), + ), + + // ID Card Verification Message + Obx( + () => + controller.idCardValidationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.idCardValidationMessage.value, + isValid: controller.isIdCardValid.value, + hasConfirmed: controller.hasConfirmedIdCard.value, + onConfirm: controller.confirmIdCardImage, + onTryAnother: controller.clearIdCardImage, + ), + ) + : const SizedBox.shrink(), + ), + + // ID Card Tips + const SizedBox(height: TSizes.spaceBtwItems), + _buildIdCardTips(idCardType), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Selfie Upload Section + _buildSectionHeader( + title: 'Selfie Upload', + subtitle: 'Take a clear selfie for identity verification', + additionalText: + 'Make sure your face is well-lit and clearly visible', + ), + + // Selfie Upload Widget + Obx( + () => ImageUploader( + image: controller.selfieImage.value, + title: 'Take a Selfie', + subtitle: 'Tap to open camera', + errorMessage: controller.selfieError.value, + isUploading: controller.isUploadingSelfie.value, + isVerifying: controller.isVerifyingFace.value, + isConfirmed: controller.hasConfirmedSelfie.value, + onTapToSelect: + () => controller.pickSelfieImage(ImageSource.camera), + onClear: controller.clearSelfieImage, + onValidate: controller.validateSelfieImage, + placeholderIcon: Icons.face, + ), + ), + + // Selfie Verification Status + Obx( + () => VerificationStatus( + isVerifying: + controller.isVerifyingFace.value && + !controller.isUploadingSelfie.value, + verifyingMessage: 'Validating your selfie...', + ), + ), + + // Selfie Verification Message + Obx( + () => + controller.selfieValidationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.selfieValidationMessage.value, + isValid: controller.isSelfieValid.value, + hasConfirmed: controller.hasConfirmedSelfie.value, + onConfirm: controller.confirmSelfieImage, + onTryAnother: controller.clearSelfieImage, + ), + ) + : const SizedBox.shrink(), + ), + + // Selfie Tips + const SizedBox(height: TSizes.spaceBtwItems), + _buildSelfieTips(), + + // Error Messages + Obx(() { + if (controller.idCardError.value.isNotEmpty) { + return Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + controller.idCardError.value, + style: const TextStyle(color: Colors.red), + ), + ); + } + if (controller.selfieError.value.isNotEmpty) { + return Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + controller.selfieError.value, + style: const TextStyle(color: Colors.red), + ), + ); + } + return const SizedBox.shrink(); + }), + ], + ), + ); + } + + Widget _buildSectionHeader({ + required String title, + required String subtitle, + required String additionalText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeMd, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: TSizes.sm), + Text( + subtitle, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + additionalText, + style: TextStyle( + fontSize: TSizes.fontSizeXs, + fontStyle: FontStyle.italic, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + Widget _buildIdCardTips(String idCardType) { + return TipsContainer( + title: "Tips for a good $idCardType photo:", + tips: [ + "Place the card on a dark, non-reflective surface", + "Ensure all four corners are visible", + "Make sure there's good lighting to avoid shadows", + "Your photo and all text should be clearly visible", + "Avoid using flash to prevent glare", + ], + backgroundColor: Colors.blue, + textColor: Colors.blue.shade800, + iconColor: Colors.blue, + borderColor: Colors.blue, + ); + } + + Widget _buildSelfieTips() { + return TipsContainer( + title: "Tips for a good selfie:", + tips: [ + "Find a well-lit area with even lighting", + "Hold the camera at eye level", + "Look directly at the camera", + "Ensure your entire face is visible", + "Remove glasses and face coverings", + ], + backgroundColor: TColors.primary, + textColor: TColors.primary, + iconColor: TColors.primary, + borderColor: TColors.primary, + ); + } + + void _showImageSourceDialog( + ImageVerificationController controller, + bool isIdCard, + String idCardType, + ) { + ImageSourceDialog.show( + title: 'Select $idCardType Image Source', + message: + 'Please ensure your ID card is clear, well-lit, and all text is readable', + onSourceSelected: controller.pickIdCardImage, + galleryOption: isIdCard, // Only allow gallery for ID card + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart similarity index 72% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart index fb5e2e6..879e89d 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart'; +import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class OfficerInfoStep extends StatelessWidget { @@ -11,30 +11,20 @@ class OfficerInfoStep extends StatelessWidget { @override Widget build(BuildContext context) { + final formKey = GlobalKey(); final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; return Form( - key: controller.formKey, + key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Officer Information', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), + const FormSectionHeader( + title: 'Officer Information', + subtitle: 'Please provide your officer details', ), - const SizedBox(height: TSizes.sm), - Text( - 'Please provide your officer details', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), // NRP field Obx( diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart similarity index 85% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart index 7e82290..2b04976 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart'; +import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class PersonalInfoStep extends StatelessWidget { @@ -11,30 +11,20 @@ class PersonalInfoStep extends StatelessWidget { @override Widget build(BuildContext context) { + final formKey = GlobalKey(); final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; return Form( - key: controller.formKey, + key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Personal Information', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), + const FormSectionHeader( + title: 'Personal Information', + subtitle: 'Please provide your personal details', ), - const SizedBox(height: TSizes.sm), - Text( - 'Please provide your personal details', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), // First Name field Obx( diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart index baf8d92..b4667f5 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/id_card_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/identity_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/officer_info_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/personal_info_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/shared/widgets/indicators/step_indicator/step_indicator.dart'; import 'package:sigap/src/utils/constants/colors.dart'; @@ -19,7 +19,6 @@ class FormRegistrationScreen extends StatelessWidget { @override Widget build(BuildContext context) { - // Get the controller final controller = Get.find(); final dark = THelperFunctions.isDarkMode(context); @@ -33,28 +32,9 @@ class FormRegistrationScreen extends StatelessWidget { return Scaffold( backgroundColor: dark ? TColors.dark : TColors.light, - appBar: AppBar( - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - elevation: 0, - title: Text( - 'Complete Your Profile', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - centerTitle: true, - leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: dark ? TColors.white : TColors.black, - size: TSizes.iconMd, - ), - onPressed: () => Get.back(), - ), - ), + appBar: _buildAppBar(context, dark), body: Obx(() { - // Make loading check more robust - showing a loading state while controller initializes + // Show loading state while controller initializes if (controller.userMetadata.value.userId == null && controller.userMetadata.value.roleId == null) { return const Center( @@ -73,18 +53,7 @@ class FormRegistrationScreen extends StatelessWidget { child: Column( children: [ // Step indicator - Padding( - padding: const EdgeInsets.all(TSizes.defaultSpace), - child: Obx( - () => StepIndicator( - currentStep: controller.currentStep.value, - totalSteps: controller.totalSteps, - stepTitles: controller.getStepTitles(), - onStepTapped: controller.goToStep, - style: StepIndicatorStyle.standard, - ), - ), - ), + _buildStepIndicator(controller), // Step content Expanded( @@ -99,54 +68,7 @@ class FormRegistrationScreen extends StatelessWidget { ), // Navigation buttons - Padding( - padding: const EdgeInsets.all(TSizes.defaultSpace), - child: Row( - children: [ - // Back button - Obx( - () => - controller.currentStep.value > 0 - ? Expanded( - child: Padding( - padding: const EdgeInsets.only( - right: TSizes.sm, - ), - child: AuthButton( - text: 'Previous', - onPressed: controller.previousStep, - isPrimary: false, - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Next/Submit button - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: - controller.currentStep.value > 0 - ? TSizes.sm - : 0.0, - ), - child: Obx( - () => AuthButton( - text: - controller.currentStep.value == - controller.totalSteps - 1 - ? 'Submit' - : 'Next', - onPressed: controller.nextStep, - isLoading: controller.isLoading.value, - ), - ), - ), - ), - ], - ), - ), + _buildNavigationButtons(controller), ], ), ); @@ -154,6 +76,89 @@ class FormRegistrationScreen extends StatelessWidget { ); } + AppBar _buildAppBar(BuildContext context, bool dark) { + return AppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'Complete Your Profile', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + centerTitle: true, + leading: IconButton( + icon: Icon( + Icons.arrow_back, + color: dark ? TColors.white : TColors.black, + size: TSizes.iconMd, + ), + onPressed: () => Get.back(), + ), + ); + } + + Widget _buildStepIndicator(FormRegistrationController controller) { + return Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Obx( + () => StepIndicator( + currentStep: controller.currentStep.value, + totalSteps: controller.totalSteps, + stepTitles: controller.getStepTitles(), + onStepTapped: controller.goToStep, + style: StepIndicatorStyle.standard, + ), + ), + ); + } + + Widget _buildNavigationButtons(FormRegistrationController controller) { + return Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Row( + children: [ + // Back button + Obx( + () => + controller.currentStep.value > 0 + ? Expanded( + child: Padding( + padding: const EdgeInsets.only(right: TSizes.sm), + child: AuthButton( + text: 'Previous', + onPressed: controller.previousStep, + isPrimary: false, + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Next/Submit button + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: controller.currentStep.value > 0 ? TSizes.sm : 0.0, + ), + child: Obx( + () => AuthButton( + text: + controller.currentStep.value == controller.totalSteps - 1 + ? 'Submit' + : 'Next', + onPressed: controller.nextStep, + isLoading: controller.isLoading.value, + ), + ), + ), + ), + ], + ), + ); + } + Widget _buildStepContent(FormRegistrationController controller) { final isOfficer = controller.selectedRole.value?.isOfficer ?? false; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart new file mode 100644 index 0000000..65f5095 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/selfie_verification_step.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; +import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; +import 'package:sigap/src/shared/widgets/info/tips_container.dart'; +import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class SelfieVerificationStep extends StatelessWidget { + const SelfieVerificationStep({super.key}); + + @override + Widget build(BuildContext context) { + // Initialize form key + final formKey = GlobalKey(); + final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + + // Selfie Upload Widget + Obx( + () => ImageUploader( + image: controller.selfieImage.value, + title: 'Take a Selfie', + subtitle: 'Tap to take a selfie (max 4MB)', + errorMessage: controller.selfieError.value, + isUploading: controller.isUploadingSelfie.value, + isVerifying: controller.isVerifyingFace.value, + isConfirmed: controller.hasConfirmedSelfie.value, + onTapToSelect: () => _captureSelfie(controller), + onClear: controller.clearSelfieImage, + onValidate: controller.validateSelfieImage, + placeholderIcon: Icons.face, + ), + ), + + // Verification Status for Selfie + Obx( + () => + controller.isVerifyingFace.value && + !controller.isUploadingSelfie.value + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: TSizes.sm), + Text('Validating your selfie...'), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Verification Message for Selfie + Obx( + () => + controller.selfieValidationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: ValidationMessageCard( + message: controller.selfieValidationMessage.value, + isValid: controller.isSelfieValid.value, + hasConfirmed: controller.hasConfirmedSelfie.value, + onConfirm: controller.confirmSelfieImage, + onTryAnother: controller.clearSelfieImage, + ), + ) + : const SizedBox.shrink(), + ), + + // Error Messages + Obx( + () => + controller.selfieError.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + controller.selfieError.value, + style: const TextStyle(color: Colors.red), + ), + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: TSizes.spaceBtwSections), + + // Tips for taking a good selfie + _buildSelfieTips(), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selfie Verification', + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: TSizes.sm), + Text( + 'Take a clear selfie for identity verification', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Make sure your face is well-lit and clearly visible', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + fontStyle: FontStyle.italic, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + Widget _buildSelfieTips() { + return TipsContainer( + title: 'Tips for a Good Selfie:', + tips: [ + 'Find a well-lit area with even lighting', + 'Hold the camera at eye level', + 'Look directly at the camera', + 'Ensure your entire face is visible', + 'Remove glasses and face coverings', + ], + backgroundColor: TColors.primary, + textColor: TColors.primary, + iconColor: TColors.primary, + borderColor: TColors.primary, + ); + } + + void _captureSelfie(SelfieVerificationController controller) { + controller.pickSelfieImage(ImageSource.camera); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart similarity index 77% rename from sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart rename to sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart index 420ddf1..83e71ac 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/unit_info_step.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart'; import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart'; +import 'package:sigap/src/shared/widgets/form/form_section_header.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class UnitInfoStep extends StatelessWidget { @@ -12,30 +12,20 @@ class UnitInfoStep extends StatelessWidget { @override Widget build(BuildContext context) { + final formKey = GlobalKey(); final controller = Get.find(); + final mainController = Get.find(); + mainController.formKey = formKey; return Form( - key: controller.formKey, + key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Unit Information', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), + const FormSectionHeader( + title: 'Unit Information', + subtitle: 'Please provide your unit details', ), - const SizedBox(height: TSizes.sm), - Text( - 'Please provide your unit details', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), // Position field Obx( diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart deleted file mode 100644 index a0add1a..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart +++ /dev/null @@ -1,554 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; - -class IdCardVerificationStep extends StatelessWidget { - const IdCardVerificationStep({super.key}); - - @override - Widget build(BuildContext context) { - final controller = Get.find(); - final mainController = Get.find(); - final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Form( - key: controller.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$idCardType Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Upload a clear image of your $idCardType', - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(color: TColors.textSecondary), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure all text and your photo are clearly visible', - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: TColors.textSecondary), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Error Messages - Obx( - () => - controller.idCardError.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - controller.idCardError.value, - style: const TextStyle(color: Colors.red), - ), - ) - : const SizedBox.shrink(), - ), - - // ID Card Upload Widget - _buildIdCardUploader(controller, isOfficer), - - // Verification Message for ID Card - Obx( - () => - controller.idCardValidationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isIdCardValid.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isIdCardValid.value - ? Icons.check_circle - : Icons.error, - color: - controller.isIdCardValid.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.idCardValidationMessage.value, - style: TextStyle( - color: - controller.isIdCardValid.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - if (controller.isIdCardValid.value && - !controller.hasConfirmedIdCard.value) ...[ - const SizedBox(height: TSizes.md), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: - () => controller.confirmIdCardImage(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - ), - child: const Text('Confirm Image'), - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: TextButton( - onPressed: - () => controller.clearIdCardImage(), - child: const Text('Try Another Image'), - ), - ), - ], - ), - ], - if (controller.hasConfirmedIdCard.value) - const Padding( - padding: EdgeInsets.only(top: TSizes.sm), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: Colors.green, - size: TSizes.iconSm, - ), - SizedBox(width: TSizes.xs), - Text( - 'Image confirmed', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - - ], - ), - ); - } - - Widget _buildIdCardUploader( - IdCardVerificationController controller, - bool isOfficer, - ) { - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Obx(() { - // Background color based on error state or confirmation - final backgroundColor = - controller.idCardError.value.isNotEmpty - ? TColors.error.withOpacity(0.1) - : controller.hasConfirmedIdCard.value - ? Colors.green.withOpacity(0.1) - : Colors.grey.withOpacity(0.1); - - // Determine border color based on error state or confirmation - final borderColor = - controller.idCardError.value.isNotEmpty - ? TColors.error - : controller.hasConfirmedIdCard.value - ? Colors.green - : Colors.grey.withOpacity(0.5); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.idCardImage.value == null) - GestureDetector( - onTap: () => _showImageSourceDialog(controller, isOfficer), - child: Container( - height: 180, - width: double.infinity, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all( - color: borderColor, - width: - controller.idCardError.value.isNotEmpty - ? 2 - : 1, - ), - ), - child: - controller.isUploadingIdCard.value - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: TSizes.sm), - Text( - 'Uploading...', - style: TextStyle(color: TColors.textSecondary), - ), - ], - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - controller.idCardError.value.isNotEmpty - ? Icons.error_outline - : Icons.add_a_photo, - size: TSizes.iconLg, - color: - controller.idCardError.value.isNotEmpty - ? TColors.error - : Colors.grey, - ), - const SizedBox(height: TSizes.sm), - Text( - controller.idCardError.value.isNotEmpty - ? 'Please upload your $idCardType image first' - : 'Upload $idCardType Image', - style: TextStyle( - color: - controller.idCardError.value.isNotEmpty - ? TColors.error - : TColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Tap to select an image (max 4MB)', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: - controller.idCardError.value.isNotEmpty - ? TColors.error.withOpacity(0.8) - : TColors.textSecondary, - ), - ), - ], - ), - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - alignment: Alignment.topRight, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - border: Border.all(color: borderColor, width: 2), - ), - child: Stack( - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - 2, - ), - child: Image.file( - File(controller.idCardImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - // Error overlay for uploaded image - if (controller.idCardError.value.isNotEmpty && - !controller.isUploadingIdCard.value && - !controller.isVerifying.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - 2, - ), - color: TColors.error.withOpacity(0.2), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - color: TColors.error, - size: TSizes.iconLg, - ), - const SizedBox(height: TSizes.sm), - Text( - 'Invalid Image', - style: TextStyle( - color: TColors.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: TSizes.md, - ), - child: Text( - controller.idCardError.value, - textAlign: TextAlign.center, - style: TextStyle( - color: TColors.error, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - ], - ), - ), - ), - // Loading overlay - if (controller.isUploadingIdCard.value || - controller.isVerifying.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - 2, - ), - color: Colors.black.withOpacity(0.5), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Colors.white, - ), - SizedBox(height: TSizes.sm), - Text( - 'Processing...', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - if (!controller.hasConfirmedIdCard.value && - !controller.isUploadingIdCard.value && - !controller.isVerifying.value) - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => controller.clearIdCardImage(), - child: Container( - padding: const EdgeInsets.all(TSizes.xs), - decoration: BoxDecoration( - color: - controller.idCardError.value.isNotEmpty - ? TColors.error - : Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: TSizes.iconSm, - ), - ), - ), - ), - ], - ), - const SizedBox(height: TSizes.sm), - if (!controller.isIdCardValid.value && - !controller.isVerifying.value && - !controller.isUploadingIdCard.value) - ElevatedButton.icon( - onPressed: () => controller.validateIdCardImage(), - icon: const Icon(Icons.check_circle), - label: Text('Check $idCardType Validity'), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - // Show file size information if image is uploaded - if (controller.idCardImage.value != null) - FutureBuilder( - future: File(controller.idCardImage.value!.path).length(), - builder: (context, snapshot) { - if (snapshot.hasData) { - final fileSizeKB = snapshot.data! / 1024; - final fileSizeMB = fileSizeKB / 1024; - final isOversized = - snapshot.data! > controller.maxFileSizeBytes; - return Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - 'File size: ${fileSizeMB.toStringAsFixed(2)} MB', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: - isOversized - ? TColors.error - : TColors.textSecondary, - fontWeight: - isOversized - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - ], - ); - }); - } - - void _showImageSourceDialog( - IdCardVerificationController controller, - bool isOfficer, - ) { - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - final String title = 'Select $idCardType Image Source'; - final String message = - 'Please ensure your ID card is clear, well-lit, and all text is readable. Maximum file size: 4MB'; - - Get.dialog( - Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - ), - child: Padding( - padding: const EdgeInsets.all(TSizes.defaultSpace), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.md), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildImageSourceOption( - icon: Icons.camera_alt, - label: 'Camera', - onTap: () { - controller.pickIdCardImage(ImageSource.camera); - Get.back(); - }, - ), - _buildImageSourceOption( - icon: Icons.image, - label: 'Gallery', - onTap: () { - controller.pickIdCardImage(ImageSource.gallery); - Get.back(); - }, - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildImageSourceOption({ - required IconData icon, - required String label, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, color: TColors.primary, size: TSizes.iconLg), - ), - const SizedBox(height: TSizes.sm), - Text(label), - ], - ), - ); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart deleted file mode 100644 index fb099b6..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart +++ /dev/null @@ -1,587 +0,0 @@ -import 'package:calendar_date_picker2/calendar_date_picker2.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart'; -import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; -import 'package:sigap/src/utils/validators/validation.dart'; - -class IdentityVerificationStep extends StatelessWidget { - const IdentityVerificationStep({super.key}); - - @override - Widget build(BuildContext context) { - final controller = Get.find(); - final ImageVerificationController imageController; - - try { - imageController = Get.find(); - } catch (e) { - // Handle the case when ImageVerificationController is not registered yet - // Use a local variable or default behavior - } - - final mainController = Get.find(); - final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Form( - key: controller.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Additional Information', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Please provide additional personal details', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Different fields based on role - if (!isOfficer) ...[ - // NIK field for viewers - Obx( - () => CustomTextField( - label: 'NIK (Identity Number)', - controller: controller.nikController, - validator: - (value) => TValidators.validateUserInput('NIK', value, 16), - errorText: controller.nikError.value, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.number, - hintText: 'e.g., 1234567890123456', - onChanged: (value) { - controller.nikController.text = value; - controller.nikError.value = ''; - }, - ), - ), - - // Full Name field - Obx( - () => CustomTextField( - label: 'Full Name', - controller: controller.fullNameController, - validator: - (value) => - TValidators.validateUserInput('Full Name', value, 100), - errorText: controller.fullNameError.value, - textInputAction: TextInputAction.next, - hintText: 'Enter your full name as on KTP', - onChanged: (value) { - controller.fullNameController.text = value; - controller.fullNameError.value = ''; - }, - ), - ), - - // Place of Birth field with city selection - Obx(() => _buildPlaceOfBirthField(context, controller)), - ], - - // Birth Date field with calendar picker - Obx(() => _buildBirthDatePicker(context, controller)), - - // Gender selection - Obx(() => _buildGenderSelection(context, controller)), - - // Address field - Obx( - () => CustomTextField( - label: 'Address', - controller: controller.addressController, - validator: - (value) => - TValidators.validateUserInput('Address', value, 255), - errorText: controller.addressError.value, - textInputAction: TextInputAction.next, - hintText: 'Enter your address as on KTP', - maxLines: 3, - onChanged: (value) { - controller.addressController.text = value; - controller.addressError.value = ''; - }, - ), - ), - - // Verification Status - Obx( - () => - controller.isVerifying.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Processing your personal information...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Verification Message - Obx( - () => - controller.verificationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isVerified.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isVerified.value - ? Icons.check_circle - : Icons.error, - color: - controller.isVerified.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.verificationMessage.value, - style: TextStyle( - color: - controller.isVerified.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - const SizedBox(height: TSizes.spaceBtwSections), - - // Data verification button - ElevatedButton.icon( - onPressed: - controller.isVerifying.value - ? null - : () => controller.verifyIdCardWithOCR(), - icon: const Icon(Icons.verified_user), - label: Text( - controller.isVerified.value - ? 'Re-verify Personal Information' - : 'Verify Personal Information', - ), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - - const SizedBox(height: TSizes.spaceBtwItems), - - // Face verification section - Text( - 'Face Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Verify that your face matches with your ID card photo', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Face Verification Status - Obx( - () => - controller.isVerifyingFace.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Comparing face with ID photo...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Face match verification button - ElevatedButton.icon( - onPressed: - controller.isVerifyingFace.value || - controller.isFaceVerified.value - ? null - : () => controller.verifyFaceMatch(), - icon: const Icon(Icons.face_retouching_natural), - label: Text( - controller.isFaceVerified.value - ? 'Face Verified Successfully' - : 'Verify Face Match', - ), - style: ElevatedButton.styleFrom( - backgroundColor: - controller.isFaceVerified.value - ? Colors.green - : TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - disabledBackgroundColor: - controller.isFaceVerified.value - ? Colors.green.withOpacity(0.7) - : null, - ), - ), - - // Face Verification Message - Obx( - () => - controller.faceVerificationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isFaceVerified.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isFaceVerified.value - ? Icons.check_circle - : Icons.error, - color: - controller.isFaceVerified.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.faceVerificationMessage.value, - style: TextStyle( - color: - controller.isFaceVerified.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - ); - } - - Widget _buildPlaceOfBirthField( - BuildContext context, - IdentityVerificationController controller, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: TSizes.xs), - - GestureDetector( - onTap: () => _navigateToCitySelection(context, controller), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: TSizes.md, - vertical: TSizes.md, - ), - decoration: BoxDecoration( - border: Border.all( - color: - controller.placeOfBirthError.value.isNotEmpty - ? TColors.error - : TColors.textSecondary, - ), - borderRadius: BorderRadius.circular(TSizes.borderRadiusLg), - color: Colors.transparent, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.placeOfBirthController.text.isEmpty - ? 'Select Place of Birth' - : controller.placeOfBirthController.text, - style: TextStyle( - color: - controller.placeOfBirthController.text.isEmpty - ? Theme.of(context).textTheme.bodyMedium?.color - : TColors.textSecondary, - ), - ), - Icon( - Icons.location_city, - size: TSizes.iconSm, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ], - ), - ), - ), - - if (controller.placeOfBirthError.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), - child: Text( - controller.placeOfBirthError.value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: TColors.error, - fontSize: 12, - ), - ), - ), - - const SizedBox(height: TSizes.spaceBtwItems), - ], - ); - } - - void _navigateToCitySelection( - BuildContext context, - IdentityVerificationController controller, - ) async { - final selectedCity = await Get.to(() => const CitySelectionPage()); - - if (selectedCity != null && selectedCity.isNotEmpty) { - controller.placeOfBirthController.text = selectedCity; - controller.placeOfBirthError.value = ''; - } - } - - Widget _buildGenderSelection( - BuildContext context, - IdentityVerificationController controller, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Gender', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: TSizes.xs), - - Row( - children: [ - Expanded( - child: RadioListTile( - title: const Text('Male'), - value: 'Male', - groupValue: controller.selectedGender.value, - onChanged: (value) { - controller.selectedGender.value = value!; - controller.genderError.value = ''; - }, - activeColor: TColors.primary, - contentPadding: EdgeInsets.zero, - dense: true, - ), - ), - Expanded( - child: RadioListTile( - title: const Text('Female'), - value: 'Female', - groupValue: controller.selectedGender.value, - onChanged: (value) { - controller.selectedGender.value = value!; - controller.genderError.value = ''; - }, - activeColor: TColors.primary, - contentPadding: EdgeInsets.zero, - dense: true, - ), - ), - ], - ), - - if (controller.genderError.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), - child: Text( - controller.genderError.value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: TColors.error, - fontSize: 12, - ), - ), - ), - - const SizedBox(height: TSizes.spaceBtwItems), - ], - ); - } - - Widget _buildBirthDatePicker( - BuildContext context, - IdentityVerificationController controller, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Birth Date', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: TSizes.xs), - - GestureDetector( - onTap: () => _showDatePicker(context, controller), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: TSizes.md, - vertical: TSizes.md, - ), - decoration: BoxDecoration( - border: Border.all( - color: - controller.birthDateError.value.isNotEmpty - ? TColors.error - : TColors.textSecondary, - ), - borderRadius: BorderRadius.circular(TSizes.borderRadiusLg), - color: Colors.transparent, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.birthDateController.text.isEmpty - ? 'Select Birth Date' - : controller.birthDateController.text, - style: TextStyle( - color: - controller.birthDateController.text.isEmpty - ? Theme.of(context).textTheme.bodyMedium?.color - : TColors.textSecondary, - ), - ), - Icon( - Icons.calendar_today, - size: TSizes.iconSm, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ], - ), - ), - ), - - if (controller.birthDateError.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), - child: Text( - controller.birthDateError.value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: TColors.error, - fontSize: 12, - ), - ), - ), - - const SizedBox(height: TSizes.spaceBtwItems), - ], - ); - } - - void _showDatePicker( - BuildContext context, - IdentityVerificationController controller, - ) async { - final results = await showCalendarDatePicker2Dialog( - context: context, - config: CalendarDatePicker2WithActionButtonsConfig( - calendarType: CalendarDatePicker2Type.single, - selectedDayHighlightColor: TColors.primary, - lastDate: DateTime.now(), // Can't select future dates - firstDate: DateTime(1900), // Reasonable minimum birth year - ), - dialogSize: const Size(325, 400), - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - value: - controller.birthDateController.text.isNotEmpty - ? [_parseDate(controller.birthDateController.text)] - : [], - ); - - if (results != null && results.isNotEmpty && results[0] != null) { - final selectedDate = results[0]!; - final formattedDate = DateFormat('yyyy-MM-dd').format(selectedDate); - controller.birthDateController.text = formattedDate; - controller.birthDateError.value = ''; - } - } - - DateTime? _parseDate(String dateStr) { - try { - return DateFormat('yyyy-MM-dd').parse(dateStr); - } catch (e) { - return null; - } - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart deleted file mode 100644 index 243956f..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart +++ /dev/null @@ -1,856 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; - -class ImageVerificationStep extends StatelessWidget { - const ImageVerificationStep({super.key}); - - @override - Widget build(BuildContext context) { - final controller = Get.find(); - final mainController = Get.find(); - final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Form( - key: controller.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Identity Document Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Please upload your identity documents for verification', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // ID Card Upload Section - Text( - '$idCardType Upload', - style: TextStyle( - fontSize: TSizes.fontSizeMd, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Upload a clear image of your $idCardType', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure all text and your photo are clearly visible', - style: TextStyle( - fontSize: TSizes.fontSizeXs, - fontStyle: FontStyle.italic, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // ID Card Upload Widget - _buildIdCardUploader(controller, isOfficer), - - // Verification Status for ID Card - Obx( - () => - controller.isVerifying.value && - !controller.isUploadingIdCard.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Validating your ID card...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Verification Message for ID Card - Obx( - () => - controller.idCardValidationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isIdCardValid.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isIdCardValid.value - ? Icons.check_circle - : Icons.error, - color: - controller.isIdCardValid.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.idCardValidationMessage.value, - style: TextStyle( - color: - controller.isIdCardValid.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - if (controller.isIdCardValid.value && - !controller.hasConfirmedIdCard.value) ...[ - const SizedBox(height: TSizes.md), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: - () => controller.confirmIdCardImage(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - ), - child: const Text('Confirm Image'), - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: TextButton( - onPressed: - () => controller.clearIdCardImage(), - child: const Text('Try Another Image'), - ), - ), - ], - ), - ], - if (controller.hasConfirmedIdCard.value) - const Padding( - padding: EdgeInsets.only(top: TSizes.sm), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: Colors.green, - size: TSizes.iconSm, - ), - SizedBox(width: TSizes.xs), - Text( - 'Image confirmed', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - const SizedBox(height: TSizes.spaceBtwSections), - - // Selfie Upload Section - Text( - 'Selfie Upload', - style: TextStyle( - fontSize: TSizes.fontSizeMd, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Take a clear selfie for identity verification', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure your face is well-lit and clearly visible', - style: TextStyle( - fontSize: TSizes.fontSizeXs, - fontStyle: FontStyle.italic, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Selfie Upload Widget - _buildSelfieUploader(controller), - - // Verification Status for Selfie - Obx( - () => - controller.isVerifyingFace.value && - !controller.isUploadingSelfie.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Validating your selfie...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Verification Message for Selfie - Obx( - () => - controller.selfieValidationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isSelfieValid.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isSelfieValid.value - ? Icons.check_circle - : Icons.error, - color: - controller.isSelfieValid.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.selfieValidationMessage.value, - style: TextStyle( - color: - controller.isSelfieValid.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - if (controller.isSelfieValid.value && - !controller.hasConfirmedSelfie.value) ...[ - const SizedBox(height: TSizes.md), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: - () => controller.confirmSelfieImage(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - ), - child: const Text('Confirm Image'), - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: TextButton( - onPressed: - () => controller.clearSelfieImage(), - child: const Text('Try Another Image'), - ), - ), - ], - ), - ], - if (controller.hasConfirmedSelfie.value) - const Padding( - padding: EdgeInsets.only(top: TSizes.sm), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: Colors.green, - size: TSizes.iconSm, - ), - SizedBox(width: TSizes.xs), - Text( - 'Image confirmed', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Error Messages - Obx(() { - if (controller.idCardError.value.isNotEmpty) { - return Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - controller.idCardError.value, - style: const TextStyle(color: Colors.red), - ), - ); - } - if (controller.selfieError.value.isNotEmpty) { - return Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - controller.selfieError.value, - style: const TextStyle(color: Colors.red), - ), - ); - } - return const SizedBox.shrink(); - }), - ], - ), - ); - } - - Widget _buildIdCardUploader( - ImageVerificationController controller, - bool isOfficer, - ) { - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Obx(() { - // Determine border color based on error state or confirmation - final borderColor = - controller.idCardError.value.isNotEmpty - ? TColors.error - : controller.hasConfirmedIdCard.value - ? Colors.green - : Colors.grey.withOpacity(0.5); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.idCardImage.value == null) - GestureDetector( - onTap: () => _showImageSourceDialog(controller, true), - child: Container( - height: 180, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all( - color: - controller.idCardError.value.isNotEmpty - ? TColors.error - : Colors.grey.withOpacity(0.5), - ), - ), - child: - controller.isUploadingIdCard.value - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: TSizes.sm), - Text( - 'Uploading...', - style: TextStyle(color: TColors.textSecondary), - ), - ], - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_a_photo, - size: TSizes.iconLg, - color: Colors.grey, - ), - const SizedBox(height: TSizes.sm), - Text( - 'Upload $idCardType Image', - style: TextStyle( - color: TColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Tap to select an image', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - ], - ), - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - alignment: Alignment.topRight, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - border: Border.all(color: borderColor, width: 2), - ), - child: Stack( - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - child: Image.file( - File(controller.idCardImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - // Loading overlay - if (controller.isUploadingIdCard.value || - controller.isVerifying.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - color: Colors.black.withOpacity(0.5), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Colors.white, - ), - SizedBox(height: TSizes.sm), - Text( - 'Processing...', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - if (!controller.hasConfirmedIdCard.value && - !controller.isUploadingIdCard.value && - !controller.isVerifying.value) - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => controller.clearIdCardImage(), - child: Container( - padding: const EdgeInsets.all(TSizes.xs), - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: TSizes.iconSm, - ), - ), - ), - ), - ], - ), - const SizedBox(height: TSizes.sm), - if (!controller.isIdCardValid.value && - !controller.isVerifying.value && - !controller.isUploadingIdCard.value) - ElevatedButton.icon( - onPressed: () => controller.validateIdCardImage(), - icon: const Icon(Icons.check_circle), - label: Text('Check $idCardType Validity'), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - ], - ), - ], - ); - }); - } - - Widget _buildSelfieUploader(ImageVerificationController controller) { - return Obx(() { - // Determine border color based on error state or confirmation - final borderColor = - controller.selfieError.value.isNotEmpty - ? TColors.error - : controller.hasConfirmedSelfie.value - ? Colors.green - : Colors.grey.withOpacity(0.5); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.selfieImage.value == null) - GestureDetector( - onTap: () => _showSelfieDialog(controller), - child: Container( - height: 180, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all( - color: - controller.selfieError.value.isNotEmpty - ? TColors.error - : Colors.grey.withOpacity(0.5), - ), - ), - child: - controller.isUploadingSelfie.value - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: TSizes.sm), - Text( - 'Uploading...', - style: TextStyle(color: TColors.textSecondary), - ), - ], - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.face, - size: TSizes.iconLg, - color: Colors.grey, - ), - const SizedBox(height: TSizes.sm), - Text( - 'Take a Selfie', - style: TextStyle( - color: TColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Tap to open camera', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - ], - ), - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - alignment: Alignment.topRight, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - border: Border.all(color: borderColor, width: 2), - ), - child: Stack( - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - child: Image.file( - File(controller.selfieImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - // Loading overlay - if (controller.isUploadingSelfie.value || - controller.isVerifyingFace.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - color: Colors.black.withOpacity(0.5), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Colors.white, - ), - SizedBox(height: TSizes.sm), - Text( - 'Processing...', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - if (!controller.hasConfirmedSelfie.value && - !controller.isUploadingSelfie.value && - !controller.isVerifyingFace.value) - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => controller.clearSelfieImage(), - child: Container( - padding: const EdgeInsets.all(TSizes.xs), - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: TSizes.iconSm, - ), - ), - ), - ), - ], - ), - const SizedBox(height: TSizes.sm), - if (!controller.isSelfieValid.value && - !controller.isVerifyingFace.value && - !controller.isUploadingSelfie.value) - ElevatedButton.icon( - onPressed: () => controller.validateSelfieImage(), - icon: const Icon(Icons.check_circle), - label: const Text('Check Selfie Validity'), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - ], - ), - ], - ); - }); - } - - void _showImageSourceDialog( - ImageVerificationController controller, - bool isIdCard, - ) { - final mainController = Get.find(); - final String title = - isIdCard - ? 'Select ${mainController.selectedRole.value?.isOfficer ?? false ? "KTA" : "KTP"} Image Source' - : 'Select Selfie Source'; - - final String message = - isIdCard - ? 'Please ensure your ID card is clear, well-lit, and all text is readable' - : 'Please ensure your face is clearly visible and well-lit'; - - Get.dialog( - Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - ), - child: Padding( - padding: const EdgeInsets.all(TSizes.defaultSpace), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.md), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildImageSourceOption( - icon: Icons.camera_alt, - label: 'Camera', - onTap: () { - if (isIdCard) { - controller.pickIdCardImage(ImageSource.camera); - } else { - controller.pickSelfieImage(ImageSource.camera); - } - Get.back(); - }, - ), - if (isIdCard) // Only show gallery option for ID card - _buildImageSourceOption( - icon: Icons.image, - label: 'Gallery', - onTap: () { - controller.pickIdCardImage(ImageSource.gallery); - Get.back(); - }, - ), - ], - ), - ], - ), - ), - ), - ); - } - - void _showSelfieDialog(ImageVerificationController controller) { - controller.pickSelfieImage(ImageSource.camera); - } - - Widget _buildImageSourceOption({ - required IconData icon, - required String label, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, color: TColors.primary, size: TSizes.iconLg), - ), - const SizedBox(height: TSizes.sm), - Text(label), - ], - ), - ); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart deleted file mode 100644 index 2ac5e77..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart +++ /dev/null @@ -1,561 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart'; -import 'package:sigap/src/utils/constants/colors.dart'; -import 'package:sigap/src/utils/constants/sizes.dart'; - -class SelfieVerificationStep extends StatelessWidget { - const SelfieVerificationStep({super.key}); - - @override - Widget build(BuildContext context) { - final controller = Get.find(); - - return Form( - key: controller.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Selfie Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Take a clear selfie for identity verification', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure your face is well-lit and clearly visible', - style: TextStyle( - fontSize: TSizes.fontSizeXs, - fontStyle: FontStyle.italic, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Selfie Upload Widget - _buildSelfieUploader(controller), - - // Verification Status for Selfie - Obx( - () => - controller.isVerifyingFace.value && - !controller.isUploadingSelfie.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Validating your selfie...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Verification Message for Selfie - Obx( - () => - controller.selfieValidationMessage.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: - controller.isSelfieValid.value - ? Colors.green.withOpacity(0.1) - : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular( - TSizes.borderRadiusSm, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - controller.isSelfieValid.value - ? Icons.check_circle - : Icons.error, - color: - controller.isSelfieValid.value - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.selfieValidationMessage.value, - style: TextStyle( - color: - controller.isSelfieValid.value - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - if (controller.isSelfieValid.value && - !controller.hasConfirmedSelfie.value) ...[ - const SizedBox(height: TSizes.md), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: - () => controller.confirmSelfieImage(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - ), - child: const Text('Confirm Image'), - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: TextButton( - onPressed: - () => controller.clearSelfieImage(), - child: const Text('Try Another Image'), - ), - ), - ], - ), - ], - if (controller.hasConfirmedSelfie.value) - const Padding( - padding: EdgeInsets.only(top: TSizes.sm), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: Colors.green, - size: TSizes.iconSm, - ), - SizedBox(width: TSizes.xs), - Text( - 'Image confirmed', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // Error Messages - Obx( - () => - controller.selfieError.value.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - controller.selfieError.value, - style: const TextStyle(color: Colors.red), - ), - ) - : const SizedBox.shrink(), - ), - - const SizedBox(height: TSizes.spaceBtwSections), - - // Tips for taking a good selfie - Container( - padding: const EdgeInsets.all(TSizes.md), - decoration: BoxDecoration( - color: TColors.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Tips for a Good Selfie:', - style: TextStyle( - fontWeight: FontWeight.bold, - color: TColors.primary, - ), - ), - const SizedBox(height: TSizes.xs), - _buildTip('Find a well-lit area with even lighting'), - _buildTip('Hold the camera at eye level'), - _buildTip('Look directly at the camera'), - _buildTip('Ensure your entire face is visible'), - _buildTip('Remove glasses and face coverings'), - ], - ), - ), - ], - ), - ); - } - - Widget _buildTip(String text) { - return Padding( - padding: const EdgeInsets.only(bottom: TSizes.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.check_circle, size: TSizes.iconXs, color: TColors.primary), - const SizedBox(width: TSizes.xs), - Expanded( - child: Text( - text, - style: TextStyle( - fontSize: TSizes.fontSizeXs, - color: TColors.textSecondary, - ), - ), - ), - ], - ), - ); - } - - Widget _buildSelfieUploader(SelfieVerificationController controller) { - return Obx(() { - // Background color based on error state or confirmation - final backgroundColor = - controller.selfieError.value.isNotEmpty - ? TColors.error.withOpacity(0.1) - : controller.hasConfirmedSelfie.value - ? Colors.green.withOpacity(0.1) - : Colors.grey.withOpacity(0.1); - - // Determine border color based on error state or confirmation - final borderColor = - controller.selfieError.value.isNotEmpty - ? TColors.error - : controller.hasConfirmedSelfie.value - ? Colors.green - : Colors.grey.withOpacity(0.5); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.selfieImage.value == null) - GestureDetector( - onTap: () => _captureSelfie(controller), - child: Container( - height: 180, - width: double.infinity, - decoration: BoxDecoration( - color: backgroundColor, // Using the dynamic background color - borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), - border: Border.all( - color: borderColor, - width: - controller.selfieError.value.isNotEmpty - ? 2 - : 1, // Thicker border for error state - ), - ), - child: - controller.isUploadingSelfie.value - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: TSizes.sm), - Text( - 'Processing...', - style: TextStyle(color: TColors.textSecondary), - ), - ], - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - controller.selfieError.value.isNotEmpty - ? Icons.error_outline - : Icons.face, - size: TSizes.iconLg, - color: - controller.selfieError.value.isNotEmpty - ? TColors.error - : Colors.grey, - ), - const SizedBox(height: TSizes.sm), - Text( - controller.selfieError.value.isNotEmpty - ? 'Error: Please try again' - : 'Take a Selfie', - style: TextStyle( - color: - controller.selfieError.value.isNotEmpty - ? TColors.error - : TColors.textSecondary, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Tap to take a selfie (max 4MB)', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: - controller.selfieError.value.isNotEmpty - ? TColors.error.withOpacity(0.8) - : TColors.textSecondary, - ), - ), - ], - ), - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - alignment: Alignment.topRight, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - border: Border.all(color: borderColor, width: 2), - ), - child: Stack( - alignment: Alignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - - 2, // Adjust for border width - ), - child: Image.file( - File(controller.selfieImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - // Error overlay for uploaded image - if (controller.selfieError.value.isNotEmpty && - !controller.isUploadingSelfie.value && - !controller.isVerifyingFace.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - - 2, // Adjust for border width - ), - color: TColors.error.withOpacity(0.2), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - color: TColors.error, - size: TSizes.iconLg, - ), - const SizedBox(height: TSizes.sm), - Text( - 'Invalid Selfie', - style: TextStyle( - color: TColors.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: TSizes.xs), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: TSizes.md, - ), - child: Text( - 'Please take a clearer selfie', - textAlign: TextAlign.center, - style: TextStyle( - color: TColors.error, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - ], - ), - ), - ), - // Loading overlay - if (controller.isUploadingSelfie.value || - controller.isVerifyingFace.value) - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd - - 2, // Adjust for border width - ), - color: Colors.black.withOpacity(0.5), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Colors.white, - ), - SizedBox(height: TSizes.sm), - Text( - 'Processing...', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - if (!controller.hasConfirmedSelfie.value && - !controller.isUploadingSelfie.value && - !controller.isVerifyingFace.value) - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => controller.clearSelfieImage(), - child: Container( - padding: const EdgeInsets.all(TSizes.xs), - decoration: BoxDecoration( - color: - controller.selfieError.value.isNotEmpty - ? TColors.error - : Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: TSizes.iconSm, - ), - ), - ), - ), - ], - ), - const SizedBox(height: TSizes.sm), - if (!controller.isSelfieValid.value && - !controller.isVerifyingFace.value && - !controller.isUploadingSelfie.value) - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => controller.validateSelfieImage(), - icon: const Icon(Icons.check_circle), - label: const Text('Check Selfie Validity'), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - ), - ), - ), - const SizedBox(width: TSizes.sm), - OutlinedButton.icon( - onPressed: () => _captureSelfie(controller), - icon: const Icon(Icons.refresh), - label: const Text('Retake'), - style: OutlinedButton.styleFrom( - foregroundColor: - controller.selfieError.value.isNotEmpty - ? TColors.error - : null, - side: BorderSide( - color: - controller.selfieError.value.isNotEmpty - ? TColors.error - : Colors.grey.withOpacity(0.5), - ), - ), - ), - ], - ), - // File size information - if (controller.selfieImage.value != null) - FutureBuilder( - future: File(controller.selfieImage.value!.path).length(), - builder: (context, snapshot) { - if (snapshot.hasData) { - final fileSizeKB = snapshot.data! / 1024; - final fileSizeMB = fileSizeKB / 1024; - final isOversized = - snapshot.data! > controller.maxFileSizeBytes; - return Padding( - padding: const EdgeInsets.only(top: TSizes.sm), - child: Text( - 'File size: ${fileSizeMB.toStringAsFixed(2)} MB', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: - isOversized - ? TColors.error - : TColors.textSecondary, - fontWeight: - isOversized - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - ], - ); - }); - } - - void _captureSelfie(SelfieVerificationController controller) { - controller.pickSelfieImage(ImageSource.camera); - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart index af9c70e..7428f4a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart @@ -17,6 +17,9 @@ class SignInScreen extends StatelessWidget { @override Widget build(BuildContext context) { + // Init form key + final formKey = GlobalKey(); + // Get the controller final controller = Get.find(); @@ -35,7 +38,7 @@ class SignInScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24.0), child: Form( - key: controller.signinFormKey, + key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -92,7 +95,7 @@ class SignInScreen extends StatelessWidget { Obx( () => AuthButton( text: 'Sign In', - onPressed: controller.credentialsSignIn, + onPressed: () => controller.credentialsSignIn(formKey), isLoading: controller.isLoading.value, ), ), @@ -112,7 +115,7 @@ class SignInScreen extends StatelessWidget { color: TColors.light, size: 20, ), - onPressed: () => controller.googleSignIn(), + onPressed: () => controller.googleSignIn(formKey), ), const SizedBox(height: 16), diff --git a/sigap-mobile/lib/src/features/daily-ops/data/models/models/kta_model.dart b/sigap-mobile/lib/src/features/daily-ops/data/models/models/kta_model.dart new file mode 100644 index 0000000..651afa6 --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/data/models/models/kta_model.dart @@ -0,0 +1,146 @@ +import 'package:equatable/equatable.dart'; + +/// Model representing a KTA (Kartu Tanda Anggota) police officer ID card +class KtaModel extends Equatable { + /// Full name of the officer + final String name; + + /// NRP (Nomor Registrasi Polisi) - Police Registration Number + final String nrp; + + /// Police station/unit where the officer is assigned + final String policeUnit; + + /// Issue date of the ID card in format dd-mm-yyyy + final String issueDate; + + /// Card unique identification number + final String cardNumber; + + /// URL to the officer's photo on the card + final String? photoUrl; + + /// Additional details about the card or officer + final Map? extraData; + + const KtaModel({ + required this.name, + required this.nrp, + required this.policeUnit, + required this.issueDate, + required this.cardNumber, + this.photoUrl, + this.extraData, + }); + + /// Create a KTA model from a map/JSON + factory KtaModel.fromJson(Map json) { + return KtaModel( + name: json['name'] ?? '', + nrp: json['nrp'] ?? '', + policeUnit: json['police_unit'] ?? '', + issueDate: json['issue_date'] ?? '', + cardNumber: json['card_number'] ?? '', + photoUrl: json['photo_url'], + extraData: json['extra_data'] as Map?, + ); + } + + /// Convert the KTA model to a map/JSON + Map toJson() { + return { + 'name': name, + 'nrp': nrp, + 'police_unit': policeUnit, + 'issue_date': issueDate, + 'card_number': cardNumber, + 'photo_url': photoUrl, + 'extra_data': extraData, + }; + } + + /// Create an empty KTA model + factory KtaModel.empty() { + return const KtaModel( + name: '', + nrp: '', + policeUnit: '', + issueDate: '', + cardNumber: '', + ); + } + + /// Format the NRP with proper spacing for display + /// e.g., "1234567890" becomes "12345 67890" + String get formattedNrp { + if (nrp.length < 5) return nrp; + return '${nrp.substring(0, 5)} ${nrp.substring(5)}'; + } + + /// Check if the KTA model contains valid and complete information + bool get isValid { + return name.isNotEmpty && + nrp.isNotEmpty && + policeUnit.isNotEmpty && + cardNumber.isNotEmpty; + } + + /// Create a copy of this KTA model with modified fields + KtaModel copyWith({ + String? name, + String? nrp, + String? policeUnit, + String? issueDate, + String? cardNumber, + String? photoUrl, + Map? extraData, + }) { + return KtaModel( + name: name ?? this.name, + nrp: nrp ?? this.nrp, + policeUnit: policeUnit ?? this.policeUnit, + issueDate: issueDate ?? this.issueDate, + cardNumber: cardNumber ?? this.cardNumber, + photoUrl: photoUrl ?? this.photoUrl, + extraData: extraData ?? this.extraData, + ); + } + + /// Parse the issue date into a DateTime object + /// Expects format: DD-MM-YYYY + DateTime? get parsedIssueDate { + try { + if (issueDate.isEmpty) return null; + + // Split the date string by '-' + final parts = issueDate.split('-'); + if (parts.length != 3) return null; + + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + + if (day == null || month == null || year == null) return null; + + return DateTime(year, month, day); + } catch (e) { + return null; + } + } + + @override + List get props => [ + name, + nrp, + policeUnit, + issueDate, + cardNumber, + photoUrl, + extraData, + ]; + + @override + String toString() { + return 'KtaModel(name: $name, nrp: $nrp, policeUnit: $policeUnit, issueDate: $issueDate, cardNumber: $cardNumber)'; + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart index ab636d7..468f039 100644 --- a/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:logger/logger.dart'; import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/features/onboarding/data/dummy/onboarding_dummy.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; @@ -14,7 +15,7 @@ class OnboardingController extends GetxController static OnboardingController get instance => Get.find(); // Storage for onboarding state - final _storage = GetStorage(); + final storage = GetStorage(); // Location service final _locationService = Get.find(); @@ -70,7 +71,7 @@ class OnboardingController extends GetxController // Method to go to next page void nextPage() { if (currentIndex.value == contents.length - 1) { - navigateToWelcomeScreen(); + skipOnboarding(); } else { pageController.nextPage( duration: const Duration(milliseconds: 500), @@ -81,13 +82,13 @@ class OnboardingController extends GetxController // Method to skip to welcome screen void skipToWelcomeScreen() { - navigateToWelcomeScreen(); + skipOnboarding(); } // Method to navigate to welcome screen - void navigateToWelcomeScreen() { + void skipOnboarding() { // Mark onboarding as completed in storage - _storage.write('ONBOARDING_COMPLETED', true); + storage.write('isFirstTime', true); Get.offAllNamed(AppRoutes.welcome); } @@ -108,8 +109,15 @@ class OnboardingController extends GetxController TFullScreenLoader.stopLoading(); + Logger().i('isFirstTime before: ${storage.read('isFirstTime')}'); + + storage.write('isFirstTime', false); + + Logger().i('isFirstTime after: ${storage.read('isFirstTime')}'); + if (isLocationValid) { // If location is valid, proceed to role selection + Get.offAllNamed(AppRoutes.signupWithRole); // TLoaders.successSnackBar( @@ -118,7 +126,6 @@ class OnboardingController extends GetxController // ); // Store isfirstTime to false in storage - _storage.write('isFirstTime', false); } else { // If location is invalid, show warning screen Get.offAllNamed(AppRoutes.locationWarning); @@ -141,6 +148,8 @@ class OnboardingController extends GetxController } void goToSignIn() { + storage.write('isFirstTime', true); + Get.offAllNamed(AppRoutes.signIn); } } diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart index 2937b92..691024c 100644 --- a/sigap-mobile/lib/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart @@ -31,7 +31,7 @@ class OnboardingScreen extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(TSizes.md), child: TextButton( - onPressed: controller.skipToWelcomeScreen, + onPressed: controller.skipOnboarding, child: Text( 'Skip', style: Theme.of(context).textTheme.bodyMedium, diff --git a/sigap-mobile/lib/src/features/personalization/data/models/models/ktp_model.dart b/sigap-mobile/lib/src/features/personalization/data/models/models/ktp_model.dart new file mode 100644 index 0000000..f4ec1cb --- /dev/null +++ b/sigap-mobile/lib/src/features/personalization/data/models/models/ktp_model.dart @@ -0,0 +1,257 @@ +import 'package:equatable/equatable.dart'; + +/// Model representing a KTP (Kartu Tanda Penduduk) Indonesian ID card +class KtpModel extends Equatable { + /// NIK (Nomor Induk Kependudukan) - National Identity Number + final String nik; + + /// Full name as shown on the KTP + final String name; + + /// Place of birth + final String birthPlace; + + /// Date of birth in format dd-mm-yyyy + final String birthDate; + + /// Gender (Male/Female) + final String gender; + + /// Blood type + final String? bloodType; + + /// Full address + final String address; + + /// RT/RW (Neighborhood units) + final String? rtRw; + + /// Kelurahan/Desa (Urban/Rural village) + final String? kelurahan; + + /// Kecamatan (District) + final String? kecamatan; + + /// Religion + final String? religion; + + /// Marital status + final String? maritalStatus; + + /// Occupation + final String? occupation; + + /// Nationality + final String nationality; + + /// URL to the person's photo on the card + final String? photoUrl; + + /// Issue date of the KTP + final String? issueDate; + + /// Additional details extracted from the KTP + final Map? extraData; + + const KtpModel({ + required this.nik, + required this.name, + required this.birthPlace, + required this.birthDate, + required this.gender, + required this.address, + required this.nationality, + this.bloodType, + this.rtRw, + this.kelurahan, + this.kecamatan, + this.religion, + this.maritalStatus, + this.occupation, + this.photoUrl, + this.issueDate, + this.extraData, + }); + + /// Create a KTP model from a map/JSON + factory KtpModel.fromJson(Map json) { + return KtpModel( + nik: json['nik'] ?? '', + name: json['name'] ?? '', + birthPlace: json['birth_place'] ?? '', + birthDate: json['birth_date'] ?? '', + gender: json['gender'] ?? '', + address: json['address'] ?? '', + nationality: json['nationality'] ?? 'WNI', + bloodType: json['blood_type'], + rtRw: json['rt_rw'], + kelurahan: json['kelurahan'], + kecamatan: json['kecamatan'], + religion: json['religion'], + maritalStatus: json['marital_status'], + occupation: json['occupation'], + photoUrl: json['photo_url'], + issueDate: json['issue_date'], + extraData: json['extra_data'] as Map?, + ); + } + + /// Convert the KTP model to a map/JSON + Map toJson() { + return { + 'nik': nik, + 'name': name, + 'birth_place': birthPlace, + 'birth_date': birthDate, + 'gender': gender, + 'address': address, + 'nationality': nationality, + 'blood_type': bloodType, + 'rt_rw': rtRw, + 'kelurahan': kelurahan, + 'kecamatan': kecamatan, + 'religion': religion, + 'marital_status': maritalStatus, + 'occupation': occupation, + 'photo_url': photoUrl, + 'issue_date': issueDate, + 'extra_data': extraData, + }; + } + + /// Create an empty KTP model + factory KtpModel.empty() { + return const KtpModel( + nik: '', + name: '', + birthPlace: '', + birthDate: '', + gender: '', + address: '', + nationality: 'WNI', + ); + } + + /// Parse the birth date into a DateTime object + /// Expects format: DD-MM-YYYY + DateTime? get parsedBirthDate { + try { + if (birthDate.isEmpty) return null; + + // Split the date string by '-' + final parts = birthDate.split('-'); + if (parts.length != 3) return null; + + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + + if (day == null || month == null || year == null) return null; + + return DateTime(year, month, day); + } catch (e) { + return null; + } + } + + /// Format the NIK with proper spacing for display + /// e.g., "1234567890123456" becomes "1234 5678 9012 3456" + String get formattedNik { + if (nik.length != 16) return nik; + + return '${nik.substring(0, 4)} ${nik.substring(4, 8)} ${nik.substring(8, 12)} ${nik.substring(12)}'; + } + + /// Get the person's age based on birth date + int? get age { + final birthDateTime = parsedBirthDate; + if (birthDateTime == null) return null; + + final today = DateTime.now(); + int age = today.year - birthDateTime.year; + + // Adjust age if birthday hasn't occurred yet this year + if (today.month < birthDateTime.month || + (today.month == birthDateTime.month && today.day < birthDateTime.day)) { + age--; + } + + return age; + } + + /// Check if the KTP model contains valid and complete information + bool get isValid { + return nik.length == 16 && + name.isNotEmpty && + birthPlace.isNotEmpty && + birthDate.isNotEmpty && + gender.isNotEmpty && + address.isNotEmpty; + } + + /// Create a copy of this KTP model with modified fields + KtpModel copyWith({ + String? nik, + String? name, + String? birthPlace, + String? birthDate, + String? gender, + String? bloodType, + String? address, + String? rtRw, + String? kelurahan, + String? kecamatan, + String? religion, + String? maritalStatus, + String? occupation, + String? nationality, + String? photoUrl, + String? issueDate, + Map? extraData, + }) { + return KtpModel( + nik: nik ?? this.nik, + name: name ?? this.name, + birthPlace: birthPlace ?? this.birthPlace, + birthDate: birthDate ?? this.birthDate, + gender: gender ?? this.gender, + bloodType: bloodType ?? this.bloodType, + address: address ?? this.address, + rtRw: rtRw ?? this.rtRw, + kelurahan: kelurahan ?? this.kelurahan, + kecamatan: kecamatan ?? this.kecamatan, + religion: religion ?? this.religion, + maritalStatus: maritalStatus ?? this.maritalStatus, + occupation: occupation ?? this.occupation, + nationality: nationality ?? this.nationality, + photoUrl: photoUrl ?? this.photoUrl, + issueDate: issueDate ?? this.issueDate, + extraData: extraData ?? this.extraData, + ); + } + + @override + List get props => [ + nik, + name, + birthPlace, + birthDate, + gender, + bloodType, + address, + rtRw, + kelurahan, + kecamatan, + religion, + maritalStatus, + occupation, + nationality, + photoUrl, + issueDate, + ]; + + @override + String toString() { + return 'KtpModel(nik: $nik, name: $name, birthPlace: $birthPlace, birthDate: $birthDate, gender: $gender)'; + } +} diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart index 9322967..bfa9980 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart @@ -243,6 +243,47 @@ class UserRepository extends GetxController { } } + // Get user by email + Future getUserByEmail(String email) async { + try { + final userData = + await _supabase + .from('users') + .select('*, profiles(*), role:roles(*)') + .eq('email', email) + .single(); + + return UserModel.fromJson(userData); + } on PostgrestException catch (error) { + _logger.e('PostgrestException in getUserByEmail: ${error.message}'); + throw TExceptions.fromCode(error.code ?? 'unknown-error'); + } catch (e) { + _logger.e('Exception in getUserByEmail: $e'); + return null; // Return null if user not found + } + } + + // Chekch if email is already in use + Future isEmailInUse(String email) async { + try { + final userData = + await _supabase + .from('users') + .select('id') + .eq('email', email) + .maybeSingle(); // โฌ…๏ธ Ganti single() dengan maybeSingle() + + return userData != null; + } on PostgrestException catch (error) { + _logger.e('PostgrestException in isEmailInUse: ${error.message}'); + throw TExceptions.fromCode(error.code ?? 'unknown-error'); + } catch (e) { + _logger.e('Exception in isEmailInUse: $e'); + return false; // Default: anggap belum digunakan jika gagal + } + } + + // Search users by name/username/email Future> searchUsers(String query, {int limit = 20}) async { try { diff --git a/sigap-mobile/lib/src/shared/widgets/form/date_picker_field.dart b/sigap-mobile/lib/src/shared/widgets/form/date_picker_field.dart new file mode 100644 index 0000000..f91457b --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/form/date_picker_field.dart @@ -0,0 +1,120 @@ +import 'package:calendar_date_picker2/calendar_date_picker2.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class DatePickerField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? errorText; + final Function(DateTime) onDateSelected; + final DateTime? firstDate; + final DateTime? lastDate; + final String dateFormat; + + const DatePickerField({ + super.key, + required this.label, + required this.controller, + this.errorText, + required this.onDateSelected, + this.firstDate, + this.lastDate, + this.dateFormat = 'yyyy-MM-dd', + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: TSizes.xs), + GestureDetector( + onTap: () => _showDatePicker(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.md, + ), + decoration: BoxDecoration( + border: Border.all( + color: + errorText != null && errorText!.isNotEmpty + ? TColors.error + : TColors.textSecondary, + ), + borderRadius: BorderRadius.circular(TSizes.borderRadiusLg), + color: Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.text.isEmpty ? 'Select $label' : controller.text, + style: TextStyle( + color: + controller.text.isEmpty + ? Theme.of(context).textTheme.bodyMedium?.color + : TColors.textSecondary, + ), + ), + Icon( + Icons.calendar_today, + size: TSizes.iconSm, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ], + ), + ), + ), + if (errorText != null && errorText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), + child: Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: TColors.error, + fontSize: 12, + ), + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } + + void _showDatePicker(BuildContext context) async { + final results = await showCalendarDatePicker2Dialog( + context: context, + config: CalendarDatePicker2WithActionButtonsConfig( + calendarType: CalendarDatePicker2Type.single, + selectedDayHighlightColor: TColors.primary, + lastDate: lastDate ?? DateTime.now(), + firstDate: firstDate ?? DateTime(1900), + ), + dialogSize: const Size(325, 400), + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + value: + controller.text.isNotEmpty + ? [_parseDate(controller.text)] + : [], + ); + + if (results != null && results.isNotEmpty && results[0] != null) { + final selectedDate = results[0]!; + final formattedDate = DateFormat(dateFormat).format(selectedDate); + controller.text = formattedDate; + onDateSelected(selectedDate); + } + } + + DateTime? _parseDate(String dateStr) { + try { + return DateFormat(dateFormat).parse(dateStr); + } catch (e) { + return null; + } + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/form/form_section_header.dart b/sigap-mobile/lib/src/shared/widgets/form/form_section_header.dart new file mode 100644 index 0000000..efe93bc --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/form/form_section_header.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class FormSectionHeader extends StatelessWidget { + final String title; + final String? subtitle; + final String? additionalText; + + const FormSectionHeader({ + super.key, + required this.title, + this.subtitle, + this.additionalText, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: TSizes.sm), + Text( + subtitle!, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary), + ), + ], + if (additionalText != null) ...[ + const SizedBox(height: TSizes.xs), + Text( + additionalText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: TColors.textSecondary, + ), + ), + ], + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/form/gender_selection.dart b/sigap-mobile/lib/src/shared/widgets/form/gender_selection.dart new file mode 100644 index 0000000..3ceaef8 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/form/gender_selection.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class GenderSelection extends StatelessWidget { + final String selectedGender; + final Function(String?) onGenderChanged; + final String? errorText; + + const GenderSelection({ + super.key, + required this.selectedGender, + required this.onGenderChanged, + this.errorText, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Gender', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: TSizes.xs), + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('Male'), + value: 'Male', + groupValue: selectedGender, + onChanged: onGenderChanged, + activeColor: TColors.primary, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('Female'), + value: 'Female', + groupValue: selectedGender, + onChanged: onGenderChanged, + activeColor: TColors.primary, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + ], + ), + if (errorText != null && errorText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs), + child: Text( + errorText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: TColors.error, + fontSize: 12, + ), + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/form/verification_status.dart b/sigap-mobile/lib/src/shared/widgets/form/verification_status.dart new file mode 100644 index 0000000..42c9106 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/form/verification_status.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class VerificationStatus extends StatelessWidget { + final bool isVerifying; + final String verifyingMessage; + + const VerificationStatus({ + super.key, + required this.isVerifying, + required this.verifyingMessage, + }); + + @override + Widget build(BuildContext context) { + return isVerifying + ? Padding( + padding: const EdgeInsets.symmetric(vertical: TSizes.spaceBtwItems), + child: Center( + child: Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: TSizes.sm), + Text(verifyingMessage), + ], + ), + ), + ) + : const SizedBox.shrink(); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart b/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart new file mode 100644 index 0000000..f9b3d3c --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/image_upload/image_source_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class ImageSourceDialog { + static Future show({ + required String title, + required String message, + required Function(ImageSource source) onSourceSelected, + bool galleryOption = true, + }) { + return Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.md), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + icon: Icons.camera_alt, + label: 'Camera', + onTap: () { + onSourceSelected(ImageSource.camera); + Get.back(); + }, + ), + if (galleryOption) + _buildImageSourceOption( + icon: Icons.image, + label: 'Gallery', + onTap: () { + onSourceSelected(ImageSource.gallery); + Get.back(); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + static Widget _buildImageSourceOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: TColors.primary, size: TSizes.iconLg), + ), + const SizedBox(height: TSizes.sm), + Text(label), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart b/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart new file mode 100644 index 0000000..3eedd74 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/image_upload/image_uploader.dart @@ -0,0 +1,325 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class ImageUploader extends StatelessWidget { + final XFile? image; + final String title; + final String subtitle; + final String? errorMessage; + final bool isUploading; + final bool isVerifying; + final bool isConfirmed; + final VoidCallback onTapToSelect; + final VoidCallback? onClear; + final VoidCallback? onValidate; + final IconData placeholderIcon; + final double height; + final Widget? processingOverlay; + final Widget? errorOverlay; + + const ImageUploader({ + super.key, + required this.image, + required this.title, + required this.subtitle, + this.errorMessage, + required this.isUploading, + required this.isVerifying, + required this.isConfirmed, + required this.onTapToSelect, + this.onClear, + this.onValidate, + this.placeholderIcon = Icons.add_a_photo, + this.height = 180, + this.processingOverlay, + this.errorOverlay, + }); + + @override + Widget build(BuildContext context) { + // Background color based on error state or confirmation + final backgroundColor = + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error.withOpacity(0.1) + : isConfirmed + ? Colors.green.withOpacity(0.1) + : Colors.grey.withOpacity(0.1); + + // Determine border color based on error state or confirmation + final borderColor = + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error + : isConfirmed + ? Colors.green + : Colors.grey.withOpacity(0.5); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (image == null) + _buildEmptyUploader(backgroundColor, borderColor) + else + _buildImagePreview(borderColor), + + // Show file size information if image is uploaded + if (image != null) + FutureBuilder( + future: File(image!.path).length(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final fileSizeKB = snapshot.data! / 1024; + final fileSizeMB = fileSizeKB / 1024; + final isOversized = fileSizeMB > 4; // 4MB limit + return Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + 'File size: ${fileSizeMB.toStringAsFixed(2)} MB', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: + isOversized ? TColors.error : TColors.textSecondary, + fontWeight: + isOversized ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ); + } + + Widget _buildEmptyUploader(Color backgroundColor, Color borderColor) { + return GestureDetector( + onTap: onTapToSelect, + child: Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all( + color: borderColor, + width: errorMessage != null && errorMessage!.isNotEmpty ? 2 : 1, + ), + ), + child: + isUploading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: TSizes.sm), + Text( + 'Uploading...', + style: TextStyle(color: TColors.textSecondary), + ), + ], + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + errorMessage != null && errorMessage!.isNotEmpty + ? Icons.error_outline + : placeholderIcon, + size: TSizes.iconLg, + color: + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error + : Colors.grey, + ), + const SizedBox(height: TSizes.sm), + Text( + errorMessage != null && errorMessage!.isNotEmpty + ? 'Please upload an image first' + : title, + style: TextStyle( + color: + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error + : TColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + subtitle, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error.withOpacity(0.8) + : TColors.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildImagePreview(Color borderColor) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.topRight, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all(color: borderColor, width: 2), + ), + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd - 2, + ), + child: Image.file( + File(image!.path), + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + // Error overlay + if (errorMessage != null && + errorMessage!.isNotEmpty && + !isUploading && + !isVerifying) + errorOverlay ?? _defaultErrorOverlay(), + + // Loading overlay + if (isUploading || isVerifying) + processingOverlay ?? _defaultProcessingOverlay(), + ], + ), + ), + + // Close button + if (!isConfirmed && !isUploading && !isVerifying && onClear != null) + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: onClear, + child: Container( + padding: const EdgeInsets.all(TSizes.xs), + decoration: BoxDecoration( + color: + errorMessage != null && errorMessage!.isNotEmpty + ? TColors.error + : Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: TSizes.iconSm, + ), + ), + ), + ), + ], + ), + + // Validate button + if (onValidate != null && !isVerifying && !isUploading && !isConfirmed) + Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: ElevatedButton.icon( + onPressed: onValidate, + icon: const Icon(Icons.check_circle), + label: Text('Verify Image'), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + ), + ), + ), + ], + ); + } + + Widget _defaultErrorOverlay() { + return Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2), + color: TColors.error.withOpacity(0.2), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: TColors.error, + size: TSizes.iconLg, + ), + const SizedBox(height: TSizes.sm), + Text( + 'Invalid Image', + style: TextStyle( + color: TColors.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.xs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: TSizes.md), + child: Text( + errorMessage ?? 'Please try another image', + textAlign: TextAlign.center, + style: TextStyle( + color: TColors.error, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _defaultProcessingOverlay() { + return Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd - 2), + color: Colors.black.withOpacity(0.5), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: TSizes.sm), + Text( + 'Processing...', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart b/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart new file mode 100644 index 0000000..4bf7770 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/info/tips_container.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class TipsContainer extends StatelessWidget { + final String title; + final List tips; + final Color backgroundColor; + final Color borderColor; + final Color textColor; + final Color iconColor; + final IconData leadingIcon; + + const TipsContainer({ + super.key, + required this.title, + required this.tips, + this.backgroundColor = Colors.blue, + this.borderColor = Colors.blue, + this.textColor = Colors.blue, + this.iconColor = Colors.blue, + this.leadingIcon = Icons.tips_and_updates, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), + border: Border.all(color: borderColor.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(leadingIcon, color: iconColor, size: TSizes.iconMd), + const SizedBox(width: TSizes.sm), + Text( + title, + style: TextStyle(fontWeight: FontWeight.bold, color: textColor), + ), + ], + ), + const SizedBox(height: TSizes.sm), + ...tips.map((tip) => _buildTipItem(tip)), + ], + ), + ); + } + + Widget _buildTipItem(String tip) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "โ€ข ", + style: TextStyle( + color: textColor.withOpacity(0.8), + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text( + tip, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: textColor.withOpacity(0.8), + ), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart index cbc4768..9c423d7 100644 --- a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart +++ b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart @@ -112,7 +112,7 @@ class CustomTextField extends StatelessWidget { // ).textTheme.bodySmall?.copyWith(color: TColors.error), // ), // ), - // const SizedBox(height: TSizes.spaceBtwInputFields), + const SizedBox(height: TSizes.spaceBtwInputFields), ], ); } diff --git a/sigap-mobile/lib/src/shared/widgets/verification/ocr_result_card.dart b/sigap-mobile/lib/src/shared/widgets/verification/ocr_result_card.dart new file mode 100644 index 0000000..3c9512a --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/verification/ocr_result_card.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart'; +import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class OcrResultCard extends StatelessWidget { + final Map extractedInfo; + final bool isOfficer; + final bool isValid; + + const OcrResultCard({ + super.key, + required this.extractedInfo, + required this.isOfficer, + this.isValid = false, + }); + + @override + Widget build(BuildContext context) { + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + + // Convert to model + final model = + isOfficer + ? _convertToKtaModel(extractedInfo) + : _convertToKtpModel(extractedInfo); + + if (extractedInfo.isEmpty) { + return _buildFallbackCard(idCardType); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + side: BorderSide( + color: isValid ? Colors.green : Colors.orange, + width: 1.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderRow(idCardType), + const Divider(height: TSizes.spaceBtwItems), + isOfficer + ? _buildKtaInfoRows(model as KtaModel) + : _buildKtpInfoRows(model as KtpModel), + if (!isValid) _buildWarningMessage(), + ], + ), + ), + ); + } + + // Convert extracted info to KTP model + KtpModel _convertToKtpModel(Map info) { + return KtpModel( + nik: info['nik'] ?? '', + name: info['nama'] ?? '', + birthPlace: info['tempat_lahir'] ?? info['birthPlace'] ?? '', + birthDate: info['tanggal_lahir'] ?? info['birthDate'] ?? '', + gender: info['gender'] ?? info['jenis_kelamin'] ?? '', + address: info['alamat'] ?? info['address'] ?? '', + nationality: info['nationality'] ?? info['kewarganegaraan'] ?? 'WNI', + religion: info['religion'] ?? info['agama'], + occupation: info['occupation'] ?? info['pekerjaan'], + maritalStatus: info['marital_status'] ?? info['status_perkawinan'], + rtRw: info['rt_rw'], + kelurahan: info['kelurahan'] ?? info['desa'], + kecamatan: info['kecamatan'], + ); + } + + // Convert extracted info to KTA model + KtaModel _convertToKtaModel(Map info) { + return KtaModel( + name: info['nama'] ?? '', + nrp: info['nrp'] ?? '', + policeUnit: info['unit'] ?? info['kesatuan'] ?? '', + issueDate: info['tanggal_terbit'] ?? info['issue_date'] ?? '', + cardNumber: info['nomor_kartu'] ?? info['card_number'] ?? '', + extraData: { + 'pangkat': info['pangkat'] ?? '', + 'tanggal_lahir': info['tanggal_lahir'] ?? '', + }, + ); + } + + Widget _buildHeaderRow(String idCardType) { + return Row( + children: [ + Icon( + isValid ? Icons.check_circle : Icons.info, + color: isValid ? Colors.green : Colors.orange, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + 'Extracted $idCardType Information', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: TSizes.fontSizeMd, + ), + ), + ), + ], + ); + } + + Widget _buildKtpInfoRows(KtpModel model) { + // Only show non-empty fields + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (model.nik.isNotEmpty) _buildInfoRow('NIK', model.nik), + if (model.name.isNotEmpty) _buildInfoRow('Name', model.name), + if (model.birthPlace.isNotEmpty) + _buildInfoRow('Birth Place', model.birthPlace), + if (model.birthDate.isNotEmpty) + _buildInfoRow('Birth Date', model.birthDate), + if (model.gender.isNotEmpty) _buildInfoRow('Gender', model.gender), + if (model.address.isNotEmpty) _buildInfoRow('Address', model.address), + if (model.religion != null && model.religion!.isNotEmpty) + _buildInfoRow('Religion', model.religion!), + if (model.maritalStatus != null && model.maritalStatus!.isNotEmpty) + _buildInfoRow('Marital Status', model.maritalStatus!), + if (model.occupation != null && model.occupation!.isNotEmpty) + _buildInfoRow('Occupation', model.occupation!), + ], + ); + } + + Widget _buildKtaInfoRows(KtaModel model) { + // Get additional fields from extraData + final pangkat = model.extraData?['pangkat'] as String? ?? ''; + final tanggalLahir = model.extraData?['tanggal_lahir'] as String? ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (model.name.isNotEmpty) _buildInfoRow('Name', model.name), + if (model.nrp.isNotEmpty) _buildInfoRow('NRP', model.nrp), + if (pangkat.isNotEmpty) _buildInfoRow('Rank', pangkat), + if (model.policeUnit.isNotEmpty) + _buildInfoRow('Unit', model.policeUnit), + if (tanggalLahir.isNotEmpty) _buildInfoRow('Birth Date', tanggalLahir), + if (model.issueDate.isNotEmpty) + _buildInfoRow('Issue Date', model.issueDate), + if (model.cardNumber.isNotEmpty) + _buildInfoRow('Card Number', model.cardNumber), + ], + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: TColors.textSecondary, + ), + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + value, + style: const TextStyle(color: TColors.textPrimary), + ), + ), + ], + ), + ); + } + + Widget _buildWarningMessage() { + return Container( + margin: const EdgeInsets.only(top: TSizes.sm), + padding: const EdgeInsets.all(TSizes.sm), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.orange, + size: TSizes.iconSm, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + 'Some information might be missing or incorrect. Please verify the extracted data.', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + color: Colors.orange.shade800, + ), + ), + ), + ], + ), + ); + } + + Widget _buildFallbackCard(String idCardType) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + side: BorderSide(color: Colors.red.shade300, width: 1.5), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red.shade400, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + 'Unable to Extract $idCardType Information', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: TSizes.fontSizeMd, + ), + ), + ), + ], + ), + const Divider(height: TSizes.spaceBtwItems), + Text( + 'We could not automatically extract information from your $idCardType. Please make sure:', + style: TextStyle( + color: TColors.textSecondary, + fontSize: TSizes.fontSizeSm, + ), + ), + const SizedBox(height: TSizes.sm), + _buildFallbackTipItem('The image is clear and not blurry'), + _buildFallbackTipItem('All text on the $idCardType is visible'), + _buildFallbackTipItem('There is good lighting with no glare'), + _buildFallbackTipItem('The entire card is visible in the frame'), + const SizedBox(height: TSizes.sm), + Text( + 'Please take a new picture of your $idCardType and try again.', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: TSizes.fontSizeSm, + color: Colors.red.shade700, + ), + ), + ], + ), + ), + ); + } + + Widget _buildFallbackTipItem(String tip) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'โ€ข ', + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text( + tip, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: Colors.red.shade700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/verification/validation_message_card.dart b/sigap-mobile/lib/src/shared/widgets/verification/validation_message_card.dart new file mode 100644 index 0000000..c14c9fe --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/verification/validation_message_card.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class ValidationMessageCard extends StatelessWidget { + final String message; + final bool isValid; + final bool hasConfirmed; + final VoidCallback? onConfirm; + final VoidCallback? onTryAnother; + + const ValidationMessageCard({ + super.key, + required this.message, + required this.isValid, + this.hasConfirmed = false, + this.onConfirm, + this.onTryAnother, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: + isValid + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusSm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + isValid ? Icons.check_circle : Icons.error, + color: isValid ? Colors.green : Colors.red, + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + message, + style: TextStyle(color: isValid ? Colors.green : Colors.red), + ), + ), + ], + ), + if (isValid && + !hasConfirmed && + onConfirm != null && + onTryAnother != null) ...[ + const SizedBox(height: TSizes.md), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: onConfirm, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Confirm Image'), + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: TextButton( + onPressed: onTryAnother, + child: const Text('Try Another Image'), + ), + ), + ], + ), + ], + if (hasConfirmed) + const Padding( + padding: EdgeInsets.only(top: TSizes.sm), + child: Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green, + size: TSizes.iconSm, + ), + SizedBox(width: TSizes.xs), + Text( + 'Image confirmed', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/utils/debug/form_key_debugger.dart b/sigap-mobile/lib/src/utils/debug/form_key_debugger.dart new file mode 100644 index 0000000..962cb1f --- /dev/null +++ b/sigap-mobile/lib/src/utils/debug/form_key_debugger.dart @@ -0,0 +1,181 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; + +class FormKeyDebugger { + static final Logger _logger = Logger(); + static final Map _trackedKeys = {}; + static final Set _loggedCombinations = {}; + static DateTime? _lastDebugTime; + static const Duration _debugCooldown = Duration(milliseconds: 500); + + /// Register a form key for tracking + static void registerFormKey(String identifier, GlobalKey key) { + if (!kDebugMode) return; + + _trackedKeys[identifier] = key; + _logger.d('๐Ÿ”‘ Registered form key: $identifier -> ${key.toString()}'); + } + + /// Debug all form keys with cooldown to prevent spam + static void debugAllFormKeys() { + if (!kDebugMode) return; + + final now = DateTime.now(); + if (_lastDebugTime != null && + now.difference(_lastDebugTime!) < _debugCooldown) { + return; // Skip if called too frequently + } + _lastDebugTime = now; + + if (_trackedKeys.isEmpty) { + _logger.d('๐Ÿ” No form keys registered for debugging'); + return; + } + + _logger.d('๐Ÿ” === FORM KEY DEBUG SESSION ==='); + _logger.d('๐Ÿ“Š Total tracked keys: ${_trackedKeys.length}'); + + final keyGroups = >{}; + final duplicates = []; + + // Group keys by their string representation + _trackedKeys.forEach((identifier, key) { + final keyString = key.toString(); + keyGroups.putIfAbsent(keyString, () => []).add(identifier); + + _logger.d('๐Ÿ”‘ $identifier: $keyString (hash: ${key.hashCode})'); + _logger.d( + ' - Current context: ${key.currentContext != null ? "โœ…" : "โŒ"}', + ); + + if (key is GlobalKey) { + _logger.d(' - Form state: ${key.currentState != null ? "โœ…" : "โŒ"}'); + _logger.d(' - Form mounted: ${key.currentState?.mounted ?? false}'); + } + }); + + // Check for duplicates + keyGroups.forEach((keyString, identifiers) { + if (identifiers.length > 1) { + duplicates.add(keyString); + _logger.e('โŒ DUPLICATE KEY DETECTED!'); + _logger.e(' Key: $keyString'); + _logger.e(' Used by: ${identifiers.join(", ")}'); + } + }); + + if (duplicates.isEmpty) { + _logger.d('โœ… No duplicate keys found'); + } else { + _logger.e('โŒ Found ${duplicates.length} duplicate key(s)'); + } + + _logger.d('๐Ÿ” === END DEBUG SESSION ==='); + } + + /// Debug a specific controller's form key + static void debugControllerFormKey(String source, GlobalKey formKey) { + if (!kDebugMode) return; + + final combination = '${source}_${formKey.toString()}'; + if (_loggedCombinations.contains(combination)) { + return; // Already logged this combination + } + + _loggedCombinations.add(combination); + + // Use microtask to avoid blocking UI + Future.microtask(() { + _logger.d( + '๐Ÿ”‘ Controller "$source" form key: $formKey (hashCode: ${formKey.hashCode})', + ); + + if (formKey is GlobalKey) { + _logger.d(' - Form state exists: ${formKey.currentState != null}'); + _logger.d( + ' - Form mounted: ${formKey.currentState?.mounted ?? false}', + ); + } + }); + } + + /// Check if a key is already being tracked + static bool isKeyTracked(GlobalKey key) { + return _trackedKeys.containsValue(key); + } + + /// Get all tracked keys + static Map getTrackedKeys() { + return Map.unmodifiable(_trackedKeys); + } + + /// Clear all tracking (useful for testing) + static void clearAll() { + _trackedKeys.clear(); + _loggedCombinations.clear(); + _lastDebugTime = null; + _logger.d('๐Ÿงน FormKeyDebugger cleared all tracking data'); + } + + /// Unregister a specific key + static void unregisterFormKey(String identifier) { + if (_trackedKeys.containsKey(identifier)) { + _trackedKeys.remove(identifier); + _logger.d('๐Ÿ—‘๏ธ Unregistered form key: $identifier'); + } + } + + /// Validate form keys for common issues + static void validateFormKeys() { + if (!kDebugMode) return; + + _logger.d('๐Ÿ” Validating form keys...'); + + final issues = []; + + _trackedKeys.forEach((identifier, key) { + // Check if key has context + if (key.currentContext == null) { + issues.add('$identifier: No context'); + } + + // Check if FormState key has state + if (key is GlobalKey && key.currentState == null) { + issues.add('$identifier: No form state'); + } + + // Check if widget is mounted + if (key is GlobalKey && + key.currentState != null && + !key.currentState!.mounted) { + issues.add('$identifier: Form not mounted'); + } + }); + + if (issues.isEmpty) { + _logger.d('โœ… All form keys are valid'); + } else { + _logger.w('โš ๏ธ Found ${issues.length} form key issue(s):'); + for (final issue in issues) { + _logger.w(' - $issue'); + } + } + } + + /// Create a unique form key with automatic registration + static GlobalKey createFormKey(String identifier) { + final key = GlobalKey(debugLabel: identifier); + registerFormKey(identifier, key); + return key; + } + + /// Create a unique form key with timestamp to prevent duplicates + static GlobalKey createUniqueFormKey(String identifier) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final uniqueId = '${identifier}_$timestamp'; + final key = GlobalKey(debugLabel: uniqueId); + registerFormKey(identifier, key); + return key; + } +} diff --git a/sigap-mobile/lib/src/utils/splash-screen/splash_screen.dart b/sigap-mobile/lib/src/utils/splash-screen/splash_screen.dart new file mode 100644 index 0000000..30f0ea1 --- /dev/null +++ b/sigap-mobile/lib/src/utils/splash-screen/splash_screen.dart @@ -0,0 +1,357 @@ +library; + +import 'package:flutter/material.dart'; +import 'package:page_transition/page_transition.dart'; + +enum SplashType { simpleSplash, backgroundScreenReturn } + +enum SplashTransition { + slideTransition, + scaleTransition, + rotationTransition, + + sizeTransition, + fadeTransition, + decoratedBoxTransition, +} + +class AnimatedSplashScreen extends StatefulWidget { + /// Type of page transition + final PageTransitionType transitionType; + + /// Type of splash transition + final SplashTransition splashTransition; + + /// Only required while using [AnimatedSplashScreen.withScreenFunction] + /// here you pass your function that needs to be called before jumping + /// on to the next screen + final Future Function()? function; + + /// Custom animation to icon of splash + final Animatable? customAnimation; + + /// Background color + final Color backgroundColor; + + /// Compulsory in default constructor. + /// Here you pass your widget that will be browsed + final Widget? nextScreen; + + /// Type of AnimatedSplashScreen + final SplashType type; + + /// to make the icon in [Center] of splash + final bool centered; + + /// If you want to implement the navigation to the next page yourself. + /// By default this is [false], the widget will call Navigator.of(_context).pushReplacement() + /// using PageTransition with [transitionType] after [duration] to [nextScreen] + final bool disableNavigation; + + /// It can be string for [Image.asserts], normal [Widget] or you can user tags + /// to choose which one you image type, for example: + /// * '[n]www.my-site.com/my-image.png' to [Image.network] + final dynamic splash; + + /// Time in milliseconds after splash animation to jump to next screen + /// Default is [milliseconds: 2500], minimum is [milliseconds: 100] + final int duration; + + /// Curve of splash animation + final Curve curve; + + /// Splash animation duration, default is [milliseconds: 800] + final Duration? animationDuration; + + /// Size of an icon in splash screen + final double? splashIconSize; + + /// Here you pass your route that will be browsed + final String? nextRoute; + + factory AnimatedSplashScreen({ + Curve curve = Curves.easeInCirc, + Future Function()? function, + int duration = 2500, + required dynamic splash, + required Widget nextScreen, + Color backgroundColor = Colors.white, + Animatable? customTween, + bool centered = true, + bool disableNavigation = false, + SplashTransition? splashTransition, + PageTransitionType? pageTransitionType, + Duration? animationDuration, + double? splashIconSize, + String? nextRoute, + }) { + return AnimatedSplashScreen._internal( + backgroundColor: backgroundColor, + animationDuration: animationDuration, + transitionType: pageTransitionType ?? PageTransitionType.bottomToTop, + splashTransition: splashTransition ?? SplashTransition.fadeTransition, + splashIconSize: splashIconSize, + customAnimation: customTween, + function: function, + nextRoute: nextRoute, + duration: duration, + centered: centered, + disableNavigation: disableNavigation, + splash: splash, + type: SplashType.simpleSplash, + nextScreen: nextScreen, + curve: curve, + ); + } + + factory AnimatedSplashScreen.withScreenFunction({ + Curve curve = Curves.easeInCirc, + bool centered = true, + bool disableNavigation = false, + int duration = 2500, + required dynamic splash, + required Future Function() screenFunction, + Animatable? customTween, + Color backgroundColor = Colors.white, + SplashTransition? splashTransition, + PageTransitionType? pageTransitionType, + Duration? animationDuration, + double? splashIconSize, + }) { + return AnimatedSplashScreen._internal( + type: SplashType.backgroundScreenReturn, + animationDuration: animationDuration, + transitionType: pageTransitionType ?? PageTransitionType.bottomToTop, + splashTransition: splashTransition ?? SplashTransition.fadeTransition, + backgroundColor: backgroundColor, + splashIconSize: splashIconSize, + customAnimation: customTween, + function: screenFunction, + duration: duration, + centered: centered, + disableNavigation: disableNavigation, + nextRoute: null, + nextScreen: null, + splash: splash, + curve: curve, + ); + } + + factory AnimatedSplashScreen.withScreenRouteFunction({ + Curve curve = Curves.easeInCirc, + bool centered = true, + bool disableNavigation = false, + int duration = 2500, + required dynamic splash, + required Future Function() screenRouteFunction, + Animatable? customTween, + Color backgroundColor = Colors.white, + SplashTransition? splashTransition, + PageTransitionType? pageTransitionType, + Duration? animationDuration, + double? splashIconSize, + }) { + return AnimatedSplashScreen._internal( + type: SplashType.backgroundScreenReturn, + animationDuration: animationDuration, + transitionType: pageTransitionType ?? PageTransitionType.bottomToTop, + splashTransition: splashTransition ?? SplashTransition.fadeTransition, + backgroundColor: backgroundColor, + splashIconSize: splashIconSize, + customAnimation: customTween, + function: screenRouteFunction, + duration: duration, + centered: centered, + disableNavigation: disableNavigation, + nextRoute: null, + nextScreen: null, + splash: splash, + curve: curve, + ); + } + + const AnimatedSplashScreen._internal({ + required this.animationDuration, + required this.splashTransition, + required this.customAnimation, + required this.backgroundColor, + required this.transitionType, + required this.splashIconSize, + required this.nextScreen, + required this.function, + required this.duration, + required this.centered, + required this.disableNavigation, + required this.splash, + required this.curve, + required this.type, + required this.nextRoute, + }); + + @override + _AnimatedSplashScreenState createState() => _AnimatedSplashScreenState(); +} + +class _AnimatedSplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + static late BuildContext _context; + late Animation _animation; + + AnimatedSplashScreen get w => widget; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + duration: w.animationDuration ?? Duration(milliseconds: 800), + vsync: this, + ); + + Animatable animation = + w.customAnimation ?? + () { + switch (w.splashTransition) { + case SplashTransition.slideTransition: + return Tween(end: Offset.zero, begin: Offset(1, 0)); + + case SplashTransition.decoratedBoxTransition: + return DecorationTween( + end: BoxDecoration(color: Colors.black87), + begin: BoxDecoration(color: Colors.redAccent), + ); + + default: + return Tween(begin: 0.0, end: 1.0); + } + }() + as Animatable; + + _animation = animation.animate( + CurvedAnimation(parent: _animationController, curve: w.curve), + ); + _animationController.forward().then((value) => doTransition()); + } + + /// call function case needed and then jump to next screen + doTransition() async { + if (w.type == SplashType.backgroundScreenReturn) + navigator(await w.function!()); + else if (!w.disableNavigation) + navigator(w.nextRoute ?? w.nextScreen); + } + + @override + void dispose() { + _animationController.reset(); + _animationController.dispose(); + super.dispose(); + } + + navigator(screen) { + Future.delayed( + Duration(milliseconds: w.duration < 100 ? 100 : w.duration), + ).then((_) { + try { + if (screen is String) { + Navigator.of(_context).pushReplacementNamed(screen); + } else { + Navigator.of(_context).pushReplacement( + PageTransition(type: w.transitionType, child: screen), + ); + } + } catch (msg) { + print( + 'AnimatedSplashScreen -> ' + 'error in jump to next screen, probably ' + 'this run is in hot reload: $msg', + ); + } + }); + } + + /// Return icon of splash screen + Widget getSplash() { + final size = + w.splashIconSize ?? MediaQuery.of(context).size.shortestSide * 0.2; + + Widget main({required Widget child}) => + w.centered ? Center(child: child) : child; + + return getTransition( + child: main( + child: SizedBox( + height: size, + child: + w.splash is String + ? () { + if (w.splash.toString().contains('[n]')) + return Image.network( + w.splash.toString().replaceAll('[n]', ''), + ); + else + return Image.asset(w.splash); + }() + : (w.splash is IconData + ? Icon(w.splash, size: size) + : w.splash), + ), + ), + ); + } + + /// return transtion + Widget getTransition({required Widget child}) { + switch (w.splashTransition) { + case SplashTransition.slideTransition: + return SlideTransition( + position: (_animation as Animation), + child: child, + ); + + case SplashTransition.scaleTransition: + return ScaleTransition( + scale: (_animation as Animation), + child: child, + ); + + case SplashTransition.rotationTransition: + return RotationTransition( + turns: (_animation as Animation), + child: child, + ); + + case SplashTransition.sizeTransition: + return SizeTransition( + sizeFactor: (_animation as Animation), + child: child, + ); + + case SplashTransition.fadeTransition: + return FadeTransition( + opacity: (_animation as Animation), + child: child, + ); + + case SplashTransition.decoratedBoxTransition: + return DecoratedBoxTransition( + decoration: (_animation as Animation), + child: child, + ); + + default: + return FadeTransition( + opacity: (_animation as Animation), + child: child, + ); + } + } + + @override + Widget build(BuildContext context) { + _context = context; + + return Scaffold(backgroundColor: w.backgroundColor, body: getSplash()); + } +} diff --git a/sigap-mobile/pubspec.lock b/sigap-mobile/pubspec.lock index 79f9129..4084bc7 100644 --- a/sigap-mobile/pubspec.lock +++ b/sigap-mobile/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.9" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: diff --git a/sigap-mobile/pubspec.yaml b/sigap-mobile/pubspec.yaml index 7e9bba6..d27aa08 100644 --- a/sigap-mobile/pubspec.yaml +++ b/sigap-mobile/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: time_slot: calendar_date_picker2: easy_date_timeline: + equatable: ^2.0.7 # --- Logging & Debugging --- logger: