From 4d195fe483451574741d6b214e31511f646dab83 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Wed, 21 May 2025 17:44:08 +0700 Subject: [PATCH] update registerstore worked --- .../data/api/retrofit/ApiConfig.kt | 6 +- .../data/repository/UserRepository.kt | 236 ++++++++++++------ .../ui/auth/RegisterStoreActivity.kt | 175 ++++++++++--- .../ui/auth/RegisterStoreViewModel.kt | 41 ++- .../alya/ecommerce_serang/utils/ImageUtils.kt | 191 ++++++++++++++ 5 files changed, 540 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/utils/ImageUtils.kt diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiConfig.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiConfig.kt index 78e0394..b277cea 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiConfig.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiConfig.kt @@ -22,9 +22,9 @@ class ApiConfig { val client = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(authInterceptor) - .connectTimeout(60, TimeUnit.SECONDS) // Increase to 60 seconds - .readTimeout(60, TimeUnit.SECONDS) // Increase to 60 seconds - .writeTimeout(60, TimeUnit.SECONDS) + .connectTimeout(180, TimeUnit.SECONDS) // 3 minutes + .readTimeout(300, TimeUnit.SECONDS) // 5 minutes + .writeTimeout(300, TimeUnit.SECONDS) // 5 minutes .build() val retrofit = Retrofit.Builder() diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt index 2f2177b..f5a5c9f 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt @@ -24,6 +24,9 @@ import com.alya.ecommerce_serang.data.api.response.customer.order.ListProvinceRe import com.alya.ecommerce_serang.data.api.response.customer.profile.EditProfileResponse import com.alya.ecommerce_serang.data.api.retrofit.ApiService import com.alya.ecommerce_serang.utils.FileUtils +import com.alya.ecommerce_serang.utils.ImageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody @@ -32,6 +35,9 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.io.File class UserRepository(private val apiService: ApiService) { + + private val ALLOWED_FILE_TYPES = Regex("^(jpeg|jpg|png|pdf)$", RegexOption.IGNORE_CASE) + //post data without message/response suspend fun requestOtpRep(email: String): OtpResponse { return apiService.getOTP(OtpRequest(email)) @@ -84,7 +90,7 @@ class UserRepository(private val apiService: ApiService) { cityId: Int, provinceId: Int, postalCode: Int, - detail: String?, + detail: String, bankName: String, bankNum: Int, storeName: String, @@ -98,6 +104,17 @@ class UserRepository(private val apiService: ApiService) { accountName: String ): Result { return try { + Log.d("RegisterStoreRepo", "Registration params: " + + "storeName=$storeName, " + + "storeTypeId=$storeTypeId, " + + "location=($latitude,$longitude), " + + "address=$street, $subdistrict, cityId=$cityId, provinceId=$provinceId, " + + "postalCode=$postalCode, " + + "bankDetails=$bankName, $bankNum, $accountName, " + + "couriers=${couriers.joinToString()}, " + + "files: storeImg=${storeImg != null}, ktp=${ktp != null}, npwp=${npwp != null}, " + + "nib=${nib != null}, persetujuan=${persetujuan != null}, qris=${qris != null}") + val descriptionPart = description.toRequestBody("text/plain".toMediaTypeOrNull()) val storeTypeIdPart = storeTypeId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) val latitudePart = latitude.toRequestBody("text/plain".toMediaTypeOrNull()) @@ -107,7 +124,7 @@ class UserRepository(private val apiService: ApiService) { val cityIdPart = cityId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) val provinceIdPart = provinceId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) val postalCodePart = postalCode.toString().toRequestBody("text/plain".toMediaTypeOrNull()) - val detailPart = detail?.toRequestBody("text/plain".toMediaTypeOrNull()) + val detailPart = detail.toRequestBody("text/plain".toMediaTypeOrNull()) val bankNamePart = bankName.toRequestBody("text/plain".toMediaTypeOrNull()) val bankNumPart = bankNum.toString().toRequestBody("text/plain".toMediaTypeOrNull()) val storeNamePart = storeName.toRequestBody("text/plain".toMediaTypeOrNull()) @@ -116,89 +133,56 @@ class UserRepository(private val apiService: ApiService) { // Create a Map for courier values val courierMap = HashMap() - couriers.forEach { courier -> - courierMap["couriers[]"] = courier.toRequestBody("text/plain".toMediaTypeOrNull()) + couriers.forEachIndexed { index, courier -> + // Add index to make keys unique + courierMap["couriers[$index]"] = courier.toRequestBody("text/plain".toMediaTypeOrNull()) } - // Convert URIs to MultipartBody.Part - val storeImgPart = storeImg?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "store_img_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("storeimg", file.name, requestFile) + val storeImgPart = try { + processImageFile(context, storeImg, "storeimg", "store_img") + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("Foto toko: ${e.message}")) } - val ktpPart = ktp?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "ktp_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("ktp", file.name, requestFile) + val ktpPart = try { + processImageFile(context, ktp, "ktp", "ktp") + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("KTP: ${e.message}")) } - val npwpPart = npwp?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "npwp_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } + val npwpPart = try { + npwp?.let { + processDocumentFile(context, it, "npwp", "npwp") } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("npwp", file.name, requestFile) + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("NPWP: ${e.message}")) } - val nibPart = nib?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "nib_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("nib", file.name, requestFile) + val nibPart = try { + processDocumentFile(context, nib, "nib", "nib") + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("NIB: ${e.message}")) } - val persetujuanPart = persetujuan?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "persetujuan_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } + val persetujuanPart = try { + persetujuan?.let { + processDocumentFile(context, it, "persetujuan", "persetujuan") } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("persetujuan", file.name, requestFile) + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("Persetujuan: ${e.message}")) } - val qrisPart = qris?.let { - val inputStream = context.contentResolver.openInputStream(it) - val file = File(context.cacheDir, "qris_${System.currentTimeMillis()}") - inputStream?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } + val qrisPart = try { + qris?.let { + processDocumentFile(context, it, "qris", "qris") } - val mimeType = context.contentResolver.getType(it) ?: "application/octet-stream" - val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) - MultipartBody.Part.createFormData("qris", file.name, requestFile) + } catch (e: IllegalArgumentException) { + return Result.Error(Exception("QRIS: ${e.message}")) } + Log.d("RegisterStoreRepo", "All parts prepared, making API call") + + // Make the API call val response = apiService.registerStore( descriptionPart, @@ -210,7 +194,7 @@ class UserRepository(private val apiService: ApiService) { cityIdPart, provinceIdPart, postalCodePart, - detailPart ?: "".toRequestBody("text/plain".toMediaTypeOrNull()), + detailPart, bankNamePart, bankNumPart, storeNamePart, @@ -226,16 +210,128 @@ class UserRepository(private val apiService: ApiService) { // Check if response is successful if (response.isSuccessful) { + Log.d("RegisterStoreRepo", "Registration successful") Result.Success(response.body() ?: throw Exception("Response body is null")) } else { - Result.Error(Exception("Registration failed with code: ${response.code()}")) + val errorBody = response.errorBody()?.string() ?: "No error details" + Log.e("RegisterStore", "Registration failed: ${response.code()}, Error: $errorBody") + Result.Error(Exception("Registration failed with code: ${response.code()}\nDetails: $errorBody")) } } catch (e: Exception) { + Log.e("RegisterStoreRepo", "Registration exception", e) Result.Error(e) } } + private suspend fun processImageFile( + context: Context, + uri: Uri?, + formName: String, + filePrefix: String + ): MultipartBody.Part? { + if (uri == null) { + Log.d(TAG, "$formName is null, skipping") + return null + } + + return withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Processing $formName image") + + // Check file type + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" + Log.d(TAG, "$formName MIME type: $mimeType") + + // Validate file type + if (!ImageUtils.isAllowedFileType(context, uri, ALLOWED_FILE_TYPES)) { + Log.e(TAG, "$formName has invalid file type: $mimeType") + throw IllegalArgumentException("$formName hanya menerima file JPEG, JPG, atau PNG") + } + + // Only compress image files, not PDFs + if (mimeType.startsWith("image/")) { + Log.d(TAG, "Compressing $formName image") + + // Compress image + val compressedFile = ImageUtils.compressImage( + context = context, + uri = uri, + filename = filePrefix, + maxWidth = 1024, + maxHeight = 1024, + quality = 80 + ) + + val requestFile = compressedFile.asRequestBody(mimeType.toMediaTypeOrNull()) + Log.d(TAG, "$formName compressed size: ${compressedFile.length() / 1024} KB") + + MultipartBody.Part.createFormData(formName, compressedFile.name, requestFile) + } else { + throw IllegalArgumentException("$formName harus berupa file gambar (JPEG, JPG, atau PNG)") + } + } catch (e: Exception) { + Log.e(TAG, "Error processing $formName image", e) + throw e + } + } + } + + // Process document files (handle PDFs separately) + private suspend fun processDocumentFile( + context: Context, + uri: Uri?, + formName: String, + filePrefix: String + ): MultipartBody.Part? { + if (uri == null) { + Log.d(TAG, "$formName is null, skipping") + return null + } + + return withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Processing $formName document") + + val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" + Log.d(TAG, "$formName MIME type: $mimeType") + + // Validate file type + if (!ImageUtils.isAllowedFileType(context, uri, ALLOWED_FILE_TYPES)) { + Log.e(TAG, "$formName has invalid file type: $mimeType") + throw IllegalArgumentException("$formName hanya menerima file JPEG, JPG, PNG, atau PDF") + } + + // For image documents, compress them + if (mimeType.startsWith("image/")) { + return@withContext processImageFile(context, uri, formName, filePrefix) + } + + // For PDFs, copy as is + if (mimeType.contains("pdf")) { + val inputStream = context.contentResolver.openInputStream(uri) + val file = File(context.cacheDir, "${filePrefix}_${System.currentTimeMillis()}.pdf") + + inputStream?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + + Log.d(TAG, "$formName PDF size: ${file.length() / 1024} KB") + + val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull()) + MultipartBody.Part.createFormData(formName, file.name, requestFile) + } else { + throw IllegalArgumentException("$formName harus berupa file PDF atau gambar (JPEG, JPG, PNG)") + } + } catch (e: Exception) { + Log.e(TAG, "Error processing $formName document", e) + throw e + } + } + } + suspend fun login(email: String, password: String): Result { return try { val response = apiService.login(LoginRequest(email, password)) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreActivity.kt index 46a91b0..b7954af 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreActivity.kt @@ -63,17 +63,22 @@ class RegisterStoreActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate: Starting RegisterStoreActivity") binding = ActivityRegisterStoreBinding.inflate(layoutInflater) setContentView(binding.root) sessionManager = SessionManager(this) + Log.d(TAG, "onCreate: SessionManager initialized") WindowCompat.setDecorFitsSystemWindows(window, false) + Log.d(TAG, "onCreate: Window decoration set") enableEdgeToEdge() + Log.d(TAG, "onCreate: Edge-to-edge enabled") // Apply insets to your root layout ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + Log.d(TAG, "onCreate: Applying window insets") val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.setPadding( systemBars.left, @@ -86,43 +91,63 @@ class RegisterStoreActivity : AppCompatActivity() { provinceAdapter = ProvinceAdapter(this) cityAdapter = CityAdapter(this) + Log.d(TAG, "onCreate: Adapters initialized") setupDataBinding() + Log.d(TAG, "onCreate: Data binding setup completed") + setupSpinners() // Location spinners + Log.d(TAG, "onCreate: Spinners setup completed") // Setup observers setupStoreTypesObserver() // Store type observer setupObservers() + Log.d(TAG, "onCreate: Observers setup completed") setupMap() - setupDocumentUploads() - setupCourierSelection() + Log.d(TAG, "onCreate: Map setup completed") + setupDocumentUploads() + Log.d(TAG, "onCreate: Document uploads setup completed") + + setupCourierSelection() + Log.d(TAG, "onCreate: Courier selection setup completed") + + Log.d(TAG, "onCreate: Fetching store types from API") viewModel.fetchStoreTypes() + + Log.d(TAG, "onCreate: Fetching provinces from API") viewModel.getProvinces() // Setup register button binding.btnRegister.setOnClickListener { + Log.d(TAG, "Register button clicked") if (viewModel.validateForm()) { + Log.d(TAG, "Form validation successful, proceeding with registration") viewModel.registerStore(this) } else { + Log.e(TAG, "Form validation failed") Toast.makeText(this, "Harap lengkapi semua field yang wajib diisi", Toast.LENGTH_SHORT).show() } } + + Log.d(TAG, "onCreate: RegisterStoreActivity setup completed") } private fun setupObservers() { + Log.d(TAG, "setupObservers: Setting up LiveData observers") + // Observe province state viewModel.provincesState.observe(this) { state -> when (state) { is Result.Loading -> { - Log.d(TAG, "Loading provinces...") + Log.d(TAG, "setupObservers: Loading provinces...") binding.provinceProgressBar?.visibility = View.VISIBLE binding.spinnerProvince.isEnabled = false } is Result.Success -> { - Log.d(TAG, "Provinces loaded: ${state.data.size}") + Log.d(TAG, "setupObservers: Provinces loaded successfully: ${state.data.size} provinces") binding.provinceProgressBar?.visibility = View.GONE binding.spinnerProvince.isEnabled = true @@ -130,11 +155,9 @@ class RegisterStoreActivity : AppCompatActivity() { provinceAdapter.updateData(state.data) } is Result.Error -> { -// Log.e(TAG, "Error loading provinces: ${state.}") + Log.e(TAG, "setupObservers: Error loading provinces: ${state.exception.message}") binding.provinceProgressBar?.visibility = View.GONE binding.spinnerProvince.isEnabled = true - -// Toast.makeText(this, "Gagal memuat provinsi: ${state.message}", Toast.LENGTH_SHORT).show() } } } @@ -143,12 +166,12 @@ class RegisterStoreActivity : AppCompatActivity() { viewModel.citiesState.observe(this) { state -> when (state) { is Result.Loading -> { - Log.d(TAG, "Loading cities...") + Log.d(TAG, "setupObservers: Loading cities...") binding.cityProgressBar?.visibility = View.VISIBLE binding.spinnerCity.isEnabled = false } is Result.Success -> { - Log.d(TAG, "Cities loaded: ${state.data.size}") + Log.d(TAG, "setupObservers: Cities loaded successfully: ${state.data.size} cities") binding.cityProgressBar?.visibility = View.GONE binding.spinnerCity.isEnabled = true @@ -156,11 +179,9 @@ class RegisterStoreActivity : AppCompatActivity() { cityAdapter.updateData(state.data) } is Result.Error -> { -// Log.e(TAG, "Error loading cities: ${state.message}") + Log.e(TAG, "setupObservers: Error loading cities: ${state.exception.message}") binding.cityProgressBar?.visibility = View.GONE binding.spinnerCity.isEnabled = true - -// Toast.makeText(this, "Gagal memuat kota: ${state.message}", Toast.LENGTH_SHORT).show() } } } @@ -169,29 +190,38 @@ class RegisterStoreActivity : AppCompatActivity() { viewModel.registerState.observe(this) { result -> when (result) { is Result.Loading -> { + Log.d(TAG, "setupObservers: Store registration in progress...") showLoading(true) } is Result.Success -> { + Log.d(TAG, "setupObservers: Store registration successful") showLoading(false) Toast.makeText(this, "Toko berhasil didaftarkan", Toast.LENGTH_SHORT).show() finish() // Return to previous screen } is Result.Error -> { + Log.e(TAG, "setupObservers: Store registration failed: ${result.exception.message}") showLoading(false) Toast.makeText(this, "Gagal mendaftarkan toko: ${result.exception.message}", Toast.LENGTH_SHORT).show() } } } + + Log.d(TAG, "setupObservers: Observers setup completed") } private fun setupStoreTypesObserver() { + Log.d(TAG, "setupStoreTypesObserver: Setting up store types observer") + // Observe loading state viewModel.isLoadingType.observe(this) { isLoading -> if (isLoading) { + Log.d(TAG, "setupStoreTypesObserver: Loading store types...") // Show loading indicator for store types spinner binding.spinnerStoreType.isEnabled = false binding.storeTypeProgressBar?.visibility = View.VISIBLE } else { + Log.d(TAG, "setupStoreTypesObserver: Store types loading completed") binding.spinnerStoreType.isEnabled = true binding.storeTypeProgressBar?.visibility = View.GONE } @@ -200,30 +230,37 @@ class RegisterStoreActivity : AppCompatActivity() { // Observe error messages viewModel.errorMessage.observe(this) { errorMsg -> if (errorMsg.isNotEmpty()) { + Log.e(TAG, "setupStoreTypesObserver: Error loading store types: $errorMsg") Toast.makeText(this, "Error loading store types: $errorMsg", Toast.LENGTH_SHORT).show() } } // Observe store types data viewModel.storeTypes.observe(this) { storeTypes -> - Log.d(TAG, "Store types loaded: ${storeTypes.size}") + Log.d(TAG, "setupStoreTypesObserver: Store types loaded: ${storeTypes.size}") if (storeTypes.isNotEmpty()) { // Add "Pilih Jenis UMKM" as the first item if it's not already there val displayList = if (storeTypes.any { it.name == "Pilih Jenis UMKM" || it.id == 0 }) { + Log.d(TAG, "setupStoreTypesObserver: Default item already exists in store types list") storeTypes } else { + Log.d(TAG, "setupStoreTypesObserver: Adding default item to store types list") val defaultItem = StoreTypesItem(name = "Pilih Jenis UMKM", id = 0) listOf(defaultItem) + storeTypes } // Setup spinner with API data setupStoreTypeSpinner(displayList) + } else { + Log.w(TAG, "setupStoreTypesObserver: Received empty store types list") } } + + Log.d(TAG, "setupStoreTypesObserver: Store types observer setup completed") } private fun setupStoreTypeSpinner(storeTypes: List) { - Log.d(TAG, "Setting up store type spinner with ${storeTypes.size} items") + Log.d(TAG, "setupStoreTypeSpinner: Setting up store type spinner with ${storeTypes.size} items") // Create a custom adapter to display just the name but hold the whole object val adapter = object : ArrayAdapter( @@ -250,9 +287,11 @@ class RegisterStoreActivity : AppCompatActivity() { } adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + Log.d(TAG, "setupStoreTypeSpinner: Store type adapter created") // Set adapter to spinner binding.spinnerStoreType.adapter = adapter + Log.d(TAG, "setupStoreTypeSpinner: Adapter set to spinner") // Set item selection listener binding.spinnerStoreType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { @@ -264,6 +303,8 @@ class RegisterStoreActivity : AppCompatActivity() { // Store the actual ID from the API, not just position viewModel.storeTypeId.value = selectedItem.id Log.d(TAG, "Set storeTypeId to ${selectedItem.id}") + } else { + Log.d(TAG, "Default or null store type selected, not setting storeTypeId") } } @@ -274,9 +315,12 @@ class RegisterStoreActivity : AppCompatActivity() { // Hide progress bar after setup binding.storeTypeProgressBar?.visibility = View.GONE + Log.d(TAG, "setupStoreTypeSpinner: Store type spinner setup completed") } private fun setupSpinners() { + Log.d(TAG, "setupSpinners: Setting up province and city spinners") + // Setup province spinner binding.spinnerProvince.adapter = provinceAdapter binding.spinnerProvince.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { @@ -286,9 +330,11 @@ class RegisterStoreActivity : AppCompatActivity() { if (provinceId != null) { Log.d(TAG, "Setting province ID: $provinceId") viewModel.provinceId.value = provinceId + Log.d(TAG, "Fetching cities for province ID: $provinceId") viewModel.getCities(provinceId) // Reset city selection when province changes + Log.d(TAG, "Clearing city adapter for new province selection") cityAdapter.clear() binding.spinnerCity.setSelection(0) } else { @@ -297,7 +343,7 @@ class RegisterStoreActivity : AppCompatActivity() { } override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing + Log.d(TAG, "No province selected") } } @@ -317,73 +363,74 @@ class RegisterStoreActivity : AppCompatActivity() { } override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing + Log.d(TAG, "No city selected") } } // Add initial hints to the spinners if (provinceAdapter.isEmpty) { + Log.d(TAG, "Adding default province hint") provinceAdapter.add("Pilih Provinsi") } if (cityAdapter.isEmpty) { + Log.d(TAG, "Adding default city hint") cityAdapter.add("Pilih Kabupaten/Kota") } + + Log.d(TAG, "setupSpinners: Province and city spinners setup completed") } -// private fun setupSubdistrictSpinner(cityId: Int) { -// // This would typically be populated from API based on cityId -// val subdistricts = listOf("Pilih Kecamatan", "Kecamatan 1", "Kecamatan 2", "Kecamatan 3") -// val subdistrictAdapter = ArrayAdapter(this, R.layout.simple_spinner_dropdown_item, subdistricts) -// binding.spinnerSubdistrict.adapter = subdistrictAdapter -// binding.spinnerSubdistrict.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { -// override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { -// if (position > 0) { -// viewModel.subdistrict.value = subdistricts[position] -// } -// } -// override fun onNothingSelected(parent: AdapterView<*>?) {} -// } -// } - private fun setupDocumentUploads() { + Log.d(TAG, "setupDocumentUploads: Setting up document upload buttons") + // Store Image binding.containerStoreImg.setOnClickListener { + Log.d(TAG, "Store image container clicked, picking image") pickImage(PICK_STORE_IMAGE_REQUEST) } // KTP binding.containerKtp.setOnClickListener { + Log.d(TAG, "KTP container clicked, picking image") pickImage(PICK_KTP_REQUEST) } // NIB binding.containerNib.setOnClickListener { + Log.d(TAG, "NIB container clicked, picking document") pickDocument(PICK_NIB_REQUEST) } // NPWP binding.containerNpwp?.setOnClickListener { + Log.d(TAG, "NPWP container clicked, picking image") pickImage(PICK_NPWP_REQUEST) } // SPPIRT binding.containerSppirt.setOnClickListener { + Log.d(TAG, "SPPIRT container clicked, picking document") pickDocument(PICK_PERSETUJUAN_REQUEST) } // Halal binding.containerHalal.setOnClickListener { + Log.d(TAG, "Halal container clicked, picking document") pickDocument(PICK_QRIS_REQUEST) } + + Log.d(TAG, "setupDocumentUploads: Document upload buttons setup completed") } private fun pickImage(requestCode: Int) { + Log.d(TAG, "pickImage: Launching image picker with request code: $requestCode") val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) startActivityForResult(intent, requestCode) } private fun pickDocument(requestCode: Int) { + Log.d(TAG, "pickDocument: Launching document picker with request code: $requestCode") val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" @@ -393,66 +440,87 @@ class RegisterStoreActivity : AppCompatActivity() { } private fun setupCourierSelection() { + Log.d(TAG, "setupCourierSelection: Setting up courier checkboxes") + binding.checkboxJne.setOnCheckedChangeListener { _, isChecked -> + Log.d(TAG, "JNE checkbox ${if (isChecked) "checked" else "unchecked"}") handleCourierSelection("jne", isChecked) } binding.checkboxJnt.setOnCheckedChangeListener { _, isChecked -> + Log.d(TAG, "JNT checkbox ${if (isChecked) "checked" else "unchecked"}") handleCourierSelection("tiki", isChecked) } binding.checkboxPos.setOnCheckedChangeListener { _, isChecked -> + Log.d(TAG, "POS checkbox ${if (isChecked) "checked" else "unchecked"}") handleCourierSelection("pos", isChecked) } + + Log.d(TAG, "setupCourierSelection: Courier checkboxes setup completed") } private fun handleCourierSelection(courier: String, isSelected: Boolean) { if (isSelected) { if (!viewModel.selectedCouriers.contains(courier)) { viewModel.selectedCouriers.add(courier) + Log.d(TAG, "handleCourierSelection: Added courier: $courier. Current couriers: ${viewModel.selectedCouriers}") } } else { viewModel.selectedCouriers.remove(courier) + Log.d(TAG, "handleCourierSelection: Removed courier: $courier. Current couriers: ${viewModel.selectedCouriers}") } } private fun setupMap() { + Log.d(TAG, "setupMap: Setting up map container") // This would typically integrate with Google Maps SDK // For simplicity, we're just using a placeholder binding.mapContainer.setOnClickListener { + Log.d(TAG, "Map container clicked, checking location permission") // Request location permission if not granted if (ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { + Log.d(TAG, "Location permission not granted, requesting permission") ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), LOCATION_PERMISSION_REQUEST - ) viewModel.latitude.value = "-6.2088" viewModel.longitude.value = "106.8456" + Log.d(TAG, "Location permission granted, setting default location") + Toast.makeText(this, "Lokasi dipilih", Toast.LENGTH_SHORT).show() + Log.d(TAG, "Default location set - Lat: ${viewModel.latitude.value}, Long: ${viewModel.longitude.value}") Toast.makeText(this, "Lokasi dipilih", Toast.LENGTH_SHORT).show() } else { + Log.d(TAG, "Location permission already granted, setting location") // Show map selection UI // This would typically launch Maps UI for location selection // For now, we'll just set some dummy coordinates viewModel.latitude.value = "-6.2088" viewModel.longitude.value = "106.8456" + Log.d(TAG, "Location set - Lat: ${viewModel.latitude.value}, Long: ${viewModel.longitude.value}") Toast.makeText(this, "Lokasi dipilih", Toast.LENGTH_SHORT).show() } } + + Log.d(TAG, "setupMap: Map container setup completed") } private fun setupDataBinding() { + Log.d(TAG, "setupDataBinding: Setting up two-way data binding for text fields") + // Two-way data binding for text fields binding.etStoreName.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { viewModel.storeName.value = s.toString() + Log.d(TAG, "Store name updated: ${s.toString()}") } }) @@ -461,6 +529,7 @@ class RegisterStoreActivity : AppCompatActivity() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { viewModel.storeDescription.value = s.toString() + Log.d(TAG, "Store description updated: ${s.toString().take(20)}${if ((s?.length ?: 0) > 20) "..." else ""}") } }) @@ -469,6 +538,7 @@ class RegisterStoreActivity : AppCompatActivity() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { viewModel.street.value = s.toString() + Log.d(TAG, "Street address updated: ${s.toString()}") } }) @@ -478,9 +548,10 @@ class RegisterStoreActivity : AppCompatActivity() { override fun afterTextChanged(s: Editable?) { try { viewModel.postalCode.value = s.toString().toInt() + Log.d(TAG, "Postal code updated: ${s.toString()}") } catch (e: NumberFormatException) { // Handle invalid input - //show toast + Log.e(TAG, "Invalid postal code input: ${s.toString()}, error: $e") } } }) @@ -490,6 +561,7 @@ class RegisterStoreActivity : AppCompatActivity() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { viewModel.addressDetail.value = s.toString() + Log.d(TAG, "Address detail updated: ${s.toString()}") } }) @@ -497,7 +569,20 @@ class RegisterStoreActivity : AppCompatActivity() { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { - viewModel.bankNumber.value = s.toString().toInt() + val input = s.toString() + if (input.isNotEmpty()) { + try { + viewModel.bankNumber.value = input.toInt() + Log.d(TAG, "Bank number updated: $input") + } catch (e: NumberFormatException) { + // Handle invalid input if needed + Log.e(TAG, "Failed to parse bank number. Input: $input, Error: $e") + } + } else { + // Handle empty input - perhaps set to 0 or null depending on your requirements + viewModel.bankNumber.value = 0 // or 0 + Log.d(TAG, "Bank number set to default: 0") + } } }) @@ -506,6 +591,7 @@ class RegisterStoreActivity : AppCompatActivity() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { viewModel.subdistrict.value = s.toString() + Log.d(TAG, "Subdistrict updated: ${s.toString()}") } }) @@ -513,46 +599,63 @@ class RegisterStoreActivity : AppCompatActivity() { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { - viewModel.subdistrict.value = s.toString() + viewModel.bankName.value = s.toString() + Log.d(TAG, "Bank name updated: ${s.toString()}") } }) + + Log.d(TAG, "setupDataBinding: Text field data binding setup completed") } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + Log.d(TAG, "onActivityResult: Request code: $requestCode, Result code: $resultCode") if (resultCode == Activity.RESULT_OK && data != null) { val uri = data.data + Log.d(TAG, "onActivityResult: URI received: $uri") when (requestCode) { PICK_STORE_IMAGE_REQUEST -> { + Log.d(TAG, "Store image selected") viewModel.storeImageUri = uri updateImagePreview(uri, binding.imgStore, binding.layoutUploadStoreImg) } PICK_KTP_REQUEST -> { + Log.d(TAG, "KTP image selected") viewModel.ktpUri = uri updateImagePreview(uri, binding.imgKtp, binding.layoutUploadKtp) } PICK_NPWP_REQUEST -> { + Log.d(TAG, "NPWP document selected") viewModel.npwpUri = uri updateDocumentPreview(binding.layoutUploadNpwp) } PICK_NIB_REQUEST -> { + Log.d(TAG, "NIB document selected") viewModel.nibUri = uri updateDocumentPreview(binding.layoutUploadNib) } PICK_PERSETUJUAN_REQUEST -> { + Log.d(TAG, "SPPIRT document selected") viewModel.persetujuanUri = uri updateDocumentPreview(binding.layoutUploadSppirt) } PICK_QRIS_REQUEST -> { + Log.d(TAG, "Halal document selected") viewModel.qrisUri = uri updateDocumentPreview(binding.layoutUploadHalal) } + else -> { + Log.w(TAG, "Unknown request code: $requestCode") + } } + } else { + Log.w(TAG, "File selection canceled or failed") } } private fun updateImagePreview(uri: Uri?, imageView: ImageView, uploadLayout: LinearLayout) { uri?.let { + Log.d(TAG, "updateImagePreview: Setting image URI: $uri") imageView.setImageURI(it) imageView.visibility = View.VISIBLE uploadLayout.visibility = View.GONE @@ -560,6 +663,7 @@ class RegisterStoreActivity : AppCompatActivity() { } private fun updateDocumentPreview(uploadLayout: LinearLayout) { + Log.d(TAG, "updateDocumentPreview: Updating document preview UI") // For documents, we just show a success indicator val checkIcon = ImageView(this) checkIcon.setImageResource(android.R.drawable.ic_menu_gallery) @@ -569,6 +673,7 @@ class RegisterStoreActivity : AppCompatActivity() { uploadLayout.removeAllViews() uploadLayout.addView(checkIcon) uploadLayout.addView(successText) + Log.d(TAG, "updateDocumentPreview: Document preview updated with success indicator") } //later implement get location form gps diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreViewModel.kt index 41bfbf1..b29fa41 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterStoreViewModel.kt @@ -13,6 +13,7 @@ import com.alya.ecommerce_serang.data.api.response.customer.order.CitiesItem import com.alya.ecommerce_serang.data.api.response.customer.order.ProvincesItem import com.alya.ecommerce_serang.data.repository.Result import com.alya.ecommerce_serang.data.repository.UserRepository +import com.alya.ecommerce_serang.utils.ImageUtils import kotlinx.coroutines.launch class RegisterStoreViewModel( @@ -71,6 +72,44 @@ class RegisterStoreViewModel( val selectedCouriers = mutableListOf() fun registerStore(context: Context) { + val allowedFileTypes = Regex("^(jpeg|jpg|png|pdf)$", RegexOption.IGNORE_CASE) + + // Check each file if present + if (storeImageUri != null && !ImageUtils.isAllowedFileType(context, storeImageUri, allowedFileTypes)) { + _errorMessage.value = "Foto toko harus berupa file JPEG, JPG, atau PNG" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } + + if (ktpUri != null && !ImageUtils.isAllowedFileType(context, ktpUri, allowedFileTypes)) { + _errorMessage.value = "KTP harus berupa file JPEG, JPG, atau PNG" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } + + if (npwpUri != null && !ImageUtils.isAllowedFileType(context, npwpUri, allowedFileTypes)) { + _errorMessage.value = "NPWP harus berupa file JPEG, JPG, PNG, atau PDF" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } + + if (nibUri != null && !ImageUtils.isAllowedFileType(context, nibUri, allowedFileTypes)) { + _errorMessage.value = "NIB harus berupa file JPEG, JPG, PNG, atau PDF" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } + + if (persetujuanUri != null && !ImageUtils.isAllowedFileType(context, persetujuanUri, allowedFileTypes)) { + _errorMessage.value = "Persetujuan harus berupa file JPEG, JPG, PNG, atau PDF" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } + + if (qrisUri != null && !ImageUtils.isAllowedFileType(context, qrisUri, allowedFileTypes)) { + _errorMessage.value = "QRIS harus berupa file JPEG, JPG, PNG, atau PDF" + _registerState.value = Result.Error(Exception(_errorMessage.value ?: "Invalid file type")) + return + } viewModelScope.launch { try { _registerState.value = Result.Loading @@ -86,7 +125,7 @@ class RegisterStoreViewModel( cityId = cityId.value ?: 0, provinceId = provinceId.value ?: 0, postalCode = postalCode.value ?: 0, - detail = addressDetail.value, + detail = addressDetail.value ?: "", bankName = bankName.value ?: "", bankNum = bankNumber.value ?: 0, storeName = storeName.value ?: "", diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/ImageUtils.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/ImageUtils.kt new file mode 100644 index 0000000..44602ad --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/ImageUtils.kt @@ -0,0 +1,191 @@ +package com.alya.ecommerce_serang.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import kotlin.math.min + +object ImageUtils { + private const val TAG = "ImageUtils" + private const val MAX_WIDTH = 1024 + private const val MAX_HEIGHT = 1024 + private const val QUALITY = 80 + + /** + * Compresses an image from a Uri + * + * @param context The context + * @param uri The URI of the image to compress + * @param maxWidth Maximum width (default 1024px) + * @param maxHeight Maximum height (default 1024px) + * @param quality JPEG quality (0-100, default 80) + * @return A File containing the compressed image + */ + fun compressImage( + context: Context, + uri: Uri, + filename: String, + maxWidth: Int = MAX_WIDTH, + maxHeight: Int = MAX_HEIGHT, + quality: Int = QUALITY + ): File { + Log.d(TAG, "Starting image compression for $filename") + + // Create input stream and decode the original bitmap + val inputStream = context.contentResolver.openInputStream(uri) + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + // First decode with inJustDecodeBounds=true to check dimensions + BitmapFactory.decodeStream(inputStream, null, options) + inputStream?.close() + + val originalWidth = options.outWidth + val originalHeight = options.outHeight + val mimeType = options.outMimeType ?: "image/jpeg" + + Log.d(TAG, "Original size: ${originalWidth}x${originalHeight}, mime: $mimeType") + + // Calculate inSampleSize based on required dimensions + val inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight) + + // Open a new input stream since we closed the previous one + val newInputStream = context.contentResolver.openInputStream(uri) + + // Decode with actual sampling + val decodingOptions = BitmapFactory.Options().apply { + this.inSampleSize = inSampleSize + this.inPreferredConfig = Bitmap.Config.ARGB_8888 + } + + val sampledBitmap = BitmapFactory.decodeStream(newInputStream, null, decodingOptions) + ?: throw IllegalArgumentException("Failed to decode bitmap from URI") + newInputStream?.close() + + Log.d(TAG, "Decoded size: ${sampledBitmap.width}x${sampledBitmap.height}, sample size: $inSampleSize") + + // Create output file + val extension = when { + mimeType.contains("png") -> ".png" + mimeType.contains("webp") -> ".webp" + else -> ".jpg" + } + val outputFile = File(context.cacheDir, "${filename}_${System.currentTimeMillis()}$extension") + + // Scale if still needed (in case inSampleSize couldn't get exact dimensions) + val scaledBitmap = if (sampledBitmap.width > maxWidth || sampledBitmap.height > maxHeight) { + val widthRatio = maxWidth.toFloat() / sampledBitmap.width + val heightRatio = maxHeight.toFloat() / sampledBitmap.height + val scaleFactor = min(widthRatio, heightRatio) + + val scaledWidth = (sampledBitmap.width * scaleFactor).toInt() + val scaledHeight = (sampledBitmap.height * scaleFactor).toInt() + + Log.d(TAG, "Scaling to: ${scaledWidth}x${scaledHeight}") + Bitmap.createScaledBitmap(sampledBitmap, scaledWidth, scaledHeight, true) + } else { + sampledBitmap + } + + // Save to file with compression + val format = when { + mimeType.contains("png") -> Bitmap.CompressFormat.PNG + mimeType.contains("webp") && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R -> + Bitmap.CompressFormat.WEBP_LOSSY + mimeType.contains("webp") -> Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } + + FileOutputStream(outputFile).use { out -> + scaledBitmap.compress(format, quality, out) + out.flush() + } + + // Clean up + if (scaledBitmap != sampledBitmap) { + scaledBitmap.recycle() + } + sampledBitmap.recycle() + + val originalSize = getUriFileSize(context, uri) + val compressedSize = outputFile.length() + + Log.d(TAG, "Compression complete. Original size: ${originalSize/1024}KB, " + + "Compressed size: ${compressedSize/1024}KB, " + + "Reduction: ${(100 - (compressedSize * 100 / originalSize))}%") + + return outputFile + } + + /** + * Calculate the optimal inSampleSize value for bitmap downsampling + */ + private fun calculateInSampleSize(options: BitmapFactory.Options, maxWidth: Int, maxHeight: Int): Int { + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + + if (height > maxHeight || width > maxWidth) { + val heightRatio = Math.round(height.toFloat() / maxHeight.toFloat()) + val widthRatio = Math.round(width.toFloat() / maxWidth.toFloat()) + inSampleSize = if (heightRatio < widthRatio) heightRatio else widthRatio + } + + // Ensure power of 2 for better performance + var powerOf2 = 1 + while (powerOf2 * 2 <= inSampleSize) { + powerOf2 *= 2 + } + + return powerOf2 + } + + /** + * Get the file size from a Uri + */ + private fun getUriFileSize(context: Context, uri: Uri): Long { + val cursor = context.contentResolver.query(uri, null, null, null, null) + val sizeIndex = cursor?.getColumnIndex(android.provider.OpenableColumns.SIZE) + cursor?.moveToFirst() + + val size = if (sizeIndex != null && sizeIndex >= 0) { + cursor.getLong(sizeIndex) + } else { + // If size can't be determined from cursor, read stream length + context.contentResolver.openInputStream(uri)?.use { it.available().toLong() } ?: 0L + } + + cursor?.close() + return size + } + + fun isAllowedFileType(context: Context, uri: Uri?, allowedTypes: Regex): Boolean { + if (uri == null) return false + + val mimeType = context.contentResolver.getType(uri) ?: "" + Log.d(TAG, "Checking file type: $mimeType") + + // Get file extension from mime type + val extension = when { + mimeType.contains("jpeg") || mimeType.contains("jpg") -> "jpg" + mimeType.contains("png") -> "png" + mimeType.contains("pdf") -> "pdf" + else -> { + // If mime type is not helpful, try to get extension from URI + val fileName = uri.path?.substringAfterLast('/') ?: "" + fileName.substringAfterLast('.', "").lowercase(Locale.ROOT) + } + } + + val isAllowed = allowedTypes.matches(extension) + Log.d(TAG, "File extension: $extension, Allowed: $isAllowed") + + return isAllowed + } +} \ No newline at end of file