diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aea446e..83acdfd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,10 +2,10 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) - id("kotlin-kapt") - id ("androidx.navigation.safeargs") + alias(libs.plugins.ksp) // Use KSP instead of kapt + id("androidx.navigation.safeargs") id("kotlin-parcelize") -// id("com.google.dagger.hilt.android") + alias(libs.plugins.dagger.hilt) // Use alias from catalog } val localProperties = Properties().apply { @@ -98,11 +98,27 @@ dependencies { implementation("org.osmdroid:osmdroid-android:6.0.3") // implementation(libs.hilt.android) -// kapt("com.google.dagger:hilt-compiler:2.48") -// -// // For ViewModel injection (if needed) -// implementation(libs.androidx.hilt.lifecycle.viewmodel) -// kapt("androidx.hilt:hilt-compiler:1.0.0") + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Androidx Hilt + implementation(libs.androidx.hilt.navigation.fragment) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + + implementation("androidx.work:work-runtime-ktx:2.8.1") + implementation("androidx.work:work-runtime:2.8.1") + + implementation("io.ktor:ktor-client-android:3.0.1") + implementation("io.ktor:ktor-client-core:3.0.1") + implementation("io.ktor:ktor-client-websockets:3.0.1") + implementation("io.ktor:ktor-client-logging:3.0.1") + implementation("io.ktor:ktor-client-okhttp:3.0.1") + implementation("io.ktor:ktor-client-content-negotiation:3.0.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.1") + + implementation("io.socket:socket.io-client:2.1.0") // or latest version + } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fada1ca..47fc23d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,10 +7,16 @@ - - + + + + + + + + + + + + + diff --git a/app/src/main/java/com/alya/ecommerce_serang/app/App.kt b/app/src/main/java/com/alya/ecommerce_serang/app/App.kt index 31a11eb..11361c4 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/app/App.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/app/App.kt @@ -1,7 +1,18 @@ package com.alya.ecommerce_serang.app import android.app.Application +import dagger.hilt.android.HiltAndroidApp -//@HiltAndroidApp +@HiltAndroidApp class App : Application(){ +// override fun onCreate() { +// super.onCreate() +// +// val sessionManager = SessionManager(this) +// if (sessionManager.getUserId() != null) { +// val serviceIntent = Intent(this, SimpleWebSocketService::class.java) +// startService(serviceIntent) +// } +// } + } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt new file mode 100644 index 0000000..13064e8 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt @@ -0,0 +1,5 @@ +package com.alya.ecommerce_serang.data.api.dto + +class ChatRequest { + +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/RegisterRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/RegisterRequest.kt index 650eab3..d1a4482 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/RegisterRequest.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/RegisterRequest.kt @@ -8,7 +8,11 @@ data class RegisterRequest ( val password: String?, val username: String?, val phone: String?, - @SerializedName("birth_date") val birthDate: String?, - @SerializedName("userimg") val image: String?, + @SerializedName("birth_date") + val birthDate: String?, + + @SerializedName("userimg") + val image: String? = null, + val otp: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/SearchRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/SearchRequest.kt new file mode 100644 index 0000000..87c39cf --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/SearchRequest.kt @@ -0,0 +1,8 @@ +package com.alya.ecommerce_serang.data.api.dto + +import com.google.gson.annotations.SerializedName + +data class SearchRequest( + @SerializedName("search_query") + val searchQuery: String +) \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt new file mode 100644 index 0000000..e21b42f --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt @@ -0,0 +1,6 @@ +package com.alya.ecommerce_serang.data.api.dto + +data class UpdateChatRequest ( + val id: Int, + val status: String +) \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt new file mode 100644 index 0000000..95fbd16 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt @@ -0,0 +1,39 @@ +package com.alya.ecommerce_serang.data.api.response.chat + +import com.google.gson.annotations.SerializedName + +data class ChatHistoryResponse( + + @field:SerializedName("chat") + val chat: List, + + @field:SerializedName("message") + val message: String +) + +data class ChatItem( + + @field:SerializedName("attachment") + val attachment: String? = null, + + @field:SerializedName("product_id") + val productId: Int, + + @field:SerializedName("chat_room_id") + val chatRoomId: Int, + + @field:SerializedName("created_at") + val createdAt: String, + + @field:SerializedName("id") + val id: Int, + + @field:SerializedName("message") + val message: String, + + @field:SerializedName("sender_id") + val senderId: Int, + + @field:SerializedName("status") + val status: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt new file mode 100644 index 0000000..61daec2 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt @@ -0,0 +1,39 @@ +package com.alya.ecommerce_serang.data.api.response.chat + +import com.google.gson.annotations.SerializedName + +data class SendChatResponse( + + @field:SerializedName("chatLine") + val chatLine: ChatLine, + + @field:SerializedName("message") + val message: String +) + +data class ChatLine( + + @field:SerializedName("attachment") + val attachment: String? = null, + + @field:SerializedName("product_id") + val productId: Int, + + @field:SerializedName("chat_room_id") + val chatRoomId: Int, + + @field:SerializedName("created_at") + val createdAt: String, + + @field:SerializedName("id") + val id: Int, + + @field:SerializedName("message") + val message: String, + + @field:SerializedName("sender_id") + val senderId: Int, + + @field:SerializedName("status") + val status: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt new file mode 100644 index 0000000..6cb3912 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt @@ -0,0 +1,39 @@ +package com.alya.ecommerce_serang.data.api.response.chat + +import com.google.gson.annotations.SerializedName + +data class UpdateChatResponse( + + @field:SerializedName("address") + val address: Address, + + @field:SerializedName("message") + val message: String +) + +data class Address( + + @field:SerializedName("attachment") + val attachment: String? = null, + + @field:SerializedName("product_id") + val productId: Int, + + @field:SerializedName("chat_room_id") + val chatRoomId: Int, + + @field:SerializedName("created_at") + val createdAt: String, + + @field:SerializedName("id") + val id: Int, + + @field:SerializedName("message") + val message: String, + + @field:SerializedName("sender_id") + val senderId: Int, + + @field:SerializedName("status") + val status: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/CreateSearchResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/CreateSearchResponse.kt new file mode 100644 index 0000000..b6490bd --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/CreateSearchResponse.kt @@ -0,0 +1,24 @@ +package com.alya.ecommerce_serang.data.api.response.product + +import com.google.gson.annotations.SerializedName + +data class CreateSearchResponse( + + @field:SerializedName("search") + val search: Search +) + +data class Search( + + @field:SerializedName("user_id") + val userId: Int, + + @field:SerializedName("created_at") + val createdAt: String, + + @field:SerializedName("id") + val id: Int, + + @field:SerializedName("search_query") + val searchQuery: String +) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/SearchHistoryResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/SearchHistoryResponse.kt new file mode 100644 index 0000000..43dabc0 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/customer/product/SearchHistoryResponse.kt @@ -0,0 +1,15 @@ +package com.alya.ecommerce_serang.data.api.response.product + +import com.google.gson.annotations.SerializedName + +data class SearchHistoryResponse( + + @field:SerializedName("data") + val data: List +) + +data class DataItem( + + @field:SerializedName("search_query") + val searchQuery: String +) 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 15d6858..e0e5352 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 @@ -10,9 +10,12 @@ import com.alya.ecommerce_serang.data.api.dto.OrderRequest import com.alya.ecommerce_serang.data.api.dto.OrderRequestBuy import com.alya.ecommerce_serang.data.api.dto.OtpRequest import com.alya.ecommerce_serang.data.api.dto.RegisterRequest +import com.alya.ecommerce_serang.data.api.dto.SearchRequest import com.alya.ecommerce_serang.data.api.dto.UpdateCart import com.alya.ecommerce_serang.data.api.response.store.product.CreateProductResponse import com.alya.ecommerce_serang.data.api.response.store.product.ViewStoreProductsResponse +import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest + import okhttp3.MultipartBody import okhttp3.RequestBody import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse @@ -21,6 +24,9 @@ import com.alya.ecommerce_serang.data.api.response.auth.RegisterResponse import com.alya.ecommerce_serang.data.api.response.customer.cart.AddCartResponse import com.alya.ecommerce_serang.data.api.response.customer.cart.ListCartResponse import com.alya.ecommerce_serang.data.api.response.customer.cart.UpdateCartResponse +import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse +import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse +import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse import com.alya.ecommerce_serang.data.api.response.order.AddEvidenceResponse import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse @@ -41,6 +47,10 @@ import com.alya.ecommerce_serang.data.api.response.customer.profile.CreateAddres import com.alya.ecommerce_serang.data.api.response.customer.profile.ProfileResponse 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.product.SearchHistoryResponse +import com.alya.ecommerce_serang.data.api.response.product.CreateSearchResponse + + import retrofit2.Call import retrofit2.Response import retrofit2.http.Body @@ -48,6 +58,8 @@ import retrofit2.http.DELETE import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.HeaderMap import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT @@ -225,4 +237,31 @@ interface ApiService { @Part complaintimg: MultipartBody.Part ): Response + @POST("search") + suspend fun saveSearchQuery( + @Body searchRequest: SearchRequest + ): Response + + @GET("search") + suspend fun getSearchHistory(): Response + + @Multipart + @POST("sendchat") + suspend fun sendChatLine( + @Part("store_id") storeId: RequestBody, + @Part("message") message: RequestBody, + @Part("product_id") productId: RequestBody, + @Part chatimg: MultipartBody.Part? + ): Response + + + @PUT("chatstatus") + suspend fun updateChatStatus( + @Body request: UpdateChatRequest + ): Response + + @GET("chat/{chatRoomId}") + suspend fun getChatDetail( + @Path("chatRoomId") chatRoomId: Int + ): Response } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt new file mode 100644 index 0000000..751f93b --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt @@ -0,0 +1,131 @@ +package com.alya.ecommerce_serang.data.repository + +import android.util.Log +import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest +import com.alya.ecommerce_serang.data.api.dto.UserProfile +import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse +import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse +import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse +import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import java.io.File +import javax.inject.Inject + +class ChatRepository @Inject constructor( + private val apiService: ApiService +) { + private val TAG = "ChatRepository" + + suspend fun fetchUserProfile(): Result { + return try { + val response = apiService.getUserProfile() + if (response.isSuccessful) { + response.body()?.user?.let { + Result.Success(it) // ✅ Returning only UserProfile + } ?: Result.Error(Exception("User data not found")) + } else { + Result.Error(Exception("Error fetching profile: ${response.code()}")) + } + } catch (e: Exception) { + Result.Error(e) + } + } + + suspend fun sendChatMessage( + storeId: Int, + message: String, + productId: Int, + imageFile: File? = null + ): Result { + return try { + // Create request bodies for text fields + val storeIdBody = RequestBody.create("text/plain".toMediaTypeOrNull(), storeId.toString()) + val messageBody = RequestBody.create("text/plain".toMediaTypeOrNull(), message) + val productIdBody = RequestBody.create("text/plain".toMediaTypeOrNull(), productId.toString()) + + // Create multipart body for the image file + val imageMultipart = if (imageFile != null && imageFile.exists()) { + // Log detailed file information + Log.d(TAG, "Image file: ${imageFile.absolutePath}") + Log.d(TAG, "Image file size: ${imageFile.length()} bytes") + Log.d(TAG, "Image file exists: ${imageFile.exists()}") + Log.d(TAG, "Image file can read: ${imageFile.canRead()}") + + val requestFile = RequestBody.create("image/*".toMediaTypeOrNull(), imageFile) + MultipartBody.Part.createFormData("chatimg", imageFile.name, requestFile) + } else { + // Pass null when no image is provided + null + } + + // Log request info + Log.d(TAG, "Sending message to store ID: $storeId, product ID: $productId") + Log.d(TAG, "Message content: $message") + Log.d(TAG, "Has image: ${imageFile != null && imageFile.exists()}") + + // Make the API call + val response = apiService.sendChatLine( + storeId = storeIdBody, + message = messageBody, + productId = productIdBody, + chatimg = imageMultipart + ) + + if (response.isSuccessful) { + response.body()?.let { + Result.Success(it) + } ?: Result.Error(Exception("Send chat response is empty")) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Log.e(TAG, "HTTP Error: ${response.code()}, Body: $errorBody") + Result.Error(Exception("API Error: ${response.code()} - $errorBody")) + } + } catch (e: Exception) { + Log.e(TAG, "Exception sending message", e) + e.printStackTrace() + Result.Error(e) + } + } + + suspend fun updateMessageStatus( + messageId: Int, + status: String + ): Result { + return try { + val requestBody = UpdateChatRequest( + id = messageId, + status = status + ) + + val response = apiService.updateChatStatus(requestBody) + + if (response.isSuccessful) { + response.body()?.let { + Result.Success(it) + } ?: Result.Error(Exception("Update status response is empty")) + } else { + Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error")) + } + } catch (e: Exception) { + Result.Error(e) + } + } + + suspend fun getChatHistory(chatRoomId: Int): Result { + return try { + val response = apiService.getChatDetail(chatRoomId) + + if (response.isSuccessful) { + response.body()?.let { + Result.Success(it) + } ?: Result.Error(Exception("Chat history response is empty")) + } else { + Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error")) + } + } catch (e: Exception) { + Result.Error(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt index dbf61cc..cee8bc0 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt @@ -5,13 +5,16 @@ import com.alya.ecommerce_serang.data.api.dto.CartItem import com.alya.ecommerce_serang.data.api.dto.CategoryItem import com.alya.ecommerce_serang.data.api.dto.Preorder import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.data.api.dto.SearchRequest import com.alya.ecommerce_serang.data.api.response.store.product.CreateProductResponse import com.alya.ecommerce_serang.data.api.response.customer.cart.AddCartResponse import com.alya.ecommerce_serang.data.api.response.customer.product.ProductResponse import com.alya.ecommerce_serang.data.api.response.customer.product.ReviewsItem import com.alya.ecommerce_serang.data.api.response.customer.product.StoreProduct import com.alya.ecommerce_serang.data.api.response.store.product.UpdateProductResponse +import com.alya.ecommerce_serang.data.api.response.product.Search import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import com.alya.ecommerce_serang.utils.SessionManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -194,6 +197,70 @@ class ProductRepository(private val apiService: ApiService) { } } + suspend fun searchProducts(query: String): Result> = + withContext(Dispatchers.IO) { + try { + // First save the search query + saveSearchQuery(query) + + // Then fetch all products + val response = apiService.getAllProduct() + + if (response.isSuccessful) { + val allProducts = response.body()?.products ?: emptyList() + + // Filter products based on the search query + val filteredProducts = allProducts.filter { product -> + product.name.contains(query, ignoreCase = true) || + (product.description?.contains(query, ignoreCase = true) ?: false) + } + + Log.d(TAG, "Found ${filteredProducts.size} products matching '$query'") + Result.Success(filteredProducts) + } else { + Result.Error(Exception("Failed to fetch products for search. Code: ${response.code()}")) + } + } catch (e: Exception) { + Log.e(TAG, "Error searching products", e) + Result.Error(e) + } + } + + suspend fun saveSearchQuery(query: String): Result = + withContext(Dispatchers.IO) { + try { + val response = apiService.saveSearchQuery(SearchRequest(query)) + + if (response.isSuccessful) { + Result.Success(response.body()?.search) + } else { + Log.e(TAG, "Failed to save search query. Code: ${response.code()}") + Result.Error(Exception("Failed to save search query")) + } + } catch (e: Exception) { + Log.e(TAG, "Error saving search query", e) + Result.Error(e) + } + } + + suspend fun getSearchHistory(): Result> = + withContext(Dispatchers.IO) { + try { + val response = apiService.getSearchHistory() + + if (response.isSuccessful) { + val searches = response.body()?.data?.map { it.searchQuery } ?: emptyList() + Result.Success(searches) + } else { + Log.e(TAG, "Failed to fetch search history. Code: ${response.code()}") + Result.Error(Exception("Failed to fetch search history")) + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching search history", e) + Result.Error(e) + } + } + suspend fun updateProduct(productId: Int?, updatedProduct: Map) : UpdateProductResponse { // Build the request with the updated fields val response = apiService.updateProduct(productId, updatedProduct) 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 5b4c97c..dfcd46b 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 @@ -56,6 +56,102 @@ class UserRepository(private val apiService: ApiService) { } } +// suspend fun sendChatMessage( +// storeId: Int, +// message: String, +// productId: Int, +// imageFile: File? = null +// ): Result { +// return try { +// // Create multipart request builder +// val requestBodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM) +// +// // Add text fields +// requestBodyBuilder.addFormDataPart("store_id", storeId.toString()) +// requestBodyBuilder.addFormDataPart("message", message) +// requestBodyBuilder.addFormDataPart("product_id", productId.toString()) +// +// // Add image if it exists +// if (imageFile != null && imageFile.exists()) { +// val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull()) +// requestBodyBuilder.addFormDataPart("chatimg", imageFile.name, requestFile) +// } +// +// // Build the final request body +// val requestBody = requestBodyBuilder.build() +// +// // Make the API call using a custom endpoint that takes a plain MultipartBody +// val response = apiService.sendChatLineWithBody(requestBody) +// +// if (response.isSuccessful) { +// response.body()?.let { +// Result.Success(it) +// } ?: Result.Error(Exception("Send chat response is empty")) +// } else { +// val errorBody = response.errorBody()?.string() ?: "Unknown error" +// Log.e("ChatRepository", "HTTP Error: ${response.code()}, Body: $errorBody") +// Result.Error(Exception("API Error: ${response.code()} - $errorBody")) +// } +// } catch (e: Exception) { +// Log.e("ChatRepository", "Exception sending message", e) +// e.printStackTrace() +// Result.Error(e) +// } +// } +// +// /** +// * Updates the status of a message (sent, delivered, read) +// * +// * @param messageId The ID of the message to update +// * @param status The new status to set +// * @return Result containing the updated message details or error +// */ +// suspend fun updateMessageStatus( +// messageId: Int, +// status: String +// ): Result { +// return try { +// val requestBody = UpdateChatRequest( +// id = messageId, +// status = status +// ) +// +// val response = apiService.updateChatStatus(requestBody) +// +// if (response.isSuccessful) { +// response.body()?.let { +// Result.Success(it) +// } ?: Result.Error(Exception("Update status response is empty")) +// } else { +// Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error")) +// } +// } catch (e: Exception) { +// Result.Error(e) +// } +// } +// +// /** +// * Gets the chat history for a specific chat room +// * +// * @param chatRoomId The ID of the chat room +// * @return Result containing the list of chat messages or error +// */ +// suspend fun getChatHistory(chatRoomId: Int): Result { +// return try { +// val response = apiService.getChatDetail(chatRoomId) +// +// if (response.isSuccessful) { +// response.body()?.let { +// Result.Success(it) +// } ?: Result.Error(Exception("Chat history response is empty")) +// } else { +// Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error")) +// } +// } catch (e: Exception) { +// Result.Error(e) +// } +// } + } diff --git a/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt b/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt new file mode 100644 index 0000000..89d5d8b --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt @@ -0,0 +1,35 @@ +package com.alya.ecommerce_serang.di + +import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import com.alya.ecommerce_serang.data.repository.ChatRepository +import com.alya.ecommerce_serang.data.repository.UserRepository +import com.alya.ecommerce_serang.ui.chat.SocketIOService +import com.alya.ecommerce_serang.utils.SessionManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ChatModule { + + @Provides + @Singleton + fun provideChatRepository(apiService: ApiService): ChatRepository { + return ChatRepository(apiService) + } + + @Provides + @Singleton + fun provideUserRepository(apiService: ApiService): UserRepository { + return UserRepository(apiService) + } + + @Provides + @Singleton + fun provideSocketIOService(sessionManager: SessionManager): SocketIOService { + return SocketIOService(sessionManager) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/di/NotificationModule.kt b/app/src/main/java/com/alya/ecommerce_serang/di/NotificationModule.kt new file mode 100644 index 0000000..5df72f4 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/di/NotificationModule.kt @@ -0,0 +1,82 @@ +package com.alya.ecommerce_serang.di + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.graphics.Color +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import com.alya.ecommerce_serang.utils.SessionManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NotificationModule { + + @Provides + @Singleton + fun provideContext(@ApplicationContext context: Context): Context { + return context + } + + @Provides + @Singleton + fun provideSessionManager(@ApplicationContext context: Context): SessionManager { + return SessionManager(context) + } + + @Provides + @Singleton + fun provideApiService(sessionManager: SessionManager): ApiService { + return ApiConfig.getApiService(sessionManager) + } + + @Singleton + @Provides + fun provideNotificationBuilder( + @ApplicationContext context: Context + ): NotificationCompat.Builder { + // Create a unique channel ID for your app + val channelId = "websocket_notifications" + + // Ensure the notification channel exists + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "WebSocket Notifications", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications received via WebSocket" + enableLights(true) + lightColor = Color.BLUE + enableVibration(true) + vibrationPattern = longArrayOf(0, 1000, 500, 1000) + } + + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + return NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.baseline_alarm_24) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + } + + @Singleton + @Provides + fun provideNotificationManager( + @ApplicationContext context: Context + ): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/MainActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/MainActivity.kt index 3877327..0c93bc6 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/MainActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/MainActivity.kt @@ -1,7 +1,16 @@ package com.alya.ecommerce_serang.ui +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController @@ -9,27 +18,73 @@ import com.alya.ecommerce_serang.R import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.api.retrofit.ApiService import com.alya.ecommerce_serang.databinding.ActivityMainBinding +import com.alya.ecommerce_serang.ui.notif.WebSocketManager import com.alya.ecommerce_serang.utils.SessionManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject -//@AndroidEntryPoint +@AndroidEntryPoint class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var apiService: ApiService private lateinit var sessionManager: SessionManager +// private val viewModel: NotifViewModel by viewModels() private val navController by lazy { (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController } + + companion object{ + private const val NOTIFICATION_PERMISSION_CODE = 100 + } + + @Inject + lateinit var webSocketManager: WebSocketManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) sessionManager = SessionManager(this) - apiService = ApiConfig.getApiService(sessionManager) // Inject SessionManager + apiService = ApiConfig.getApiService(sessionManager) + + 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, + 0 + ) + windowInsets + } + + requestNotificationPermissionIfNeeded() + + // Start WebSocket service through WebSocketManager after permission check + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + webSocketManager.startWebSocketConnection() + } + } else { + webSocketManager.startWebSocketConnection() + } setupBottomNavigation() observeDestinationChanges() + } + override fun onDestroy() { + super.onDestroy() + if (isFinishing) { + webSocketManager.stopWebSocketConnection() + } } private fun setupBottomNavigation() { @@ -58,7 +113,40 @@ class MainActivity : AppCompatActivity() { navController.addOnDestinationChangedListener { _, destination, _ -> binding.bottomNavigation.isVisible = when (destination.id) { R.id.homeFragment, R.id.chatFragment, R.id.profileFragment -> true - else -> false // Bottom Navigation tidak terlihat di layar lain + else -> false + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_CODE + ) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == NOTIFICATION_PERMISSION_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "Notification permission granted", Toast.LENGTH_SHORT).show() + webSocketManager.startWebSocketConnection() + } else { + Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show() } } } 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 49e34a5..e2c4839 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 @@ -58,6 +58,7 @@ class LoginActivity : AppCompatActivity() { val sessionManager = SessionManager(this) sessionManager.saveToken(accessToken) +// sessionManager.saveUserId(response.userId) Toast.makeText(this, "Login Successful", Toast.LENGTH_SHORT).show() 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 ec126d1..72d1e1a 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 @@ -1,5 +1,6 @@ package com.alya.ecommerce_serang.ui.auth +import android.app.DatePickerDialog import android.content.Intent import android.os.Bundle import android.util.Log @@ -7,6 +8,9 @@ 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.RegisterRequest import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.repository.Result @@ -16,6 +20,9 @@ import com.alya.ecommerce_serang.ui.MainActivity import com.alya.ecommerce_serang.utils.BaseViewModelFactory import com.alya.ecommerce_serang.utils.SessionManager import com.alya.ecommerce_serang.utils.viewmodel.RegisterViewModel +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale class RegisterActivity : AppCompatActivity() { private lateinit var binding: ActivityRegisterBinding @@ -30,17 +37,45 @@ class RegisterActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - sessionManager = SessionManager(this) - if (!sessionManager.getToken().isNullOrEmpty()) { - // User already logged in, redirect to MainActivity - startActivity(Intent(this, MainActivity::class.java)) - finish() - } - - enableEdgeToEdge() binding = ActivityRegisterBinding.inflate(layoutInflater) setContentView(binding.root) + sessionManager = SessionManager(this) + Log.d("RegisterActivity", "Token in storage: '${sessionManager.getToken()}'") + Log.d("RegisterActivity", "User ID in storage: '${sessionManager.getUserId()}'") + + try { + // Use the new isLoggedIn method + if (sessionManager.isLoggedIn()) { + Log.d("RegisterActivity", "User logged in, redirecting to MainActivity") + startActivity(Intent(this, MainActivity::class.java)) + finish() + return + } else { + Log.d("RegisterActivity", "User not logged in, showing RegisterActivity") + } + } catch (e: Exception) { + // Handle any exceptions + Log.e("RegisterActivity", "Error checking login status: ${e.message}", e) + // Clear potentially corrupt data + sessionManager.clearAll() + } + + 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 + } + // Observe OTP state observeOtpState() @@ -53,7 +88,7 @@ class RegisterActivity : AppCompatActivity() { val phone = binding.etNumberPhone.text.toString() val username = binding.etUsername.text.toString() val name = binding.etFullname.text.toString() - val image = "not yet" + val image = null val userData = RegisterRequest(name, email, password, username, phone, birthDate, image) @@ -94,6 +129,9 @@ class RegisterActivity : AppCompatActivity() { startActivity(intent) } + binding.etBirthDate.setOnClickListener{ + showDatePicker() + } } private fun observeOtpState() { @@ -140,4 +178,21 @@ class RegisterActivity : AppCompatActivity() { } } } + + private fun showDatePicker() { + val calendar = Calendar.getInstance() + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + + DatePickerDialog( + this, + { _, selectedYear, selectedMonth, selectedDay -> + calendar.set(selectedYear, selectedMonth, selectedDay) + val sdf = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault()) + binding.etBirthDate.setText(sdf.format(calendar.time)) + }, + year, month, day + ).show() + } } \ No newline at end of file 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 new file mode 100644 index 0000000..619185e --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt @@ -0,0 +1,443 @@ +package com.alya.ecommerce_serang.ui.chat + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.alya.ecommerce_serang.BuildConfig.BASE_URL +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.api.retrofit.ApiService +import com.alya.ecommerce_serang.databinding.ActivityChatBinding +import com.alya.ecommerce_serang.ui.auth.LoginActivity +import com.alya.ecommerce_serang.utils.Constants +import com.alya.ecommerce_serang.utils.SessionManager +import com.bumptech.glide.Glide +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class ChatActivity : AppCompatActivity() { + + private lateinit var binding: ActivityChatBinding + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var apiService: ApiService + + private lateinit var chatAdapter: ChatAdapter + + private val viewModel: ChatViewModel by viewModels() + + // For image attachment + private var tempImageUri: Uri? = null + +// // Chat parameters from intent +// private var chatRoomId: Int = 0 +// private var storeId: Int = 0 +// private var productId: Int = 0 + + // Typing indicator handler + private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private val stopTypingRunnable = Runnable { + viewModel.sendTypingStatus(false) + } + + // Activity Result Launchers + private val pickImageLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + handleSelectedImage(uri) + } + } + } + + private val takePictureLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + tempImageUri?.let { uri -> + handleSelectedImage(uri) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityChatBinding.inflate(layoutInflater) + setContentView(binding.root) + + sessionManager = SessionManager(this) + apiService = ApiConfig.getApiService(sessionManager) + + Log.d("ChatActivity", "Token in storage: '${sessionManager.getToken()}'") +// Log.d("ChatActivity", "User ID in storage: '${sessionManager.getUserId()}'") + + 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 + } + + // Get parameters from intent + val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0) + val productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0) + val productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: "" + val productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: "" + val productImage = intent.getStringExtra(Constants.EXTRA_PRODUCT_IMAGE) ?: "" + val productRating = intent.getFloatExtra(Constants.EXTRA_PRODUCT_RATING, 0f) + val storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: "" + + + // Check if user is logged in + val token = sessionManager.getToken() + + if (token.isEmpty()) { + // User not logged in, redirect to login + Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show() + startActivity(Intent(this, LoginActivity::class.java)) + finish() + return + } + + // Set chat parameters to ViewModel + viewModel.setChatParameters( + storeId = storeId, + productId = productId, + productName = productName, + productPrice = productPrice, + productImage = productImage, + productRating = productRating, + storeName = storeName + ) + // Setup UI components + setupRecyclerView() + setupListeners() + setupTypingIndicator() + observeViewModel() + + + } + + private fun setupRecyclerView() { + chatAdapter = ChatAdapter() + binding.recyclerChat.apply { + adapter = chatAdapter + layoutManager = LinearLayoutManager(this@ChatActivity).apply { + stackFromEnd = true + } + } + } + + + private fun setupListeners() { + // Back button + binding.btnBack.setOnClickListener { + onBackPressed() + } + + // Options button + binding.btnOptions.setOnClickListener { + showOptionsMenu() + } + + // Send button + binding.btnSend.setOnClickListener { + val message = binding.editTextMessage.text.toString().trim() + val currentState = viewModel.state.value + if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) { + viewModel.sendMessage(message) + binding.editTextMessage.text.clear() + } + } + + // Attachment button + binding.btnAttachment.setOnClickListener { + checkPermissionsAndShowImagePicker() + } + } + + private fun setupTypingIndicator() { + binding.editTextMessage.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + viewModel.sendTypingStatus(true) + + // Reset the timer + typingHandler.removeCallbacks(stopTypingRunnable) + typingHandler.postDelayed(stopTypingRunnable, 1000) + } + + override fun afterTextChanged(s: Editable?) {} + }) + } + + private fun observeViewModel() { + viewModel.chatRoomId.observe(this, Observer { chatRoomId -> + if (chatRoomId > 0) { + // Chat room has been created, now we can join the Socket.IO room + viewModel.joinSocketRoom(chatRoomId) + + // Now we can also load chat history + viewModel.loadChatHistory(chatRoomId) + Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId") + + } + }) + // Observe state changes using LiveData + viewModel.state.observe(this, Observer { state -> + // Update messages + chatAdapter.submitList(state.messages) + + // Scroll to bottom if new message + if (state.messages.isNotEmpty()) { + binding.recyclerChat.scrollToPosition(state.messages.size - 1) + } + + // Update product info + 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 + + // Load product image + if (state.productImageUrl.isNotEmpty()) { + Glide.with(this@ChatActivity) + .load(BASE_URL + state.productImageUrl) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgProduct) + } + + // 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 showOptionsMenu() { + val options = arrayOf( + getString(R.string.block_user), + getString(R.string.report), + getString(R.string.clear_chat), + getString(R.string.cancel) + ) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.options)) + .setItems(options) { dialog, which -> + when (which) { + 0 -> Toast.makeText(this, R.string.block_user_selected, Toast.LENGTH_SHORT).show() + 1 -> Toast.makeText(this, R.string.report_selected, Toast.LENGTH_SHORT).show() + 2 -> Toast.makeText(this, R.string.clear_chat_selected, Toast.LENGTH_SHORT).show() + } + dialog.dismiss() + } + .show() + } + + private fun checkPermissionsAndShowImagePicker() { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA), + Constants.REQUEST_STORAGE_PERMISSION + ) + } else { + showImagePickerOptions() + } + } + + private fun showImagePickerOptions() { + val options = arrayOf( + getString(R.string.take_photo), + getString(R.string.choose_from_gallery), + getString(R.string.cancel) + ) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.select_attachment)) + .setItems(options) { dialog, which -> + when (which) { + 0 -> openCamera() + 1 -> openGallery() + } + dialog.dismiss() + } + .show() + } + + private fun openCamera() { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFileName = "IMG_${timeStamp}.jpg" + val storageDir = getExternalFilesDir(null) + val imageFile = File(storageDir, imageFileName) + + tempImageUri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + imageFile + ) + + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, tempImageUri) + } + + takePictureLauncher.launch(intent) + } + + private fun openGallery() { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + pickImageLauncher.launch(intent) + } + + private fun handleSelectedImage(uri: Uri) { + // Get the file from Uri + val filePathColumn = arrayOf(MediaStore.Images.Media.DATA) + val cursor = contentResolver.query(uri, filePathColumn, null, null, null) + cursor?.moveToFirst() + val columnIndex = cursor?.getColumnIndex(filePathColumn[0]) + val filePath = cursor?.getString(columnIndex ?: 0) + cursor?.close() + + if (filePath != null) { + viewModel.setSelectedImageFile(File(filePath)) + Toast.makeText(this, R.string.image_selected, Toast.LENGTH_SHORT).show() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showImagePickerOptions() + } else { + Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_SHORT).show() + } + } + } + + override fun onDestroy() { + super.onDestroy() + typingHandler.removeCallbacks(stopTypingRunnable) + } + + companion object { + private const val TAG = "ChatActivity" + + /** + * Create an intent to start the ChatActivity + */ + fun createIntent( + context: Activity, + storeId: Int, + productId: Int, + productName: String?, + productPrice: String, + productImage: String?, + productRating: String?, + storeName: String?, + chatRoomId: Int = 0 + ){ + val intent = Intent(context, ChatActivity::class.java).apply { + putExtra(Constants.EXTRA_STORE_ID, storeId) + putExtra(Constants.EXTRA_PRODUCT_ID, productId) + putExtra(Constants.EXTRA_PRODUCT_NAME, productName) + putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice) + putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage) + putExtra(Constants.EXTRA_PRODUCT_RATING, productRating) + putExtra(Constants.EXTRA_STORE_NAME, storeName) + + if (chatRoomId > 0) { + putExtra(Constants.EXTRA_CHAT_ROOM_ID, chatRoomId) + } + } + 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 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 new file mode 100644 index 0000000..0e2f083 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt @@ -0,0 +1,136 @@ +package com.alya.ecommerce_serang.ui.chat + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.alya.ecommerce_serang.BuildConfig.BASE_URL +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding +import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding +import com.alya.ecommerce_serang.utils.Constants +import com.bumptech.glide.Glide + +class ChatAdapter : ListAdapter(ChatMessageDiffCallback()) { + + companion object { + private const val VIEW_TYPE_MESSAGE_SENT = 1 + private const val VIEW_TYPE_MESSAGE_RECEIVED = 2 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return if (viewType == VIEW_TYPE_MESSAGE_SENT) { + val binding = ItemMessageSentBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + SentMessageViewHolder(binding) + } else { + val binding = ItemMessageReceivedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ReceivedMessageViewHolder(binding) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val message = getItem(position) + + when (holder.itemViewType) { + VIEW_TYPE_MESSAGE_SENT -> (holder as SentMessageViewHolder).bind(message) + VIEW_TYPE_MESSAGE_RECEIVED -> (holder as ReceivedMessageViewHolder).bind(message) + } + } + + override fun getItemViewType(position: Int): Int { + val message = getItem(position) + return if (message.isSentByMe) { + VIEW_TYPE_MESSAGE_SENT + } else { + VIEW_TYPE_MESSAGE_RECEIVED + } + } + + /** + * ViewHolder for messages sent by the current user + */ + inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: ChatUiMessage) { + binding.tvMessage.text = message.message + binding.tvTimestamp.text = message.time + + // Show message status + val statusIcon = when (message.status) { + Constants.STATUS_SENT -> R.drawable.check_single_24 + Constants.STATUS_DELIVERED -> R.drawable.check_double_24 + Constants.STATUS_READ -> R.drawable.check_double_read_24 + else -> R.drawable.check_single_24 + } + binding.imgStatus.setImageResource(statusIcon) + + // Handle attachment if exists + if (message.attachment?.isNotEmpty() == true) { + binding.imgAttachment.visibility = View.VISIBLE + Glide.with(binding.root.context) + .load(BASE_URL + message.attachment) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgAttachment) + } else { + binding.imgAttachment.visibility = View.GONE + } + } + } + + /** + * ViewHolder for messages received from other users + */ + inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: ChatUiMessage) { + binding.tvMessage.text = message.message + binding.tvTimestamp.text = message.time + + // Handle attachment if exists + if (message.attachment?.isNotEmpty() == true) { + binding.imgAttachment.visibility = View.VISIBLE + Glide.with(binding.root.context) + .load(BASE_URL + message.attachment) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgAttachment) + } else { + binding.imgAttachment.visibility = View.GONE + } + + // Load avatar image + Glide.with(binding.root.context) + .load(R.drawable.placeholder_image) // Replace with actual avatar URL if available + .circleCrop() + .into(binding.imgAvatar) + } + } +} + +/** + * DiffUtil callback for optimizing RecyclerView updates + */ +class ChatMessageDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt index 79c13a4..4bd12ae 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt @@ -1,32 +1,337 @@ -package com.alya.ecommerce_serang.ui.chat - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.alya.ecommerce_serang.R -import com.alya.ecommerce_serang.utils.viewmodel.ChatViewModel - -class ChatFragment : Fragment() { - - companion object { - fun newInstance() = ChatFragment() - } - - private val viewModel: ChatViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // TODO: Use the ViewModel - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_chat, container, false) - } -} \ No newline at end of file +//package com.alya.ecommerce_serang.ui.chat +// +//import android.Manifest +//import android.app.Activity +//import android.content.Intent +//import android.content.pm.PackageManager +//import android.net.Uri +//import android.os.Bundle +//import android.provider.MediaStore +//import android.text.Editable +//import android.text.TextWatcher +//import androidx.fragment.app.Fragment +//import android.view.LayoutInflater +//import android.view.View +//import android.view.ViewGroup +//import android.widget.Toast +//import androidx.activity.result.contract.ActivityResultContracts +//import androidx.core.app.ActivityCompat +//import androidx.core.content.ContextCompat +//import androidx.core.content.FileProvider +//import androidx.fragment.app.viewModels +//import androidx.lifecycle.lifecycleScope +//import androidx.navigation.fragment.navArgs +//import androidx.recyclerview.widget.LinearLayoutManager +//import com.alya.ecommerce_serang.BuildConfig.BASE_URL +//import com.alya.ecommerce_serang.R +//import com.alya.ecommerce_serang.databinding.FragmentChatBinding +//import com.alya.ecommerce_serang.utils.Constants +//import com.bumptech.glide.Glide +//import dagger.hilt.android.AndroidEntryPoint +//import kotlinx.coroutines.launch +//import java.io.File +//import java.text.SimpleDateFormat +//import java.util.Locale +// +//@AndroidEntryPoint +//class ChatFragment : Fragment() { +// +// private var _binding: FragmentChatBinding? = null +// private val binding get() = _binding!! +// +// private val viewModel: ChatViewModel by viewModels() +//// private val args: ChatFragmentArgs by navArgs() +// +// private lateinit var chatAdapter: ChatAdapter +// +// // For image attachment +// private var tempImageUri: Uri? = null +// +// // Typing indicator handler +// private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper()) +// private val stopTypingRunnable = Runnable { +// viewModel.sendTypingStatus(false) +// } +// +// // Activity Result Launchers +// private val pickImageLauncher = registerForActivityResult( +// ActivityResultContracts.StartActivityForResult() +// ) { result -> +// if (result.resultCode == Activity.RESULT_OK) { +// result.data?.data?.let { uri -> +// handleSelectedImage(uri) +// } +// } +// } +// +// private val takePictureLauncher = registerForActivityResult( +// ActivityResultContracts.StartActivityForResult() +// ) { result -> +// if (result.resultCode == Activity.RESULT_OK) { +// tempImageUri?.let { uri -> +// handleSelectedImage(uri) +// } +// } +// } +// +// override fun onCreateView( +// inflater: LayoutInflater, +// container: ViewGroup?, +// savedInstanceState: Bundle? +// ): View { +// _binding = FragmentChatBinding.inflate(inflater, container, false) +// return binding.root +// } +// +// override fun onViewCreated(view: View, savedInstanceState: Bundle?) { +// super.onViewCreated(view, savedInstanceState) +// +// setupRecyclerView() +// setupListeners() +// setupTypingIndicator() +// observeViewModel() +// } +// +// private fun setupRecyclerView() { +// chatAdapter = ChatAdapter() +// binding.recyclerChat.apply { +// adapter = chatAdapter +// layoutManager = LinearLayoutManager(requireContext()).apply { +// stackFromEnd = true +// } +// } +// } +// +// private fun setupListeners() { +// // Back button +// binding.btnBack.setOnClickListener { +// requireActivity().onBackPressed() +// } +// +// // Options button +// binding.btnOptions.setOnClickListener { +// showOptionsMenu() +// } +// +// // Send button +// binding.btnSend.setOnClickListener { +// val message = binding.editTextMessage.text.toString().trim() +// if (message.isNotEmpty() || viewModel.state.value.hasAttachment) { +// viewModel.sendMessage(message) +// binding.editTextMessage.text.clear() +// } +// } +// +// // Attachment button +// binding.btnAttachment.setOnClickListener { +// checkPermissionsAndShowImagePicker() +// } +// } +// +// private fun setupTypingIndicator() { +// binding.editTextMessage.addTextChangedListener(object : TextWatcher { +// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} +// +// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { +// viewModel.sendTypingStatus(true) +// +// // Reset the timer +// typingHandler.removeCallbacks(stopTypingRunnable) +// typingHandler.postDelayed(stopTypingRunnable, 1000) +// } +// +// override fun afterTextChanged(s: Editable?) {} +// }) +// } +// +// private fun observeViewModel() { +// viewLifecycleOwner.lifecycleScope.launch { +// viewModel.state.collectLatest { state -> +// // Update messages +// chatAdapter.submitList(state.messages) +// +// // Scroll to bottom if new message +// if (state.messages.isNotEmpty()) { +// binding.recyclerChat.scrollToPosition(state.messages.size - 1) +// } +// +// // Update product info +// 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 +// +// // Load product image +// if (state.productImageUrl.isNotEmpty()) { +// Glide.with(requireContext()) +// .load(BASE_URL + state.productImageUrl) +// .centerCrop() +// .placeholder(R.drawable.placeholder_image) +// .error(R.drawable.placeholder_image) +// .into(binding.imgProduct) +// } +// +// // Show/hide loading indicators +// binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE +// binding.btnSend.isEnabled = !state.isSending +// +// // 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 +// +// // Handle connection state +// handleConnectionState(state.connectionState) +// +// // Show error if any +// state.error?.let { error -> +// Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() +// viewModel.clearError() +// } +// } +// } +// } +// +// 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) +// } +// } +// } +// +// private fun showOptionsMenu() { +// val options = arrayOf( +// getString(R.string.block_user), +// getString(R.string.report), +// getString(R.string.clear_chat), +// getString(R.string.cancel) +// ) +// +// androidx.appcompat.app.AlertDialog.Builder(requireContext()) +// .setTitle(getString(R.string.options)) +// .setItems(options) { dialog, which -> +// when (which) { +// 0 -> Toast.makeText(requireContext(), R.string.block_user_selected, Toast.LENGTH_SHORT).show() +// 1 -> Toast.makeText(requireContext(), R.string.report_selected, Toast.LENGTH_SHORT).show() +// 2 -> Toast.makeText(requireContext(), R.string.clear_chat_selected, Toast.LENGTH_SHORT).show() +// } +// dialog.dismiss() +// } +// .show() +// } +// +// private fun checkPermissionsAndShowImagePicker() { +// if (ContextCompat.checkSelfPermission( +// requireContext(), +// Manifest.permission.READ_EXTERNAL_STORAGE +// ) != PackageManager.PERMISSION_GRANTED +// ) { +// ActivityCompat.requestPermissions( +// requireActivity(), +// arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA), +// Constants.REQUEST_STORAGE_PERMISSION +// ) +// } else { +// showImagePickerOptions() +// } +// } +// +// private fun showImagePickerOptions() { +// val options = arrayOf( +// getString(R.string.take_photo), +// getString(R.string.choose_from_gallery), +// getString(R.string.cancel) +// ) +// +// androidx.appcompat.app.AlertDialog.Builder(requireContext()) +// .setTitle(getString(R.string.select_attachment)) +// .setItems(options) { dialog, which -> +// when (which) { +// 0 -> openCamera() +// 1 -> openGallery() +// } +// dialog.dismiss() +// } +// .show() +// } +// +// private fun openCamera() { +// val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) +// val imageFileName = "IMG_${timeStamp}.jpg" +// val storageDir = requireContext().getExternalFilesDir(null) +// val imageFile = File(storageDir, imageFileName) +// +// tempImageUri = FileProvider.getUriForFile( +// requireContext(), +// "${requireContext().packageName}.fileprovider", +// imageFile +// ) +// +// val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { +// putExtra(MediaStore.EXTRA_OUTPUT, tempImageUri) +// } +// +// takePictureLauncher.launch(intent) +// } +// +// private fun openGallery() { +// val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) +// pickImageLauncher.launch(intent) +// } +// +// private fun handleSelectedImage(uri: Uri) { +// // Get the file from Uri +// val filePathColumn = arrayOf(MediaStore.Images.Media.DATA) +// val cursor = requireContext().contentResolver.query(uri, filePathColumn, null, null, null) +// cursor?.moveToFirst() +// val columnIndex = cursor?.getColumnIndex(filePathColumn[0]) +// val filePath = cursor?.getString(columnIndex ?: 0) +// cursor?.close() +// +// if (filePath != null) { +// viewModel.setSelectedImageFile(File(filePath)) +// Toast.makeText(requireContext(), R.string.image_selected, Toast.LENGTH_SHORT).show() +// } +// } +// +// override fun onRequestPermissionsResult( +// requestCode: Int, +// permissions: Array, +// grantResults: IntArray +// ) { +// super.onRequestPermissionsResult(requestCode, permissions, grantResults) +// if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) { +// if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { +// showImagePickerOptions() +// } else { +// Toast.makeText(requireContext(), R.string.permission_denied, Toast.LENGTH_SHORT).show() +// } +// } +// } +// +// override fun onDestroyView() { +// super.onDestroyView() +// typingHandler.removeCallbacks(stopTypingRunnable) +// _binding = null +// } +//} \ No newline at end of file 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 new file mode 100644 index 0000000..38979d2 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt @@ -0,0 +1,56 @@ +package com.alya.ecommerce_serang.ui.chat + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.repository.ChatRepository +import com.alya.ecommerce_serang.databinding.FragmentChatListBinding +import com.alya.ecommerce_serang.utils.BaseViewModelFactory +import com.alya.ecommerce_serang.utils.SessionManager + +class ChatListFragment : Fragment() { + + private var _binding: FragmentChatListBinding? = null + + private val binding get() = _binding!! + private lateinit var socketService: SocketIOService + private lateinit var sessionManager: SessionManager + private val viewModel: com.alya.ecommerce_serang.ui.chat.ChatViewModel by viewModels { + BaseViewModelFactory { + val apiService = ApiConfig.getApiService(sessionManager) + val chatRepository = ChatRepository(apiService) + ChatViewModel(chatRepository, socketService, sessionManager) + } + } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sessionManager = SessionManager(requireContext()) + + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentChatListBinding.inflate(inflater, container, false) + return _binding!!.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupView() + } + + private fun setupView(){ + binding.btnTrial.setOnClickListener{ + val intent = Intent(requireContext(), ChatActivity::class.java) + startActivity(intent) + } + } +} \ 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 new file mode 100644 index 0000000..1ebaca8 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -0,0 +1,463 @@ +package com.alya.ecommerce_serang.ui.chat + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alya.ecommerce_serang.data.api.response.chat.ChatItem +import com.alya.ecommerce_serang.data.api.response.chat.ChatLine +import com.alya.ecommerce_serang.data.repository.ChatRepository +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.launch +import java.io.File +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val chatRepository: ChatRepository, + private val socketService: SocketIOService, + private val sessionManager: SessionManager +) : ViewModel() { + + private val TAG = "ChatViewModel" + + // UI state using LiveData + private val _state = MutableLiveData(ChatUiState()) + val state: LiveData = _state + + private val _chatRoomId = MutableLiveData(0) + val chatRoomId: LiveData = _chatRoomId + + // Store and product parameters + private var storeId: Int = 0 + private var productId: Int = 0 + private var currentUserId: Int? = null + private var defaultUserId: Int = 0 + + // Product details for display + private var productName: String = "" + private var productPrice: String = "" + private var productImage: String = "" + private var productRating: Float = 0f + private var storeName: String = "" + + // For image attachment + private var selectedImageFile: File? = null + + init { + // Try to get current user ID from the repository + viewModelScope.launch { + when (val result = chatRepository.fetchUserProfile()) { + is Result.Success -> { + currentUserId = result.data?.userId + Log.e(TAG, "User ID: $currentUserId") + + // Move the validation and subsequent logic inside the coroutine + if (currentUserId == null || currentUserId == 0) { + Log.e(TAG, "Error: User ID is not set or invalid") + updateState { it.copy(error = "User authentication error. Please login again.") } + } else { + // Set up socket listeners + setupSocketListeners() + } + } + is Result.Error -> { + Log.e(TAG, "Error fetching user profile: ${result.exception.message}") + updateState { it.copy(error = "User authentication error. Please login again.") } + } + is Result.Loading -> { + // Handle loading state if needed + } + } + } + } + + /** + * Set chat parameters received from activity + */ + fun setChatParameters( + storeId: Int, + productId: Int, + productName: String, + productPrice: String, + productImage: String, + productRating: Float, + storeName: String + ) { + this.storeId = storeId + this.productId = productId + this.productName = productName + this.productPrice = productPrice + this.productImage = productImage + this.productRating = productRating + this.storeName = storeName + + // Update state with product info + updateState { + it.copy( + productName = productName, + productPrice = productPrice, + productImageUrl = productImage, + productRating = productRating, + storeName = storeName + ) + } + + // Connect to socket and load chat history + val existingChatRoomId = _chatRoomId.value ?: 0 + if (existingChatRoomId > 0) { + // If we already have a chat room ID, we can load the chat history + loadChatHistory(existingChatRoomId) + + // And join the Socket.IO room + joinSocketRoom(existingChatRoomId) + } + } + + fun joinSocketRoom(roomId: Int) { + if (roomId <= 0) { + Log.e(TAG, "Cannot join room: Invalid room ID") + return + } + + socketService.joinRoom() + } + + /** + * Sets up listeners for Socket.IO events + */ + private fun setupSocketListeners() { + viewModelScope.launch { + // Listen for connection state changes + socketService.connectionState.collect { connectionState -> + updateState { it.copy(connectionState = connectionState) } + + // Join room when connected + if (connectionState is ConnectionState.Connected) { + socketService.joinRoom() + } + } + } + + viewModelScope.launch { + // Listen for new messages + socketService.newMessages.collect { chatLine -> + chatLine?.let { + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.toMutableList().apply { + add(convertChatLineToUiMessage(it)) + } + updateState { it.copy(messages = updatedMessages) } + + // Update message status if received from others + if (it.senderId != currentUserId) { + updateMessageStatus(it.id, Constants.STATUS_READ) + } + } + } + } + + viewModelScope.launch { + // Listen for typing status updates + socketService.typingStatus.collect { typingStatus -> + typingStatus?.let { + if (typingStatus.roomId == (_chatRoomId.value ?: 0) && typingStatus.userId != currentUserId) { + updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) } + } + } + } + } + } + + /** + * Helper function to update LiveData state + */ + private fun updateState(update: (ChatUiState) -> ChatUiState) { + _state.value?.let { + _state.value = update(it) + } + } + + /** + * Loads chat history + */ + fun loadChatHistory(chatRoomId: Int) { + if (chatRoomId <= 0) { + Log.e(TAG, "Cannot load chat history: Chat room ID is 0") + return + } + + viewModelScope.launch { + updateState { it.copy(isLoading = true) } + + when (val result = chatRepository.getChatHistory(chatRoomId)) { + is Result.Success -> { + val messages = result.data.chat.map { chatLine -> + convertChatLineToUiMessageHistory(chatLine) + } + + updateState { + it.copy( + messages = messages, + isLoading = false, + error = null + ) + } + + Log.d(TAG, "Loaded ${messages.size} messages for chat room $chatRoomId") + + // Update status of unread messages + result.data.chat + .filter { it.senderId != currentUserId && it.status != Constants.STATUS_READ } + .forEach { updateMessageStatus(it.id, Constants.STATUS_READ) } + } + is Result.Error -> { + updateState { + it.copy( + isLoading = false, + error = result.exception.message + ) + } + Log.e(TAG, "Error loading chat history: ${result.exception.message}") + } + is Result.Loading -> { + updateState { it.copy(isLoading = true) } + } + } + } + } + + /** + * Sends a chat message + */ + fun sendMessage(message: String) { + if (message.isBlank()) return + + if (storeId == 0 || productId == 0) { + Log.e(TAG, "Cannot send message: Store ID or Product ID is 0") + updateState { it.copy(error = "Cannot send message. Invalid parameters.") } + return + } + + viewModelScope.launch { + updateState { it.copy(isSending = true) } + + when (val result = chatRepository.sendChatMessage( + storeId = storeId, + message = message, + productId = productId, + imageFile = selectedImageFile + )) { + is Result.Success -> { + // Add new message to the list + val chatLine = result.data.chatLine + val newMessage = convertChatLineToUiMessage(chatLine) + + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.toMutableList().apply { + add(newMessage) + } + + updateState { + it.copy( + messages = updatedMessages, + isSending = false, + hasAttachment = false, + error = null + ) + } + + Log.d(TAG, "Message sent successfully: ${chatLine.id}") + + // Update the chat room ID if it's the first message + // This is the key part - we get the chat room ID from the response + val newChatRoomId = chatLine.chatRoomId + if ((_chatRoomId.value ?: 0) == 0 && newChatRoomId > 0) { + Log.d(TAG, "Chat room created: $newChatRoomId") + _chatRoomId.value = newChatRoomId + + // Now that we have a chat room ID, we can join the Socket.IO room + joinSocketRoom(newChatRoomId) + } + + // Emit the message via Socket.IO for real-time updates + socketService.sendMessage(chatLine) + + // Clear the image attachment + selectedImageFile = null + } + is Result.Error -> { + val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") { + "Failed to send message. Please try again." + } else { + result.exception.message + } + + updateState { + it.copy( + isSending = false, + error = errorMsg + ) + } + Log.e(TAG, "Error sending message: ${result.exception.message}") + } + is Result.Loading -> { + updateState { it.copy(isSending = true) } + } + } + } + } + + /** + * Updates a message status (delivered, read) + */ + fun updateMessageStatus(messageId: Int, status: String) { + viewModelScope.launch { + try { + val result = chatRepository.updateMessageStatus(messageId, status) + + if (result is Result.Success) { + // Update local message status + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.map { message -> + if (message.id == messageId) { + message.copy(status = status) + } else { + message + } + } + updateState { it.copy(messages = updatedMessages) } + + Log.d(TAG, "Message status updated: $messageId -> $status") + } else if (result is Result.Error) { + Log.e(TAG, "Error updating message status: ${result.exception.message}") + } + } catch (e: Exception) { + Log.e(TAG, "Exception updating message status", e) + } + } + } + + /** + * Sets the selected image file for attachment + */ + fun setSelectedImageFile(file: File?) { + selectedImageFile = file + updateState { it.copy(hasAttachment = file != null) } + + Log.d(TAG, "Image attachment ${if (file != null) "selected" else "cleared"}") + } + + /** + * Sends typing status to the other user + */ + fun sendTypingStatus(isTyping: Boolean) { + val roomId = _chatRoomId.value ?: 0 + if (roomId <= 0) return + + socketService.sendTypingStatus(roomId, isTyping) + } + + /** + * Clears any error message in the state + */ + fun clearError() { + updateState { it.copy(error = null) } + } + + /** + * Converts a ChatLine from API to a UI message model + */ + private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage { + // Format the timestamp for display + val formattedTime = try { + val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + inputFormat.timeZone = TimeZone.getTimeZone("UTC") + val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()) + + val date = inputFormat.parse(chatLine.createdAt) + date?.let { outputFormat.format(it) } ?: "" + } catch (e: Exception) { + Log.e(TAG, "Error formatting date: ${chatLine.createdAt}", e) + "" + } + + return ChatUiMessage( + id = chatLine.id, + message = chatLine.message, + attachment = chatLine.attachment ?: "", // Handle null attachment + status = chatLine.status, + time = formattedTime, + isSentByMe = chatLine.senderId == currentUserId + ) + } + + private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage { + // Format the timestamp for display + val formattedTime = try { + val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + inputFormat.timeZone = TimeZone.getTimeZone("UTC") + val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()) + + val date = inputFormat.parse(chatItem.createdAt) + date?.let { outputFormat.format(it) } ?: "" + } catch (e: Exception) { + Log.e(TAG, "Error formatting date: ${chatItem.createdAt}", e) + "" + } + + return ChatUiMessage( + attachment = chatItem.attachment, // Handle null attachment + id = chatItem.id, + message = chatItem.message, + status = chatItem.status, + time = formattedTime, + isSentByMe = chatItem.senderId == currentUserId, + ) + } + + override fun onCleared() { + super.onCleared() + // Disconnect Socket.IO when ViewModel is cleared + socketService.disconnect() + Log.d(TAG, "ViewModel cleared, Socket.IO disconnected") + } +} + +/** + * Data class representing the UI state for the chat screen + */ +data class ChatUiState( + val messages: List = emptyList(), + val isLoading: Boolean = false, + val isSending: Boolean = false, + val hasAttachment: Boolean = false, + val isOtherUserTyping: Boolean = false, + val error: String? = null, + val connectionState: ConnectionState = ConnectionState.Disconnected(), + + // Product info + val productName: String = "", + val productPrice: String = "", + val productImageUrl: String = "", + val productRating: Float = 0f, + val storeName: String = "" +) + +/** + * Data class representing a chat message in the UI + */ +data class ChatUiMessage( + val id: Int, + val message: String, + val attachment: String?, + val status: String, + val time: String, + val isSentByMe: Boolean +) \ No newline at end of file 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 new file mode 100644 index 0000000..1ac378f --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt @@ -0,0 +1,252 @@ +package com.alya.ecommerce_serang.ui.chat + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.alya.ecommerce_serang.BuildConfig +import com.alya.ecommerce_serang.data.api.response.chat.ChatLine +import com.alya.ecommerce_serang.utils.Constants +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONObject +import java.net.URISyntaxException + +class SocketIOService( + private val sessionManager: SessionManager +) { + private val TAG = "SocketIOService" + + // Socket.IO client + private var socket: Socket? = null + + // Connection state + private var isConnected = false + + // StateFlows for internal observing (these are needed for suspend functions in ViewModel) + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected()) + val connectionState: StateFlow = _connectionState + + private val _newMessages = MutableStateFlow(null) + val newMessages: StateFlow = _newMessages + + private val _typingStatus = MutableStateFlow(null) + val typingStatus: StateFlow = _typingStatus + + // LiveData for Activity/Fragment observing + private val _connectionStateLiveData = MutableLiveData(ConnectionState.Disconnected()) + val connectionStateLiveData: LiveData = _connectionStateLiveData + + private val _newMessagesLiveData = MutableLiveData() + val newMessagesLiveData: LiveData = _newMessagesLiveData + + private val _typingStatusLiveData = MutableLiveData() + val typingStatusLiveData: LiveData = _typingStatusLiveData + + /** + * Initializes the Socket.IO client + */ + init { + try { + // Get token from SessionManager + val token = sessionManager.getToken() + + // Set up Socket.IO options with auth token + val options = IO.Options().apply { + forceNew = true + reconnection = true + reconnectionAttempts = 5 + reconnectionDelay = 3000 + + // Add auth information + if (!token.isNullOrEmpty()) { + auth = mapOf("token" to token) + } + } + + // Create Socket.IO client + socket = IO.socket(BuildConfig.BASE_URL, options) + + // Set up event listeners + setupSocketListeners() + + Log.d(TAG, "Socket.IO initialized with token: $token") + } catch (e: URISyntaxException) { + Log.e(TAG, "Error initializing Socket.IO client", e) + _connectionState.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}") + _connectionStateLiveData.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}") + } + } + + /** + * 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(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) + } + } + } + } + + /** + * Connects to the Socket.IO server + */ + fun connect() { + if (isConnected) return + + Log.d(TAG, "Connecting to Socket.IO server...") + _connectionState.value = ConnectionState.Connecting + _connectionStateLiveData.value = ConnectionState.Connecting + socket?.connect() + } + + /** + * Joins a specific chat room + */ + fun joinRoom() { + 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") + } + + /** + * Emits a new message event + */ + fun sendMessage(message: ChatLine) { + if (!isConnected) { + connect() + return + } + + val messageJson = Gson().toJson(message) + socket?.emit(Constants.EVENT_NEW_MESSAGE, messageJson) + Log.d(TAG, "Sent message via Socket.IO: $messageJson") + } + + /** + * Sends typing status update + */ + fun sendTypingStatus(roomId: Int, isTyping: Boolean) { + if (!isConnected) return + + // Get user ID from SessionManager + val userId = sessionManager.getUserId()?.toIntOrNull() + if (userId == null) { + Log.e(TAG, "Cannot send typing status: User ID is null or invalid") + return + } + + val typingData = JSONObject().apply { + put("userId", userId) + put("roomId", roomId) + put("isTyping", isTyping) + } + + socket?.emit(Constants.EVENT_TYPING, typingData) + Log.d(TAG, "Sent typing status: User $userId in room $roomId is typing: $isTyping") + } + + /** + * Disconnects from the Socket.IO server + */ + fun disconnect() { + Log.d(TAG, "Disconnecting from Socket.IO server...") + socket?.disconnect() + isConnected = false + _connectionState.value = ConnectionState.Disconnected("Disconnected by user") + _connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected by user")) + } + + /** + * Returns whether the socket is connected + */ + val isSocketConnected: Boolean + get() = isConnected +} + +/** + * Sealed class representing connection states + */ +sealed class ConnectionState { + object Connecting : ConnectionState() + object Connected : ConnectionState() + data class Disconnected(val reason: String = "") : ConnectionState() + data class Error(val message: String) : ConnectionState() +} + +/** + * Data class for typing status events + */ +data class TypingStatus( + val userId: Int, + val roomId: Int, + val isTyping: Boolean +) \ No newline at end of file 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 c0dd046..c19d02e 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 @@ -6,12 +6,14 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.alya.ecommerce_serang.R import com.alya.ecommerce_serang.data.api.dto.CategoryItem @@ -19,6 +21,7 @@ import com.alya.ecommerce_serang.data.api.dto.ProductsItem import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.repository.ProductRepository import com.alya.ecommerce_serang.databinding.FragmentHomeBinding +import com.alya.ecommerce_serang.ui.notif.NotificationActivity import com.alya.ecommerce_serang.ui.product.DetailProductActivity import com.alya.ecommerce_serang.utils.BaseViewModelFactory import com.alya.ecommerce_serang.utils.HorizontalMarginItemDecoration @@ -47,6 +50,7 @@ class HomeFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sessionManager = SessionManager(requireContext()) + } override fun onCreateView( @@ -60,9 +64,12 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + initUi() setupRecyclerView() observeData() + setupSearchView() + } private fun setupRecyclerView() { @@ -95,6 +102,41 @@ class HomeFragment : Fragment() { } } + private fun setupSearchView() { + binding.searchContainer.search.apply { + // When user clicks the search box, navigate to search fragment + setOnClickListener { + findNavController().navigate( + HomeFragmentDirections.actionHomeFragmentToSearchHomeFragment(null) + ) + } + +// Handle search action if user presses search on keyboard + setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + val query = text.toString().trim() + if (query.isNotEmpty()) { + findNavController().navigate( + HomeFragmentDirections.actionHomeFragmentToSearchHomeFragment(query) + ) + } + return@setOnEditorActionListener true + } + false + } + } + + // Setup cart and notification buttons + binding.searchContainer.btnCart.setOnClickListener { + // Navigate to cart + } + + binding.searchContainer.btnNotification.setOnClickListener { + val intent = Intent(requireContext(), NotificationActivity::class.java) + startActivity(intent) + } + } + private fun observeData() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -109,7 +151,7 @@ class HomeFragment : Fragment() { binding.loading.root.isVisible = false binding.error.root.isVisible = false binding.home.isVisible = true - productAdapter?.updateLimitedProducts(state.products) // Ensure productAdapter is initialized + productAdapter?.updateLimitedProducts(state.products) } is HomeUiState.Error -> { binding.loading.root.isVisible = false @@ -125,18 +167,16 @@ class HomeFragment : Fragment() { } } - viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.categories.collect { categories -> Log.d("Categories", "Updated Categories: $categories") - categories.forEach { Log.d("Category Image", "Category: ${it.name}, Image: ${it.image}") } categoryAdapter?.updateLimitedCategory(categories) } } } } - private fun initUi() { // For LightStatusBar setLightStatusBar() @@ -161,7 +201,6 @@ class HomeFragment : Fragment() { ) } - private fun handleProductClick(product: ProductsItem) { val intent = Intent(requireContext(), DetailProductActivity::class.java) intent.putExtra("PRODUCT_ID", product.id) // Pass product ID @@ -169,7 +208,7 @@ class HomeFragment : Fragment() { } private fun handleCategoryProduct(category: CategoryItem) { - + // Your implementation } override fun onDestroyView() { @@ -179,7 +218,7 @@ class HomeFragment : Fragment() { _binding = null } - private fun showLoading(isLoading: Boolean) { - binding.progressBar.isVisible = isLoading - } +// 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/SearchHistoryAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHistoryAdapter.kt new file mode 100644 index 0000000..a00b3d7 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHistoryAdapter.kt @@ -0,0 +1,56 @@ +package com.alya.ecommerce_serang.ui.home + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.alya.ecommerce_serang.databinding.ItemRecentSearchBinding + +class SearchHistoryAdapter( + private val onItemClick: (String) -> Unit +) : ListAdapter(DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemRecentSearchBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val query = getItem(position) + holder.bind(query) + } + + inner class ViewHolder(private val binding: ItemRecentSearchBinding) : + RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + onItemClick(getItem(position)) + } + } + } + + fun bind(query: String) { + binding.recentSearchText.text = query + } + } + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeFragment.kt new file mode 100644 index 0000000..94003c6 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeFragment.kt @@ -0,0 +1,162 @@ +package com.alya.ecommerce_serang.ui.home + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.repository.ProductRepository +import com.alya.ecommerce_serang.databinding.FragmentSearchHomeBinding +import com.alya.ecommerce_serang.ui.product.DetailProductActivity +import com.alya.ecommerce_serang.utils.BaseViewModelFactory +import com.alya.ecommerce_serang.utils.SessionManager + +class SearchHomeFragment : Fragment() { + private var _binding: FragmentSearchHomeBinding? = null + private val binding get() = _binding!! + private var searchResultsAdapter: SearchResultsAdapter? = null + private lateinit var sessionManager: SessionManager + private val args: SearchHomeFragmentArgs by navArgs() + + private val viewModel: SearchHomeViewModel by viewModels { + BaseViewModelFactory { + val apiService = ApiConfig.getApiService(sessionManager) + val productRepository = ProductRepository(apiService) + SearchHomeViewModel(productRepository) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sessionManager = SessionManager(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSearchHomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUI() + setupSearchResultsRecyclerView() + observeData() + + // Perform search with the query passed from HomeFragment + args.query?.let { query -> + // Wait until layout is done, then set query text + binding.searchView.post { + binding.searchView.setQuery(query, false) // sets "food" as text, doesn't submit + } + + viewModel.searchProducts(query) + } + } + + private fun setupUI() { + // Setup back button + binding.backButton.setOnClickListener { + findNavController().navigateUp() + } + + // Setup search view + binding.searchView.apply { + setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + query?.let { + if (it.isNotEmpty()) { + viewModel.searchProducts(it) + hideKeyboard() + } + } + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + newText?.let { + if (it.isEmpty()) { + // Clear the search results if user clears the input + searchResultsAdapter?.submitList(emptyList()) + binding.noResultsText.isVisible = false + return true + } + + // Optional: do real-time search + if (it.length >= 2) { + viewModel.searchProducts(it) + } + } + return true + } + }) + + // Request focus and show keyboard + if (args.query.isNullOrEmpty()) { + requestFocus() + postDelayed({ + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(findFocus(), InputMethodManager.SHOW_IMPLICIT) + }, 200) + } + } + } + + private fun setupSearchResultsRecyclerView() { + searchResultsAdapter = SearchResultsAdapter { product -> + navigateToProductDetail(product) + } + + binding.searchResultsRecyclerView.apply { + adapter = searchResultsAdapter + layoutManager = GridLayoutManager(requireContext(), 2) + } + } + + private fun observeData() { + viewModel.searchResults.observe(viewLifecycleOwner) { products -> + + searchResultsAdapter?.submitList(products) + binding.noResultsText.isVisible = products.isEmpty() && !viewModel.isSearching.value!! + binding.searchResultsRecyclerView.isVisible = products.isNotEmpty() + } + + viewModel.isSearching.observe(viewLifecycleOwner) { isSearching -> + binding.progressBar.isVisible = isSearching + } + } + + private fun navigateToProductDetail(product: ProductsItem) { + val intent = Intent(requireContext(), DetailProductActivity::class.java) + intent.putExtra("PRODUCT_ID", product.id) + startActivity(intent) + } + + private fun hideKeyboard() { + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + binding.searchView.let { + imm.hideSoftInputFromWindow(it.windowToken, 0) + } + } + + + override fun onDestroyView() { + super.onDestroyView() + searchResultsAdapter = null + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeViewModel.kt new file mode 100644 index 0000000..fec5c4e --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchHomeViewModel.kt @@ -0,0 +1,76 @@ +package com.alya.ecommerce_serang.ui.home + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.data.repository.ProductRepository +import com.alya.ecommerce_serang.data.repository.Result +import kotlinx.coroutines.launch + +class SearchHomeViewModel (private val productRepository: ProductRepository) : ViewModel() { + + private val _searchResults = MutableLiveData>(emptyList()) + val searchResults: LiveData> = _searchResults + + private val _searchHistory = MutableLiveData>(emptyList()) + val searchHistory: LiveData> = _searchHistory + + private val _isSearching = MutableLiveData(false) + val isSearching: LiveData = _isSearching + + private val _isSearchActive = MutableLiveData(false) + val isSearchActive: LiveData = _isSearchActive + + fun searchProducts(query: String) { + Log.d("HomeViewModel", "searchProducts called with query: '$query'") + + if (query.isBlank()) { + Log.d("HomeViewModel", "Query is blank, clearing results") + _searchResults.value = emptyList() + _isSearchActive.value = false + return + } + + _isSearching.value = true + _isSearchActive.value = true + + viewModelScope.launch { + Log.d("HomeViewModel", "Starting search coroutine") + + when (val result = productRepository.searchProducts(query)) { + is Result.Success -> { + Log.d("HomeViewModel", "Search successful, found ${result.data.size} products") + _searchResults.postValue(result.data) + + // Double check the state after assignment + Log.d("HomeViewModel", "Updated searchResults value has ${result.data.size} items") + } + is Result.Error -> { + Log.e("HomeViewModel", "Search failed", result.exception) + _searchResults.postValue(emptyList()) + } + else -> {} + } + _isSearching.postValue(false) + } + } + + fun clearSearch() { + _isSearchActive.value = false + _searchResults.value = emptyList() + _isSearching.value = false + } + + fun loadSearchHistory() { + viewModelScope.launch { + when (val result = productRepository.getSearchHistory()) { + is Result.Success -> _searchHistory.value = result.data + is Result.Error -> Log.e("HomeViewModel", "Failed to load search history", result.exception) + else -> {} + } + } + } +} \ 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 new file mode 100644 index 0000000..7a95fdc --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt @@ -0,0 +1,78 @@ +package com.alya.ecommerce_serang.ui.home + +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.databinding.ItemProductGridBinding +import com.bumptech.glide.Glide + +class SearchResultsAdapter( + private val onItemClick: (ProductsItem) -> Unit +) : ListAdapter(DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemProductGridBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val product = getItem(position) + holder.bind(product) + } + + inner class ViewHolder(private val binding: ItemProductGridBinding) : + RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + onItemClick(getItem(position)) + } + } + } + + fun bind(product: ProductsItem) { + binding.productName.text = product.name + binding.productPrice.text = (product.price) + + // Load image with Glide + Glide.with(binding.root.context) + .load(product.image) + .placeholder(R.drawable.placeholder_image) +// .error(R.drawable.error_image) + .into(binding.productImage) + + // Set store name if available + product.storeId?.toString().let { + binding.storeName.text = it + } + } + } + + override fun submitList(list: List?) { + Log.d("SearchResultsAdapter", "Submitting list with ${list?.size ?: 0} items") + super.submitList(list) + } + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ProductsItem, newItem: ProductsItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ProductsItem, newItem: ProductsItem): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotifViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotifViewModel.kt new file mode 100644 index 0000000..d2040c4 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotifViewModel.kt @@ -0,0 +1,67 @@ +package com.alya.ecommerce_serang.ui.notif + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alya.ecommerce_serang.data.api.dto.UserProfile +import com.alya.ecommerce_serang.data.repository.Result +import com.alya.ecommerce_serang.data.repository.UserRepository +import com.alya.ecommerce_serang.utils.SessionManager +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NotifViewModel @Inject constructor( + private val notificationBuilder: NotificationCompat.Builder, + private val notificationManager: NotificationManagerCompat, + @ApplicationContext private val context: Context, + private val userRepository: UserRepository, + private val webSocketManager: WebSocketManager, + private val sessionManager: SessionManager + +) : ViewModel() { + + private val _userProfile = MutableStateFlow>(Result.Loading) + val userProfile: StateFlow> = _userProfile.asStateFlow() + + init { + fetchUserProfile() + } + + // Fetch user profile to get necessary data + fun fetchUserProfile() { + viewModelScope.launch { + _userProfile.value = Result.Loading + val result = userRepository.fetchUserProfile() + _userProfile.value = result + + // If successful, save the user ID for WebSocket use + if (result is Result.Success && result.data != null) { + sessionManager.saveUserId(result.data.userId.toString()) + } + } + } + + // Start WebSocket connection + fun startWebSocketConnection() { + webSocketManager.startWebSocketConnection() + } + + // Stop WebSocket connection + fun stopWebSocketConnection() { + webSocketManager.stopWebSocketConnection() + } + + // Call when ViewModel is cleared (e.g., app closing) + override fun onCleared() { + super.onCleared() + // No need to stop here - the service will manage its own lifecycle + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotificationActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotificationActivity.kt new file mode 100644 index 0000000..1e22402 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/NotificationActivity.kt @@ -0,0 +1,118 @@ +package com.alya.ecommerce_serang.ui.notif + +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.alya.ecommerce_serang.data.repository.Result +import com.alya.ecommerce_serang.databinding.ActivityNotificationBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint // Required for Hilt +class NotificationActivity : AppCompatActivity() { + + private lateinit var binding: ActivityNotificationBinding + private val viewModel: NotifViewModel by viewModels() + + // Permission request code + private val NOTIFICATION_PERMISSION_CODE = 100 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.userProfile.collect { result -> + when (result) { + is com.alya.ecommerce_serang.data.repository.Result.Success -> { + // User profile loaded successfully + // Potentially do something with user profile + } + is com.alya.ecommerce_serang.data.repository.Result.Error -> { + // Handle error - show message, etc. + Toast.makeText(this@NotificationActivity, + "Failed to load profile", + Toast.LENGTH_SHORT + ).show() + } + Result.Loading -> { + // Show loading indicator if needed + } + } + } + } + } + + // Start WebSocket connection +// viewModel.startWebSocketConnection() + + binding = ActivityNotificationBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Check and request notification permission for Android 13+ + requestNotificationPermissionIfNeeded() + + // Set up button click listeners +// setupButtonListeners() + + + } + +// private fun setupButtonListeners() { +// binding.simpleNotification.setOnClickListener { +// viewModel.showSimpleNotification() +// } +// +// binding.updateNotification.setOnClickListener { +// viewModel.updateSimpleNotification() +// } +// +// binding.cancelNotification.setOnClickListener { +// viewModel.cancelSimpleNotification() +// } +// } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_CODE + ) + } + } + } + + // Handle permission request result + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == NOTIFICATION_PERMISSION_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted + Toast.makeText(this, "Notification permission granted", Toast.LENGTH_SHORT).show() + } else { + // Permission denied + Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show() + // You might want to show a dialog explaining why notifications are important + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/SimpleWebSocketService.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/SimpleWebSocketService.kt new file mode 100644 index 0000000..49913e7 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/SimpleWebSocketService.kt @@ -0,0 +1,162 @@ +package com.alya.ecommerce_serang.ui.notif + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.alya.ecommerce_serang.BuildConfig +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.utils.SessionManager +import dagger.hilt.android.AndroidEntryPoint +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.cancel +import kotlinx.coroutines.launch +import org.json.JSONObject +import javax.inject.Inject + +@AndroidEntryPoint +class SimpleWebSocketService : Service() { + + companion object { + private const val TAG = "SocketIOService" + private const val NOTIFICATION_CHANNEL_ID = "websocket_service_channel" + private const val FOREGROUND_SERVICE_ID = 1001 + } + + @Inject + lateinit var notificationBuilder: NotificationCompat.Builder + + @Inject + lateinit var notificationManager: NotificationManagerCompat + + @Inject + lateinit var sessionManager: SessionManager + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var socket: Socket? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created") + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) +// .setSmallIcon(R.drawable.ic_notification) // Replace with your app's icon + .setPriority(NotificationCompat.PRIORITY_MIN) // Set the lowest priority + .setSound(null) // No sound + .setVibrate(longArrayOf(0L)) // No vibration + .setContentText("") // Empty text or minimal text + .setOngoing(true) // Keeps it ongoing + .build() + + startForeground(1, notification) + + + startForeground(FOREGROUND_SERVICE_ID, notification) + serviceScope.launch { initSocket() } + return START_STICKY + } + + private suspend fun initSocket() { + val userId = sessionManager.getUserId() ?: run { + Log.e(TAG, "User ID not available") + stopSelf() + return + } + + val options = IO.Options().apply { + forceNew = true + reconnection = true + reconnectionDelay = 1000 // Retry every 1 second if disconnected + reconnectionAttempts = Int.MAX_VALUE + } + + socket = IO.socket(BuildConfig.BASE_URL, options) + socket?.apply { + on(Socket.EVENT_CONNECT) { + Log.d(TAG, "Socket.IO connected") + emit("joinRoom", userId) + } + + on("notification") { args -> + if (args.isNotEmpty()) { + val data = args[0] as? JSONObject + val title = data?.optString("title", "New Notification") ?: "Notification" + val message = data?.optString("message", "") ?: "" + showNotification(title, message) + } + } + + on(Socket.EVENT_DISCONNECT) { + Log.d(TAG, "Socket.IO disconnected") + } + + on(Socket.EVENT_CONNECT_ERROR) { args -> + Log.e(TAG, "Socket.IO connection error: ${args.firstOrNull()}") + } + + connect() + } + } + + private fun showNotification(title: String, message: String) { + val notification = notificationBuilder + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.baseline_alarm_24) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + notificationManager.notify(System.currentTimeMillis().toInt(), notification) + } else { + Log.e(TAG, "Notification permission not granted") + } + } else { + notificationManager.notify(System.currentTimeMillis().toInt(), notification) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "WebSocket Service Channel", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Channel for WebSocket Service" + } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + override fun onDestroy() { + Log.d(TAG, "Service destroyed") + socket?.disconnect() + socket?.off() + serviceScope.cancel() + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/WebSocketManager.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/WebSocketManager.kt new file mode 100644 index 0000000..f80f46b --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/WebSocketManager.kt @@ -0,0 +1,51 @@ +package com.alya.ecommerce_serang.ui.notif + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import com.alya.ecommerce_serang.utils.SessionManager +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WebSocketManager @Inject constructor( + @ApplicationContext private val context: Context, + private val sessionManager: SessionManager +) { + companion object { + private const val TAG = "WebSocketManager" + + } + + fun startWebSocketConnection() { + try { + // Only start if we have a token + if (sessionManager.getToken().isNullOrEmpty()) { + Log.d(TAG, "No auth token available, not starting WebSocket service") + return + } + + Log.d(TAG, "Starting WebSocket service") + val serviceIntent = Intent(context, SimpleWebSocketService::class.java) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } catch (e: Exception) { + Log.e(TAG, "Error starting WebSocket service: ${e.message}") + } + } + + fun stopWebSocketConnection() { + try { + Log.d(TAG, "Stopping WebSocket service") + context.stopService(Intent(context, SimpleWebSocketService::class.java)) + } catch (e: Exception) { + Log.e(TAG, "Error stopping WebSocket service: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt index 257e479..8de79a8 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt @@ -9,8 +9,12 @@ import android.widget.Button import android.widget.ImageButton import android.widget.TextView 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 androidx.recyclerview.widget.LinearLayoutManager import com.alya.ecommerce_serang.BuildConfig.BASE_URL import com.alya.ecommerce_serang.R @@ -24,6 +28,7 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiService import com.alya.ecommerce_serang.data.repository.ProductRepository import com.alya.ecommerce_serang.data.repository.Result import com.alya.ecommerce_serang.databinding.ActivityDetailProductBinding +import com.alya.ecommerce_serang.ui.chat.ChatActivity import com.alya.ecommerce_serang.ui.home.HorizontalProductAdapter import com.alya.ecommerce_serang.ui.order.CheckoutActivity import com.alya.ecommerce_serang.utils.BaseViewModelFactory @@ -41,7 +46,6 @@ class DetailProductActivity : AppCompatActivity() { private var reviewsAdapter: ReviewsAdapter? = null private var currentQuantity = 1 - private val viewModel: ProductUserViewModel by viewModels { BaseViewModelFactory { val apiService = ApiConfig.getApiService(sessionManager) @@ -57,6 +61,22 @@ class DetailProductActivity : AppCompatActivity() { sessionManager = SessionManager(this) apiService = ApiConfig.getApiService(sessionManager) + 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 + } + setupUI() setupObservers() loadData() @@ -199,6 +219,9 @@ class DetailProductActivity : AppCompatActivity() { binding.tvDescription.text = product.description + binding.btnChat.setOnClickListener{ + navigateToChat() + } val fullImageUrl = when (val img = product.image) { is String -> { @@ -362,8 +385,30 @@ class DetailProductActivity : AppCompatActivity() { ) } + private fun navigateToChat(){ + val productDetail = viewModel.productDetail.value ?: return + val storeDetail = viewModel.storeDetail.value + + if (storeDetail !is Result.Success || storeDetail.data == null) { + Toast.makeText(this, "Store information not available", Toast.LENGTH_SHORT).show() + return + } + ChatActivity.createIntent( + context = this, + storeId = productDetail.storeId, + productId = productDetail.productId, + productName = productDetail.productName, + productPrice = productDetail.price, + productImage = productDetail.image, + productRating = productDetail.rating, + storeName = storeDetail.data.storeName, + chatRoomId = 0 + ) + + } + companion object { - const val EXTRA_PRODUCT_ID = "extra_product_id" + private const val EXTRA_PRODUCT_ID = "extra_product_id" fun start(context: Context, productId: Int) { val intent = Intent(context, DetailProductActivity::class.java) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/ProfileFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/ProfileFragment.kt index b527fd9..d1921f9 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/ProfileFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/ProfileFragment.kt @@ -39,7 +39,6 @@ class ProfileFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sessionManager = SessionManager(requireContext()) - } override fun onCreateView( diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt index 08efbbe..af8a8c4 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt @@ -11,7 +11,7 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.api.retrofit.ApiService import com.alya.ecommerce_serang.data.repository.MyStoreRepository import com.alya.ecommerce_serang.databinding.ActivityMyStoreBinding -import com.alya.ecommerce_serang.ui.chat.ChatFragment +import com.alya.ecommerce_serang.ui.chat.ChatListFragment import com.alya.ecommerce_serang.ui.profile.mystore.balance.BalanceActivity import com.alya.ecommerce_serang.ui.profile.mystore.product.ProductActivity import com.alya.ecommerce_serang.ui.profile.mystore.profile.DetailStoreProfileActivity @@ -124,7 +124,7 @@ class MyStoreActivity : AppCompatActivity() { binding.layoutInbox.setOnClickListener { supportFragmentManager.beginTransaction() - .replace(android.R.id.content, ChatFragment()) + .replace(android.R.id.content, ChatListFragment()) .addToBackStack(null) .commit() } diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt new file mode 100644 index 0000000..02dcb5e --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt @@ -0,0 +1,40 @@ +package com.alya.ecommerce_serang.utils + +object Constants { + // API Endpoints + const val ENDPOINT_SEND_CHAT = "/sendchat" + const val ENDPOINT_UPDATE_CHAT_STATUS = "/chatstatus" + const val ENDPOINT_GET_CHAT_DETAIL = "/chatdetail" + + // Shared Preferences + const val PREF_NAME = "app_preferences" + const val KEY_USER_ID = "user_id" + const val KEY_TOKEN = "token" + + // Intent extras + const val EXTRA_CHAT_ROOM_ID = "chat_room_id" + const val EXTRA_STORE_ID = "store_id" + const val EXTRA_PRODUCT_ID = "product_id" + const val EXTRA_STORE_NAME = "store_name" + const val EXTRA_PRODUCT_NAME = "product_name" + const val EXTRA_PRODUCT_PRICE = "product_price" + const val EXTRA_PRODUCT_IMAGE = "product_image" + const val EXTRA_PRODUCT_RATING = "product_rating" + + // Request codes + const val REQUEST_IMAGE_PICK = 1001 + const val REQUEST_CAMERA = 1002 + const val REQUEST_STORAGE_PERMISSION = 1003 + + // Socket.IO events + const val EVENT_JOIN_ROOM = "joinRoom" + const val EVENT_NEW_MESSAGE = "new_message" + const val EVENT_MESSAGE_DELIVERED = "message_delivered" + const val EVENT_MESSAGE_READ = "message_read" + const val EVENT_TYPING = "typing" + + // Message status + const val STATUS_SENT = "sent" + const val STATUS_DELIVERED = "delivered" + const val STATUS_READ = "read" +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/SessionManager.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/SessionManager.kt index 6feaa5c..d3fd47b 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/SessionManager.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/SessionManager.kt @@ -3,6 +3,7 @@ package com.alya.ecommerce_serang.utils import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit class SessionManager(context: Context) { private var sharedPreferences: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -10,23 +11,58 @@ class SessionManager(context: Context) { companion object { private const val PREFS_NAME = "app_prefs" private const val USER_TOKEN = "user_token" + private const val USER_ID = "user_id" // New constant for storing user ID + } fun saveToken(token: String) { - val editor = sharedPreferences.edit() - editor.putString(USER_TOKEN, token) - editor.apply() + sharedPreferences.edit() { + putString(USER_TOKEN, token) + } + Log.d("SessionManager", "Saved token: $token") } - fun getToken(): String? { - val token = sharedPreferences.getString(USER_TOKEN, null) + fun getToken(): String { + val token = sharedPreferences.getString(USER_TOKEN, "") ?: "" Log.d("SessionManager", "Retrieved token: $token") return token } + fun saveUserId(userId: String) { + sharedPreferences.edit() { + putString(USER_ID, userId) + } + Log.d("SessionManager", "Saved user ID: $userId") + } + + fun getUserId(): String { + val userId = sharedPreferences.getString(USER_ID, "") ?: "" + Log.d("SessionManager", "Retrieved user ID: $userId") + return userId + } + + fun isLoggedIn(): Boolean { + return getToken().isNotEmpty() + } + + fun clearUserId() { + sharedPreferences.edit() { + remove(USER_ID) + } + } + fun clearToken() { - val editor = sharedPreferences.edit() - editor.remove(USER_TOKEN) - editor.apply() + sharedPreferences.edit() { + remove(USER_TOKEN) + } + } + + + + //clear data when log out + fun clearAll() { + sharedPreferences.edit() { + clear() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/ChatViewModel.kt deleted file mode 100644 index 394a67a..0000000 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/ChatViewModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.alya.ecommerce_serang.utils.viewmodel - -import androidx.lifecycle.ViewModel - -class ChatViewModel : ViewModel() { - // TODO: Implement the ViewModel -} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/HomeViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/HomeViewModel.kt index 0e321ba..0f0437b 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/viewmodel/HomeViewModel.kt @@ -52,6 +52,8 @@ class HomeViewModel ( loadProducts() loadCategories() } + + } sealed class HomeUiState { diff --git a/app/src/main/res/color/bottom_nav_icon_color.xml b/app/src/main/res/color/bottom_nav_icon_color.xml new file mode 100644 index 0000000..1da8b16 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_icon_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/bottom_nav_text_color.xml b/app/src/main/res/color/bottom_nav_text_color.xml new file mode 100644 index 0000000..481be1a --- /dev/null +++ b/app/src/main/res/color/bottom_nav_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_alarm_24.xml b/app/src/main/res/drawable/baseline_alarm_24.xml new file mode 100644 index 0000000..59acdcb --- /dev/null +++ b/app/src/main/res/drawable/baseline_alarm_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_attach_file_24.xml b/app/src/main/res/drawable/baseline_attach_file_24.xml new file mode 100644 index 0000000..fe3f21d --- /dev/null +++ b/app/src/main/res/drawable/baseline_attach_file_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_edit_text_background.xml b/app/src/main/res/drawable/bg_edit_text_background.xml new file mode 100644 index 0000000..0cfc787 --- /dev/null +++ b/app/src/main/res/drawable/bg_edit_text_background.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_message_received.xml b/app/src/main/res/drawable/bg_message_received.xml new file mode 100644 index 0000000..128f72d --- /dev/null +++ b/app/src/main/res/drawable/bg_message_received.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_message_sent.xml b/app/src/main/res/drawable/bg_message_sent.xml new file mode 100644 index 0000000..f0f1d90 --- /dev/null +++ b/app/src/main/res/drawable/bg_message_sent.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_nav_background.xml b/app/src/main/res/drawable/bottom_nav_background.xml new file mode 100644 index 0000000..68362a6 --- /dev/null +++ b/app/src/main/res/drawable/bottom_nav_background.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/check_double_24.xml b/app/src/main/res/drawable/check_double_24.xml new file mode 100644 index 0000000..06b7aff --- /dev/null +++ b/app/src/main/res/drawable/check_double_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/check_double_read_24.xml b/app/src/main/res/drawable/check_double_read_24.xml new file mode 100644 index 0000000..33b9cbc --- /dev/null +++ b/app/src/main/res/drawable/check_double_read_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/check_single_24.xml b/app/src/main/res/drawable/check_single_24.xml new file mode 100644 index 0000000..c3d38c5 --- /dev/null +++ b/app/src/main/res/drawable/check_single_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_calendar_today_24.xml b/app/src/main/res/drawable/outline_calendar_today_24.xml new file mode 100644 index 0000000..5ca556c --- /dev/null +++ b/app/src/main/res/drawable/outline_calendar_today_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml new file mode 100644 index 0000000..f6e2b69 --- /dev/null +++ b/app/src/main/res/layout/activity_chat.xml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_detail_product.xml b/app/src/main/res/layout/activity_detail_product.xml index f9182d4..65458ad 100644 --- a/app/src/main/res/layout/activity_detail_product.xml +++ b/app/src/main/res/layout/activity_detail_product.xml @@ -5,7 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/white" + android:theme="@style/Theme.Ecommerce_serang" tools:context=".ui.product.DetailProductActivity"> @@ -147,7 +147,8 @@ android:id="@+id/recyclerViewReviews" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" + android:layout_marginTop="4dp" + android:layout_marginBottom="8dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:itemCount="1" tools:listitem="@layout/item_review" /> @@ -392,6 +393,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" + android:layout_marginBottom="8dp" android:orientation="horizontal" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:itemCount="3" @@ -419,7 +421,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" - android:backgroundTint="@color/white" app:contentInsetStart="0dp"> - + app:itemIconSize="32dp" + app:itemPaddingBottom="4dp" + app:itemTextAppearanceActive="@style/BottomNavigationTextStyle" + app:itemTextAppearanceInactive="@style/BottomNavigationTextStyle" + android:elevation="8dp" + app:itemIconTint="@color/bottom_nav_icon_color" + app:itemTextColor="@color/bottom_nav_text_color" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_notification.xml b/app/src/main/res/layout/activity_notification.xml new file mode 100644 index 0000000..e821fd0 --- /dev/null +++ b/app/src/main/res/layout/activity_notification.xml @@ -0,0 +1,29 @@ + + + +