Merge branch 'master' into gracia

This commit is contained in:
Gracia Hotmauli
2025-05-12 13:30:32 +07:00
committed by GitHub
80 changed files with 5020 additions and 170 deletions

View File

@ -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
}

View File

@ -11,8 +11,12 @@
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".app.App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
@ -34,6 +38,22 @@
<activity
android:name=".ui.profile.mystore.sells.order.DetailOrderActivity"
android:exported="false" />
<activity
android:name=".ui.chat.ChatActivity"
android:exported="false" />
<!-- <provider -->
<!-- android:name="androidx.startup.InitializationProvider" -->
<!-- android:authorities="${applicationId}.androidx-startup" -->
<!-- tools:node="remove" /> -->
<service
android:name=".ui.notif.SimpleWebSocketService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".ui.notif.NotificationActivity"
android:exported="false" />
<activity
android:name=".ui.order.detail.AddEvidencePaymentActivity"
android:exported="false" />

View File

@ -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)
// }
// }
}

View File

@ -0,0 +1,5 @@
package com.alya.ecommerce_serang.data.api.dto
class ChatRequest {
}

View File

@ -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
)

View File

@ -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
)

View File

@ -0,0 +1,6 @@
package com.alya.ecommerce_serang.data.api.dto
data class UpdateChatRequest (
val id: Int,
val status: String
)

View File

@ -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<ChatItem>,
@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
)

View File

@ -0,0 +1,42 @@
package com.alya.ecommerce_serang.data.api.response.chat
import com.google.gson.annotations.SerializedName
data class ChatListResponse(
@field:SerializedName("chat")
val chat: List<ChatItemList>,
@field:SerializedName("message")
val message: String
)
data class ChatItemList(
@field:SerializedName("store_id")
val storeId: Int,
@field:SerializedName("user_id")
val userId: Int,
@field:SerializedName("user_image")
val userImage: String? = null,
@field:SerializedName("user_name")
val userName: String,
@field:SerializedName("chat_room_id")
val chatRoomId: Int,
@field:SerializedName("latest_message_time")
val latestMessageTime: String,
@field:SerializedName("store_name")
val storeName: String,
@field:SerializedName("message")
val message: String,
@field:SerializedName("store_image")
val storeImage: String? = null
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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<DataItem>
)
data class DataItem(
@field:SerializedName("search_query")
val searchQuery: String
)

View File

@ -1,5 +1,6 @@
package com.alya.ecommerce_serang.data.api.retrofit
import com.alya.ecommerce_serang.data.api.dto.AddEvidenceRequest
import com.alya.ecommerce_serang.data.api.dto.CartItem
import com.alya.ecommerce_serang.data.api.dto.CompletedOrderRequest
@ -10,20 +11,19 @@ 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 okhttp3.MultipartBody
import okhttp3.RequestBody
import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
import com.alya.ecommerce_serang.data.api.response.auth.OtpResponse
import com.alya.ecommerce_serang.data.api.response.auth.RegisterResponse
import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
import com.alya.ecommerce_serang.data.api.response.chat.ChatListResponse
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.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.order.AddEvidenceResponse
import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse
import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse
import com.alya.ecommerce_serang.data.api.response.customer.order.CourierCostResponse
import com.alya.ecommerce_serang.data.api.response.customer.order.CreateOrderResponse
import com.alya.ecommerce_serang.data.api.response.customer.order.ListCityResponse
@ -39,8 +39,17 @@ import com.alya.ecommerce_serang.data.api.response.customer.product.StoreRespons
import com.alya.ecommerce_serang.data.api.response.customer.profile.AddressResponse
import com.alya.ecommerce_serang.data.api.response.customer.profile.CreateAddressResponse
import com.alya.ecommerce_serang.data.api.response.customer.profile.ProfileResponse
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
import com.alya.ecommerce_serang.data.api.response.product.CreateSearchResponse
import com.alya.ecommerce_serang.data.api.response.product.SearchHistoryResponse
import com.alya.ecommerce_serang.data.api.response.store.product.CreateProductResponse
import com.alya.ecommerce_serang.data.api.response.store.product.DeleteProductResponse
import com.alya.ecommerce_serang.data.api.response.store.product.UpdateProductResponse
import com.alya.ecommerce_serang.data.api.response.store.product.ViewStoreProductsResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.Body
@ -226,4 +235,34 @@ interface ApiService {
@Part complaintimg: MultipartBody.Part
): Response<ComplaintResponse>
@POST("search")
suspend fun saveSearchQuery(
@Body searchRequest: SearchRequest
): Response<CreateSearchResponse>
@GET("search")
suspend fun getSearchHistory(): Response<SearchHistoryResponse>
@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<SendChatResponse>
@PUT("chatstatus")
suspend fun updateChatStatus(
@Body request: UpdateChatRequest
): Response<UpdateChatResponse>
@GET("chat/{chatRoomId}")
suspend fun getChatDetail(
@Path("chatRoomId") chatRoomId: Int
): Response<ChatHistoryResponse>
@GET("chat")
suspend fun getChatList(
): Response<ChatListResponse>
}

View File

@ -0,0 +1,149 @@
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.ChatItemList
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.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import javax.inject.Inject
class ChatRepository @Inject constructor(
private val apiService: ApiService
) {
private val TAG = "ChatRepository"
suspend fun fetchUserProfile(): Result<UserProfile?> {
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? = null,
imageFile: File? = null,
chatRoomId: Int? = null // Not used in the actual API call but kept for compatibility
): Result<SendChatResponse> {
return try {
// Create multipart request parts
val storeIdPart = storeId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val messagePart = message.toRequestBody("text/plain".toMediaTypeOrNull())
// Add product ID part if provided
val productIdPart = if (productId != null && productId > 0) {
productId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
} else {
null
}
// Create image part if file is provided
val imagePart = if (imageFile != null && imageFile.exists()) {
val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData("chatimg", imageFile.name, requestFile)
} else {
null
}
// Debug log the request parameters
Log.d("ChatRepository", "Sending chat with: storeId=$storeId, productId=$productId, " +
"message length=${message.length}, hasImage=${imageFile != null}")
// Make API call using your actual endpoint and parameter names
val response = apiService.sendChatLine(
storeId = storeIdPart,
message = messagePart,
productId = productIdPart,
chatimg = imagePart
)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.Success(body)
} else {
Result.Error(Exception("Empty response body"))
}
} else {
val errorBody = response.errorBody()?.string() ?: "{}"
Log.e("ChatRepository", "API Error: ${response.code()} - $errorBody")
Result.Error(Exception("API Error: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
Log.e("ChatRepository", "Exception sending message", e)
Result.Error(e)
}
}
suspend fun updateMessageStatus(
messageId: Int,
status: String
): Result<UpdateChatResponse> {
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<ChatHistoryResponse> {
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)
}
}
suspend fun getListChat(): Result<List<ChatItemList>> {
return try {
val response = apiService.getChatList()
if (response.isSuccessful){
val chat = response.body()?.chat ?: emptyList()
Result.Success(chat)
} else {
Result.Error(Exception("Failed to fetch categories. Code: ${response.code()}"))
}
} catch (e: Exception){
Result.Error(e)
}
}
}

View File

@ -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<List<ProductsItem>> =
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<Search?> =
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<List<String>> =
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<String, Any?>) : UpdateProductResponse {
// Build the request with the updated fields
val response = apiService.updateProduct(productId, updatedProduct)

View File

@ -56,6 +56,102 @@ class UserRepository(private val apiService: ApiService) {
}
}
// suspend fun sendChatMessage(
// storeId: Int,
// message: String,
// productId: Int,
// imageFile: File? = null
// ): Result<SendChatResponse> {
// 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<UpdateChatResponse> {
// 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<ChatHistoryResponse> {
// 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)
// }
// }
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<out String>,
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()
}
}
}

View File

@ -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()

View File

@ -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()
}
}

View File

@ -0,0 +1,520 @@
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()}'")
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) ?: ""
val chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0)
// 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()
// If opened from ChatListFragment with a valid chatRoomId
if (chatRoomId > 0) {
// Directly set the chatRoomId and load chat history
viewModel._chatRoomId.value = chatRoomId
}
}
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
if (!state.productName.isNullOrEmpty()) {
binding.tvProductName.text = state.productName
binding.tvProductPrice.text = state.productPrice
binding.ratingBar.rating = state.productRating
binding.tvRating.text = state.productRating.toString()
binding.tvSellerName.text = state.storeName
// Load product image
if (!state.productImageUrl.isNullOrEmpty()) {
Glide.with(this@ChatActivity)
.load(BASE_URL + state.productImageUrl)
.centerCrop()
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(binding.imgProduct)
}
// Make sure the product section is visible
binding.productContainer.visibility = View.VISIBLE
} else {
// Hide the product section if info is missing
binding.productContainer.visibility = View.GONE
}
// 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) {
try {
Log.d(TAG, "Processing selected image: $uri")
// First try the direct approach to get the file path
var filePath: String? = null
// For newer Android versions, we need to handle content URIs properly
if (uri.scheme == "content") {
val cursor = contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndex(MediaStore.Images.Media.DATA)
if (columnIndex != -1) {
filePath = it.getString(columnIndex)
Log.d(TAG, "Found file path from cursor: $filePath")
}
}
}
// If we couldn't get the path directly, create a copy in our cache directory
if (filePath == null) {
contentResolver.openInputStream(uri)?.use { inputStream ->
val fileName = "img_${System.currentTimeMillis()}.jpg"
val outputFile = File(cacheDir, fileName)
outputFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
filePath = outputFile.absolutePath
Log.d(TAG, "Created temp file from input stream: $filePath")
}
}
} else if (uri.scheme == "file") {
// Direct file URI
filePath = uri.path
Log.d(TAG, "Got file path directly from URI: $filePath")
}
// Process the file path
if (filePath != null) {
val file = File(filePath)
if (file.exists()) {
// Check file size (limit to 5MB)
if (file.length() > 5 * 1024 * 1024) {
Toast.makeText(this, "Image too large (max 5MB), please select a smaller image", Toast.LENGTH_SHORT).show()
return
}
// Set the file to the ViewModel
viewModel.setSelectedImageFile(file)
Toast.makeText(this, R.string.image_selected, Toast.LENGTH_SHORT).show()
Log.d(TAG, "Successfully set image file: ${file.absolutePath}, size: ${file.length()} bytes")
} else {
Log.e(TAG, "File does not exist: $filePath")
Toast.makeText(this, "Could not access the selected image", Toast.LENGTH_SHORT).show()
}
} else {
Log.e(TAG, "Could not get file path from URI: $uri")
Toast.makeText(this, "Could not process the selected image", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e(TAG, "Error handling selected image", e)
Toast.makeText(this, "Error processing image: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
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 = 0,
productName: String? = null,
productPrice: String = "",
productImage: String? = null,
productRating: String? = null,
storeName: String? = null,
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)
// Convert productRating string to float if provided
if (productRating != null) {
try {
putExtra(Constants.EXTRA_PRODUCT_RATING, productRating.toFloat())
} catch (e: NumberFormatException) {
putExtra(Constants.EXTRA_PRODUCT_RATING, 0f)
}
} else {
putExtra(Constants.EXTRA_PRODUCT_RATING, 0f)
}
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)
// }
// }
// }

View File

@ -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<ChatUiMessage, RecyclerView.ViewHolder>(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<ChatUiMessage>() {
override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
return oldItem == newItem
}
}

View File

@ -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)
}
}
//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<out String>,
// 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
// }
//}

View File

@ -0,0 +1,69 @@
package com.alya.ecommerce_serang.ui.chat
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
import com.alya.ecommerce_serang.R
import com.alya.ecommerce_serang.data.api.response.chat.ChatItemList
import com.alya.ecommerce_serang.databinding.ItemChatBinding
import com.bumptech.glide.Glide
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class ChatListAdapter(
private val chatList: List<ChatItemList>,
private val onClick: (ChatItemList) -> Unit
) : RecyclerView.Adapter<ChatListAdapter.ChatViewHolder>() {
inner class ChatViewHolder(private val binding: ItemChatBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(chat: ChatItemList) {
binding.txtStoreName.text = chat.storeName
binding.txtMessage.text = chat.message
binding.txtTime.text = formatTime(chat.latestMessageTime)
// Process image URL properly
val imageUrl = chat.storeImage?.let {
if (it.startsWith("/")) BASE_URL + it else it
}
Glide.with(binding.imgStore.context)
.load(imageUrl)
.placeholder(R.drawable.ic_person)
.error(R.drawable.placeholder_image)
.into(binding.imgStore)
// Handle click event
binding.root.setOnClickListener {
onClick(chat)
}
}
private fun formatTime(isoTime: String): String {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
val date = inputFormat.parse(isoTime)
val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
outputFormat.format(date ?: Date())
} catch (e: Exception) {
""
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
val binding = ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ChatViewHolder(binding)
}
override fun getItemCount(): Int = chatList.size
override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
holder.bind(chatList[position])
}
}

View File

@ -0,0 +1,88 @@
package com.alya.ecommerce_serang.ui.chat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
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.data.repository.Result
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())
socketService = SocketIOService(sessionManager)
}
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)
viewModel.getChatList()
observeChatList()
}
private fun observeChatList() {
viewModel.chatList.observe(viewLifecycleOwner) { result ->
when (result) {
is Result.Success -> {
val adapter = ChatListAdapter(result.data) { chatItem ->
// Use the ChatActivity.createIntent factory method for proper navigation
ChatActivity.createIntent(
context = requireActivity(),
storeId = chatItem.storeId,
productId = 0, // Default value since we don't have it in ChatListItem
productName = null, // Null is acceptable as per ChatActivity
productPrice = "",
productImage = null,
productRating = null,
storeName = chatItem.storeName,
chatRoomId = chatItem.chatRoomId
)
}
binding.chatListRecyclerView.adapter = adapter
}
is Result.Error -> {
Toast.makeText(requireContext(), "Failed to load chats", Toast.LENGTH_SHORT).show()
}
Result.Loading -> {
// Optional: show progress bar
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,509 @@
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.ChatItemList
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<ChatUiState> = _state
val _chatRoomId = MutableLiveData<Int>(0)
val chatRoomId: LiveData<Int> = _chatRoomId
private val _chatList = MutableLiveData<Result<List<ChatItemList>>>()
val chatList: LiveData<Result<List<ChatItemList>>> = _chatList
// 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? = 0,
productName: String? = null,
productPrice: String? = null,
productImage: String? = null,
productRating: Float? = 0f,
storeName: String
) {
this.storeId = storeId
this.productId = productId!!
this.productName = productName.toString()
this.productPrice = productPrice.toString()
this.productImage = productImage.toString()
this.productRating = productRating!!
this.storeName = storeName
// Update state with product info
updateState {
it.copy(
productName = productName.toString(),
productPrice = productPrice.toString(),
productImageUrl = productImage.toString(),
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() && selectedImageFile == null) {
Log.e(TAG, "Cannot send message: Both message and image are empty")
return
}
// Check if we have the necessary parameters
if (storeId <= 0) {
Log.e(TAG, "Cannot send message: Store ID is invalid")
updateState { it.copy(error = "Cannot send message. Invalid store ID.") }
return
}
// Get the existing chatRoomId (not used in API but may be needed for Socket.IO)
val existingChatRoomId = _chatRoomId.value ?: 0
// Log debug information
Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId")
Log.d(TAG, "Current user ID: $currentUserId")
Log.d(TAG, "Has attachment: ${selectedImageFile != null}")
// Check image file size if present
selectedImageFile?.let { file ->
if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit
updateState { it.copy(error = "Image file is too large. Please select a smaller image.") }
return
}
}
viewModelScope.launch {
updateState { it.copy(isSending = true) }
try {
// Send the message using the repository
// Note: We keep the chatRoomId parameter for compatibility with the repository method signature,
// but it's not actually used in the API call
val result = chatRepository.sendChatMessage(
storeId = storeId,
message = message,
productId = productId,
imageFile = selectedImageFile,
chatRoomId = existingChatRoomId
)
when (result) {
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
val newChatRoomId = chatLine.chatRoomId
if (existingChatRoomId == 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) }
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception in sendMessage", e)
updateState {
it.copy(
isSending = false,
error = "An unexpected error occurred: ${e.message}"
)
}
}
}
}
/**
* 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")
}
fun getChatList() {
viewModelScope.launch {
_chatList.value = com.alya.ecommerce_serang.data.repository.Result.Loading
_chatList.value = chatRepository.getListChat()
}
}
}
/**
* Data class representing the UI state for the chat screen
*/
data class ChatUiState(
val messages: List<ChatUiMessage> = 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
)

View File

@ -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>(ConnectionState.Disconnected())
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _newMessages = MutableStateFlow<ChatLine?>(null)
val newMessages: StateFlow<ChatLine?> = _newMessages
private val _typingStatus = MutableStateFlow<TypingStatus?>(null)
val typingStatus: StateFlow<TypingStatus?> = _typingStatus
// LiveData for Activity/Fragment observing
private val _connectionStateLiveData = MutableLiveData<ConnectionState>(ConnectionState.Disconnected())
val connectionStateLiveData: LiveData<ConnectionState> = _connectionStateLiveData
private val _newMessagesLiveData = MutableLiveData<ChatLine?>()
val newMessagesLiveData: LiveData<ChatLine?> = _newMessagesLiveData
private val _typingStatusLiveData = MutableLiveData<TypingStatus?>()
val typingStatusLiveData: LiveData<TypingStatus?> = _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
)

View File

@ -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
// }
}

View File

@ -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<String, SearchHistoryAdapter.ViewHolder>(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<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
}
}
}

View File

@ -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
}
}

View File

@ -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<List<ProductsItem>>(emptyList())
val searchResults: LiveData<List<ProductsItem>> = _searchResults
private val _searchHistory = MutableLiveData<List<String>>(emptyList())
val searchHistory: LiveData<List<String>> = _searchHistory
private val _isSearching = MutableLiveData(false)
val isSearching: LiveData<Boolean> = _isSearching
private val _isSearchActive = MutableLiveData(false)
val isSearchActive: LiveData<Boolean> = _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 -> {}
}
}
}
}

View File

@ -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<ProductsItem, SearchResultsAdapter.ViewHolder>(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<ProductsItem>?) {
Log.d("SearchResultsAdapter", "Submitting list with ${list?.size ?: 0} items")
super.submitList(list)
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ProductsItem>() {
override fun areItemsTheSame(oldItem: ProductsItem, newItem: ProductsItem): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ProductsItem, newItem: ProductsItem): Boolean {
return oldItem == newItem
}
}
}
}

View File

@ -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<UserProfile?>>(Result.Loading)
val userProfile: StateFlow<Result<UserProfile?>> = _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
}
}

View File

@ -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<out String>,
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
}
}
}
}

View File

@ -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()
}
}

View File

@ -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}")
}
}
}

View File

@ -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)

View File

@ -39,7 +39,6 @@ class ProfileFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sessionManager = SessionManager(requireContext())
}
override fun onCreateView(

View File

@ -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
@ -109,7 +109,7 @@ class MyStoreActivity : AppCompatActivity() {
binding.layoutInbox.setOnClickListener {
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, ChatFragment())
.replace(android.R.id.content, ChatListFragment())
.addToBackStack(null)
.commit()
}

View File

@ -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"
}

View File

@ -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()
}
}
}

View File

@ -1,7 +0,0 @@
package com.alya.ecommerce_serang.utils.viewmodel
import androidx.lifecycle.ViewModel
class ChatViewModel : ViewModel() {
// TODO: Implement the ViewModel
}

View File

@ -52,6 +52,8 @@ class HomeViewModel (
loadProducts()
loadCategories()
}
}
sealed class HomeUiState {

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bottom_navigation_icon_color_active" android:state_checked="true"/>
<item android:color="@color/bottom_navigation_icon_color_inactive" android:state_checked="false"/>
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bottom_navigation_text_color_active" android:state_checked="true"/>
<item android:color="@color/bottom_navigation_text_color_inactive" android:state_checked="false"/>
</selector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#211E1E" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM12.5,8L11,8v6l4.75,2.85 0.75,-1.23 -4,-2.37L12.5,8zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#211E1E" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
</vector>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F5F5F5" />
<corners android:radius="20dp" />
<padding
android:bottom="8dp"
android:left="12dp"
android:right="12dp"
android:top="8dp" />
</shape>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E0E0E0" />
<corners android:radius="16dp" />
<padding
android:bottom="8dp"
android:left="12dp"
android:right="12dp"
android:top="8dp" />
</shape>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E1F5FE" />
<corners android:radius="16dp" />
<padding
android:bottom="8dp"
android:left="12dp"
android:right="12dp"
android:top="8dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/white"/>
<corners
android:topLeftRadius="20dp"
android:topRightRadius="20dp"/>
<stroke
android:width="1dp"
android:color="#E0E0E0"/>
</shape>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#211E1E" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#489EC6" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#211E1E" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#211E1E" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,3h-1L19,1h-2v2L7,3L7,1L5,1v2L4,3c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,5c0,-1.1 -0.9,-2 -2,-2zM20,21L4,21L4,10h16v11zM20,8L4,8L4,5h16v3z"/>
</vector>

View File

@ -0,0 +1,242 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.Ecommerce_serang"
tools:context=".ui.chat.ChatActivity">
<!-- Top Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/chatToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back"
android:src="@drawable/ic_back_24"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgProfile"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_person"
app:layout_constraintStart_toEndOf="@+id/btnBack"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvStoreName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="SnackEnak"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toTopOf="@+id/imgProfile"
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
<TextView
android:id="@+id/tvLastActive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Aktif 3 jam lalu"
android:textColor="#888888"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
<ImageButton
android:id="@+id/btnOptions"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Options"
android:src="@drawable/ic_arrow_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<!-- Product Card -->
<androidx.cardview.widget.CardView
android:id="@+id/cardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:layout_constraintTop_toBottomOf="@+id/chatToolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/product_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/imgProduct"
android:layout_width="64dp"
android:layout_height="64dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_image"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Keripik Balado"
android:textColor="@android:color/black"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Rp65.000"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
<RatingBar
android:id="@+id/ratingBar"
style="?android:attr/ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:isIndicator="true"
android:numStars="5"
android:rating="5.0"
android:stepSize="0.1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
<TextView
android:id="@+id/tvRating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="5.0"
android:textColor="#F9A825"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
app:layout_constraintStart_toEndOf="@+id/ratingBar"
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
<TextView
android:id="@+id/tvSellerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="SnackEnak"
android:textColor="#666666"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerChat"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/tvTypingIndicator"
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTypingIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:text="User is typing..."
android:textColor="#666666"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
tools:visibility="visible" />
<!-- Chat input area -->
<LinearLayout
android:id="@+id/layoutChatInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="8dp"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnAttachment"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Attachment"
android:src="@drawable/baseline_attach_file_24" />
<EditText
android:id="@+id/editTextMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:background="@drawable/bg_edit_text_background"
android:hint="Tulis pesan"
android:fontFamily="@font/dmsans_regular"
android:inputType="textMultiLine"
android:maxLines="4"
android:minHeight="40dp"
android:padding="8dp" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Send"
android:src="@drawable/baseline_attach_file_24" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -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">
<!-- Main Content -->
@ -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">
<LinearLayout

View File

@ -6,38 +6,38 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.Ecommerce_serang"
tools:context=".ui.MainActivity">
<!-- NavHostFragment -->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="0dp"
android:name="androidx.navigation.fragment.NavHostFragment"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
<com.google.android.material.divider.MaterialDivider
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
app:dividerColor="@color/gray_1"
app:layout_constraintBottom_toTopOf="@id/bottom_navigation" />
<!-- BottomNavigationView -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/bottom_nav_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/Widget.Material3.BottomNavigationView"
app:menu="@menu/bottom_navigation_menu"
app:itemIconTint="#489EC6"
app:itemIconSize="@dimen/m3_comp_navigation_bar_active_indicator_height" />
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" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.notif.NotificationActivity">
<Button
android:id="@+id/simple_notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="simple notificaton"/>
<Button
android:id="@+id/update_notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="update notificaton"/>
<Button
android:id="@+id/cancel_notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="cancel notificaton"/>
</LinearLayout>

View File

@ -5,7 +5,7 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:theme="@style/Theme.Ecommerce_serang"
tools:context=".ui.auth.RegisterActivity">
<LinearLayout
@ -144,18 +144,24 @@
android:textSize="18sp"
android:text="@string/birth_date"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
style="@style/SharpedBorderStyleOutline"
app:endIconMode="custom"
app:endIconDrawable="@drawable/outline_calendar_today_24">
<AutoCompleteTextView
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_birth_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_birth_date"
android:inputType="date"/>
android:hint="Pilih tanggal"
android:focusable="false"
android:clickable="true"
android:minHeight="50dp"/>
</com.google.android.material.textfield.TextInputLayout>
<TextView

View File

@ -1,13 +1,264 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.chat.ChatFragment">
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
<androidx.appcompat.widget.Toolbar
android:id="@+id/chatToolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Hello" />
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent">
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back"
android:src="@drawable/ic_back_24"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgProfile"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:src="@drawable/placeholder_image"
app:layout_constraintStart_toEndOf="@+id/btnBack"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvStoreName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="SnackEnak"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toTopOf="@+id/imgProfile"
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
<TextView
android:id="@+id/tvLastActive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Aktif 3 jam lalu"
android:textColor="#888888"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
<ImageButton
android:id="@+id/btnOptions"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Options"
android:src="@drawable/ic_arrow_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<!-- Connection Status -->
<TextView
android:id="@+id/tvConnectionStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFC107"
android:padding="4dp"
android:textAlignment="center"
android:textColor="#000000"
android:text="Connecting..."
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/chatToolbar"
tools:visibility="visible" />
<!-- Product Card -->
<androidx.cardview.widget.CardView
android:id="@+id/cardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:layout_constraintTop_toBottomOf="@+id/tvConnectionStatus">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/imgProduct"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_image"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Keripik Balado"
android:textColor="@android:color/black"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Rp65.000"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
<RatingBar
android:id="@+id/ratingBar"
style="?android:attr/ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:isIndicator="true"
android:numStars="5"
android:rating="5.0"
android:stepSize="0.1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
<TextView
android:id="@+id/tvRating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="5.0"
android:textColor="#F9A825"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
app:layout_constraintStart_toEndOf="@+id/ratingBar"
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
<TextView
android:id="@+id/tvSellerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="SnackEnak"
android:textColor="#666666"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerChat"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/tvTypingIndicator"
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTypingIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:text="User is typing..."
android:textColor="#666666"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
tools:visibility="visible" />
<!-- Chat input area -->
<LinearLayout
android:id="@+id/layoutChatInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="8dp"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnAttachment"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Attachment"
android:src="@drawable/baseline_attach_file_24" />
<EditText
android:id="@+id/editTextMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:background="@drawable/bg_edit_text_background"
android:hint="Tulis pesan"
android:inputType="textMultiLine"
android:maxLines="4"
android:minHeight="40dp"
android:padding="8dp" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Send"
android:src="@drawable/baseline_attach_file_24" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
tools:context=".ui.chat.ChatListFragment">
<TextView
android:id="@+id/chatHeaderTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Pesan"
android:textSize="24sp"
android:padding="16dp"
android:layout_marginHorizontal="8dp"
android:fontFamily="@font/dmsans_bold" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
app:dividerColor="@color/black_100"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatListRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false"
tools:listitem="@layout/item_chat"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</LinearLayout>

View File

@ -4,36 +4,31 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:theme="@style/Theme.Ecommerce_serang"
tools:context=".ui.home.HomeFragment">
<include
android:id="@+id/searchContainer"
layout="@layout/view_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent" />
<!-- Home content in ScrollView -->
<ScrollView
android:id="@+id/home"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/searchContainer"
app:layout_constraintBottom_toBottomOf="parent">
<!-- Your existing home content here -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/searchContainer"
layout="@layout/view_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- Remove searchContainer from here, it's now at the top level -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/banners"
@ -41,7 +36,8 @@
android:layout_height="132dp"
android:layout_marginTop="4dp"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/searchContainer"
app:layout_constraintTop_toTopOf="parent"
android:background="@drawable/banner_default"
tools:layout_editor_absoluteX="16dp" />
<TextView
@ -49,9 +45,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="24dp"
android:layout_marginTop="16dp"
android:text="@string/fragment_home_categories"
android:textColor="@color/black"
android:textColor="@color/blue_500"
android:fontFamily="@font/dmsans_bold"
android:textSize="22sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banners" />
@ -64,8 +61,8 @@
android:layout_marginEnd="32dp"
android:text="@string/show_all"
android:textAllCaps="false"
android:textColor="@color/blue1"
android:textSize="16sp"
android:textColor="@color/blue_600"
android:textSize="14sp"
app:layout_constraintBaseline_toBaselineOf="@id/categoriesText"
app:layout_constraintEnd_toEndOf="parent" />
@ -73,7 +70,7 @@
android:id="@+id/categories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="19dp"
android:layout_marginTop="8dp"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal"
@ -83,16 +80,15 @@
tools:layout_editor_absoluteX="0dp"
tools:listitem="@layout/item_category_home" />
<TextView
android:id="@+id/new_products_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="24dp"
android:layout_marginTop="16dp"
android:text="@string/sold_product_text"
android:textColor="@color/black"
android:textColor="@color/blue_500"
android:fontFamily="@font/dmsans_bold"
android:textSize="22sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/categories" />
@ -104,7 +100,7 @@
android:layout_marginEnd="32dp"
android:text="@string/show_all"
android:textAllCaps="false"
android:textColor="@color/blue1"
android:textColor="@color/blue_600"
android:textSize="16sp"
app:layout_constraintBaseline_toBaselineOf="@id/new_products_text"
app:layout_constraintEnd_toEndOf="parent" />
@ -123,6 +119,58 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchResultsRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/searchContainer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- All other search-related elements at the top level -->
<LinearLayout
android:id="@+id/searchHistoryHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/searchContainer">
<!-- ... -->
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchHistoryRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/searchHistoryHeader"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/noResultsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No results found"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchContainer" />
<ProgressBar
android:id="@+id/searchProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchContainer" />
<include
android:id="@+id/loading"
layout="@layout/view_loading"/>

View File

@ -5,6 +5,7 @@
android:layout_height="match_parent"
android:background="@color/white"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:theme="@style/Theme.Ecommerce_serang"
tools:context=".ui.profile.ProfileFragment">
<!-- Profile Header -->
@ -320,12 +321,5 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tvLogout" />
<!-- Bottom Navigation -->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".ui.home.SearchHomeFragment">
<LinearLayout
android:id="@+id/searchToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Kembali"
android:src="@drawable/ic_back_24" />
<androidx.appcompat.widget.SearchView
android:id="@+id/searchView"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="@drawable/search_background"
android:iconifiedByDefault="false"
android:queryHint="Search products..." />
</LinearLayout>
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/light_gray"
app:layout_constraintTop_toBottomOf="@id/searchToolbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchResultsRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider"
app:spanCount="2"
tools:listitem="@layout/item_product_grid" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider" />
<TextView
android:id="@+id/noResultsText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No results found"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,60 @@
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:elevation="2dp"
android:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/imgStore"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/circle_background"
android:clipToOutline="true"
android:scaleType="centerCrop"
android:src="@drawable/ic_person" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/txtStoreName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Store Name"
android:textStyle="bold" />
<TextView
android:id="@+id/txtMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Last message"
android:textColor="#666" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_vertical">
<TextView
android:id="@+id/txtTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="09.30"
android:textSize="12sp"
android:textColor="#999" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="60dp"
android:paddingBottom="4dp">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgAvatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_person"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/layoutMessage" />
<LinearLayout
android:id="@+id/layoutMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/bg_message_received"
android:orientation="vertical"
app:layout_constraintStart_toEndOf="@+id/imgAvatar"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="270dp"
android:textColor="@android:color/black"
android:textSize="14sp"
tools:text="Boleh banget teh. Teteh mau nawar berapa?" />
<ImageView
android:id="@+id/imgAttachment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:maxWidth="220dp"
android:scaleType="fitCenter"
android:visibility="gone"
tools:src="@drawable/placeholder_image"
tools:visibility="visible" />
</LinearLayout>
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="10sp"
app:layout_constraintStart_toStartOf="@+id/layoutMessage"
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
tools:text="12:30" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="60dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp">
<LinearLayout
android:id="@+id/layoutMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_message_sent"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="270dp"
android:textColor="@android:color/black"
android:textSize="14sp"
tools:text="Beli 1, 60 rb bisa teh?" />
<ImageView
android:id="@+id/imgAttachment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:maxWidth="220dp"
android:scaleType="fitCenter"
android:visibility="gone"
tools:src="@drawable/placeholder_image"
tools:visibility="visible" />
</LinearLayout>
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="4dp"
android:textColor="#888888"
android:textSize="10sp"
app:layout_constraintEnd_toStartOf="@+id/imgStatus"
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
tools:text="12:30" />
<ImageView
android:id="@+id/imgStatus"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/placeholder_image"
app:layout_constraintBottom_toBottomOf="@+id/tvTimestamp"
app:layout_constraintEnd_toEndOf="@+id/layoutMessage"
app:layout_constraintTop_toTopOf="@+id/tvTimestamp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/productImage"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/placeholder_image" />
<TextView
android:id="@+id/productName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="2"
android:textSize="14sp"
app:layout_constraintTop_toBottomOf="@id/productImage"
tools:text="Product Name" />
<TextView
android:id="@+id/storeName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="12sp"
android:fontFamily="@font/dmsans_medium"
app:layout_constraintTop_toBottomOf="@id/productName"
tools:text="Store Name" />
<TextView
android:id="@+id/productPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:textColor="@color/blue1"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/storeName"
tools:text="Rp 150.000" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:padding="16dp">
<ImageView
android:id="@+id/historyIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/outline_home_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/recentSearchText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:text="Cari Produk"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/historyIcon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -12,6 +12,7 @@
android:hint="@string/fragment_home_search"
android:textColor="@color/soft_gray"
android:textSize="16sp"
android:fontFamily="@font/dmsans_regular"
android:layout_marginStart="8dp"
android:drawablePadding="8dp"
android:paddingHorizontal="25dp"

View File

@ -9,7 +9,11 @@
android:id="@+id/homeFragment"
android:name="com.alya.ecommerce_serang.ui.home.HomeFragment"
android:label="fragment_home"
tools:layout="@layout/fragment_home" />
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_homeFragment_to_searchHomeFragment"
app:destination="@id/searchHomeFragment" />
</fragment>
<fragment
android:id="@+id/profileFragment"
android:name="com.alya.ecommerce_serang.ui.profile.ProfileFragment"
@ -17,9 +21,19 @@
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/chatFragment"
android:name="com.alya.ecommerce_serang.ui.chat.ChatFragment"
android:name="com.alya.ecommerce_serang.ui.chat.ChatListFragment"
android:label="fragment_chat"
tools:layout="@layout/fragment_chat" />
tools:layout="@layout/fragment_chat_list" />
<fragment
android:id="@+id/searchHomeFragment"
android:name="com.alya.ecommerce_serang.ui.home.SearchHomeFragment"
android:label="Search"
tools:layout="@layout/fragment_search_home">
<argument
android:name="query"
app:argType="string"
app:nullable="true" />
</fragment>
<activity
android:id="@+id/registerActivity"
android:name="com.alya.ecommerce_serang.ui.auth.RegisterActivity"

View File

@ -41,4 +41,9 @@
<color name="gray_1">#E8ECF2</color>
<color name="soft_gray">#7D8FAB</color>
<color name="blue1">#489EC6</color>
<color name="bottom_navigation_icon_color_active">#489EC6</color>
<color name="bottom_navigation_icon_color_inactive">#8E8E8E</color>
<color name="bottom_navigation_text_color_active">#489EC6</color>
<color name="bottom_navigation_text_color_inactive">#8E8E8E</color>
</resources>

View File

@ -117,5 +117,25 @@
<item>Other reason</item>
</string-array>
<!-- Chat Activity -->
<string name="image_attached">Image attached</string>
<string name="write_message">Tulis pesan</string>
<string name="options">Options</string>
<string name="block_user">Block User</string>
<string name="report">Report</string>
<string name="clear_chat">Clear Chat</string>
<string name="block_user_selected">Block user selected</string>
<string name="report_selected">Report selected</string>
<string name="clear_chat_selected">Clear chat selected</string>
<string name="permission_denied">Permission denied</string>
<string name="take_photo">Take Photo</string>
<string name="choose_from_gallery">Choose from Gallery</string>
<string name="select_attachment">Select Attachment</string>
<string name="image_selected">Image selected</string>
<string name="connecting">Connecting...</string>
<string name="disconnected_reconnecting">Disconnected. Reconnecting...</string>
<string name="connection_error">Connection error: %1$s</string>
<string name="typing">User is typing...</string>
</resources>

View File

@ -25,4 +25,39 @@
<item name="cornerRadius">8dp</item>
<item name="backgroundTint">@color/blue_500</item>
</style>
<style name="SharpedBorderStyleOutline">
<item name="boxStrokeColor">@color/black_300</item>
<item name="boxStrokeWidth">1dp</item>
<item name="boxCornerRadiusTopStart">4dp</item>
<item name="boxCornerRadiusTopEnd">4dp</item>
<item name="boxCornerRadiusBottomStart">4dp</item>
<item name="boxCornerRadiusBottomEnd">4dp</item>
<item name="android:textColorHint">@color/black_300</item>
</style>
<style name="CustomBottomNavActiveIndicator" parent="@style/Widget.Material3.BottomNavigationView.ActiveIndicator">
<item name="android:width">40dp</item>
<item name="android:height">4dp</item>
<item name="shapeAppearanceOverlay">@style/CustomActiveIndicatorShape</item>
</style>
<style name="CustomActiveIndicatorShape">
<item name="cornerSize">2dp</item>
</style>
<style name="BottomNavigationTextStyle" parent="TextAppearance.MaterialComponents.Caption">
<item name="android:textSize">14sp</item>
<item name="fontFamily">@font/dmsans_semibold</item>
<item name="android:paddingTop">8dp</item>
<item name="android:layout_marginTop">4dp</item>
</style>
<style name="BottomAppBar">
<item name="strokeColor">@color/light_gray</item>
<item name="strokeWidth">2dp</item>
<item name="color">@color/white</item>
<item name="cornerRadius">8dp</item>
<item name="backgroundTint">@color/white</item>
</style>
</resources>

View File

@ -1,16 +1,45 @@
<resources>
<!-- Base application theme. -->
<style name="Theme.Ecommerce_serang" parent="Theme.Material3.Light.NoActionBar">
<!-- Customize your light theme here. -->
<!-- Primary Color Customization -->
<item name="colorPrimary">@color/blue_500</item>
<item name="colorPrimaryDark">@color/white</item>
<item name="colorAccent">@color/black</item>
<item name="colorPrimaryVariant">@color/blue_600</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary Color Customization -->
<item name="colorSecondary">@color/blue_500</item>
<item name="colorSecondaryVariant">@color/blue_600</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Surface and Background Colors -->
<item name="colorSurface">@color/white</item>
<item name="colorOnSurface">@color/black</item>
<item name="colorPrimaryContainer">@color/blue_500</item>
<item name="colorOnPrimaryContainer">@color/white</item>
<item name="android:colorBackground">@color/white</item>
<!-- <item name="colorBackground">@color/white</item>-->
<!-- Container Colors -->
<item name="colorPrimaryContainer">@color/blue_50</item>
<item name="colorOnPrimaryContainer">@color/blue_500</item>
<!-- Status Bar and Navigation Bar -->
<!-- Remove Content Insets -->
<item name="android:contentInsetStart">0dp</item>
<item name="android:contentInsetLeft">0dp</item>
<!-- Bottom Navigation Specific -->
<item name="bottomNavigationStyle">@style/Widget.MaterialComponents.BottomNavigationView.Colored</item>
<item name="bottomAppBarStyle">@style/BottomAppBar</item>
<!-- Remove Purple Accent Color -->
<item name="colorAccent">@color/blue_500</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>-->
</style>
<!-- Text Styles -->

View File

@ -2,7 +2,6 @@
buildscript {
dependencies {
classpath ("androidx.navigation:navigation-safe-args-gradle-plugin:2.5.1")
// classpath("com.google.dagger:hilt-android-gradle-plugin:2.55")
}
}

View File

@ -1,8 +1,10 @@
[versions]
agp = "8.5.2"
glide = "4.16.0"
hiltAndroid = "2.51"
hiltAndroid = "2.48" # Updated from 2.44 for better compatibility
hiltLifecycleViewmodel = "1.0.0-alpha03"
hiltCompiler = "2.48" # Added for consistency
ksp = "1.9.0-1.0.13"
kotlin = "1.9.0"
coreKtx = "1.10.1"
@ -45,7 +47,15 @@ androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltCompiler" }
androidx-hilt-common = { module = "androidx.hilt:hilt-common", version = "1.0.0" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version = "1.0.0" }
androidx-hilt-navigation-fragment = { module = "androidx.hilt:hilt-navigation-fragment", version = "1.0.0" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version = "1.0.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }