diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e68783..4259d5c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + - + @Body createAddressRequest: CreateAddressRequest + ): Response @GET("mystore") suspend fun getStore (): Response @@ -95,8 +100,23 @@ interface ApiService { @Body cartRequest: CartItem ): Response + @PUT("cart/update") + suspend fun updateCart( + @Body updateCart: UpdateCart + ): Response + @POST("couriercost") suspend fun countCourierCost( @Body courierCost : CourierCostRequest - ): CourierCostResponse + ): Response + + @GET("cities/{id}") + suspend fun getCityProvId( + @Path("id") provId : Int + ): Response + + @GET("provinces") + suspend fun getListProv( + ): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt index c4c0114..c924899 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt @@ -1,10 +1,14 @@ package com.alya.ecommerce_serang.data.repository import android.util.Log +import com.alya.ecommerce_serang.data.api.dto.CreateAddressRequest import com.alya.ecommerce_serang.data.api.dto.OrderRequest import com.alya.ecommerce_serang.data.api.response.order.CreateOrderResponse +import com.alya.ecommerce_serang.data.api.response.order.ListCityResponse +import com.alya.ecommerce_serang.data.api.response.order.ListProvinceResponse import com.alya.ecommerce_serang.data.api.response.product.ProductResponse import com.alya.ecommerce_serang.data.api.response.product.StoreResponse +import com.alya.ecommerce_serang.data.api.response.profile.CreateAddressResponse import com.alya.ecommerce_serang.data.api.retrofit.ApiService import retrofit2.Response @@ -33,17 +37,31 @@ class OrderRepository(private val apiService: ApiService) { return if (response.isSuccessful) response.body() else null } + //post data with message/response + suspend fun addAddress(createAddressRequest: CreateAddressRequest): Result { + return try { + val response = apiService.createAddress(createAddressRequest) + if (response.isSuccessful){ + response.body()?.let { + Result.Success(it) + } ?: Result.Error(Exception("Add Address failed")) + } else { + Log.e("OrderRepository", "Error: ${response.errorBody()?.string()}") + Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error")) + } + } catch (e: Exception) { + Result.Error(e) + } + } - //not yet implement the api service address -// suspend fun getAddressDetails(addressId: Int): AddressesItem { -// // Simulate API call to get address details -// kotlinx.coroutines.delay(300) // Simulate network request -// // Return mock data -// return AddressesItem( -// id = addressId, -// label = "Rumah", -// fullAddress = "Jl. Pegangasan Timur No. 42, Jakarta" -// ) -// } + suspend fun getListProvinces(): ListProvinceResponse? { + val response = apiService.getListProv() + return if (response.isSuccessful) response.body() else null + } + + suspend fun getListCities(provId : Int): ListCityResponse?{ + val response = apiService.getCityProvId(provId) + return if (response.isSuccessful) response.body() else null + } } \ No newline at end of file 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 15fde04..5b4c97c 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 @@ -10,12 +10,9 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiService class UserRepository(private val apiService: ApiService) { + //post data without message/response suspend fun requestOtpRep(email: String): OtpResponse { - -// fun requestOtpRep(email: String): Result { - return apiService.getOTP(OtpRequest(email)) - } suspend fun registerUser(request: RegisterRequest): String { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressActivity.kt index eaad1db..86b16d5 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressActivity.kt @@ -1,21 +1,289 @@ package com.alya.ecommerce_serang.ui.order +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import com.alya.ecommerce_serang.R +import androidx.constraintlayout.motion.widget.Debug.getLocation +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.alya.ecommerce_serang.data.api.dto.CreateAddressRequest +import com.alya.ecommerce_serang.data.api.dto.UserProfile +import com.alya.ecommerce_serang.data.api.response.order.CitiesItem +import com.alya.ecommerce_serang.data.api.response.order.ProvincesItem +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import com.alya.ecommerce_serang.data.repository.OrderRepository +import com.alya.ecommerce_serang.databinding.ActivityAddAddressBinding +import com.alya.ecommerce_serang.utils.SavedStateViewModelFactory +import com.alya.ecommerce_serang.utils.SessionManager +import kotlinx.coroutines.launch class AddAddressActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_add_address) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets + private lateinit var binding: ActivityAddAddressBinding + private lateinit var apiService: ApiService + private lateinit var sessionManager: SessionManager + private lateinit var profileUser: UserProfile + private lateinit var locationManager: LocationManager + + private var latitude: Double? = null + private var longitude: Double? = null + private val provinceAdapter by lazy { ProvinceAdapter(this) } + private val cityAdapter by lazy { CityAdapter(this) } + + private val viewModel: AddAddressViewModel by viewModels { + SavedStateViewModelFactory(this) { savedStateHandle -> + val apiService = ApiConfig.getApiService(sessionManager) + val orderRepository = OrderRepository(apiService) + AddAddressViewModel(orderRepository, savedStateHandle) } } -} \ No newline at end of file + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityAddAddressBinding.inflate(layoutInflater) + setContentView(binding.root) + + sessionManager = SessionManager(this) + apiService = ApiConfig.getApiService(sessionManager) + locationManager = getSystemService(LOCATION_SERVICE) as LocationManager + + setupToolbar() + setupAutoComplete() + setupButtonListeners() + collectFlows() + requestLocationPermission() + + + } + + private fun viewModelAddAddress(request: CreateAddressRequest) { + // Call the private fun in your ViewModel using reflection or expose it in ViewModel + val method = AddAddressViewModel::class.java.getDeclaredMethod("addAddress", CreateAddressRequest::class.java) + method.isAccessible = true + method.invoke(viewModel, request) + } + // UI setup methods + private fun setupToolbar() { + binding.toolbar.setNavigationOnClickListener { finish() } + } + + private fun setupAutoComplete() { + // Set adapters + binding.autoCompleteProvinsi.setAdapter(provinceAdapter) + binding.autoCompleteKabupaten.setAdapter(cityAdapter) + + // Set listeners + binding.autoCompleteProvinsi.setOnItemClickListener { _, _, position, _ -> + provinceAdapter.getProvinceId(position)?.let { provinceId -> + viewModel.getCities(provinceId) + binding.autoCompleteKabupaten.text.clear() + } + } + + binding.autoCompleteKabupaten.setOnItemClickListener { _, _, position, _ -> + cityAdapter.getCityId(position)?.let { cityId -> + viewModel.selectedCityId = cityId + } + } + } + + private fun setupButtonListeners() { + binding.buttonSimpan.setOnClickListener { + validateAndSubmitForm() + } + } + + private fun collectFlows() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.provincesState.collect { state -> + handleProvinceState(state) + } + } + + launch { + viewModel.citiesState.collect { state -> + handleCityState(state) + } + } + + launch { + viewModel.addressSubmissionState.collect { state -> + handleAddressSubmissionState(state) + } + } + } + } + } + + private fun handleProvinceState(state: ViewState>) { + when (state) { + is ViewState.Loading -> null //showProvinceLoading(true) + is ViewState.Success -> { + provinceAdapter.updateData(state.data) + } + is ViewState.Error -> { + showError(state.message) + } + } + } + + private fun handleCityState(state: ViewState>) { + when (state) { + is ViewState.Loading -> null //showCityLoading(true) + is ViewState.Success -> { +// showCityLoading(false) + cityAdapter.updateData(state.data) + } + is ViewState.Error -> { +// showCityLoading(false) + showError(state.message) + } + } + } + + private fun handleAddressSubmissionState(state: ViewState) { + when (state) { + is ViewState.Loading -> showSubmitLoading(true) + is ViewState.Success -> { + showSubmitLoading(false) + showSuccessAndFinish(state.data) + } + is ViewState.Error -> { + showSubmitLoading(false) + showError(state.message) + } + } + } + +// private fun showProvinceLoading(isLoading: Boolean) { +// // Implement province loading indicator +// binding.provinceProgressBar?.visibility = if (isLoading) View.VISIBLE else View.GONE +// } +// +// private fun showCityLoading(isLoading: Boolean) { +// // Implement city loading indicator +// binding.cityProgressBar?.visibility = if (isLoading) View.VISIBLE else View.GONE +// } +// + private fun showSubmitLoading(isLoading: Boolean) { + binding.buttonSimpan.isEnabled = !isLoading + binding.buttonSimpan.text = if (isLoading) "Menyimpan..." else "Simpan" + // You might want to show a progress bar as well + } + + private fun showError(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun showSuccessAndFinish(message: String) { + Toast.makeText(this, "Sukses: $message", Toast.LENGTH_SHORT).show() + finish() + } + + private fun validateAndSubmitForm() { + val lat = latitude + val long = longitude + + if (lat == null || long == null) { + showError("Lokasi belum terdeteksi") + return + } + + val street = binding.etDetailAlamat.text.toString() + val subDistrict = binding.etKecamatan.text.toString() + val postalCode = binding.etKodePos.text.toString() + val recipient = binding.etNamaPenerima.text.toString() + val phone = binding.etNomorHp.text.toString() + val userId = profileUser.userId + val isStoreLocation = false + + val provinceId = viewModel.selectedProvinceId + val cityId = viewModel.selectedCityId + + if (street.isBlank() || recipient.isBlank() || phone.isBlank()) { + showError("Lengkapi semua field wajib") + return + } + + if (provinceId == null) { + showError("Pilih provinsi terlebih dahulu") + return + } + + if (cityId == null) { + showError("Pilih kota/kabupaten terlebih dahulu") + return + } + + val request = CreateAddressRequest( + lat = lat, + long = long, + street = street, + subDistrict = subDistrict, + cityId = cityId, + provId = provinceId, + postCode = postalCode, + detailAddress = street, + userId = userId, + recipient = recipient, + phone = phone, + isStoreLocation = isStoreLocation + ) + + viewModel.addAddress(request) + } + + private val locationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) getLocation() else Toast.makeText(this, "Izin lokasi ditolak",Toast.LENGTH_SHORT).show() + } + + private fun requestLocationPermission() { + locationPermissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) + } + + @SuppressLint("MissingPermission") + private fun requestLocation() { + val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + + if (!isGpsEnabled && !isNetworkEnabled) { + Toast.makeText(this, "Provider lokasi tidak tersedia", Toast.LENGTH_SHORT).show() + return + } + + val provider = if (isGpsEnabled) LocationManager.GPS_PROVIDER else LocationManager.NETWORK_PROVIDER + + locationManager.requestSingleUpdate(provider, object : LocationListener { + override fun onLocationChanged(location: Location) { + latitude = location.latitude + longitude = location.longitude + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) { + Toast.makeText(this@AddAddressActivity, "Provider dimatikan", Toast.LENGTH_SHORT).show() + } + }, null) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 100 && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + requestLocation() + } else { + Toast.makeText(this, "Location permission denied", Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressViewModel.kt new file mode 100644 index 0000000..0f1e09c --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/AddAddressViewModel.kt @@ -0,0 +1,105 @@ +package com.alya.ecommerce_serang.ui.order + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alya.ecommerce_serang.data.api.dto.CreateAddressRequest +import com.alya.ecommerce_serang.data.api.response.order.CitiesItem +import com.alya.ecommerce_serang.data.api.response.order.ProvincesItem +import com.alya.ecommerce_serang.data.repository.OrderRepository +import com.alya.ecommerce_serang.data.repository.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class AddAddressViewModel(private val repository: OrderRepository, private val savedStateHandle: SavedStateHandle): ViewModel() { + // Flow states for data + private val _addressSubmissionState = MutableStateFlow>(com.alya.ecommerce_serang.ui.order.ViewState.Loading) + val addressSubmissionState = _addressSubmissionState.asStateFlow() + + private val _provincesState = MutableStateFlow>>(com.alya.ecommerce_serang.ui.order.ViewState.Loading) + val provincesState = _provincesState.asStateFlow() + + private val _citiesState = MutableStateFlow>>(com.alya.ecommerce_serang.ui.order.ViewState.Loading) + val citiesState = _citiesState.asStateFlow() + + // Stored in SavedStateHandle for configuration changes + var selectedProvinceId: Int? + get() = savedStateHandle.get("selectedProvinceId") + set(value) { savedStateHandle["selectedProvinceId"] = value } + + var selectedCityId: Int? + get() = savedStateHandle.get("selectedCityId") + set(value) { savedStateHandle["selectedCityId"] = value } + + init { + // Load provinces on initialization + getProvinces() + } + + fun addAddress(request: CreateAddressRequest){ + viewModelScope.launch { + when (val result = repository.addAddress(request)) { + is Result.Success -> { + val message = result.data.message // Ambil `message` dari CreateAddressResponse + _addressSubmissionState.value = ViewState.Success(message) + } + is Result.Error -> { + _addressSubmissionState.value = ViewState.Error(result.exception.message ?: "Unknown error") + } + is Result.Loading -> { + // Optional, karena sudah set Loading di awal + } + } + } + } + + fun getProvinces(){ + viewModelScope.launch { + try { + val result = repository.getListProvinces() + result?.let { + _provincesState.value = com.alya.ecommerce_serang.ui.order.ViewState.Success(it.provinces) + } + } catch (e: Exception) { + Log.e("AddAddressViewModel", "Error fetching provinces: ${e.message}") + } + } + } + + fun getCities(provinceId: Int){ + viewModelScope.launch { + try { + selectedProvinceId = provinceId + val result = repository.getListCities(provinceId) + result?.let { + _citiesState.value = com.alya.ecommerce_serang.ui.order.ViewState.Success(it.cities) + } + } catch (e: Exception) { + Log.e("AddAddressViewModel", "Error fetching cities: ${e.message}") + } + } + } + + fun setSelectedProvinceId(id: Int) { + selectedProvinceId = id + } + + fun setSelectedCityId(id: Int) { + selectedCityId = id + } + + fun getSelectedProvinceId(): Int? = selectedProvinceId + fun getSelectedCityId(): Int? = selectedCityId + + companion object { + private const val TAG = "AddAddressViewModel" + } +} + +sealed class ViewState { + object Loading : com.alya.ecommerce_serang.ui.order.ViewState() + data class Success(val data: T) : com.alya.ecommerce_serang.ui.order.ViewState() + data class Error(val message: String) : com.alya.ecommerce_serang.ui.order.ViewState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/ProvinceAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/ProvinceAdapter.kt new file mode 100644 index 0000000..5af133a --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/ProvinceAdapter.kt @@ -0,0 +1,49 @@ +package com.alya.ecommerce_serang.ui.order + +import android.content.Context +import android.widget.ArrayAdapter +import com.alya.ecommerce_serang.data.api.response.order.CitiesItem +import com.alya.ecommerce_serang.data.api.response.order.ProvincesItem + +// UI adapters and helpers +class ProvinceAdapter( + context: Context, + resource: Int = android.R.layout.simple_dropdown_item_1line +) : ArrayAdapter(context, resource, ArrayList()) { + + private val provinces = ArrayList() + + fun updateData(newProvinces: List) { + provinces.clear() + provinces.addAll(newProvinces) + + clear() + addAll(provinces.map { it.province }) + notifyDataSetChanged() + } + + fun getProvinceId(position: Int): Int? { + return provinces.getOrNull(position)?.provinceId?.toIntOrNull() + } +} + +class CityAdapter( + context: Context, + resource: Int = android.R.layout.simple_dropdown_item_1line +) : ArrayAdapter(context, resource, ArrayList()) { + + private val cities = ArrayList() + + fun updateData(newCities: List) { + cities.clear() + cities.addAll(newCities) + + clear() + addAll(cities.map { it.cityName }) + notifyDataSetChanged() + } + + fun getCityId(position: Int): Int? { + return cities.getOrNull(position)?.cityId?.toIntOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/BaseViewModelFactory.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/BaseViewModelFactory.kt index 9673190..e224d9e 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/BaseViewModelFactory.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/BaseViewModelFactory.kt @@ -1,7 +1,10 @@ package com.alya.ecommerce_serang.utils +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.savedstate.SavedStateRegistryOwner class BaseViewModelFactory( private val creator: () -> VM @@ -10,4 +13,20 @@ class BaseViewModelFactory( @Suppress("UNCHECKED_CAST") return creator() as T } +} + +// Add a new factory for SavedStateHandle ViewModels +class SavedStateViewModelFactory( + private val owner: SavedStateRegistryOwner, + private val creator: (SavedStateHandle) -> VM +) : AbstractSavedStateViewModelFactory(owner, null) { + + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return creator(handle) as T + } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_address.xml b/app/src/main/res/layout/activity_add_address.xml index a65d055..8872804 100644 --- a/app/src/main/res/layout/activity_add_address.xml +++ b/app/src/main/res/layout/activity_add_address.xml @@ -131,7 +131,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:hint="Pilih Kabupaten / Kota" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"> diff --git a/app/src/main/res/layout/activity_address.xml b/app/src/main/res/layout/activity_address.xml index 6ad6c1d..ec92734 100644 --- a/app/src/main/res/layout/activity_address.xml +++ b/app/src/main/res/layout/activity_address.xml @@ -29,9 +29,11 @@ android:textAlignment="textEnd" android:layout_gravity="center" android:paddingEnd="16dp" + android:paddingVertical="16dp" android:textColor="@color/blue_500" android:fontFamily="@font/dmsans_semibold" android:textSize="14sp" + android:clickable="true" android:text="Tambah Alamat" tools:ignore="RtlCompat" /> diff --git a/app/src/main/res/layout/activity_checkout.xml b/app/src/main/res/layout/activity_checkout.xml index 2a992f2..6ffe7e6 100644 --- a/app/src/main/res/layout/activity_checkout.xml +++ b/app/src/main/res/layout/activity_checkout.xml @@ -47,13 +47,23 @@ android:layout_gravity="start" android:padding="8dp"> + +