diff --git a/.gitignore b/.gitignore index aa724b7..9683136 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.iml +*.log .gradle /local.properties /.idea/caches @@ -12,4 +13,4 @@ /captures .externalNativeBuild .cxx -local.properties +/app/google-services.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..185a927 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# E-Commerce Serang (Android App) + +A mobile e-commerce platform built with **Kotlin** (Android) and a backend in **Express**, tailored for small businesses (UMKM). Supports browsing, ordering, chatting, and tracking — with **shipping cost calculation (RajaOngkir)** and **push notifications** via Firebase Cloud Messaging. + +--- + +## Overview + +This Android app includes: + +- Account registration, login, and OTP verification +- Browsing products by category and store +- Cart and checkout orders +- Shipping cost estimation via RajaOngkir API +- Checkout order, tracking, and status updates +- Real-time buyer–seller chat +- Store registration and product management +- Store Balance as active status +- Top up store balance +- Write rating and feedback for purchased products +- Push notifications for user activity + +The app communicates with a custom backend server via REST API and WebSocket. + +## Tech Stack +- MVVM architecture +- Retrofit for API communication +- Hilt for dependency injection +- Socket.IO client for real-time chat +- Firebase Cloud Messaging (FCM) for notifications +- Coroutines for async operations +- Glide for image loading +- ViewBinding for UI access +- LiveData and StateFlow for reactive UI +- RajaOngkir API integration for shipping cost + +## Project Structure + - api/retrofit/ + - data/ + - di/ + - ui/ + - auth/ + - home/ + - cart/ + - order/ + - history/ + - review/ + - chat/ + - profile/ + - store/ + - addProduct/ + - sells/ + - balance/ + - review/ + - product/ + - notif/ + - utils/ + - google-services.json + +## How to Run + +1. Clone this project and open it in Android Studio +2. Add your `google-services.json` for Firebase (FCM) +3. Update the API base URL in the Retrofit client +5. Settings BASE_URL in your local.properties +4. Build and run on an emulator or physical device + + diff --git a/app/.gitignore b/app/.gitignore index 42afabf..65d12b9 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +google-services.json \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json deleted file mode 100644 index f88005d..0000000 --- a/app/google-services.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "project_info": { - "project_number": "284675201257", - "project_id": "ecommerce-serang", - "storage_bucket": "ecommerce-serang.firebasestorage.app" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:284675201257:android:2755670e3dbb1b48683878", - "android_client_info": { - "package_name": "com.alya.ecommerce_serang" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyB-nWHsVbdV4PPIH06JZSStIVXjv9Qc4iU" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54fdf3b..4455d19 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,11 +82,11 @@ - + + + + + , @field:SerializedName("auto_completed_at") - val autoCompletedAt: String? = null, + val autoCompletedAt: String, @field:SerializedName("is_store_location") val isStoreLocation: Boolean? = null, diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/OrderListResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/OrderListResponse.kt index dd8c43f..2ab5242 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/OrderListResponse.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/OrderListResponse.kt @@ -15,7 +15,7 @@ data class OrderListResponse( data class OrderItemsItem( @field:SerializedName("review_id") - val reviewId: Int? = null, + val reviewId: Int, @field:SerializedName("quantity") val quantity: Int, diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/SubdistrictResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/SubdistrictResponse.kt new file mode 100644 index 0000000..56b103a --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/SubdistrictResponse.kt @@ -0,0 +1,21 @@ +package com.alya.ecommerce_serang.data.api.response.customer.order + +import com.google.gson.annotations.SerializedName + +data class SubdistrictResponse( + + @field:SerializedName("subdistricts") + val subdistricts: List, + + @field:SerializedName("message") + val message: String +) + +data class SubdistrictsItem( + + @field:SerializedName("subdistrict_id") + val subdistrictId: String, + + @field:SerializedName("subdistrict_name") + val subdistrictName: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/VillagesResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/VillagesResponse.kt new file mode 100644 index 0000000..04da40e --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/order/VillagesResponse.kt @@ -0,0 +1,24 @@ +package com.alya.ecommerce_serang.data.api.response.customer.order + +import com.google.gson.annotations.SerializedName + +data class VillagesResponse( + + @field:SerializedName("villages") + val villages: List, + + @field:SerializedName("message") + val message: String +) + +data class VillagesItem( + + @field:SerializedName("village_id") + val villageId: String, + + @field:SerializedName("village_name") + val villageName: String, + + @field:SerializedName("postal_code") + val postalCode: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/profile/AddressResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/profile/AddressResponse.kt index 55b23f8..588ae30 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/profile/AddressResponse.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/profile/AddressResponse.kt @@ -4,15 +4,18 @@ import com.google.gson.annotations.SerializedName data class AddressResponse( - @field:SerializedName("addresses") + @field:SerializedName("addresses") val addresses: List, - @field:SerializedName("message") + @field:SerializedName("message") val message: String ) data class AddressesItem( + @field:SerializedName("village_id") + val villageId: String, + @field:SerializedName("is_store_location") val isStoreLocation: Boolean, @@ -23,7 +26,7 @@ data class AddressesItem( val userId: Int, @field:SerializedName("province_id") - val provinceId: Int, + val provinceId: String, @field:SerializedName("phone") val phone: String, @@ -50,5 +53,5 @@ data class AddressesItem( val longitude: String, @field:SerializedName("city_id") - val cityId: Int + val cityId: String ) 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 b277cea..f53ae8a 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 @@ -15,10 +15,14 @@ class ApiConfig { val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY + //httplogginginterceptor ntuk debug dan monitoring request/response } val authInterceptor = AuthInterceptor(tokenManager) + // utk tambak token auth otomatis pada header + // Konfigurasi OkHttpClient + //Low-level HTTP client yang melakukan actual network request val client = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .addInterceptor(authInterceptor) @@ -27,13 +31,17 @@ class ApiConfig { .writeTimeout(300, TimeUnit.SECONDS) // 5 minutes .build() + // Konfigurasi Retrofit val retrofit = Retrofit.Builder() + //almat domain backend .baseUrl(BuildConfig.BASE_URL) .addConverterFactory(GsonConverterFactory.create()) + //gson convertes: mengkonversi JSON ke object Kotlin dan sebaliknya .client(client) .build() return retrofit.create(ApiService::class.java) + // retrofit : menyederhanakan HTTP Request dgn mengubah interface Kotlin di ApiService menjadi HTTP calls secara otomatis } fun getUnauthenticatedApiService(): ApiService { diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt index 1a04e32..6a85bca 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt @@ -53,6 +53,8 @@ import com.alya.ecommerce_serang.data.api.response.customer.order.ListCityRespon import com.alya.ecommerce_serang.data.api.response.customer.order.ListProvinceResponse import com.alya.ecommerce_serang.data.api.response.customer.order.OrderDetailResponse import com.alya.ecommerce_serang.data.api.response.customer.order.OrderListResponse +import com.alya.ecommerce_serang.data.api.response.customer.order.SubdistrictResponse +import com.alya.ecommerce_serang.data.api.response.customer.order.VillagesResponse import com.alya.ecommerce_serang.data.api.response.customer.product.AllProductResponse import com.alya.ecommerce_serang.data.api.response.customer.product.CategoryResponse import com.alya.ecommerce_serang.data.api.response.customer.product.DetailStoreProductResponse @@ -68,14 +70,14 @@ import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse import com.alya.ecommerce_serang.data.api.response.product.CreateSearchResponse import com.alya.ecommerce_serang.data.api.response.product.SearchHistoryResponse -import com.alya.ecommerce_serang.data.api.response.store.sells.PaymentConfirmationResponse +import com.alya.ecommerce_serang.data.api.response.store.GenericResponse import com.alya.ecommerce_serang.data.api.response.store.product.CreateProductResponse import com.alya.ecommerce_serang.data.api.response.store.product.DeleteProductResponse import com.alya.ecommerce_serang.data.api.response.store.product.UpdateProductResponse import com.alya.ecommerce_serang.data.api.response.store.product.ViewStoreProductsResponse -import com.alya.ecommerce_serang.data.api.response.store.GenericResponse import com.alya.ecommerce_serang.data.api.response.store.profile.StoreDataResponse import com.alya.ecommerce_serang.data.api.response.store.review.ProductReviewResponse +import com.alya.ecommerce_serang.data.api.response.store.sells.PaymentConfirmationResponse import com.alya.ecommerce_serang.data.api.response.store.topup.BalanceTopUpResponse import com.alya.ecommerce_serang.data.api.response.store.topup.TopUpResponse import okhttp3.MultipartBody @@ -512,4 +514,14 @@ interface ApiService { @GET("store/reviews") suspend fun getStoreProductReview( ): Response + + @GET("subdistrict/{cityId}") + suspend fun getSubdistrict( + @Path("cityId") cityId: String + ): Response + + @GET("villages/{subdistrictId}") + suspend fun getVillages( + @Path("subdistrictId") subdistrictId: String + ): Response } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/MyStoreRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/MyStoreRepository.kt index 801c8ea..0100c5e 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/MyStoreRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/MyStoreRepository.kt @@ -1,10 +1,12 @@ package com.alya.ecommerce_serang.data.repository import android.util.Log +import com.alya.ecommerce_serang.data.api.dto.ProductsItem import com.alya.ecommerce_serang.data.api.dto.Store import com.alya.ecommerce_serang.data.api.response.auth.ListStoreTypeResponse import com.alya.ecommerce_serang.data.api.response.customer.product.StoreResponse import com.alya.ecommerce_serang.data.api.response.store.profile.StoreDataResponse +import com.alya.ecommerce_serang.data.api.response.store.sells.OrderListResponse import com.alya.ecommerce_serang.data.api.retrofit.ApiService import okhttp3.MultipartBody import okhttp3.RequestBody @@ -71,4 +73,104 @@ class MyStoreRepository(private val apiService: ApiService) { street, subdistrict, detail, postalCode, latitude, longitude, userPhone, storeType, storeimg ) } + + suspend fun getSellList(status: String): Result { + return try { + Log.d("SellsRepository", "Add Evidence : $status") + val response = apiService.getSellList(status) + + if (response.isSuccessful) { + val allListSell = response.body() + if (allListSell != null) { + Log.d("SellsRepository", "Add Evidence successfully: ${allListSell.message}") + Result.Success(allListSell) + } else { + Log.e("SellsRepository", "Response body was null") + Result.Error(Exception("Empty response from server")) + } + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Log.e("SellsRepository", "Error Add Evidence : $errorBody") + Result.Error(Exception(errorBody)) + } + } catch (e: Exception) { + Log.e("SellsRepository", "Exception Add Evidence ", e) + Result.Error(e) + } + } + + suspend fun getBalance(): Result { + return try { + val response = apiService.getMyStoreData() + + if (response.isSuccessful) { + val body = response.body() + ?: return Result.Error(IllegalStateException("Response body is null")) + + // Validate the balance field + val balanceRaw = body.store.balance + balanceRaw.toDoubleOrNull() + ?: return Result.Error(NumberFormatException("Invalid balance format: $balanceRaw")) + + Result.Success(body) + } else { + Result.Error( + Exception("Failed to load balance: ${response.code()} ${response.message()}") + ) + } + } catch (e: Exception) { + Log.e("MyStoreRepository", "Error fetching balance", e) + Result.Error(e) + } + } + + suspend fun fetchMyStoreProducts(): List { + return try { + val response = apiService.getStoreProduct() + if (response.isSuccessful) { + response.body()?.products?.filterNotNull() ?: emptyList() + } else { + throw Exception("Failed to fetch store products: ${response.message()}") + } + } catch (e: Exception) { + Log.e("ProductRepository", "Error fetching store products", e) + throw e + } + } + +// private fun fetchBalance() { +// showLoading(true) +// lifecycleScope.launch { +// try { +// val response = ApiConfig.getApiService(sessionManager).getMyStoreData() +// if (response.isSuccessful && response.body() != null) { +// val storeData = response.body()!! +// val balance = storeData.store.balance +// +// // Format the balance +// try { +// val balanceValue = balance.toDouble() +// binding.tvBalance.text = String.format("Rp%,.0f", balanceValue) +// } catch (e: Exception) { +// binding.tvBalance.text = "Rp$balance" +// } +// } else { +// Toast.makeText( +// this@BalanceActivity, +// "Gagal memuat data saldo: ${response.message()}", +// Toast.LENGTH_SHORT +// ).show() +// } +// } catch (e: Exception) { +// Log.e(TAG, "Error fetching balance", e) +// Toast.makeText( +// this@BalanceActivity, +// "Error: ${e.message}", +// Toast.LENGTH_SHORT +// ).show() +// } finally { +// showLoading(false) +// } +// } +// } } \ 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 f5a5c9f..df62ae7 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 @@ -21,6 +21,8 @@ import com.alya.ecommerce_serang.data.api.response.auth.RegisterStoreResponse import com.alya.ecommerce_serang.data.api.response.auth.VerifRegisterResponse import com.alya.ecommerce_serang.data.api.response.customer.order.ListCityResponse import com.alya.ecommerce_serang.data.api.response.customer.order.ListProvinceResponse +import com.alya.ecommerce_serang.data.api.response.customer.order.SubdistrictResponse +import com.alya.ecommerce_serang.data.api.response.customer.order.VillagesResponse 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 @@ -68,6 +70,16 @@ class UserRepository(private val apiService: ApiService) { return if (response.isSuccessful) response.body() else null } + suspend fun getListSubdistrict(cityId : String): SubdistrictResponse? { + val response = apiService.getSubdistrict(cityId) + return if (response.isSuccessful) response.body() else null + } + + suspend fun getListVillages(subId: String): VillagesResponse? { + val response = apiService.getVillages(subId) + return if (response.isSuccessful) response.body() else null + } + suspend fun registerUser(request: RegisterRequest): RegisterResponse { val response = apiService.register(request) // API call @@ -87,7 +99,7 @@ class UserRepository(private val apiService: ApiService) { longitude: String, street: String, subdistrict: String, - cityId: Int, + cityId: String, provinceId: Int, postalCode: Int, detail: String, diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/LoginActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/LoginActivity.kt index 531b526..01e7307 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/LoginActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/LoginActivity.kt @@ -8,9 +8,7 @@ import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat import com.alya.ecommerce_serang.data.api.dto.FcmReq import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.repository.Result @@ -43,20 +41,18 @@ class LoginActivity : AppCompatActivity() { setContentView(binding.root) WindowCompat.setDecorFitsSystemWindows(window, false) - enableEdgeToEdge() - // Apply insets to your root layout - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding( - systemBars.left, - systemBars.top, - systemBars.right, - systemBars.bottom - ) - windowInsets - } +// ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> +// val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) +// view.setPadding( +// systemBars.left, +// systemBars.top, +// systemBars.right, +// systemBars.bottom +// ) +// windowInsets +// } // onBackPressedDispatcher.addCallback(this) { // // Handle the back button event @@ -105,6 +101,7 @@ class LoginActivity : AppCompatActivity() { finish() } is com.alya.ecommerce_serang.data.repository.Result.Error -> { + Log.e("LoginActivity", "Login Failed: ${result.exception.message}") Toast.makeText(this, "Login Failed: ${result.exception.message}", Toast.LENGTH_LONG).show() } is Result.Loading -> { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt index 04b5780..8a2c0a8 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt @@ -43,24 +43,6 @@ class RegisterActivity : AppCompatActivity() { setContentView(binding.root) sessionManager = SessionManager(this) - WindowCompat.setDecorFitsSystemWindows(window, false) - - enableEdgeToEdge() - - // Apply insets to your root layout - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding( - systemBars.left, - systemBars.top, - systemBars.right, - systemBars.bottom - ) - windowInsets - } - - - Log.d("RegisterActivity", "Token in storage: '${sessionManager.getToken()}'") Log.d("RegisterActivity", "User ID in storage: '${sessionManager.getUserId()}'") @@ -104,7 +86,7 @@ class RegisterActivity : AppCompatActivity() { } } - // Function to navigate to the next fragment + // navigate step register in fragment fun navigateToStep(step: Int, userData: RegisterRequest?) { val fragment = when (step) { 1 -> RegisterStep1Fragment.newInstance() diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep1Fragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep1Fragment.kt index 7e1eb62..814a366 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep1Fragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep1Fragment.kt @@ -204,6 +204,13 @@ class RegisterStep1Fragment : Fragment() { } } } + + registerViewModel.toastMessage.observe(viewLifecycleOwner){ event -> + //memanggil toast check value email dan phone + event.getContentIfNotHandled()?.let { msg -> + Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show() + } + } } private fun validateAndProceed() { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep2Fragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep2Fragment.kt index e7e5bb6..15e3c22 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep2Fragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep2Fragment.kt @@ -88,7 +88,7 @@ class RegisterStep2Fragment : Fragment() { // Update the email sent message userData?.let { - binding.tvEmailSent.text = "We've sent a verification code to ${it.email}" + binding.tvEmailSent.text = "Kami telah mengirimkan kode OTP ke alamat email ${it.email}. Silahkan periksa email anda." } // Start the resend cooldown timer @@ -119,7 +119,7 @@ class RegisterStep2Fragment : Fragment() { Log.d(TAG, "verifyOtp called with OTP: $otp") if (otp.isEmpty()) { - Toast.makeText(requireContext(), "Please enter the verification code", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Masukkan kode OTP anda", Toast.LENGTH_SHORT).show() return } @@ -153,13 +153,13 @@ class RegisterStep2Fragment : Fragment() { } is com.alya.ecommerce_serang.data.repository.Result.Success -> { binding.progressBar.visibility = View.GONE - Toast.makeText(requireContext(), "Verification code resent", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Kode OTP sudah dikirim", Toast.LENGTH_SHORT).show() startResendCooldown() } is Result.Error -> { Log.e(TAG, "OTP request: Error - ${result.exception.message}") binding.progressBar.visibility = View.GONE - Toast.makeText(requireContext(), "Failed to resend code: ${result.exception.message}", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Gagal mengirim kode OTP", Toast.LENGTH_SHORT).show() } else -> { Log.d(TAG, "OTP request: Unknown state") @@ -180,7 +180,7 @@ class RegisterStep2Fragment : Fragment() { countDownTimer = object : CountDownTimer(30000, 1000) { override fun onTick(millisUntilFinished: Long) { timeRemaining = (millisUntilFinished / 1000).toInt() - binding.tvTimer.text = "Resend available in 00:${String.format("%02d", timeRemaining)}" + binding.tvTimer.text = "Kirim ulang OTP dalam waktu 00:${String.format("%02d", timeRemaining)}" if (timeRemaining % 5 == 0) { Log.d(TAG, "Cooldown remaining: $timeRemaining seconds") } @@ -188,7 +188,7 @@ class RegisterStep2Fragment : Fragment() { override fun onFinish() { Log.d(TAG, "Cooldown finished, enabling resend button") - binding.tvTimer.text = "You can now resend the code" + binding.tvTimer.text = "Dapat mengirim ulang kode OTP" binding.tvResendOtp.isEnabled = true binding.tvResendOtp.setTextColor(ContextCompat.getColor(requireContext(), R.color.blue1)) timeRemaining = 0 @@ -222,7 +222,8 @@ class RegisterStep2Fragment : Fragment() { binding.btnVerify.isEnabled = true // Show error message - Toast.makeText(requireContext(), "Registration Failed: ${result.exception.message}", Toast.LENGTH_SHORT).show() + Log.e(TAG, "Registration Failed: ${result.exception.message}") + Toast.makeText(requireContext(), "Gagal melakukan regsitrasi", Toast.LENGTH_SHORT).show() } else -> { Log.d(TAG, "Registration: Unknown state") @@ -251,15 +252,10 @@ class RegisterStep2Fragment : Fragment() { sessionManager.saveToken(accessToken) Log.d(TAG, "Token saved to SessionManager: $accessToken") - // Also save user ID if available in the login response -// result.data.?.let { userId -> -// sessionManager.saveUserId(userId) -// } - - Log.d(TAG, "Login successful, token saved: $accessToken") - // Proceed to Step 3 Log.d(TAG, "Proceeding to Step 3 after successful login") + + // call navigate register step from activity (activity as? RegisterActivity)?.navigateToStep(3, null ) } is Result.Error -> { @@ -269,7 +265,7 @@ class RegisterStep2Fragment : Fragment() { // Show error message but continue to Step 3 anyway Log.e(TAG, "Login failed but proceeding to Step 3", result.exception) - Toast.makeText(requireContext(), "Note: Auto-login failed, but registration was successful", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Berhasil membuat akun, namun belum login", Toast.LENGTH_SHORT).show() // Proceed to Step 3 (activity as? RegisterActivity)?.navigateToStep(3, null) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt index e73281e..9cea2de 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt @@ -23,11 +23,14 @@ import com.alya.ecommerce_serang.ui.auth.LoginActivity import com.alya.ecommerce_serang.ui.auth.RegisterActivity import com.alya.ecommerce_serang.ui.order.address.CityAdapter import com.alya.ecommerce_serang.ui.order.address.ProvinceAdapter +import com.alya.ecommerce_serang.ui.order.address.SubdsitrictAdapter import com.alya.ecommerce_serang.ui.order.address.ViewState +import com.alya.ecommerce_serang.ui.order.address.VillagesAdapter import com.alya.ecommerce_serang.utils.BaseViewModelFactory import com.alya.ecommerce_serang.utils.SessionManager import com.alya.ecommerce_serang.utils.viewmodel.RegisterViewModel import com.google.android.material.progressindicator.LinearProgressIndicator +import com.google.gson.Gson class RegisterStep3Fragment : Fragment() { private var _binding: FragmentRegisterStep3Binding? = null @@ -49,6 +52,8 @@ class RegisterStep3Fragment : Fragment() { // For province and city selection private val provinceAdapter by lazy { ProvinceAdapter(requireContext()) } private val cityAdapter by lazy { CityAdapter(requireContext()) } + private val subdistrictAdapter by lazy { SubdsitrictAdapter(requireContext()) } + private val villagesAdapter by lazy { VillagesAdapter(requireContext()) } companion object { private const val TAG = "RegisterStep3Fragment" @@ -114,7 +119,7 @@ class RegisterStep3Fragment : Fragment() { // Observe address submission state observeAddressSubmissionState() - // Load provinces + // Load provinces from raja ongkir Log.d(TAG, "Requesting provinces data") registerViewModel.getProvinces() setupProvinceObserver() @@ -171,9 +176,10 @@ class RegisterStep3Fragment : Fragment() { } private fun setupAutoComplete() { - // Same implementation as before binding.autoCompleteProvinsi.setAdapter(provinceAdapter) binding.autoCompleteKabupaten.setAdapter(cityAdapter) + binding.autoCompleteKecamatan.setAdapter(subdistrictAdapter) + binding.autoCompleteDesa.setAdapter(villagesAdapter) binding.autoCompleteProvinsi.setOnClickListener { binding.autoCompleteProvinsi.showDropDown() @@ -188,6 +194,24 @@ class RegisterStep3Fragment : Fragment() { } } + binding.autoCompleteKecamatan.setOnClickListener { + if (subdistrictAdapter.count > 0) { + Log.d(TAG, "Subdistrict dropdown clicked, showing ${subdistrictAdapter.count} cities") + binding.autoCompleteKecamatan.showDropDown() + } else { + Toast.makeText(requireContext(), "Pilih kabupaten / kota terlebih dahulu", Toast.LENGTH_SHORT).show() + } + } + + binding.autoCompleteDesa.setOnClickListener { + if (villagesAdapter.count > 0) { + Log.d(TAG, "Village dropdown clicked, showing ${villagesAdapter.count} cities") + binding.autoCompleteDesa.showDropDown() + } else { + Toast.makeText(requireContext(), "Pilih kecamatan terlebih dahulu", Toast.LENGTH_SHORT).show() + } + } + binding.autoCompleteProvinsi.setOnItemClickListener { _, _, position, _ -> val provinceId = provinceAdapter.getProvinceId(position) Log.d(TAG, "Province selected at position $position, ID: $provinceId") @@ -206,13 +230,44 @@ class RegisterStep3Fragment : Fragment() { cityId?.let { id -> Log.d(TAG, "Selected city ID set to: $id") - registerViewModel.selectedCityId = id + registerViewModel.updateSelectedCityId(id) + registerViewModel.getSubdistrict(id) + binding.autoCompleteKecamatan.text.clear() } } + + binding.autoCompleteKecamatan.setOnItemClickListener{ _, _, position, _ -> + val subdistrictId = subdistrictAdapter.getSubdistrictId(position) + Log.d(TAG, "Subdistrict selected at position $position, ID: $subdistrictId") + + subdistrictId?.let { id -> + Log.d(TAG, "Selected subdistrict ID set to: $id") + registerViewModel.selectedSubdistrict = id + registerViewModel.getVillages(id) + binding.autoCompleteDesa.text.clear() + } + } + + binding.autoCompleteDesa.setOnItemClickListener{ _, _, position, _ -> + val villageId = villagesAdapter.getVillageId(position) +// val postalCode = villagesAdapter.getPostalCode(position) + Log.d(TAG, "Village selected at position $position, ID: $villageId") + + villageId?.let { id -> + Log.d(TAG, "Selected village ID set to: $id") + registerViewModel.selectedVillages = id + } + +// postalCode?.let { postCode -> +// registerViewModel.selectedPostalCode = postCode +// } + +// binding.etKodePos.setText(registerViewModel.selectedPostalCode ?: "") + } } private fun setupProvinceObserver() { - // Same implementation as before + // pake raja ongkir registerViewModel.provincesState.observe(viewLifecycleOwner) { state -> when (state) { is ViewState.Loading -> { @@ -256,8 +311,46 @@ class RegisterStep3Fragment : Fragment() { } } } + registerViewModel.subdistrictState.observe(viewLifecycleOwner) { state -> + when (state) { + is ViewState.Loading -> { + binding.progressBarKecamatan.visibility = View.VISIBLE + } + is ViewState.Success -> { + Log.d(TAG, "Subdistrict: Success - received ${state.data.size} kecamatan") + binding.progressBarKecamatan.visibility = View.GONE + subdistrictAdapter.updateData(state.data) + Log.d(TAG, "Updated subdistrict adapter with ${state.data.size} items") + } + is ViewState.Error -> { + Log.e(TAG, "Subdistrict: Error - ${state.message}") + binding.progressBarKecamatan.visibility = View.GONE + showError("Failed to load kecamatan: ${state.message}") + } + } + } + + registerViewModel.villagesState.observe(viewLifecycleOwner) { state -> + when (state) { + is ViewState.Loading -> { + binding.progressBarDesa.visibility = View.VISIBLE + } + is ViewState.Success -> { + Log.d(TAG, "Village: Success - received ${state.data.size} desa") + binding.progressBarDesa.visibility = View.GONE + villagesAdapter.updateData(state.data) + Log.d(TAG, "Updated village adapter with ${state.data.size} items") + } + is ViewState.Error -> { + Log.e(TAG, "Village: Error - ${state.message}") + binding.progressBarDesa.visibility = View.GONE + showError("Failed to load desa: ${state.message}") + } + } + } } + private fun submitAddress() { Log.d(TAG, "submitAddress called") if (!validateAddressForm()) { @@ -276,13 +369,16 @@ class RegisterStep3Fragment : Fragment() { Log.d(TAG, "Using user ID: $userId") val street = binding.etDetailAlamat.text.toString().trim() - val subDistrict = binding.etKecamatan.text.toString().trim() - val postalCode = binding.etKodePos.text.toString().trim() val recipient = binding.etNamaPenerima.text.toString().trim() val phone = binding.etNomorHp.text.toString().trim() + val postalCode = binding.etKodePos.text.toString().trim() val provinceId = registerViewModel.selectedProvinceId?.toInt() ?: 0 - val cityId = registerViewModel.selectedCityId?.toInt() ?: 0 + val cityId = registerViewModel.selectedCityId.toString() + val subDistrict = registerViewModel.selectedSubdistrict.toString() +// val postalCode = registerViewModel.selectedPostalCode.toString() + + val villageId = registerViewModel.selectedVillages ?: "" Log.d(TAG, "Address data - Street: $street, SubDistrict: $subDistrict, PostalCode: $postalCode") Log.d(TAG, "Address data - Recipient: $recipient, Phone: $phone") @@ -291,21 +387,25 @@ class RegisterStep3Fragment : Fragment() { // Create address request val addressRequest = CreateAddressRequest( + userId = user.id, // must match the type expected in the DB lat = defaultLatitude, long = defaultLongitude, street = street, subDistrict = subDistrict, - cityId = cityId, + cityId = cityId, // ⚠️ Make sure this is Int provId = provinceId, postCode = postalCode, + idVillage = villageId, // Or provide a real ID if needed detailAddress = street, - userId = userId, + isStoreLocation = false, recipient = recipient, - phone = phone, - isStoreLocation = false + phone = phone ) Log.d(TAG, "Address request created: $addressRequest") + val gson = Gson() + val jsonString = gson.toJson(addressRequest) + Log.d(TAG, "Request JSON: $jsonString") // Show loading binding.progressBar.visibility = View.VISIBLE @@ -318,13 +418,13 @@ class RegisterStep3Fragment : Fragment() { private fun validateAddressForm(): Boolean { val street = binding.etDetailAlamat.text.toString().trim() - val subDistrict = binding.etKecamatan.text.toString().trim() - val postalCode = binding.etKodePos.text.toString().trim() val recipient = binding.etNamaPenerima.text.toString().trim() val phone = binding.etNomorHp.text.toString().trim() val provinceId = registerViewModel.selectedProvinceId val cityId = registerViewModel.selectedCityId + val subDistrict = registerViewModel.selectedSubdistrict.toString() + val postalCode = registerViewModel.selectedPostalCode Log.d(TAG, "Validating - Street: $street, SubDistrict: $subDistrict, PostalCode: $postalCode") Log.d(TAG, "Validating - Recipient: $recipient, Phone: $phone") @@ -409,8 +509,4 @@ class RegisterStep3Fragment : Fragment() { ViewCompat.setWindowInsetsAnimationCallback(binding.root, null) _binding = null } -// -// // Data classes for province and city -// data class Province(val id: String, val name: String) -// data class City(val id: String, val name: String) } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/cart/CartActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/cart/CartActivity.kt index 09b10cc..6a6c69d 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/cart/CartActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/cart/CartActivity.kt @@ -1,6 +1,7 @@ package com.alya.ecommerce_serang.ui.cart import android.os.Bundle +import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge @@ -145,7 +146,9 @@ class CartActivity : AppCompatActivity() { private fun observeViewModel() { viewModel.cartItems.observe(this) { cartItems -> if (cartItems.isNullOrEmpty()) { + binding.emptyCart.visibility = View.VISIBLE showEmptyState(true) + } else { showEmptyState(false) storeAdapter.submitList(cartItems) @@ -153,7 +156,8 @@ class CartActivity : AppCompatActivity() { } viewModel.isLoading.observe(this) { isLoading -> - // Show/hide loading indicator if needed + binding.progressBarCart?.visibility = if (isLoading) View.VISIBLE else View.GONE + Log.d("CartActivity", "Loading state: $isLoading") } viewModel.errorMessage.observe(this) { errorMessage -> diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt index 39fc4a1..cf9fa48 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt @@ -27,6 +27,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.alya.ecommerce_serang.BuildConfig.BASE_URL import com.alya.ecommerce_serang.R @@ -63,6 +64,8 @@ class ChatActivity : AppCompatActivity() { // For image attachment private var tempImageUri: Uri? = null + private var imageAttach = false + // Typing indicator handler private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper()) private val stopTypingRunnable = Runnable { @@ -127,6 +130,7 @@ class ChatActivity : AppCompatActivity() { return } + // set up data toko binding.tvStoreName.text = storeName val fullImageUrl = when (val img = storeImg) { is String -> { @@ -140,7 +144,7 @@ class ChatActivity : AppCompatActivity() { .placeholder(R.drawable.placeholder_image) .into(binding.imgProfile) - // Set chat parameters to ViewModel + // Set chat parameter to send to ViewModel with product viewModel.setChatParameters( storeId = storeId, productId = productId, @@ -157,16 +161,17 @@ class ChatActivity : AppCompatActivity() { } // Setup UI components + // rv isi chat setupRecyclerView() setupWindowInsets() setupListeners() setupTypingIndicator() + // observe listener from viewmodel observeViewModel() // If opened from ChatListFragment with a valid chatRoomId if (chatRoomId > 0) { - // Directly set the chatRoomId and load chat history - viewModel._chatRoomId.value = chatRoomId + viewModel.setChatRoomId(chatRoomId) } } @@ -269,6 +274,7 @@ class ChatActivity : AppCompatActivity() { } // Options button + binding.btnOptions.visibility = View.GONE binding.btnOptions.setOnClickListener { showOptionsMenu() } @@ -281,6 +287,7 @@ class ChatActivity : AppCompatActivity() { // This will automatically handle product attachment if enabled viewModel.sendMessage(message) binding.editTextMessage.text.clear() + binding.layoutAttachImage.visibility = View.GONE // Instantly scroll to show new message binding.recyclerChat.postDelayed({ @@ -291,24 +298,33 @@ class ChatActivity : AppCompatActivity() { // Attachment button binding.btnAttachment.setOnClickListener { + this.currentFocus?.let { view -> + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } checkPermissionsAndShowImagePicker() } + binding.btnCloseChat.setOnClickListener{ + binding.layoutAttachImage.visibility = View.GONE + imageAttach = false + viewModel.clearSelectedImage() + } + // Product card click to enable/disable product attachment binding.productContainer.setOnClickListener { toggleProductAttachment() } } + private fun toggleProductAttachment() { val currentState = viewModel.state.value if (currentState?.hasProductAttachment == true) { - // Disable product attachment viewModel.disableProductAttachment() updateProductAttachmentUI(false) Toast.makeText(this, "Product attachment disabled", Toast.LENGTH_SHORT).show() } else { - // Enable product attachment viewModel.enableProductAttachment() updateProductAttachmentUI(true) Toast.makeText(this, "Product will be attached to your next message", Toast.LENGTH_SHORT).show() @@ -389,77 +405,76 @@ class ChatActivity : AppCompatActivity() { } }) - viewModel.state.observe(this, Observer { state -> - Log.d(TAG, "State updated - Messages: ${state.messages.size}") + lifecycleScope.launchWhenStarted { + viewModel.state.collect() { state -> + Log.d(TAG, "State updated - Messages: ${state.messages.size}") - // Update messages - val previousCount = chatAdapter.itemCount + // Update messages + val previousCount = chatAdapter.itemCount - val displayItems = viewModel.getDisplayItems() + val displayItems = viewModel.getDisplayItems() - chatAdapter.submitList(displayItems) { - Log.d(TAG, "Messages submitted to adapter") - // Only auto-scroll for new messages or initial load - if (previousCount == 0 || state.messages.size > previousCount) { - scrollToBottomInstant() - } - } - - // Update product info - if (!state.productName.isNullOrEmpty()) { - binding.tvProductName.text = state.productName - binding.tvProductPrice.text = state.productPrice - binding.ratingBar.rating = state.productRating - binding.tvRating.text = state.productRating.toString() - binding.tvSellerName.text = state.storeName - binding.tvStoreName.text = state.storeName - - val fullImageUrl = when (val img = state.productImageUrl) { - is String -> { - if (img.startsWith("/")) BASE_URL + img.substring(1) else img + chatAdapter.submitList(displayItems) { + Log.d(TAG, "Messages submitted to adapter") + // Only auto-scroll for new messages or initial load + if (previousCount == 0 || state.messages.size > previousCount) { + scrollToBottomInstant() } - else -> R.drawable.placeholder_image } - if (!state.productImageUrl.isNullOrEmpty()) { - Glide.with(this@ChatActivity) - .load(fullImageUrl) - .centerCrop() - .placeholder(R.drawable.placeholder_image) - .error(R.drawable.placeholder_image) - .into(binding.imgProduct) + // layout attach product + if (!state.productName.isNullOrEmpty()) { + binding.tvProductName.text = state.productName + binding.tvProductPrice.text = state.productPrice + binding.ratingBar.rating = state.productRating + binding.tvRating.text = state.productRating.toString() + binding.tvSellerName.text = state.storeName + binding.tvStoreName.text = state.storeName + + val fullImageUrl = when (val img = state.productImageUrl) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + + else -> R.drawable.placeholder_image + } + + if (!state.productImageUrl.isNullOrEmpty()) { + Glide.with(this@ChatActivity) + .load(fullImageUrl) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgProduct) + } + updateProductCardUI(state.hasProductAttachment) + + binding.productContainer.visibility = View.GONE + } else { + binding.productContainer.visibility = View.GONE } - updateProductCardUI(state.hasProductAttachment) - binding.productContainer.visibility = View.GONE - } else { - binding.productContainer.visibility = View.GONE + updateInputHint(state) + + // Update attachment hint + if (state.hasAttachment) { + binding.layoutAttachImage.visibility = View.VISIBLE + } else { + binding.editTextMessage.hint = getString(R.string.write_message) + } + + // Show error if any + state.error?.let { error -> + Toast.makeText(this@ChatActivity, error, Toast.LENGTH_SHORT).show() + viewModel.clearError() + } } - - updateInputHint(state) - - // Update attachment hint - if (state.hasAttachment) { - binding.editTextMessage.hint = getString(R.string.image_attached) - } else { - binding.editTextMessage.hint = getString(R.string.write_message) - } - - // Show typing indicator - binding.tvTypingIndicator.visibility = - if (state.isOtherUserTyping) View.VISIBLE else View.GONE - - // Show error if any - state.error?.let { error -> - Toast.makeText(this@ChatActivity, error, Toast.LENGTH_SHORT).show() - viewModel.clearError() - } - }) + } } private fun updateInputHint(state: ChatUiState) { binding.editTextMessage.hint = when { - state.hasAttachment -> getString(R.string.image_attached) + state.hasAttachment -> getString(R.string.write_message) state.hasProductAttachment -> "Type your message (product will be attached)" else -> getString(R.string.write_message) } @@ -480,7 +495,7 @@ class ChatActivity : AppCompatActivity() { Toast.makeText(this, "Opening: ${productInfo.productName}", Toast.LENGTH_SHORT).show() // You can navigate to product detail here - navigateToProductDetail(productInfo.productId) + navigateToProductDetail(productInfo.productId) } private fun navigateToProductDetail(productId: Int) { @@ -504,6 +519,7 @@ class ChatActivity : AppCompatActivity() { getString(R.string.cancel) ) + AlertDialog.Builder(this) .setTitle(getString(R.string.options)) .setItems(options) { dialog, which -> @@ -578,7 +594,21 @@ class ChatActivity : AppCompatActivity() { private fun handleSelectedImage(uri: Uri) { try { - Log.d(TAG, "Processing selected image: $uri") + Log.d(TAG, "Processing selected image: ${uri.toString()}") + imageAttach = true + binding.layoutAttachImage.visibility = View.VISIBLE + val fullImageUrl = when (val img = uri.toString()) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + + Glide.with(this) + .load(fullImageUrl) + .placeholder(R.drawable.placeholder_image) + .into(binding.ivAttach) + Log.d(TAG, "Display attach image: $uri") // Always use the copy-to-cache approach for reliability contentResolver.openInputStream(uri)?.use { inputStream -> @@ -598,6 +628,7 @@ class ChatActivity : AppCompatActivity() { Log.d(TAG, "Image processed successfully: ${outputFile.absolutePath}, size: ${outputFile.length()}") viewModel.setSelectedImageFile(outputFile) + Toast.makeText(this, "Image selected", Toast.LENGTH_SHORT).show() } else { Log.e(TAG, "Failed to create image file") @@ -681,25 +712,4 @@ class ChatActivity : AppCompatActivity() { context.startActivity(intent) } } -} - -//if implement typing status -// private fun handleConnectionState(state: ConnectionState) { -// when (state) { -// is ConnectionState.Connected -> { -// binding.tvConnectionStatus.visibility = View.GONE -// } -// is ConnectionState.Connecting -> { -// binding.tvConnectionStatus.visibility = View.VISIBLE -// binding.tvConnectionStatus.text = getString(R.string.connecting) -// } -// is ConnectionState.Disconnected -> { -// binding.tvConnectionStatus.visibility = View.VISIBLE -// binding.tvConnectionStatus.text = getString(R.string.disconnected_reconnecting) -// } -// is ConnectionState.Error -> { -// binding.tvConnectionStatus.visibility = View.VISIBLE -// binding.tvConnectionStatus.text = getString(R.string.connection_error, state.message) -// } -// } -// } \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt index 8a136f0..68cd079 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt @@ -209,7 +209,7 @@ class ChatAdapter( binding.tvProductPrice.text = product.productPrice // Load product image - val fullImageUrl = if (product.productImage.startsWith("/")) { + val fullImageUrl = if (product.productImage!!.startsWith("/")) { BASE_URL + product.productImage.substring(1) } else { product.productImage @@ -246,7 +246,7 @@ class ChatAdapter( binding.tvProductPrice.text = product.productPrice // Load product image - val fullImageUrl = if (product.productImage.startsWith("/")) { + val fullImageUrl = if (product.productImage!!.startsWith("/")) { BASE_URL + product.productImage.substring(1) } else { product.productImage diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt index ed2aa87..923ad50 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt @@ -1,6 +1,7 @@ package com.alya.ecommerce_serang.ui.chat import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -55,31 +56,44 @@ class ChatListFragment : Fragment() { viewModel.chatList.observe(viewLifecycleOwner) { result -> when (result) { is Result.Success -> { - val adapter = ChatListAdapter(result.data) { chatItem -> - // Use the ChatActivity.createIntent factory method for proper navigation - ChatActivity.createIntent( - context = requireActivity(), - storeId = chatItem.storeId, - productId = 0, // Default value since we don't have it in ChatListItem - productName = null, // Null is acceptable as per ChatActivity - productPrice = "", - productImage = null, - productRating = null, - storeName = chatItem.storeName, - chatRoomId = chatItem.chatRoomId, - storeImage = chatItem.storeImage - ) + val data = result.data + + binding.tvEmptyChat.visibility = View.GONE + if (data.isNotEmpty()) { + val adapter = ChatListAdapter(data) { chatItem -> + ChatActivity.createIntent( + context = requireActivity(), + storeId = chatItem.storeId, + productId = 0, + productName = null, + productPrice = "", + productImage = null, + productRating = null, + storeName = chatItem.storeName, + chatRoomId = chatItem.chatRoomId, + storeImage = chatItem.storeImage + ) + } + binding.chatListRecyclerView.adapter = adapter + } else { + binding.tvEmptyChat.visibility = View.VISIBLE } - binding.chatListRecyclerView.adapter = adapter } is Result.Error -> { + binding.tvEmptyChat.visibility = View.VISIBLE Toast.makeText(requireContext(), "Failed to load chats", Toast.LENGTH_SHORT).show() } Result.Loading -> { + binding.progressBarChat.visibility = View.VISIBLE // Optional: show progress bar } } } + //loading chat list + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + binding.progressBarChat?.visibility = if (isLoading) View.VISIBLE else View.GONE + Log.d(TAG, "Loading state: $isLoading") + } } @@ -89,6 +103,6 @@ class ChatListFragment : Fragment() { } companion object{ - + private var TAG = "ChatListFragment" } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt index 3b550f5..8a6d001 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -14,6 +14,9 @@ import com.alya.ecommerce_serang.data.repository.Result import com.alya.ecommerce_serang.utils.Constants import com.alya.ecommerce_serang.utils.SessionManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File import java.text.SimpleDateFormat @@ -23,6 +26,28 @@ import java.util.Locale import java.util.TimeZone import javax.inject.Inject +/** + * ChatViewModel - Manages chat functionality for both buyers and store owners + * + * ARCHITECTURE OVERVIEW: + * - Handles real-time messaging via Socket.IO + * - Manages chat state using LiveData/MutableLiveData pattern + * - Supports multiple message types: TEXT, IMAGE, PRODUCT + * - Maintains separate flows for buyer and store owner chat + * + * KEY RESPONSIBILITIES: + * 1. Socket connection management and real-time message handling + * 2. Message sending/receiving with different attachment types + * 3. Chat history loading and message status updates + * 4. Product attachment functionality for commerce integration + * 5. User session management and authentication + * + * STATE MANAGEMENT PATTERN: + * - All UI state updates go through updateState() helper function + * - State updates are atomic and follow immutable pattern + * - Error states are cleared explicitly via clearError() + */ + @HiltViewModel class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository, @@ -34,9 +59,12 @@ class ChatViewModel @Inject constructor( // Product attachment flag private var shouldAttachProduct = false - // UI state using LiveData - private val _state = MutableLiveData(ChatUiState()) - val state: LiveData = _state + // use state for more seamless responsive + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state + + private val _isLoading = MutableLiveData() + val isLoading: LiveData = _isLoading val _chatRoomId = MutableLiveData(0) val chatRoomId: LiveData = _chatRoomId @@ -68,16 +96,21 @@ class ChatViewModel @Inject constructor( init { Log.d(TAG, "ChatViewModel initialized") + socketService.connect() // 🛠 force connection + setupSocketListeners() // 🛠 always listen, even before user data initializeUser() } private fun initializeUser() { + _isLoading.value = true viewModelScope.launch { Log.d(TAG, "Initializing user session...") when (val result = chatRepository.fetchUserProfile()) { is Result.Success -> { currentUserId = result.data?.userId + _isLoading.value = false + Log.d(TAG, "User session initialized - User ID: $currentUserId") if (currentUserId == null || currentUserId == 0) { @@ -85,14 +118,17 @@ class ChatViewModel @Inject constructor( updateState { it.copy(error = "User authentication error. Please login again.") } } else { Log.d(TAG, "Setting up socket listeners...") + socketService.connect() setupSocketListeners() } } is Result.Error -> { + _isLoading.value = false Log.e(TAG, "Failed to fetch user profile: ${result.exception.message}") updateState { it.copy(error = "User authentication error. Please login again.") } } is Result.Loading -> { + _isLoading.value = true Log.d(TAG, "Loading user profile...") } } @@ -201,26 +237,116 @@ class ChatViewModel @Inject constructor( if (connectionState is ConnectionState.Connected) { Log.d(TAG, "Socket connected, joining room...") - socketService.joinRoom() + val roomId = _chatRoomId.value + if (roomId != null && roomId > 0) { + socketService.joinRoom(roomId) + } } } } +// viewModelScope.launch { +// socketService.newMessages.collect { chatLine -> +// chatLine?.let { +// Log.d(TAG, "NEW message received in ViewModel: ${it.message}") +// val updatedMessages = _state.value.messages.toMutableList() +// updatedMessages.add(convertChatLineToUiMessage(it)) +// updateState { it.copy(messages = updatedMessages) } +// +// if (it.senderId != currentUserId) { +// updateMessageStatus(it.id, Constants.STATUS_READ) +// } +// } +// } +// } viewModelScope.launch { socketService.newMessages.collect { chatLine -> - chatLine?.let { - Log.d(TAG, "New message received via socket - ID: ${it.id}, SenderID: ${it.senderId}") - val currentMessages = _state.value?.messages ?: listOf() - val updatedMessages = currentMessages.toMutableList().apply { - add(convertChatLineToUiMessage(it)) + Log.d("ChatViewModel", "Collected new message from SocketIOService: ${chatLine.message}") + chatLine?.let { incomingChatLine -> + // 1. First update: Add the message to the list (potentially without full product info) + _state.update { currentState -> + val existingMessageIndex = + currentState.messages.indexOfFirst { it.id == incomingChatLine.id } + val messagesAfterInitialUpdate = if (existingMessageIndex != -1) { + // If message exists (e.g., status update), just update it + val updatedList = currentState.messages.toMutableList() + updatedList[existingMessageIndex] = mapChatLineToUiMessage( + incomingChatLine, + updatedList[existingMessageIndex].productInfo + ) // Preserve existing productInfo if any + updatedList + } else { + // New message, add it + (currentState.messages + mapChatLineToUiMessage(incomingChatLine)).distinctBy { msg -> msg.id } + } + // Sort after any update/addition + currentState.copy(messages = messagesAfterInitialUpdate.sortedBy { msg -> + SimpleDateFormat( + "yyyy-MM-dd HH:mm:ss", + Locale.getDefault() + ).parse(msg.createdAt)?.time + }) } - updateState { it.copy(messages = updatedMessages) } - if (it.senderId != currentUserId) { - Log.d(TAG, "Marking message as read: ${it.id}") - updateMessageStatus(it.id, Constants.STATUS_READ) + // 2. If it's a product message and needs details, fetch them + if (incomingChatLine.productId != 0) { // Check if it's a product message + viewModelScope.launch { + Log.d( + TAG, + "Fetching product detail for ID: ${incomingChatLine.productId}" + ) + + // Call your repository function directly + val productResponse = + chatRepository.fetchProductDetail(incomingChatLine.productId) + + if (productResponse != null && productResponse.product != null) { + val fetchedProduct = + productResponse.product // Access the nested product object + Log.d( + TAG, + "Successfully fetched product: ${fetchedProduct.productName}" + ) + + // Create a complete ProductInfo object + val fullProductInfo = ProductInfo( + productId = fetchedProduct.productId, + productName = fetchedProduct.productName, // Use productName from fetched data + productPrice = fetchedProduct.price, // Use productPrice from fetched data + productImage = fetchedProduct.image, // Use productImage from fetched data + productRating = fetchedProduct.rating.toFloat(), + storeName = fetchedProduct.productName // Use storeName from fetched data + ) + + // --- PHASE 3: Second UI update (fill in full product info) --- + _state.update { currentState -> + val updatedMessages = currentState.messages.map { msg -> + if (msg.id == incomingChatLine.id) { + // Found the message, update its productInfo with full details + msg.copy(productInfo = fullProductInfo) + } else { + msg + } + } + currentState.copy(messages = updatedMessages) + } + } else { + Log.e( + TAG, + "Failed to fetch product detail for ID ${incomingChatLine.productId} or product data is null." + ) + // Optionally, update message status to indicate error in product loading + } + } } + } + +// // Your existing logic for clearing typing status etc. +// if (incomingChatLine.isTyping == false && incomingChatLine.from?.id != sessionManager.getUserId()?.toIntOrNull()) { +// _state.update { it.copy(isOtherUserTyping = false) } +// } + } } @@ -241,10 +367,10 @@ class ChatViewModel @Inject constructor( if (roomId <= 0) { Log.e(TAG, "Cannot join room: Invalid room ID") return + } else if (roomId > 0){ + Log.d(TAG, "Joining socket room: $roomId") + socketService.joinRoom(roomId) } - - Log.d(TAG, "Joining socket room: $roomId") - socketService.joinRoom() } fun sendTypingStatus(isTyping: Boolean) { @@ -313,10 +439,13 @@ class ChatViewModel @Inject constructor( } fun getChatList() { + _isLoading.value = true Log.d(TAG, "Getting chat list...") viewModelScope.launch { - _chatList.value = Result.Loading +// _chatList.value = Result.Loading _chatList.value = chatRepository.getListChat() + _isLoading.value = false + } } @@ -695,7 +824,7 @@ class ChatViewModel @Inject constructor( } } - //update message status + //update message status fun updateMessageStatus(messageId: Int, status: String) { Log.d(TAG, "Updating message status - ID: $messageId, Status: $status") @@ -723,13 +852,26 @@ class ChatViewModel @Inject constructor( } } - //set image attachment + //set image attachment fun setSelectedImageFile(file: File?) { selectedImageFile = file updateState { it.copy(hasAttachment = file != null) } Log.d(TAG, "Image attachment ${if (file != null) "selected: ${file.name}" else "cleared"}") } + fun clearSelectedImage() { + Log.d(TAG, "Clearing selected image attachment") + + selectedImageFile?.let { file -> + Log.d(TAG, "Clearing image file: ${file.name}") + } + + selectedImageFile = null + updateState { it.copy(hasAttachment = false) } + + Log.d(TAG, "Image attachment cleared successfully") + } + // convert form chatLine api to UI chat messages private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage { val formattedTime = formatTimestamp(chatLine.createdAt) @@ -745,7 +887,7 @@ class ChatViewModel @Inject constructor( ) } - // convert chat history item to ui + // convert chat history item to ui private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage { val formattedTime = formatTimestamp(chatItem.createdAt) @@ -886,7 +1028,7 @@ class ChatViewModel @Inject constructor( } } - //format price + //format price private fun formatPrice(price: String): String { return if (price.startsWith("Rp")) price else "Rp$price" } @@ -912,9 +1054,7 @@ class ChatViewModel @Inject constructor( // helper function to update live data private fun updateState(update: (ChatUiState) -> ChatUiState) { - _state.value?.let { - _state.value = update(it) - } + _state.value = update(_state.value) } //clear any error messages @@ -1007,6 +1147,73 @@ class ChatViewModel @Inject constructor( private fun isThisYear(messageCalendar: Calendar, today: Calendar): Boolean { return messageCalendar.get(Calendar.YEAR) == today.get(Calendar.YEAR) } + + fun setChatRoomId(roomId: Int) { + _chatRoomId.value = roomId + joinSocketRoom(roomId) + loadChatHistory(roomId) + } + + private fun convertToUiMessage(chatLine: ChatLine): ChatUiMessage { + + val formattedTime = formatTimestamp(chatLine.createdAt) + return ChatUiMessage( + id = chatLine.id, + message = chatLine.message, + attachment = chatLine.attachment, + status = chatLine.status, + time = formattedTime, // or format from createdAt if needed + isSentByMe = chatLine.senderId == currentUserId, + messageType = MessageType.TEXT, // or detect from chatLine if needed + productInfo = null, // optional, if applicable + createdAt = chatLine.createdAt + ) + } + + private fun mapChatLineToUiMessage(chatLine: ChatLine, fetchedProductInfo: ProductInfo? = null): ChatUiMessage { + val isSentByMe = chatLine.senderId == sessionManager.getUserId()?.toIntOrNull() // Using senderId now + val formattedTime = try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val date = inputFormat.parse(chatLine.createdAt) + date?.let { outputFormat.format(it) } ?: "" + } catch (e: Exception) { + Log.e("ChatViewModel", "Error parsing date: ${chatLine.createdAt}", e) + "" + } + + // Determine message type based on what ChatLine provides + val messageType = when { + chatLine.attachment?.isNotEmpty() == true -> MessageType.IMAGE + chatLine.productId != 0 -> MessageType.PRODUCT // If productId is non-zero, it's a product message + else -> MessageType.TEXT + } + + // Initialize productInfo: if fetchedProductInfo is provided, use it. + // Otherwise, if ChatLine has a productId, create a ProductInfo with just the ID. + // If no productId, it's null. + val productInfo = fetchedProductInfo ?: if (chatLine.productId != 0) { + // Create a placeholder ProductInfo with just the ID for initial display + // The full details will be fetched later + ProductInfo(productId = chatLine.productId) + } else { + null + } + + return ChatUiMessage( + id = chatLine.id, + message = chatLine.message, + attachment = chatLine.attachment, + status = chatLine.status, + time = formattedTime, + isSentByMe = isSentByMe, + messageType = messageType, + productInfo = productInfo, // Use the determined productInfo + createdAt = chatLine.createdAt + ) + } } enum class MessageType { @@ -1016,12 +1223,12 @@ enum class MessageType { } data class ProductInfo( - val productId: Int, - val productName: String, - val productPrice: String, - val productImage: String, - val productRating: Float, - val storeName: String + val productId: Int, // Keep productId here + val productName: String? = null, // Make nullable + val productPrice: String? = null, // Make nullable + val productImage: String? = null, // Make nullable + val productRating: Float = 0f, // Default value + val storeName: String? = null ) // representing chat messages to UI @@ -1037,8 +1244,6 @@ data class ChatUiMessage( val createdAt: String ) - - // representing UI state to screen data class ChatUiState( val messages: List = emptyList(), @@ -1056,4 +1261,8 @@ data class ChatUiState( val productImageUrl: String = "", val productRating: Float = 0f, val storeName: String = "" -) \ No newline at end of file +) + +//data class ChatUiState( +// val messages: List = emptyList() +//) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt index 1ac378f..bb088c0 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt @@ -10,14 +10,24 @@ import com.alya.ecommerce_serang.utils.SessionManager import com.google.gson.Gson import io.socket.client.IO import io.socket.client.Socket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import org.json.JSONObject import java.net.URISyntaxException +import javax.inject.Inject +import javax.inject.Singleton -class SocketIOService( +@Singleton +class SocketIOService @Inject constructor( private val sessionManager: SessionManager ) { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val TAG = "SocketIOService" // Socket.IO client @@ -30,8 +40,8 @@ class SocketIOService( private val _connectionState = MutableStateFlow(ConnectionState.Disconnected()) val connectionState: StateFlow = _connectionState - private val _newMessages = MutableStateFlow(null) - val newMessages: StateFlow = _newMessages + private val _newMessages = MutableSharedFlow(extraBufferCapacity = 1) // Using extraBufferCapacity for a non-suspending emit + val newMessages: SharedFlow = _newMessages private val _typingStatus = MutableStateFlow(null) val typingStatus: StateFlow = _typingStatus @@ -85,63 +95,95 @@ class SocketIOService( * Sets up Socket.IO event listeners */ private fun setupSocketListeners() { - socket?.let { socket -> - // Connection events - socket.on(Socket.EVENT_CONNECT) { - Log.d(TAG, "Socket.IO connected") - isConnected = true - _connectionState.value = ConnectionState.Connected - _connectionStateLiveData.postValue(ConnectionState.Connected) - } - socket.on(Socket.EVENT_DISCONNECT) { - Log.d(TAG, "Socket.IO disconnected") - isConnected = false - _connectionState.value = ConnectionState.Disconnected("Disconnected from server") - _connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected from server")) - } + socket?.on(Constants.EVENT_NEW_MESSAGE) { args -> // Use the event name your server emits + Log.d(TAG, "Raw event received on ${Constants.EVENT_NEW_MESSAGE}: ${args.firstOrNull()}") // Check raw args - socket.on(Socket.EVENT_CONNECT_ERROR) { args -> - val error = if (args.isNotEmpty() && args[0] != null) args[0].toString() else "Unknown error" - Log.e(TAG, "Socket.IO connection error: $error") - isConnected = false - _connectionState.value = ConnectionState.Error("Connection error: $error") - _connectionStateLiveData.postValue(ConnectionState.Error("Connection error: $error")) - } - - // Chat events - socket.on(Constants.EVENT_NEW_MESSAGE) { args -> + if (args.isNotEmpty()) { try { - if (args.isNotEmpty() && args[0] != null) { - val messageJson = args[0].toString() - Log.d(TAG, "Received new message: $messageJson") - val chatLine = Gson().fromJson(messageJson, ChatLine::class.java) - _newMessages.value = chatLine - _newMessagesLiveData.postValue(chatLine) + val messageJson = args[0].toString() + val chatLine = Gson().fromJson(messageJson, ChatLine::class.java) + Log.d(TAG, "Successfully parsed ChatLine: ${chatLine.message}") + Log.d(TAG, "Emitting new message to _newMessages SharedFlow...") // New log + + // Use the serviceScope to launch a coroutine for emit() + serviceScope.launch { + _newMessages.emit(chatLine) // This ensures every message is processed + Log.d(TAG, "New message emitted to SharedFlow.") // New log after emit + } } catch (e: Exception) { - Log.e(TAG, "Error parsing new message event", e) - } - } - - socket.on(Constants.EVENT_TYPING) { args -> - try { - if (args.isNotEmpty() && args[0] != null) { - val typingData = args[0] as JSONObject - val userId = typingData.getInt("userId") - val roomId = typingData.getInt("roomId") - val isTyping = typingData.getBoolean("isTyping") - - Log.d(TAG, "Received typing status: User $userId in room $roomId is typing: $isTyping") - val status = TypingStatus(userId, roomId, isTyping) - _typingStatus.value = status - _typingStatusLiveData.postValue(status) - } - } catch (e: Exception) { - Log.e(TAG, "Error parsing typing event", e) + Log.e(TAG, "Error parsing or emitting new message: ${e.message}", e) } + } else { + Log.w(TAG, "Received empty args for ${Constants.EVENT_NEW_MESSAGE}") } } +// socket?.on(Constants.EVENT_NEW_MESSAGE) { args -> +// if (args.isNotEmpty()) { +// val messageJson = args[0].toString() +// val chatLine = Gson().fromJson(messageJson, ChatLine::class.java) +// Log.d("SocketIOService", "Message received: ${chatLine.message}") +// _newMessages.value = chatLine +// } +// } +// socket?.let { socket -> +// // Connection events +// socket.on(Socket.EVENT_CONNECT) { +// Log.d(TAG, "Socket.IO connected") +// isConnected = true +// _connectionState.value = ConnectionState.Connected +// _connectionStateLiveData.postValue(ConnectionState.Connected) +// } +// +// socket.on(Socket.EVENT_DISCONNECT) { +// Log.d(TAG, "Socket.IO disconnected") +// isConnected = false +// _connectionState.value = ConnectionState.Disconnected("Disconnected from server") +// _connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected from server")) +// } +// +// socket.on(Socket.EVENT_CONNECT_ERROR) { args -> +// val error = if (args.isNotEmpty() && args[0] != null) args[0].toString() else "Unknown error" +// Log.e(TAG, "Socket.IO connection error: $error") +// isConnected = false +// _connectionState.value = ConnectionState.Error("Connection error: $error") +// _connectionStateLiveData.postValue(ConnectionState.Error("Connection error: $error")) +// } +// +// // Chat events +// socket.on(Constants.EVENT_NEW_MESSAGE) { args -> +// try { +// if (args.isNotEmpty() && args[0] != null) { +// val messageJson = args[0].toString() +// Log.d(TAG, "Received new message: $messageJson") +// val chatLine = Gson().fromJson(messageJson, ChatLine::class.java) +// _newMessages.value = chatLine +// _newMessagesLiveData.postValue(chatLine) +// } +// } catch (e: Exception) { +// Log.e(TAG, "Error parsing new message event", e) +// } +// } +// +// socket.on(Constants.EVENT_TYPING) { args -> +// try { +// if (args.isNotEmpty() && args[0] != null) { +// val typingData = args[0] as JSONObject +// val userId = typingData.getInt("userId") +// val roomId = typingData.getInt("roomId") +// val isTyping = typingData.getBoolean("isTyping") +// +// Log.d(TAG, "Received typing status: User $userId in room $roomId is typing: $isTyping") +// val status = TypingStatus(userId, roomId, isTyping) +// _typingStatus.value = status +// _typingStatusLiveData.postValue(status) +// } +// } catch (e: Exception) { +// Log.e(TAG, "Error parsing typing event", e) +// } +// } +// } } /** @@ -159,22 +201,29 @@ class SocketIOService( /** * Joins a specific chat room */ - fun joinRoom() { + fun joinRoom(roomId: Int) { +// if (!isConnected) { +// connect() +// return +// } +// +// // Get user ID from SessionManager +// val userId = sessionManager.getUserId() +// if (userId.isNullOrEmpty()) { +// Log.e(TAG, "Cannot join room: User ID is null or empty") +// return +// } +// +// // Join the room using the current user's ID +// socket?.emit("joinRoom", roomId) // ✅ +// Log.d(TAG, "Joined room ID: $roomId") +// Log.d(TAG, "Joined room for user: $userId") if (!isConnected) { connect() - return } - // Get user ID from SessionManager - val userId = sessionManager.getUserId() - if (userId.isNullOrEmpty()) { - Log.e(TAG, "Cannot join room: User ID is null or empty") - return - } - - // Join the room using the current user's ID - socket?.emit("joinRoom", userId) - Log.d(TAG, "Joined room for user: $userId") + socket?.emit("joinRoom", roomId) + Log.d(TAG, "Joined room ID: $roomId") } /** diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt index dba398a..8bbfbf4 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt @@ -208,25 +208,6 @@ class HomeFragment : Fragment() { private fun initUi() { // For LightStatusBar setLightStatusBar() -// val banners = binding.banners -// banners.offscreenPageLimit = 1 -// -// val nextItemVisiblePx = resources.getDimension(R.dimen.viewpager_next_item_visible) -// val currentItemHorizontalMarginPx = -// resources.getDimension(R.dimen.viewpager_current_item_horizontal_margin) -// val pageTranslationX = nextItemVisiblePx + currentItemHorizontalMarginPx -// -// banners.setPageTransformer { page, position -> -// page.translationX = -pageTranslationX * position -// page.scaleY = 1 - (0.25f * kotlin.math.abs(position)) -// } -// -// banners.addItemDecoration( -// HorizontalMarginItemDecoration( -// requireContext(), -// R.dimen.viewpager_current_item_horizontal_margin -// ) -// ) } private fun handleProductClick(product: ProductsItem) { @@ -248,8 +229,4 @@ class HomeFragment : Fragment() { categoryAdapter = null _binding = null } - -// private fun showLoading(isLoading: Boolean) { -// binding.progressBar.isVisible = isLoading -// } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt index 89d8e22..6baa2b0 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt @@ -2,6 +2,7 @@ package com.alya.ecommerce_serang.ui.home import android.util.Log import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -65,6 +66,16 @@ class SearchResultsAdapter( val storeName = product.storeId?.let { storeMap[it]?.storeName } ?: "Unknown Store" binding.tvStoreName.text = storeName + val ratingStr = product.rating + val ratingValue = ratingStr?.toFloatOrNull() + + if (ratingValue != null && ratingValue > 0f) { + binding.rating.text = String.format("%.1f", ratingValue) + binding.rating.visibility = View.VISIBLE + } else { + binding.rating.text = "Belum ada rating" + binding.rating.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null) + } } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutActivity.kt index 9f4e7e4..79608f9 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log +import android.view.View import android.view.ViewGroup import android.widget.TextView import android.widget.Toast @@ -110,11 +111,6 @@ class CheckoutActivity : AppCompatActivity() { finish() } } - -// viewModel.getPaymentMethods { paymentMethods -> -// // Logging is just for debugging -// Log.d("CheckoutActivity", "Loaded ${paymentMethods.size} payment methods") -// } } private fun setupToolbar() { @@ -165,7 +161,7 @@ class CheckoutActivity : AppCompatActivity() { // Observe loading state viewModel.isLoading.observe(this) { isLoading -> binding.btnPay.isEnabled = !isLoading - // Show/hide loading indicator if you have one + } // Observe error messages @@ -273,10 +269,14 @@ class CheckoutActivity : AppCompatActivity() { private fun updateShippingUI(shipName: String, shipService: String, shipEtd: String, shipPrice: Int) { if (shipName.isNotEmpty() && shipService.isNotEmpty()) { // Display shipping name and service in one line + binding.cardShipment.visibility = View.VISIBLE + binding.tvCourierName.text = "$shipName $shipService" binding.tvDeliveryEstimate.text = "$shipEtd hari kerja" binding.tvShippingPrice.text = formatCurrency(shipPrice.toDouble()) binding.rbJne.isChecked = true + } else { + binding.cardShipment.visibility = View.GONE } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutViewModel.kt index 694fa0f..38a0b28 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/CheckoutViewModel.kt @@ -64,7 +64,7 @@ class CheckoutViewModel(private val repository: OrderRepository) : ViewModel() { shipPrice = 0, // Will be set when user selects shipping shipName = "", shipService = "", - isNego = false, // Default value + isNego = false, // Default value productId = productId, quantity = quantity, shipEtd = "", diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressActivity.kt index 9d9c4a3..cd36fe7 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressActivity.kt @@ -289,7 +289,7 @@ class AddAddressActivity : AppCompatActivity() { val isStoreLocation = false val provinceId = viewModel.selectedProvinceId - val cityId = viewModel.selectedCityId + val cityId = viewModel.selectedCityId.toString() Log.d(TAG, "Form data: street=$street, subDistrict=$subDistrict, postalCode=$postalCode, " + "recipient=$recipient, phone=$phone, userId=$userId, provinceId=$provinceId, cityId=$cityId, " + @@ -333,18 +333,19 @@ class AddAddressActivity : AppCompatActivity() { // Create request with all fields val request = CreateAddressRequest( + userId = userId, lat = latitude!!, // Safe to use !! as we've checked above long = longitude!!, street = street, subDistrict = subDistrict, - cityId = cityId, + cityId = cityId, // ⚠️ Make sure this is Int provId = provinceId, postCode = postalCode, + idVillage = "", // Or provide a real ID if needed detailAddress = street, - userId = userId, + isStoreLocation = false, recipient = recipient, - phone = phone, - isStoreLocation = isStoreLocation + phone = phone ) Log.d(TAG, "Form validation successful, submitting address: $request") diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressViewModel.kt index 7fcb067..0a0676e 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/AddAddressViewModel.kt @@ -36,8 +36,8 @@ class AddAddressViewModel(private val repository: OrderRepository, private val u get() = savedStateHandle.get("selectedProvinceId") set(value) { savedStateHandle["selectedProvinceId"] = value } - var selectedCityId: Int? - get() = savedStateHandle.get("selectedCityId") + var selectedCityId: String? + get() = savedStateHandle.get("selectedCityId") set(value) { savedStateHandle["selectedCityId"] = value } init { @@ -129,7 +129,7 @@ class AddAddressViewModel(private val repository: OrderRepository, private val u selectedProvinceId = id } - fun setSelectedCityId(id: Int) { + fun updateSelectedCityId(id: String) { selectedCityId = id } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/ProvinceAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/ProvinceAdapter.kt index 06bbeee..85f674b 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/ProvinceAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/address/ProvinceAdapter.kt @@ -5,6 +5,8 @@ import android.util.Log import android.widget.ArrayAdapter 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.api.response.customer.order.SubdistrictsItem +import com.alya.ecommerce_serang.data.api.response.customer.order.VillagesItem // UI adapters and helpers class ProvinceAdapter( @@ -12,6 +14,7 @@ class ProvinceAdapter( resource: Int = android.R.layout.simple_dropdown_item_1line ) : ArrayAdapter(context, resource, ArrayList()) { + //call from endpoint private val provinces = ArrayList() fun updateData(newProvinces: List) { @@ -46,7 +49,52 @@ class CityAdapter( notifyDataSetChanged() } - fun getCityId(position: Int): Int? { - return cities.getOrNull(position)?.cityId?.toIntOrNull() + fun getCityId(position: Int): String? { + return cities.getOrNull(position)?.cityId?.toString() + } +} + +class SubdsitrictAdapter( + 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.subdistrictName }) + notifyDataSetChanged() + } + + fun getSubdistrictId(position: Int): String? { + return cities.getOrNull(position)?.subdistrictId?.toString() + } +} + +class VillagesAdapter( + context: Context, + resource: Int = android.R.layout.simple_dropdown_item_1line +) : ArrayAdapter(context, resource, ArrayList()) { + + private val villages = ArrayList() + + fun updateData(newCities: List) { + villages.clear() + villages.addAll(newCities) + + clear() + addAll(villages.map { it.villageName }) + notifyDataSetChanged() + } + + fun getVillageId(position: Int): String? { + return villages.getOrNull(position)?.villageId?.toString() + } + fun getPostalCode(position: Int): String?{ + return villages.getOrNull(position)?.postalCode } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt index 1329a95..24a940a 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt @@ -39,7 +39,6 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File -import java.text.NumberFormat import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -63,7 +62,6 @@ class AddEvidencePaymentActivity : AppCompatActivity() { private val paymentMethods = arrayOf( "Transfer Bank", - "E-Wallet", "QRIS", ) @@ -129,7 +127,7 @@ class AddEvidencePaymentActivity : AppCompatActivity() { } private fun setupUI() { - val paymentMethods = listOf("Transfer Bank", "COD", "QRIS") + val paymentMethods = listOf("Transfer Bank", "QRIS") val adapter = SpinnerCardAdapter(this, paymentMethods) binding.spinnerPaymentMethod.adapter = adapter } @@ -320,11 +318,12 @@ class AddEvidencePaymentActivity : AppCompatActivity() { Toast.makeText(this, "Silahkan pilih metode pembayaran", Toast.LENGTH_SHORT).show() return } + binding.etAccountNumber.visibility = View.GONE - if (binding.etAccountNumber.text.toString().trim().isEmpty()) { - Toast.makeText(this, "Silahkan isi nomor rekening/HP", Toast.LENGTH_SHORT).show() - return - } +// if (binding.etAccountNumber.text.toString().trim().isEmpty()) { +// Toast.makeText(this, "Silahkan isi nomor rekening/HP", Toast.LENGTH_SHORT).show() +// return +// } if (binding.tvPaymentDate.text.toString() == "Pilih tanggal") { Toast.makeText(this, "Silahkan pilih tanggal pembayaran", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt index 1a6f8e5..a2a18af 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt @@ -10,6 +10,7 @@ import com.alya.ecommerce_serang.data.api.dto.CompletedOrderRequest import com.alya.ecommerce_serang.data.api.dto.OrdersItem import com.alya.ecommerce_serang.data.api.response.customer.order.CancelOrderResponse import com.alya.ecommerce_serang.data.api.response.customer.order.OrderListItemsItem +import com.alya.ecommerce_serang.data.api.response.customer.order.OrderListResponse import com.alya.ecommerce_serang.data.api.response.customer.order.Orders import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse import com.alya.ecommerce_serang.data.repository.OrderRepository @@ -18,6 +19,13 @@ import com.alya.ecommerce_serang.ui.order.address.ViewState import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File import java.text.SimpleDateFormat @@ -29,8 +37,8 @@ class HistoryViewModel(private val repository: OrderRepository) : ViewModel() { private const val TAG = "HistoryViewModel" } - private val _orders = MutableLiveData>>() - val orders: LiveData>> = _orders +// private val _orders = MutableLiveData>>() +// val orders: LiveData>> = _orders private val _orderCompletionStatus = MutableLiveData>() val orderCompletionStatus: LiveData> = _orderCompletionStatus @@ -59,81 +67,156 @@ class HistoryViewModel(private val repository: OrderRepository) : ViewModel() { private val _error = MutableLiveData() val error: LiveData get() = _error - fun getOrderList(status: String) { - _orders.value = ViewState.Loading - viewModelScope.launch { - try { - if (status == "all") { - // Get all orders by combining all statuses - getAllOrdersCombined() - } else { - // Get orders for specific status - when (val result = repository.getOrderList(status)) { - is Result.Success -> { - _orders.value = ViewState.Success(result.data.orders) - Log.d(TAG, "Orders loaded successfully: ${result.data.orders.size} items") + private val _selectedStatus = MutableStateFlow("all") + val selectedStatus: StateFlow = _selectedStatus.asStateFlow() + + val orders: StateFlow>> = + _selectedStatus + .flatMapLatest { status -> + flow>> { + Log.d(TAG, "⏳ Loading orders for status = $status") + emit(ViewState.Loading) + + val viewState = + if (status == "all") { + getAllOrdersCombined().also { + Log.d(TAG, "✅ Combined orders size = ${(it as? ViewState.Success)?.data?.size}") + } + } else { + when (val r = repository.getOrderList(status)) { + + is Result.Loading -> { + Log.d(TAG, " repository.getOrderList($status) → Loading") + ViewState.Loading + } + + is Result.Success -> { + Log.d(TAG, "✅ repository.getOrderList($status) success, size = ${r.data.orders.size}") + // Tag each order so the fragment’s filter works + val tagged = r.data.orders.onEach { it.displayStatus = status } + ViewState.Success(tagged) + } + + is Result.Error -> { + Log.e(TAG, "❌ repository.getOrderList($status) error = ${r.exception.message}") + ViewState.Error(r.exception.message ?: "Unknown error") + } + } } - is Result.Error -> { - _orders.value = ViewState.Error(result.exception.message ?: "Unknown error occurred") - Log.e(TAG, "Error loading orders", result.exception) - } - is Result.Loading -> { - // Keep loading state - } - } + + emit(viewState) } - } catch (e: Exception) { - _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") - Log.e(TAG, "Exception in getOrderList", e) } - } - } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + ViewState.Loading // ② initial value, still fine + ) - private suspend fun getAllOrdersCombined() { - try { - val allStatuses = listOf("unpaid", "paid", "processed", "shipped", "completed", "canceled") - val allOrders = mutableListOf() - // Use coroutineScope to allow launching async blocks - coroutineScope { - val deferreds = allStatuses.map { status -> +// fun getOrderList(status: String) { +// _orders.value = ViewState.Loading +// viewModelScope.launch { +// try { +// if (status == "all") { +// // Get all orders by combining all statuses +// getAllOrdersCombined() +// } else { +// // Get orders for specific status +// when (val result = repository.getOrderList(status)) { +// is Result.Success -> { +// _orders.value = ViewState.Success(result.data.orders) +// Log.d(TAG, "Orders loaded successfully: ${result.data.orders.size} items") +// } +// is Result.Error -> { +// _orders.value = ViewState.Error(result.exception.message ?: "Unknown error occurred") +// Log.e(TAG, "Error loading orders", result.exception) +// } +// is Result.Loading -> { +// // Keep loading state +// } +// } +// } +// } catch (e: Exception) { +// _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") +// Log.e(TAG, "Exception in getOrderList", e) +// } +// } +// } + +// private suspend fun getAllOrdersCombined() { +// try { +// val allStatuses = listOf("unpaid", "paid", "processed", "shipped", "completed", "canceled") +// val allOrders = mutableListOf() +// +// // Use coroutineScope to allow launching async blocks +// coroutineScope { +// val deferreds = allStatuses.map { status -> +// async { +// when (val result = repository.getOrderList(status)) { +// is Result.Success -> { +// // Tag each order with the status it was fetched from +// result.data.orders.onEach { it.displayStatus = status } +// } +// is Result.Error -> { +// Log.e(TAG, "Error loading orders for status $status", result.exception) +// emptyList() +// } +// is Result.Loading -> emptyList() +// } +// } +// } +// +// // Await all results and combine +// deferreds.awaitAll().forEach { orders -> +// allOrders.addAll(orders) +// } +// } +// +// // Sort orders +// val sortedOrders = allOrders.sortedByDescending { order -> +// try { +// SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(order.createdAt) +// } catch (e: Exception) { +// null +// } +// } +// +// _orders.value = ViewState.Success(sortedOrders) +// Log.d(TAG, "All orders loaded successfully: ${sortedOrders.size} items") +// +// } catch (e: Exception) { +// _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") +// Log.e(TAG, "Exception in getAllOrdersCombined", e) +// } +// } + private suspend fun getAllOrdersCombined(): ViewState> = try { + val statuses = listOf("unpaid", "paid", "processed", "shipped", "completed", "canceled") + + val all = coroutineScope { + statuses + .map { status -> async { - when (val result = repository.getOrderList(status)) { - is Result.Success -> { - // Tag each order with the status it was fetched from - result.data.orders.onEach { it.displayStatus = status } - } - is Result.Error -> { - Log.e(TAG, "Error loading orders for status $status", result.exception) - emptyList() - } - is Result.Loading -> emptyList() + when (val r = repository.getOrderList(status)) { + is Result.Success -> r.data.orders.onEach { it.displayStatus = status } + else -> emptyList() } } } - - // Await all results and combine - deferreds.awaitAll().forEach { orders -> - allOrders.addAll(orders) - } - } - - // Sort orders - val sortedOrders = allOrders.sortedByDescending { order -> - try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(order.createdAt) - } catch (e: Exception) { - null - } - } - - _orders.value = ViewState.Success(sortedOrders) - Log.d(TAG, "All orders loaded successfully: ${sortedOrders.size} items") - - } catch (e: Exception) { - _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") - Log.e(TAG, "Exception in getAllOrdersCombined", e) + .awaitAll() + .flatten() } + + val sorted = all.sortedByDescending { order -> + try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + .parse(order.createdAt) + } catch (_: Exception) { null } + } + + ViewState.Success(sorted) + } catch (e: Exception) { + ViewState.Error("Failed to load orders: ${e.message}") } fun confirmOrderCompleted(orderId: Int, status: String) { @@ -209,9 +292,52 @@ class HistoryViewModel(private val repository: OrderRepository) : ViewModel() { } } - fun refreshOrders(status: String = "all") { - Log.d(TAG, "Refreshing orders with status: $status") - // Don't set Loading here if you want to show current data while refreshing - getOrderList(status) +// fun refreshOrders(status: String = "all") { +// Log.d(TAG, "Refreshing orders with status: $status") +// // Don't set Loading here if you want to show current data while refreshing +// getOrderList(status) +// } + + fun updateStatus(status: String, forceRefresh: Boolean = false) { + Log.d(TAG, "↪️ updateStatus(status = $status, forceRefresh = $forceRefresh)") + + // No‑op guard (optional): skip if user re‑selects same tab and no refresh asked + if (_selectedStatus.value == status && !forceRefresh) { + Log.d(TAG, "🔸 Status unchanged & forceRefresh = false → skip update") + return + } + + _selectedStatus.value = status + Log.d(TAG, "✅ _selectedStatus set to \"$status\"") + + if (forceRefresh) { + Log.d(TAG, "🔄 forceRefresh = true → launching refresh()") + viewModelScope.launch { refresh(status) } + } } + + private suspend fun refresh(status: String) { + Log.d(TAG, "⏳ refresh(\"$status\") started") + + try { + if (status == "all") { + Log.d(TAG, "🌐 Calling getAllOrdersCombined()") + getAllOrdersCombined() // network → cache + } else { + Log.d(TAG, "🌐 repository.getOrderList(\"$status\")") + repository.getOrderList(status) // network → cache + } + Log.d(TAG, "✅ refresh(\"$status\") completed (repository updated)") + // Flow that watches DB/cache will emit automatically + } catch (e: Exception) { + Log.e(TAG, "❌ refresh(\"$status\") failed: ${e.message}", e) + } + } + + private fun Result.toViewState(): ViewState> = + when (this) { + is Result.Success -> ViewState.Success(data.orders) + is Result.Error -> ViewState.Error(exception.message ?: "Unknown error") + is Result.Loading -> ViewState.Loading // should rarely reach UI + } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt index f395ec2..34ba43a 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt @@ -195,7 +195,7 @@ class OrderHistoryAdapter( text = itemView.context.getString(R.string.canceled_order_btn) setOnClickListener { showCancelOrderDialog(order.orderId.toString()) - viewModel.refreshOrders() +// viewModel.refreshOrders() } } // deadlineDate.apply { @@ -213,14 +213,15 @@ class OrderHistoryAdapter( visibility = View.VISIBLE text = itemView.context.getString(R.string.dl_processed) } - btnLeft.apply { - visibility = View.VISIBLE - text = itemView.context.getString(R.string.canceled_order_btn) - setOnClickListener { - showCancelOrderDialog(order.orderId.toString()) - viewModel.refreshOrders() - } - } + // gabisa complaint +// btnLeft.apply { +// visibility = View.VISIBLE +// text = itemView.context.getString(R.string.canceled_order_btn) +// setOnClickListener { +// showCancelOrderDialog(order.orderId.toString()) +// viewModel.refreshOrders() +// } +// } } "shipped" -> { // Untuk status shipped, tampilkan "Lacak Pengiriman" dan "Terima Barang" @@ -237,7 +238,7 @@ class OrderHistoryAdapter( text = itemView.context.getString(R.string.claim_complaint) setOnClickListener { showCancelOrderDialog(order.orderId.toString()) - viewModel.refreshOrders() +// viewModel.refreshOrders() } } btnRight.apply { @@ -248,7 +249,7 @@ class OrderHistoryAdapter( // Call ViewModel viewModel.confirmOrderCompleted(order.orderId, "completed") - viewModel.refreshOrders() +// viewModel.refreshOrders() } @@ -268,13 +269,21 @@ class OrderHistoryAdapter( text = itemView.context.getString(R.string.dl_shipped) } btnRight.apply { - visibility = View.VISIBLE - text = itemView.context.getString(R.string.add_review) - setOnClickListener { - addReviewProduct(order) - viewModel.refreshOrders() - // Handle click event + val checkReview = order.orderItems[0].reviewId + if (checkReview > 0){ + visibility = View.VISIBLE + text = itemView.context.getString(R.string.add_review) + setOnClickListener { + + addReviewProduct(order) +// viewModel.refreshOrders() + // Handle click event + } + } else { + visibility = View.GONE } + + } deadlineDate.apply { visibility = View.VISIBLE @@ -518,7 +527,7 @@ class OrderHistoryAdapter( } } - // Create and show the bottom sheet using the obtained FragmentManager + // cancel sebelum bayar val bottomSheet = CancelOrderBottomSheet( orderId = orderId, onOrderCancelled = { @@ -531,6 +540,7 @@ class OrderHistoryAdapter( bottomSheet.show(fragmentActivity.supportFragmentManager, CancelOrderBottomSheet.TAG) } + // tambah review / ulasan private fun addReviewProduct(order: OrdersItem) { // Use ViewModel to fetch order details viewModel.getOrderDetails(order.orderId) @@ -550,7 +560,7 @@ class OrderHistoryAdapter( } } - // Observe the order details result + // Observe order items viewModel.orderItems.observe(itemView.findViewTreeLifecycleOwner()!!) { orderItems -> if (orderItems != null && orderItems.isNotEmpty()) { // For single item review diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryFragment.kt index 353a952..0d77c05 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryFragment.kt @@ -5,8 +5,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.viewpager2.widget.ViewPager2 import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.repository.OrderRepository import com.alya.ecommerce_serang.databinding.FragmentOrderHistoryBinding +import com.alya.ecommerce_serang.utils.BaseViewModelFactory import com.alya.ecommerce_serang.utils.SessionManager import com.google.android.material.tabs.TabLayoutMediator @@ -16,6 +21,12 @@ class OrderHistoryFragment : Fragment() { private val binding get() = _binding!! private lateinit var sessionManager: SessionManager + private val historyVm: HistoryViewModel by activityViewModels { + BaseViewModelFactory { + val api = ApiConfig.getApiService(SessionManager(requireContext())) + HistoryViewModel(OrderRepository(api)) + } + } private lateinit var viewPagerAdapter: OrderViewPagerAdapter @@ -33,6 +44,8 @@ class OrderHistoryFragment : Fragment() { sessionManager = SessionManager(requireContext()) setupViewPager() + + } private fun setupViewPager() { @@ -53,6 +66,16 @@ class OrderHistoryFragment : Fragment() { else -> "Tab $position" } }.attach() + + binding.viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + val status = viewPagerAdapter.orderStatuses[position] + /* setStatus() is the API we added earlier; TRUE → always re‑query */ + historyVm.updateStatus(status, forceRefresh = true) + } + } + ) } override fun onDestroyView() { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt index 28931bd..82a9f3f 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt @@ -8,8 +8,10 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.alya.ecommerce_serang.data.api.dto.OrdersItem import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig @@ -27,17 +29,26 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { private val binding get() = _binding!! private lateinit var sessionManager: SessionManager - private val viewModel: HistoryViewModel by viewModels { + private val viewModel: HistoryViewModel by activityViewModels { BaseViewModelFactory { - val apiService = ApiConfig.getApiService(sessionManager) - val orderRepository = OrderRepository(apiService) - HistoryViewModel(orderRepository) + val api = ApiConfig.getApiService(SessionManager(requireContext())) + HistoryViewModel(OrderRepository(api)) } } + private lateinit var orderAdapter: OrderHistoryAdapter private var status: String = "all" + private val detailOrderLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + /* force‑refresh the current tab */ + viewModel.updateStatus(status, forceRefresh = true) + } + } + companion object { private const val ARG_STATUS = "status" @@ -73,8 +84,8 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { setupRecyclerView() observeOrderList() observeViewModel() - observeOrderCompletionStatus() - loadOrders() +// observeOrderCompletionStatus() +// loadOrders() } private fun setupRecyclerView() { @@ -96,27 +107,50 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { private fun observeOrderList() { // Now we only need to observe one LiveData for all cases - viewModel.orders.observe(viewLifecycleOwner) { result -> - when (result) { - is ViewState.Success -> { - binding.progressBar.visibility = View.GONE - - if (result.data.isNullOrEmpty()) { - binding.tvEmptyState.visibility = View.VISIBLE - binding.rvOrders.visibility = View.GONE - } else { - binding.tvEmptyState.visibility = View.GONE - binding.rvOrders.visibility = View.VISIBLE - orderAdapter.submitList(result.data) +// viewModel.orders.observe(viewLifecycleOwner) { result -> +// when (result) { +// is ViewState.Success -> { +// binding.progressBar.visibility = View.GONE +// +// if (result.data.isNullOrEmpty()) { +// binding.tvEmptyState.visibility = View.VISIBLE +// binding.rvOrders.visibility = View.GONE +// } else { +// binding.tvEmptyState.visibility = View.GONE +// binding.rvOrders.visibility = View.VISIBLE +// orderAdapter.submitList(result.data) +// } +// } +// is ViewState.Error -> { +// binding.progressBar.visibility = View.GONE +// binding.tvEmptyState.visibility = View.VISIBLE +// Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() +// } +// is ViewState.Loading -> { +// binding.progressBar.visibility = View.VISIBLE +// } +// } +// } + viewLifecycleOwner.lifecycleScope.launchWhenStarted { + viewModel.orders.collect { state -> + when (state) { + is ViewState.Loading -> { + binding.progressBar.isVisible = true + } + is ViewState.Error -> { + binding.progressBar.isVisible = false + binding.tvEmptyState.isVisible = true + binding.rvOrders.isVisible = false + Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show() + } + is ViewState.Success -> { + binding.progressBar.isVisible = false + val list = state.data + .filter { status == "all" || it.displayStatus == status } + binding.tvEmptyState.isVisible = list.isEmpty() + binding.rvOrders.isVisible = list.isNotEmpty() + orderAdapter.submitList(list) } - } - is ViewState.Error -> { - binding.progressBar.visibility = View.GONE - binding.tvEmptyState.visibility = View.VISIBLE - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() - } - is ViewState.Loading -> { - binding.progressBar.visibility = View.VISIBLE } } } @@ -124,51 +158,78 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { private fun observeViewModel() { // Observe order completion +// viewModel.orderCompletionStatus.observe(viewLifecycleOwner) { result -> +// when (result) { +// is Result.Success -> { +// Toast.makeText(requireContext(), "Order completed successfully!", Toast.LENGTH_SHORT).show() +//// loadOrders() // Refresh here +// } +// is Result.Error -> { +// Toast.makeText(requireContext(), "Failed: ${result.exception.message}", Toast.LENGTH_SHORT).show() +// } +// is Result.Loading -> { +// // Show loading if needed +// } +// } +// } +// +// // Observe cancel order status +// viewModel.cancelOrderStatus.observe(viewLifecycleOwner) { result -> +// when (result) { +// is Result.Success -> { +// Toast.makeText(requireContext(), "Order cancelled successfully!", Toast.LENGTH_SHORT).show() +// loadOrders() // Refresh here +// } +// is Result.Error -> { +// Toast.makeText(requireContext(), "Failed to cancel: ${result.exception.message}", Toast.LENGTH_SHORT).show() +// } +// is Result.Loading -> { +// // Show loading if needed +// } +// } +// } viewModel.orderCompletionStatus.observe(viewLifecycleOwner) { result -> when (result) { is Result.Success -> { - Toast.makeText(requireContext(), "Order completed successfully!", Toast.LENGTH_SHORT).show() - loadOrders() // Refresh here - } - is Result.Error -> { - Toast.makeText(requireContext(), "Failed: ${result.exception.message}", Toast.LENGTH_SHORT).show() - } - is Result.Loading -> { - // Show loading if needed + Toast.makeText(requireContext(), + "Order completed!", Toast.LENGTH_SHORT).show() + viewModel.updateStatus(status, forceRefresh = true) } + is Result.Error -> + Toast.makeText(requireContext(), + "Failed: ${result.exception.message}", Toast.LENGTH_SHORT).show() + else -> { /* Loading → no UI change */ } } } - // Observe cancel order status viewModel.cancelOrderStatus.observe(viewLifecycleOwner) { result -> when (result) { is Result.Success -> { - Toast.makeText(requireContext(), "Order cancelled successfully!", Toast.LENGTH_SHORT).show() - loadOrders() // Refresh here - } - is Result.Error -> { - Toast.makeText(requireContext(), "Failed to cancel: ${result.exception.message}", Toast.LENGTH_SHORT).show() - } - is Result.Loading -> { - // Show loading if needed + Toast.makeText(requireContext(), + "Order cancelled!", Toast.LENGTH_SHORT).show() + viewModel.updateStatus(status, forceRefresh = true) } + is Result.Error -> + Toast.makeText(requireContext(), + "Failed: ${result.exception.message}", Toast.LENGTH_SHORT).show() + else -> { /* Loading */ } } } } - private fun loadOrders() { - // Simple - just call getOrderList for any status including "all" - viewModel.getOrderList(status) - } +// private fun loadOrders() { +// // Simple - just call getOrderList for any status including "all" +// viewModel.getOrderList(status) +// } - private val detailOrderLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - // Refresh order list when returning with OK result - loadOrders() - } - } +// private val detailOrderLauncher = registerForActivityResult( +// ActivityResultContracts.StartActivityForResult() +// ) { result -> +// if (result.resultCode == Activity.RESULT_OK) { +// // Refresh order list when returning with OK result +//// loadOrders() +// } +// } private fun navigateToOrderDetail(order: OrdersItem) { val intent = Intent(requireContext(), DetailOrderStatusActivity::class.java).apply { @@ -183,7 +244,9 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { override fun onOrderCancelled(orderId: String, success: Boolean, message: String) { if (success) { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - loadOrders() // Refresh the list +// loadOrders() // Refresh the list + if (success) viewModel.updateStatus(status, forceRefresh = true) + } else { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } @@ -192,7 +255,8 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { override fun onOrderCompleted(orderId: Int, success: Boolean, message: String) { if (success) { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - loadOrders() // Refresh the list +// loadOrders() // Refresh the list + if (success) viewModel.updateStatus(status, forceRefresh = true) } else { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } @@ -207,20 +271,20 @@ class OrderListFragment : Fragment(), OrderHistoryAdapter.OrderActionCallbacks { _binding = null } - private fun observeOrderCompletionStatus() { - viewModel.orderCompletionStatus.observe(viewLifecycleOwner) { result -> - when (result) { - is Result.Loading -> { - // Handle loading state if needed - } - is Result.Success -> { - Toast.makeText(requireContext(), "Order completed successfully!", Toast.LENGTH_SHORT).show() - loadOrders() - } - is Result.Error -> { - Toast.makeText(requireContext(), "Failed to complete order: ${result.exception.message}", Toast.LENGTH_SHORT).show() - } - } - } - } +// private fun observeOrderCompletionStatus() { +// viewModel.orderCompletionStatus.observe(viewLifecycleOwner) { result -> +// when (result) { +// is Result.Loading -> { +// // Handle loading state if needed +// } +// is Result.Success -> { +// Toast.makeText(requireContext(), "Order completed successfully!", Toast.LENGTH_SHORT).show() +//// loadOrders() +// } +// is Result.Error -> { +// Toast.makeText(requireContext(), "Failed to complete order: ${result.exception.message}", Toast.LENGTH_SHORT).show() +// } +// } +// } +// } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderViewPageAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderViewPageAdapter.kt index 146100d..e2e000a 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderViewPageAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderViewPageAdapter.kt @@ -9,7 +9,7 @@ class OrderViewPagerAdapter( ) : FragmentStateAdapter(fragmentActivity) { // Define all possible order statuses - private val orderStatuses = listOf( + val orderStatuses = listOf( "all", // All orders "unpaid", // Menunggu Tagihan "paid", // Belum Dibayar diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/cancelorder/CancelOrderBottomSheet.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/cancelorder/CancelOrderBottomSheet.kt index 99f25de..8fd0376 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/cancelorder/CancelOrderBottomSheet.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/cancelorder/CancelOrderBottomSheet.kt @@ -55,7 +55,7 @@ class CancelOrderBottomSheet( val btnConfirm = view.findViewById