diff --git a/api-client/build.gradle b/api-client/build.gradle index 7c03ab7..5f70343 100644 --- a/api-client/build.gradle +++ b/api-client/build.gradle @@ -13,6 +13,7 @@ test { if (project.hasProperty('prod')) { filter { excludeTestsMatching "com.kshitijpatil.tazabazar.api.TestAuthApi.register*" + excludeTestsMatching "com.kshitijpatil.tazabazar.api.TestOrderApi.*" } } } diff --git a/api-client/src/main/java/com/kshitijpatil/tazabazar/api/ApiModule.kt b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/ApiModule.kt index 1abbd15..0e1434c 100644 --- a/api-client/src/main/java/com/kshitijpatil/tazabazar/api/ApiModule.kt +++ b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/ApiModule.kt @@ -1,5 +1,6 @@ package com.kshitijpatil.tazabazar.api +import okhttp3.Interceptor import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory @@ -13,6 +14,15 @@ object ApiModule { .baseUrl(baseUrl) .addConverterFactory(moshiConverterFactory) + private fun getAuthInterceptorFor(accessToken: String): Interceptor { + return Interceptor { chain -> + val request = chain.request() + .newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + chain.proceed(request) + } + } fun provideRetrofitWith(client: OkHttpClient): Retrofit { return defaultRetrofitBuilder.client(client).build() @@ -25,5 +35,12 @@ object ApiModule { fun provideAuthApi(client: OkHttpClient): AuthApi { return provideRetrofitWith(client).create() } + + fun provideOrderApi(baseClient: OkHttpClient, accessToken: String): OrderApi { + val authorizedClient = baseClient.newBuilder() + .addInterceptor(getAuthInterceptorFor(accessToken)) + .build() + return provideRetrofitWith(authorizedClient).create() + } } diff --git a/api-client/src/main/java/com/kshitijpatil/tazabazar/api/OrderApi.kt b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/OrderApi.kt new file mode 100644 index 0000000..b30173f --- /dev/null +++ b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/OrderApi.kt @@ -0,0 +1,19 @@ +package com.kshitijpatil.tazabazar.api + +import com.kshitijpatil.tazabazar.api.dto.OrderLineDto +import com.kshitijpatil.tazabazar.api.dto.OrderResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface OrderApi { + @POST("/api/v2/orders") + suspend fun placeOrder(@Body orderLines: List): OrderResponse + + @GET("/api/v2/orders/{order_id}") + suspend fun getOrderById(@Path("order_id") orderId: String): OrderResponse + + @GET("/api/v2/users/{username}/orders") + suspend fun getOrdersByUsername(@Path("username") username: String): List +} \ No newline at end of file diff --git a/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderLineDto.kt b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderLineDto.kt new file mode 100644 index 0000000..a01a404 --- /dev/null +++ b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderLineDto.kt @@ -0,0 +1,12 @@ +package com.kshitijpatil.tazabazar.api.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class OrderLineDto( + @Json(name = "inventoryId") + val inventoryId: Int, + @Json(name = "quantity") + val quantity: Int +) diff --git a/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderResponse.kt b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderResponse.kt new file mode 100644 index 0000000..9ba6f9f --- /dev/null +++ b/api-client/src/main/java/com/kshitijpatil/tazabazar/api/dto/OrderResponse.kt @@ -0,0 +1,19 @@ +package com.kshitijpatil.tazabazar.api.dto + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class OrderResponse( + @Json(name = "created_at") + val createdAt: String, + @Json(name = "id") + val id: String, + @Json(name = "order_lines") + val orderLines: List = emptyList(), + @Json(name = "status") + val status: String, + @Json(name = "username") + val username: String +) \ No newline at end of file diff --git a/api-client/src/test/java/com/kshitijpatil/tazabazar/api/TestOrderApi.kt b/api-client/src/test/java/com/kshitijpatil/tazabazar/api/TestOrderApi.kt new file mode 100644 index 0000000..53e2c0a --- /dev/null +++ b/api-client/src/test/java/com/kshitijpatil/tazabazar/api/TestOrderApi.kt @@ -0,0 +1,54 @@ +package com.kshitijpatil.tazabazar.api + +import com.google.common.truth.Truth.assertThat +import com.kshitijpatil.tazabazar.api.dto.LoginRequest +import com.kshitijpatil.tazabazar.api.dto.OrderLineDto +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Test + +/** + * NOTE: These tests are just meant for development + * and are not ready to run in CI pipelines + */ +class TestOrderApi { + private val client = OkHttpClient.Builder().build() + private lateinit var api: OrderApi + private val authApi = ApiModule.provideAuthApi(client) + private val testLoginCredentials = LoginRequest("john.doe@test.com", "1234") + private val testOrderLines = listOf(OrderLineDto(1, 3), OrderLineDto(2, 3)) + + @Test + fun test_placeOrder() = runBlocking { + val response = authApi.login(testLoginCredentials) + assertThat(response.isSuccessful).isTrue() + val accessToken = response.body()!!.accessToken + api = ApiModule.provideOrderApi(client, accessToken) + val orderResponse = api.placeOrder(testOrderLines) + assertThat(orderResponse).isNotNull() + // TODO: Delete this order if POST call was successful + } + + @Test + fun test_getOrderById() = runBlocking { + val testOrderId = "b8128611-f1e8-46bf-a934-25e719d5187a" + val response = authApi.login(testLoginCredentials) + assertThat(response.isSuccessful).isTrue() + val accessToken = response.body()!!.accessToken + api = ApiModule.provideOrderApi(client, accessToken) + val orderResponse = api.getOrderById(testOrderId) + assertThat(orderResponse).isNotNull() + } + + @Test + fun test_getOrdersByUsername() = runBlocking { + val testOrderId = "b8128611-f1e8-46bf-a934-25e719d5187a" + val response = authApi.login(testLoginCredentials) + assertThat(response.isSuccessful).isTrue() + val accessToken = response.body()!!.accessToken + api = ApiModule.provideOrderApi(client, accessToken) + val userOrders = api.getOrdersByUsername(testLoginCredentials.username) + assertThat(userOrders).hasSize(1) + assertThat(userOrders[0].id).isEqualTo(testOrderId) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6ea01e9..fcf9f19 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,11 +17,17 @@ android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/Theme.TazaBazar" tools:targetApi="n"> - + android:exported="false" + tools:node="merge"> + + + diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/AuthRepository.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/AuthRepository.kt index 5c199a4..86f6670 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/AuthRepository.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/AuthRepository.kt @@ -62,8 +62,8 @@ class AuthRepositoryImpl( val token = authPreferenceStore.getRefreshToken().bind() val accessToken = authRemoteDataSource.refreshToken(token).bind() val now = LocalDateTime.now() - authPreferenceStore.storeAccessToken(accessToken).bind() authPreferenceStore.updateLoggedInAt(now) + authPreferenceStore.storeAccessToken(accessToken).bind() accessToken }.handleError { if (it is ApiException && it.statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) { @@ -79,8 +79,10 @@ class AuthRepositoryImpl( override suspend fun getAuthConfiguration(): AuthConfiguration { // TODO: Fetch these from remote APIs once supported - delay(500) - return AuthConfiguration(tokenExpiryMinutes = 15) + return withContext(dispatchers.io) { + delay(500) + AuthConfiguration(tokenExpiryMinutes = 15) + } } private fun DataSourceException.logExceptionsForRefreshToken() { diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/CartRepository.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/CartRepository.kt index 24e7996..1de3776 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/CartRepository.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/CartRepository.kt @@ -6,8 +6,11 @@ import com.kshitijpatil.tazabazar.data.local.entity.CartItemEntity import com.kshitijpatil.tazabazar.data.mapper.CartItemDetailViewToCartItem import com.kshitijpatil.tazabazar.model.CartConfiguration import com.kshitijpatil.tazabazar.model.CartItem +import com.kshitijpatil.tazabazar.util.AppCoroutineDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext import timber.log.Timber interface CartRepository { @@ -26,40 +29,57 @@ interface CartRepository { suspend fun getCartConfiguration(): CartConfiguration fun observeCartItemCount(): Flow + + suspend fun clearCart() } class CartRepositoryImpl( private val cartItemDao: CartItemDao, + private val dispatchers: AppCoroutineDispatchers, private val cartItemMapper: CartItemDetailViewToCartItem ) : CartRepository { // TODO: Check for quantity here override suspend fun addOrUpdateCartItem(inventoryId: Int, quantity: Int): Boolean { val cartItemEntity = CartItemEntity(inventoryId, quantity) Timber.d("Saving CartItemEntity: $cartItemEntity") - return cartItemDao.upsert(cartItemEntity) + return withContext(dispatchers.io) { + cartItemDao.upsert(cartItemEntity) + } } override suspend fun removeFromCart(inventoryId: Int) { Timber.d("Deleting CartItemEntity with inventoryId=$inventoryId") - cartItemDao.deleteById(inventoryId) + return withContext(dispatchers.io) { + cartItemDao.deleteById(inventoryId) + } } override suspend fun getAllCartItems(): List { - return cartItemDao.getAllCartDetailViews().map(cartItemMapper::map) + return withContext(dispatchers.io) { + cartItemDao.getAllCartDetailViews().map(cartItemMapper::map) + } } // TODO: Fetch it from the server and cache in some preference store override suspend fun getCartConfiguration(): CartConfiguration { Timber.d("Fetching cart configuration") - delay(500) // Fake delay - return CartConfiguration( - maxQuantityPerItem = 6, - deliveryCharges = 15f - ) + return withContext(dispatchers.io) { + delay(500) // Fake delay + CartConfiguration( + maxQuantityPerItem = 6, + deliveryCharges = 15f + ) + } } override fun observeCartItemCount(): Flow { - return cartItemDao.observeCartItemCount() + return cartItemDao.observeCartItemCount().flowOn(dispatchers.io) + } + + override suspend fun clearCart() { + withContext(dispatchers.io) { + cartItemDao.deleteAll() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/OrderRepository.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/OrderRepository.kt new file mode 100644 index 0000000..6b7a566 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/OrderRepository.kt @@ -0,0 +1,119 @@ +package com.kshitijpatil.tazabazar.data + +import com.kshitijpatil.tazabazar.api.OrderApi +import com.kshitijpatil.tazabazar.api.dto.OrderLineDto +import com.kshitijpatil.tazabazar.data.local.dao.InventoryDao +import com.kshitijpatil.tazabazar.data.local.entity.InventoryEntity +import com.kshitijpatil.tazabazar.data.local.prefs.AuthPreferenceStore +import com.kshitijpatil.tazabazar.data.mapper.OrderResponseToOrderMapper +import com.kshitijpatil.tazabazar.di.OrderApiFactory +import com.kshitijpatil.tazabazar.model.CartItem +import com.kshitijpatil.tazabazar.model.Order +import com.kshitijpatil.tazabazar.model.OrderLine +import com.kshitijpatil.tazabazar.util.AppCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.IOException +import org.threeten.bp.DateTimeException +import timber.log.Timber + +abstract class OrderRepository { + protected abstract var orderApi: OrderApi? + abstract suspend fun placeOrder(cartItems: List) + abstract suspend fun getOrdersOfCurrentUser(): List +} + +class OrderRepositoryImpl( + externalScope: CoroutineScope, + private val dispatchers: AppCoroutineDispatchers, + private val orderApiFactory: OrderApiFactory, + private val orderMapper: OrderResponseToOrderMapper, + private val inventoryDao: InventoryDao, + private val authPreferenceStore: AuthPreferenceStore, +) : OrderRepository() { + override var orderApi: OrderApi? = null + + init { + externalScope.launch(dispatchers.io) { observeAccessTokenToUpdateApi() } + } + + private suspend fun observeAccessTokenToUpdateApi() { + authPreferenceStore.observeAccessToken().collect { + orderApi = if (it == null) { + Timber.d("Access Token not found, resetting OrderApi") + null + } else { + orderApiFactory.create(it) + } + } + } + + override suspend fun placeOrder(cartItems: List) { + val api = checkNotNull(orderApi) { "OrderApi was null, can't place an order" } + Timber.d("Placing an order with ${cartItems.size} items") + val orderLines = cartItems.map(CartItem::makeOrderLine) + withContext(dispatchers.io) { + api.placeOrder(orderLines) + } + } + + /** + * Returns the orders of current user, with total calculated using + * local inventory details. Note: Orders with non-local inventories + * are skipped from the final result + */ + @Throws( + IllegalStateException::class, + IOException::class, + DateTimeException::class, + IllegalArgumentException::class + ) + override suspend fun getOrdersOfCurrentUser(): List { + val loggedInUser = authPreferenceStore.getLoggedInUser().orNull() + checkNotNull(loggedInUser) { "LoggedInUser must not be null to fetch orders" } + val api = checkNotNull(orderApi) { "OrderApi was null, make sure user is Logged-In" } + val fetchedOrders = withContext(dispatchers.io) { + api.getOrdersByUsername(loggedInUser.email) + } + Timber.d("Received ${fetchedOrders.size} orders for the current user") + val computedOrders = withContext(dispatchers.computation) { + fetchedOrders.map(orderMapper::map) + .map { associateWithInventories(it) } + .filter(::skipOrderWithMissingInventories) + .map(::updateOrderTotal) + } + return computedOrders + } + + private suspend fun associateWithInventories(order: Order): Pair> { + val invIds = order.orderLines.map(OrderLine::inventoryId) + val inventories = inventoryDao.getInventoriesByIds(invIds) + return Pair(order, inventories) + } + + private fun skipOrderWithMissingInventories(orderInvPair: Pair>): Boolean { + val (order, inventories) = orderInvPair + if (order.orderLines.size != inventories.size) { + Timber.d("Skipping Order: ${order.orderId} due to missing inventories") + return false + } + return true + } + + private fun updateOrderTotal(orderInvPair: Pair>): Order { + val (order, inventories) = orderInvPair + val orderTotal = order.orderLines.zip(inventories) + .map { (ol, inv) -> ol.quantity * inv.price } + .sum() + return order.copy(total = orderTotal) + } +} + +private fun CartItem.makeOrderLine(): OrderLineDto { + return OrderLineDto( + inventoryId = inventoryId, + quantity = quantity + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/CartItemDao.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/CartItemDao.kt index 31684da..791884c 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/CartItemDao.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/CartItemDao.kt @@ -21,6 +21,9 @@ interface CartItemDao : UpsertDao { @Query("DELETE FROM cart_item WHERE inventory_id = :inventoryId") suspend fun deleteById(inventoryId: Int) + @Query("DELETE FROM cart_item") + suspend fun deleteAll() + @Query("SELECT * FROM cart_item_detail_view WHERE inventory_id = :inventoryId") suspend fun getCartItemDetailViewById(inventoryId: Int): CartItemDetailView? diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/InventoryDao.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/InventoryDao.kt index 79e125d..6d7aef1 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/InventoryDao.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/dao/InventoryDao.kt @@ -12,6 +12,9 @@ interface InventoryDao : ReplacingDao { @Query("SELECT * FROM inventory WHERE id = :id") suspend fun getInventoryById(id: Int): InventoryEntity? + @Query("SELECT * FROM inventory WHERE id IN (:inventoryIds)") + suspend fun getInventoriesByIds(inventoryIds: List): List + @Query("SELECT * FROM inventory") suspend fun getAllInventories(): List diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/mapper/OrderMapper.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/mapper/OrderMapper.kt new file mode 100644 index 0000000..87748a6 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/mapper/OrderMapper.kt @@ -0,0 +1,44 @@ +package com.kshitijpatil.tazabazar.data.mapper + +import com.kshitijpatil.tazabazar.api.dto.OrderLineDto +import com.kshitijpatil.tazabazar.api.dto.OrderResponse +import com.kshitijpatil.tazabazar.data.local.TazaBazarTypeConverters +import com.kshitijpatil.tazabazar.model.Order +import com.kshitijpatil.tazabazar.model.OrderLine +import com.kshitijpatil.tazabazar.model.OrderStatus +import org.threeten.bp.DateTimeException +import org.threeten.bp.LocalDateTime +import org.threeten.bp.ZoneId + +class OrderResponseToOrderMapper(private val orderLineMapper: OrderLineDtoToOrderLine) : + Mapper { + + @Throws(DateTimeException::class, IllegalArgumentException::class) + override fun map(from: OrderResponse): Order { + val orderLocalDateTime = getLocalDateTimeFrom(from.createdAt) + val orderStatus = OrderStatus.valueOf(from.status) + val orderLines = from.orderLines.map(orderLineMapper::map) + return Order(orderLocalDateTime, from.id, orderLines, orderStatus) + } + + /** + * reference: [https://stackoverflow.com/a/67919362/6738702] + * @return [LocalDateTime] instance parsed from the given ISO formatted OffsetDateTime + * @throws DateTimeException Failure in parsing and converting ISO formatted OffsetDateTime to [LocalDateTime] + */ + private fun getLocalDateTimeFrom(offsetDateTimeRaw: String): LocalDateTime { + val orderOffsetDateTime = TazaBazarTypeConverters.toOffsetDateTime(offsetDateTimeRaw) + val orderZoned = orderOffsetDateTime.atZoneSameInstant(ZoneId.systemDefault()) + return orderZoned.toLocalDateTime() + } +} + +class OrderLineDtoToOrderLine : Mapper { + override fun map(from: OrderLineDto): OrderLine { + return OrderLine( + inventoryId = from.inventoryId, + quantity = from.quantity + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt index eb2d428..ba93c7e 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt @@ -67,4 +67,22 @@ object DomainModule { observeSessionStateUseCase = useCaseInstance return useCaseInstance } + + fun providePlaceOrderUseCase( + context: Context, + externalScope: CoroutineScope, + dispatchers: AppCoroutineDispatchers + ): PlaceOrderUseCase { + val repo = RepositoryModule.provideOrderRepository(context, externalScope, dispatchers) + return PlaceOrderUseCase(dispatchers.io, repo) + } + + fun provideGetUseOrdersUseCase( + applicationScope: CoroutineScope, + dispatchers: AppCoroutineDispatchers, + context: Context + ): GetUserOrdersUseCase { + val repo = RepositoryModule.provideOrderRepository(context, applicationScope, dispatchers) + return GetUserOrdersUseCase(dispatchers.io, repo) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/MapperModule.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/MapperModule.kt index b91d657..a8c3095 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/MapperModule.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/MapperModule.kt @@ -23,4 +23,6 @@ object MapperModule { val cartItemDetailViewToCartItem = CartItemDetailViewToCartItem() val loggedInUserMapper = LoginResponseUserToLoggedInUser() + val orderLineMapper = OrderLineDtoToOrderLine() + val orderMapper = OrderResponseToOrderMapper(orderLineMapper) } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/OrderApiFactory.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/OrderApiFactory.kt new file mode 100644 index 0000000..575a220 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/OrderApiFactory.kt @@ -0,0 +1,15 @@ +package com.kshitijpatil.tazabazar.di + +import com.kshitijpatil.tazabazar.api.ApiModule +import com.kshitijpatil.tazabazar.api.OrderApi +import okhttp3.OkHttpClient + +fun interface OrderApiFactory { + fun create(accessToken: String): OrderApi +} + +class DefaultOrderApiFactory(private val client: OkHttpClient) : OrderApiFactory { + override fun create(accessToken: String): OrderApi { + return ApiModule.provideOrderApi(client, accessToken) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt index f57ee1b..f0684fc 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt @@ -19,8 +19,10 @@ import com.kshitijpatil.tazabazar.data.network.AuthRemoteDataSource import com.kshitijpatil.tazabazar.data.network.AuthRemoteDataSourceImpl import com.kshitijpatil.tazabazar.data.network.ProductRemoteDataSource import com.kshitijpatil.tazabazar.model.LoggedInUser +import com.kshitijpatil.tazabazar.util.AppCoroutineDispatchers import com.kshitijpatil.tazabazar.util.NetworkUtils import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope import okhttp3.OkHttpClient import org.threeten.bp.LocalDateTime @@ -56,6 +58,9 @@ object RepositoryModule { var authRepository: AuthRepository? = null @VisibleForTesting set + @Volatile + var orderRepository: OrderRepository? = null + @VisibleForTesting set fun provideProductRepository(context: Context): ProductRepository { synchronized(lock) { @@ -91,7 +96,8 @@ object RepositoryModule { private fun createCartRepository(context: Context): CartRepository { val appDatabase = database ?: createDatabase(context) val mapper = MapperModule.cartItemDetailViewToCartItem - val repo = CartRepositoryImpl(appDatabase.cartItemDao, mapper) + val dispatchers = AppModule.provideAppCoroutineDispatchers() + val repo = CartRepositoryImpl(appDatabase.cartItemDao, dispatchers, mapper) cartRepository = repo return repo } @@ -143,6 +149,14 @@ object RepositoryModule { } } + fun provideOrderRepository( + context: Context, + externalScope: CoroutineScope, + dispatchers: AppCoroutineDispatchers + ): OrderRepository { + return orderRepository ?: createOrderRepository(context, externalScope, dispatchers) + } + private fun createAuthRepository(context: Context): AuthRepository { val client = OkhttpModule.provideOkHttpClient(context) val newRepo = AuthRepositoryImpl( @@ -182,6 +196,35 @@ object RepositoryModule { return newRepo } + private fun createOrderRepository( + context: Context, + externalScope: CoroutineScope, + dispatchers: AppCoroutineDispatchers + ): OrderRepository { + val client = OkhttpModule.provideOkHttpClient(context) + val orderApiFactory = provideOrderApiFactory(client) + val authPreferenceStore = provideAuthPreferenceStore(context) + val database = provideAppDatabase(context) + val repo = OrderRepositoryImpl( + externalScope, + dispatchers, + orderApiFactory, + MapperModule.orderMapper, + database.inventoryDao, + authPreferenceStore + ) + orderRepository = repo + return repo + } + + fun provideAppDatabase(context: Context): AppDatabase { + return database ?: createDatabase(context) + } + + fun provideOrderApiFactory(client: OkHttpClient): OrderApiFactory { + return DefaultOrderApiFactory(client) + } + fun provideAuthRemoteDataSource(client: OkHttpClient): AuthRemoteDataSource { return AuthRemoteDataSourceImpl(ApiModule.provideAuthApi(client)) } diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt index 55aaee9..df3c88f 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt @@ -14,6 +14,7 @@ import com.kshitijpatil.tazabazar.ui.auth.AuthViewModel import com.kshitijpatil.tazabazar.ui.cart.CartViewModel import com.kshitijpatil.tazabazar.ui.favorite.FavoriteProductsViewModel import com.kshitijpatil.tazabazar.ui.home.HomeViewModel +import com.kshitijpatil.tazabazar.ui.orders.OrdersViewModel import com.kshitijpatil.tazabazar.ui.profile.ProfileViewModel import java.lang.ref.WeakReference @@ -57,13 +58,23 @@ class AuthViewModelFactory( } } -class CartViewModelFactory(appContext: Context) : ViewModelProvider.Factory { - private val cartRepository = RepositoryModule.provideCartItemRepository(appContext) +class CartViewModelFactory(application: TazaBazarApplication) : ViewModelProvider.Factory { + private val cartRepository = + RepositoryModule.provideCartItemRepository(application.applicationContext) + val dispatchers = AppModule.provideAppCoroutineDispatchers() + private val placeOrderUseCase = DomainModule.providePlaceOrderUseCase( + application.applicationContext, + application.coroutineScope, + dispatchers + ) + private val observeSessionStateUseCase = DomainModule.provideObserveSessionStateUseCase( + dispatchers, application.coroutineScope, application.applicationContext + ) @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(CartViewModel::class.java)) { - return CartViewModel(cartRepository) as T + return CartViewModel(cartRepository, placeOrderUseCase, observeSessionStateUseCase) as T } throw IllegalArgumentException("ViewModel not found") } @@ -136,4 +147,20 @@ class DashboardViewModelFactory(application: TazaBazarApplication) : ViewModelPr throw IllegalArgumentException("ViewModel not found") } +} + +class OrdersViewModelFactory(application: TazaBazarApplication) : ViewModelProvider.Factory { + private val dispatchers = AppModule.provideAppCoroutineDispatchers() + private val getUserOrdersUseCase = DomainModule.provideGetUseOrdersUseCase( + application.coroutineScope, dispatchers, application.applicationContext + ) + private val observeSessionStateUseCase = DomainModule.provideObserveSessionStateUseCase( + dispatchers, application.coroutineScope, application.applicationContext + ) + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return OrdersViewModel(getUserOrdersUseCase, observeSessionStateUseCase) as T + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/domain/GetUserOrdersUseCase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/domain/GetUserOrdersUseCase.kt new file mode 100644 index 0000000..42a147a --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/domain/GetUserOrdersUseCase.kt @@ -0,0 +1,14 @@ +package com.kshitijpatil.tazabazar.domain + +import com.kshitijpatil.tazabazar.data.OrderRepository +import com.kshitijpatil.tazabazar.model.Order +import kotlinx.coroutines.CoroutineDispatcher + +class GetUserOrdersUseCase( + ioDispatcher: CoroutineDispatcher, + private val orderRepository: OrderRepository +) : CoroutineUseCase>(ioDispatcher) { + override suspend fun execute(parameters: Unit): List { + return orderRepository.getOrdersOfCurrentUser() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/domain/PlaceOrderUseCase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/domain/PlaceOrderUseCase.kt new file mode 100644 index 0000000..4e19a7f --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/domain/PlaceOrderUseCase.kt @@ -0,0 +1,14 @@ +package com.kshitijpatil.tazabazar.domain + +import com.kshitijpatil.tazabazar.data.OrderRepository +import com.kshitijpatil.tazabazar.model.CartItem +import kotlinx.coroutines.CoroutineDispatcher + +class PlaceOrderUseCase( + dispatcher: CoroutineDispatcher, + private val orderRepository: OrderRepository +) : CoroutineUseCase, Unit>(dispatcher) { + override suspend fun execute(parameters: List) { + orderRepository.placeOrder(parameters) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/model/CartItem.kt b/app/src/main/java/com/kshitijpatil/tazabazar/model/CartItem.kt index 425b9c4..e841faf 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/model/CartItem.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/model/CartItem.kt @@ -8,13 +8,4 @@ data class CartItem( val name: String, val imageUri: String, val quantity: Int, -) - -data class CartCost( - val subTotal: Float = 0f, - val delivery: Float = 0f, - val discount: Float = 0f -) { - val total: Float - get() = subTotal + delivery - discount -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/model/Order.kt b/app/src/main/java/com/kshitijpatil/tazabazar/model/Order.kt new file mode 100644 index 0000000..5a3e709 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/model/Order.kt @@ -0,0 +1,20 @@ +package com.kshitijpatil.tazabazar.model + +import org.threeten.bp.LocalDateTime + +data class OrderLine( + val inventoryId: Int, + val quantity: Int +) + +enum class OrderStatus { + ACCEPTED, PENDING, DISPATCHED, DELIVERED, CANCELLED +} + +data class Order( + val createdAt: LocalDateTime, + val orderId: String, + val orderLines: List, + val status: OrderStatus, + val total: Float = 0f +) \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartCostFooterAdapter.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartCostFooterAdapter.kt deleted file mode 100644 index aaf2fa3..0000000 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartCostFooterAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.kshitijpatil.tazabazar.ui.cart - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.kshitijpatil.tazabazar.model.CartCost - -class CartCostFooterAdapter : RecyclerView.Adapter() { - /** - * Cost details to present in the Adapter - * Changing this property will immediately notify the adapter to change - * the item it's presenting - */ - var costing: CartCost = CartCost() - set(newCost) { - if (field != newCost) { - notifyItemChanged(0) - field = newCost - } - } - - fun updateSubTotal(subTotal: Float) { - costing = costing.copy(subTotal = subTotal) - } - - fun updateDeliveryCharges(deliveryCharges: Float) { - costing = costing.copy(delivery = deliveryCharges) - } - - fun updateDiscount(discount: Float) { - costing = costing.copy(discount = discount) - } - - var isVisible: Boolean = false - set(value) { - if (field && !value) { - notifyItemRemoved(0) - } else if (!field && value) { - notifyItemInserted(0) - } - field = value - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CartCostViewHolder { - return CartCostViewHolder.create(parent) - } - - override fun onBindViewHolder(holder: CartCostViewHolder, position: Int) { - holder.bind(costing) - } - - override fun getItemCount(): Int = if (isVisible) 1 else 0 -} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFooterAdapter.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFooterAdapter.kt new file mode 100644 index 0000000..3012a01 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFooterAdapter.kt @@ -0,0 +1,34 @@ +package com.kshitijpatil.tazabazar.ui.cart + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +class CartFooterAdapter( + var onFooterActionCallback: CartFooterViewHolder.OnFooterActionCallback? = null +) : RecyclerView.Adapter(), FooterViewDataDelegate { + override var footerViewData: FooterViewData = FooterViewData() + + override fun onDataChanged() { + notifyItemChanged(0) + } + + var isVisible: Boolean = false + set(value) { + if (field && !value) { + notifyItemRemoved(0) + } else if (!field && value) { + notifyItemInserted(0) + } + field = value + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CartFooterViewHolder { + return CartFooterViewHolder.create(parent, onFooterActionCallback) + } + + override fun onBindViewHolder(holder: CartFooterViewHolder, position: Int) { + holder.bind(footerViewData) + } + + override fun getItemCount(): Int = if (isVisible) 1 else 0 +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFragment.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFragment.kt index e91c013..1892107 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFragment.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartFragment.kt @@ -4,9 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.findNavController import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.SimpleItemAnimator import com.kshitijpatil.tazabazar.R @@ -15,12 +18,18 @@ import com.kshitijpatil.tazabazar.di.CartViewModelFactory import com.kshitijpatil.tazabazar.model.CartConfiguration import com.kshitijpatil.tazabazar.model.CartItem import com.kshitijpatil.tazabazar.ui.common.CoilProductLoadImageDelegate +import com.kshitijpatil.tazabazar.util.UiState +import com.kshitijpatil.tazabazar.util.enableActionButton import com.kshitijpatil.tazabazar.util.launchAndRepeatWithViewLifecycle +import com.kshitijpatil.tazabazar.util.tazabazarApplication import com.kshitijpatil.tazabazar.widget.FadingSnackbar import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber -class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { +class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback, + CartFooterViewHolder.OnFooterActionCallback { companion object { /** Result Key to notify cart items changed */ const val CART_CHANGED_RESULT = "com.kshitijpatil.tazabazar.ui.cart.cart-changed-result" @@ -30,13 +39,16 @@ class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { private val binding: FragmentCartBinding get() = _binding!! private val loadImageDelegate = CoilProductLoadImageDelegate() private val cartItemListAdapter = CartItemListAdapter(loadImageDelegate) - private val cartCostFooterAdapter = CartCostFooterAdapter() + private val cartFooterAdapter = CartFooterAdapter() private val viewModel: CartViewModel by viewModels( ownerProducer = { requireParentFragment() }, - factoryProducer = { CartViewModelFactory(requireContext().applicationContext) } + factoryProducer = { CartViewModelFactory(tazabazarApplication) } ) private lateinit var cartConfiguration: CartConfiguration private lateinit var snackbar: FadingSnackbar + private val activityNavController: NavController by lazy { + requireActivity().findNavController(R.id.main_activity_nav_host_fragment) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -50,7 +62,7 @@ class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { ): View? { _binding = FragmentCartBinding.inflate(inflater, container, false) binding.rvCartItems.apply { - adapter = ConcatAdapter(cartItemListAdapter, cartCostFooterAdapter) + adapter = ConcatAdapter(cartItemListAdapter, cartFooterAdapter) (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } return binding.root @@ -59,18 +71,56 @@ class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) cartItemListAdapter.itemActionCallback = this + cartFooterAdapter.onFooterActionCallback = this launchAndRepeatWithViewLifecycle { launch { viewModel.cartConfiguration.collect { cartConfiguration = it - cartCostFooterAdapter.updateDeliveryCharges(it.deliveryCharges) + cartFooterAdapter.updateDeliveryCharges(it.deliveryCharges) } } launch { observeCartItems() } + launch { observePlaceOrderState() } + launch { observeLoggedInUserState() } } snackbar = view.findViewById(R.id.snackbar) } + private suspend fun observeLoggedInUserState() { + viewModel.loggedInUser + .map { it != null } + .collect { loggedIn -> cartFooterAdapter.setUserLoggedIn(loggedIn) } + } + + private suspend fun observePlaceOrderState() { + viewModel.placeOrderUiState.collect { + cartFooterAdapter.setPlaceOrderEnabled(it.enableActionButton) + when (it) { + UiState.Error -> showPlaceOrderFailed() + is UiState.Success -> navigateToOrderSuccessFragment() + else -> { + } + } + } + } + + private fun navigateToOrderSuccessFragment() { + val userFullName = viewModel.loggedInUser.value?.fullName + if (userFullName != null) { + activityNavController.navigate("app.tazabazar://orders/successful/$userFullName".toUri()) + } else { + Timber.d("nav-to-success-fragment: LoggedInUser was not set, can't perform this action") + } + } + + private fun showPlaceOrderFailed() { + snackbar.show( + R.string.error_failed_to_place_order, + actionId = R.string.action_retry, + actionClick = { viewModel.placeOrder() } + ) + } + override fun onStop() { cartItemListAdapter.itemActionCallback = null super.onStop() @@ -89,14 +139,15 @@ class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { private suspend fun observeCartItems() { viewModel.cartItems.collect { cartItems -> + Timber.d("Cart Items Changed") cartItemListAdapter.submitList(cartItems) if (cartItems.isNotEmpty()) { - cartCostFooterAdapter.isVisible = true + cartFooterAdapter.isVisible = true val subTotal = cartItems.fold(0f) { acc, item -> acc + (item.price * item.quantity) } - cartCostFooterAdapter.updateSubTotal(subTotal) + cartFooterAdapter.updateSubTotal(subTotal) } else { - cartCostFooterAdapter.isVisible = false + cartFooterAdapter.isVisible = false } } } @@ -116,4 +167,12 @@ class CartFragment : Fragment(), CartItemViewHolder.OnItemActionCallback { override fun onQuantityDecrement(item: CartItem) { viewModel.decrementQuantity(item) } + + override fun placeOrder() { + viewModel.placeOrder() + } + + override fun onLoginClicked() { + activityNavController.navigate(R.id.action_fragment_dashboard_to_navigation_auth) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartItemViewHolder.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartItemViewHolder.kt index c51fbc9..b63f265 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartItemViewHolder.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartItemViewHolder.kt @@ -1,13 +1,21 @@ package com.kshitijpatil.tazabazar.ui.cart import android.content.Context +import android.graphics.Color +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors import com.kshitijpatil.tazabazar.R -import com.kshitijpatil.tazabazar.databinding.CartCostViewBinding +import com.kshitijpatil.tazabazar.databinding.CartFooterViewBinding import com.kshitijpatil.tazabazar.databinding.CartItemViewBinding -import com.kshitijpatil.tazabazar.model.CartCost import com.kshitijpatil.tazabazar.model.CartItem import com.kshitijpatil.tazabazar.ui.common.LoadImageDelegate @@ -49,15 +57,56 @@ class CartItemViewHolder( } } -class CartCostViewHolder( - private val binding: CartCostViewBinding +class CartFooterViewHolder( + private val binding: CartFooterViewBinding, + var onFooterActionCallback: OnFooterActionCallback? = null ) : RecyclerView.ViewHolder(binding.root) { - fun bind(costing: CartCost) { + private val loginTextColor = + MaterialColors.getColor(binding.root, R.attr.colorPrimary, Color.BLACK) + private val loginTextSpan = object : ClickableSpan() { + override fun onClick(view: View) { + onFooterActionCallback?.onLoginClicked() + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.color = loginTextColor + ds.textSize = 48f + } + } + private val loginText = binding.root.context.getString(R.string.label_login) + private val promptText = binding.root.context.getString(R.string.info_to_place_order_now) + + fun bind(footerViewData: FooterViewData) { val context = binding.root.context + val costing = footerViewData.costing binding.tvSubtotal.text = getCostString(context, costing.subTotal) binding.tvDelivery.text = getCostString(context, costing.delivery) binding.tvDiscount.text = getCostString(context, costing.discount) binding.tvTotal.text = getCostString(context, costing.total) + updateUiForLoggedIn(footerViewData.userLoggedIn) + binding.btnPlaceOrder.isEnabled = footerViewData.placeOrderEnabled + binding.btnPlaceOrder.setOnClickListener { onFooterActionCallback?.placeOrder() } + + } + + private fun updateUiForLoggedIn(userLoggedIn: Boolean) { + binding.btnPlaceOrder.isVisible = userLoggedIn + setLoginPromptText(userLoggedIn) + } + + private fun setLoginPromptText(userLoggedIn: Boolean) { + val loginStart = 0 + binding.txtPromptLogin.isVisible = !userLoggedIn + if (!userLoggedIn) { + val spannablePrompt = SpannableString("$loginText $promptText").apply { + setSpan(loginTextSpan, loginStart, loginText.length, Spanned.SPAN_POINT_MARK) + } + binding.txtPromptLogin.apply { + text = spannablePrompt + movementMethod = LinkMovementMethod.getInstance() + } + } } private fun getCostString(context: Context, price: Float): String { @@ -65,10 +114,18 @@ class CartCostViewHolder( } companion object { - fun create(parent: ViewGroup): CartCostViewHolder { + fun create( + parent: ViewGroup, + onFooterActionCallback: OnFooterActionCallback? = null + ): CartFooterViewHolder { val inflater = LayoutInflater.from(parent.context) - val binding = CartCostViewBinding.inflate(inflater, parent, false) - return CartCostViewHolder(binding) + val binding = CartFooterViewBinding.inflate(inflater, parent, false) + return CartFooterViewHolder(binding, onFooterActionCallback) } } + + interface OnFooterActionCallback { + fun placeOrder() + fun onLoginClicked() + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartViewModel.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartViewModel.kt index 012109d..454abae 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartViewModel.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/CartViewModel.kt @@ -3,12 +3,23 @@ package com.kshitijpatil.tazabazar.ui.cart import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kshitijpatil.tazabazar.data.CartRepository +import com.kshitijpatil.tazabazar.domain.ObserveSessionStateUseCase +import com.kshitijpatil.tazabazar.domain.PlaceOrderUseCase +import com.kshitijpatil.tazabazar.domain.SessionState +import com.kshitijpatil.tazabazar.domain.succeeded import com.kshitijpatil.tazabazar.model.CartConfiguration import com.kshitijpatil.tazabazar.model.CartItem +import com.kshitijpatil.tazabazar.model.LoggedInUser +import com.kshitijpatil.tazabazar.util.UiState +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { +class CartViewModel( + private val cartRepository: CartRepository, + private val placeOrderUseCase: PlaceOrderUseCase, + private val observeSessionStateUseCase: ObserveSessionStateUseCase +) : ViewModel() { private val _cartItems = MutableStateFlow>(emptyList()) val cartItems: StateFlow> get() = _cartItems.asStateFlow() @@ -17,8 +28,23 @@ class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { emit(cartRepository.getCartConfiguration()) } + private val _placeOrderUiState = MutableStateFlow>(UiState.Idle) + val placeOrderUiState: StateFlow> + get() = _placeOrderUiState.asStateFlow() + + private val _loggedInUser = MutableStateFlow(null) + val loggedInUser get() = _loggedInUser.asStateFlow() + init { reloadCartItems() + viewModelScope.launch { observeSessionForLoggedInUser() } + } + + private suspend fun observeSessionForLoggedInUser() { + observeSessionStateUseCase() + .collect { + _loggedInUser.value = if (it is SessionState.LoggedIn) it.user else null + } } fun reloadCartItems() { @@ -45,4 +71,20 @@ class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { reloadCartItems() } } + + fun placeOrder() { + viewModelScope.launch { + _placeOrderUiState.emit(UiState.Loading()) + val result = placeOrderUseCase(_cartItems.value) + if (result.succeeded) { + cartRepository.clearCart() + reloadCartItems() + _placeOrderUiState.emit(UiState.Success(Unit)) + delay(500) + _placeOrderUiState.emit(UiState.Idle) + } else { + _placeOrderUiState.emit(UiState.Error) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewData.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewData.kt new file mode 100644 index 0000000..458ead3 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewData.kt @@ -0,0 +1,16 @@ +package com.kshitijpatil.tazabazar.ui.cart + +data class CartCost( + val subTotal: Float = 0f, + val delivery: Float = 0f, + val discount: Float = 0f +) { + val total: Float + get() = subTotal + delivery - discount +} + +data class FooterViewData( + val costing: CartCost = CartCost(), + val placeOrderEnabled: Boolean = false, + val userLoggedIn: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewDataDelegate.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewDataDelegate.kt new file mode 100644 index 0000000..cd7b77c --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/cart/FooterViewDataDelegate.kt @@ -0,0 +1,42 @@ +package com.kshitijpatil.tazabazar.ui.cart + +interface FooterViewDataDelegate { + var footerViewData: FooterViewData + + fun updateSubTotal(subTotal: Float) { + val newCosting = footerViewData.costing.copy(subTotal = subTotal) + val value = footerViewData.copy(costing = newCosting) + setFooterViewDataIfChanged(value) + } + + fun updateDeliveryCharges(deliveryCharges: Float) { + val newCosting = footerViewData.costing.copy(delivery = deliveryCharges) + val value = footerViewData.copy(costing = newCosting) + setFooterViewDataIfChanged(value) + } + + fun updateDiscount(discount: Float) { + val newCosting = footerViewData.costing.copy(discount = discount) + val value = footerViewData.copy(costing = newCosting) + setFooterViewDataIfChanged(value) + } + + fun setPlaceOrderEnabled(enabled: Boolean) { + val value = footerViewData.copy(placeOrderEnabled = enabled) + setFooterViewDataIfChanged(value) + } + + fun setUserLoggedIn(loggedIn: Boolean) { + val value = footerViewData.copy(userLoggedIn = loggedIn) + setFooterViewDataIfChanged(value) + } + + private fun setFooterViewDataIfChanged(value: FooterViewData) { + if (value != footerViewData) { + onDataChanged() + footerViewData = value + } + } + + fun onDataChanged() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderDetailsBottomSheet.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderDetailsBottomSheet.kt new file mode 100644 index 0000000..90ff691 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderDetailsBottomSheet.kt @@ -0,0 +1,54 @@ +package com.kshitijpatil.tazabazar.ui.orders + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.kshitijpatil.tazabazar.R +import com.kshitijpatil.tazabazar.databinding.BottomsheetOrderDetailsBinding +import com.kshitijpatil.tazabazar.di.OrdersViewModelFactory +import com.kshitijpatil.tazabazar.model.OrderStatus +import com.kshitijpatil.tazabazar.util.tazabazarApplication +import org.threeten.bp.format.DateTimeFormatter + +class OrderDetailsBottomSheet : BottomSheetDialogFragment() { + private var _binding: BottomsheetOrderDetailsBinding? = null + private val binding: BottomsheetOrderDetailsBinding get() = _binding!! + private val ordersViewModel: OrdersViewModel by activityViewModels { + OrdersViewModelFactory(tazabazarApplication) + } + private val orderDateTimeFormatter = + DateTimeFormatter.ofPattern(OrdersFragment.ORDER_DATE_FORMAT) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = BottomsheetOrderDetailsBinding.inflate(inflater, container, false) + val selectedOrder = ordersViewModel.selectedOrder + binding.txtOrderDateTime.text = orderDateTimeFormatter.format(selectedOrder.createdAt) + binding.txtOrderId.text = selectedOrder.orderId + binding.txtStatus.text = getString(selectedOrder.status.displayTextResId) + binding.txtTotal.text = getString(R.string.info_price_rupee_template, selectedOrder.total) + return binding.root + } + + private val OrderStatus.displayTextResId: Int + get() { + return when (this) { + OrderStatus.ACCEPTED -> R.string.label_order_status_accepted + OrderStatus.PENDING -> R.string.label_order_status_pending + OrderStatus.DISPATCHED -> R.string.label_order_status_dispatched + OrderStatus.DELIVERED -> R.string.label_order_status_delivered + OrderStatus.CANCELLED -> R.string.label_order_status_canceled + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryAdapter.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryAdapter.kt new file mode 100644 index 0000000..3172431 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryAdapter.kt @@ -0,0 +1,32 @@ +package com.kshitijpatil.tazabazar.ui.orders + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.kshitijpatil.tazabazar.model.Order + +class OrderHistoryAdapter( + var onDetailsClickedListener: OrderHistoryItemViewHolder.OnDetailsClickedListener? = null +) : ListAdapter(OrderHistoryDiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OrderHistoryItemViewHolder { + return OrderHistoryItemViewHolder.create(parent, onDetailsClickedListener) + } + + override fun onBindViewHolder(holder: OrderHistoryItemViewHolder, position: Int) { + val orderItem = getItem(position) + holder.bind(orderItem) + } + + object OrderHistoryDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Order, newItem: Order): Boolean { + return oldItem.orderId == newItem.orderId + } + + override fun areContentsTheSame(oldItem: Order, newItem: Order): Boolean { + return oldItem == newItem + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryItemViewHolder.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryItemViewHolder.kt new file mode 100644 index 0000000..0d26b33 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderHistoryItemViewHolder.kt @@ -0,0 +1,47 @@ +package com.kshitijpatil.tazabazar.ui.orders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.kshitijpatil.tazabazar.R +import com.kshitijpatil.tazabazar.databinding.OrderHistoryItemViewBinding +import com.kshitijpatil.tazabazar.model.Order +import org.threeten.bp.format.DateTimeFormatter + +class OrderHistoryItemViewHolder( + private val binding: OrderHistoryItemViewBinding, + var onDetailsClickedListener: OnDetailsClickedListener? = null +) : RecyclerView.ViewHolder(binding.root) { + private val customDateTimeFormatter = + DateTimeFormatter.ofPattern(OrdersFragment.ORDER_DATE_FORMAT) + + fun bind(order: Order) { + val context = binding.root.context + binding.txtDate.text = customDateTimeFormatter.format(order.createdAt) + val strippedOrderId = stripOrderIdToFirstSegment(order.orderId) + binding.txtOrderId.text = + context.getString(R.string.info_order_id_template, strippedOrderId) + binding.txtTotalCost.text = + context.getString(R.string.info_price_rupee_template, order.total) + binding.btnDetails.setOnClickListener { onDetailsClickedListener?.onDetailsClicked(order.orderId) } + } + + private fun stripOrderIdToFirstSegment(orderId: String): String { + return orderId.takeWhile { it != '-' } + } + + fun interface OnDetailsClickedListener { + fun onDetailsClicked(orderId: String) + } + + companion object { + fun create( + parent: ViewGroup, + onDetailsClickedListener: OnDetailsClickedListener? = null + ): OrderHistoryItemViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = OrderHistoryItemViewBinding.inflate(inflater, parent, false) + return OrderHistoryItemViewHolder(binding, onDetailsClickedListener) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderSuccessFragment.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderSuccessFragment.kt new file mode 100644 index 0000000..4c98de6 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrderSuccessFragment.kt @@ -0,0 +1,43 @@ +package com.kshitijpatil.tazabazar.ui.orders + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.button.MaterialButton +import com.kshitijpatil.tazabazar.R + +class OrderSuccessFragment : Fragment(R.layout.fragment_order_success) { + private val args: OrderSuccessFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + val affirmationText = + getString(R.string.info_order_success_affirmation_template, args.userFullName) + view?.let { + it.findViewById(R.id.txt_affirmation).text = affirmationText + } + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.btn_track_order) + .setOnClickListener { navigationUp() } + } + + private fun navigationUp() { + /*requireActivity() + .findNavController(R.id.main_activity_nav_host_fragment) + .popBackStack()*/ + findNavController().navigateUp() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersFragment.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersFragment.kt index 33e111b..d5b1e16 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersFragment.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersFragment.kt @@ -1,7 +1,111 @@ package com.kshitijpatil.tazabazar.ui.orders +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController import com.kshitijpatil.tazabazar.R +import com.kshitijpatil.tazabazar.databinding.FragmentOrdersBinding +import com.kshitijpatil.tazabazar.di.OrdersViewModelFactory +import com.kshitijpatil.tazabazar.util.UiState +import com.kshitijpatil.tazabazar.util.launchAndRepeatWithViewLifecycle +import com.kshitijpatil.tazabazar.util.setLoginToPerformActionPrompt +import com.kshitijpatil.tazabazar.util.tazabazarApplication +import com.kshitijpatil.tazabazar.widget.FadingSnackbar +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch -class OrdersFragment : Fragment(R.layout.fragment_orders) { +class OrdersFragment : Fragment(), OrderHistoryItemViewHolder.OnDetailsClickedListener { + private var _binding: FragmentOrdersBinding? = null + private val binding: FragmentOrdersBinding get() = _binding!! + private val orderHistoryAdapter = OrderHistoryAdapter() + private var snackbar: FadingSnackbar? = null + private val viewModel: OrdersViewModel by activityViewModels { + OrdersViewModelFactory(tazabazarApplication) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentOrdersBinding.inflate(inflater, container, false) + binding.rvOrdersHistory.adapter = orderHistoryAdapter + binding.txtPromptLogin.setLoginToPerformActionPrompt( + promptStringResId = R.string.info_to_view_order_history, + loginTextModifier = { textSize = 48f }, + onLogin = { onLoginClicked() } + ) + snackbar = binding.snackbar + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + launchAndRepeatWithViewLifecycle { + launch { updateVisibilityForStateChanges() } + } + binding.swipeRefreshOrdersHistory.setOnRefreshListener { + viewModel.refreshOrdersList() + } + } + + private suspend fun updateVisibilityForStateChanges() { + combine(viewModel.isLoggedIn, viewModel.userOrdersState, ::Pair) + .collect { (loggedIn, ordersState) -> + binding.swipeRefreshOrdersHistory.isRefreshing = + loggedIn && ordersState is UiState.Loading + val ordersListVisibleState = ordersState is UiState.Success + if (ordersState is UiState.Success) { + orderHistoryAdapter.submitList(ordersState.value) + binding.txtOrderHistoryEmpty.isVisible = ordersState.value.isEmpty() + binding.rvOrdersHistory.isVisible = ordersListVisibleState + && loggedIn + && ordersState.value.isNotEmpty() + } + binding.txtPromptLogin.isVisible = !loggedIn + binding.swipeRefreshOrdersHistory.isVisible = loggedIn + } + } + + override fun onStart() { + super.onStart() + orderHistoryAdapter.onDetailsClickedListener = this + } + + override fun onPause() { + orderHistoryAdapter.onDetailsClickedListener = null + super.onPause() + } + + override fun onDetailsClicked(orderId: String) { + val orderDetailsFound = viewModel.updateSelectedOrderById(orderId) + if (orderDetailsFound) showBottomSheetWithOrderDetails() + else snackbar?.show(R.string.error_order_details_not_found) + } + + private fun showBottomSheetWithOrderDetails() { + findNavController().navigate(R.id.action_navigation_orders_to_bottom_sheet_order_details) + } + + private fun onLoginClicked() { + requireActivity().findNavController(R.id.main_activity_nav_host_fragment) + .navigate(R.id.action_fragment_dashboard_to_navigation_auth) + } + + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + companion object { + const val ORDER_DATE_FORMAT = "EEE, dd LLL, yyyy" + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersViewModel.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersViewModel.kt new file mode 100644 index 0000000..a01724f --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/orders/OrdersViewModel.kt @@ -0,0 +1,78 @@ +package com.kshitijpatil.tazabazar.ui.orders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kshitijpatil.tazabazar.domain.GetUserOrdersUseCase +import com.kshitijpatil.tazabazar.domain.ObserveSessionStateUseCase +import com.kshitijpatil.tazabazar.domain.Result +import com.kshitijpatil.tazabazar.domain.SessionState +import com.kshitijpatil.tazabazar.model.Order +import com.kshitijpatil.tazabazar.util.UiState +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber + +class OrdersViewModel( + private val getUserOrdersUseCase: GetUserOrdersUseCase, + private val observeSessionStateUseCase: ObserveSessionStateUseCase +) : ViewModel() { + private val _userOrdersState = MutableStateFlow>>(UiState.Idle) + val userOrdersState: StateFlow>> + get() = _userOrdersState.asStateFlow() + private val _isLoggedIn = MutableStateFlow(false) + + val isLoggedIn: StateFlow = observeSessionStateUseCase() + .map { it is SessionState.LoggedIn } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private var _selectedOrder: Order? = null + + /** this field should only be accessed when updateSelectedOrder was succeeded */ + val selectedOrder: Order get() = _selectedOrder!! + + init { + viewModelScope.launch { observeLoggedInState() } + } + + private suspend fun observeLoggedInState() { + isLoggedIn.collect { loggedIn -> + // if the user isn't logged-in anymore and there were fetched + // orders for that user, clear them + if (!loggedIn && userOrdersState.value is UiState.Success) { + _userOrdersState.emit(UiState.Idle) + } + // load/reload user orders when state changes to logged-in + if (loggedIn) loadUserOrders() + } + } + + /** Sets the selected order by searching [userOrdersState]'s list by [orderId] + * @return true if the current [userOrdersState] is [UiState.Success] and [orderId] + * was found in the available list, false otherwise. + */ + fun updateSelectedOrderById(orderId: String): Boolean { + val currentOrderState = _userOrdersState.value + if (currentOrderState is UiState.Success) { + _selectedOrder = currentOrderState.value.find { it.orderId == orderId } + return _selectedOrder != null + } + return false + } + + private suspend fun loadUserOrders() { + Timber.d("called") + _userOrdersState.emit(UiState.Loading()) + val userOrdersState = when (val result = getUserOrdersUseCase(Unit)) { + is Result.Error -> UiState.Error + Result.Loading -> UiState.Loading() + is Result.Success -> UiState.Success(result.data) + } + _userOrdersState.emit(userOrdersState) + } + + fun refreshOrdersList() { + viewModelScope.launch { + loadUserOrders() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/profile/ProfileViewModel.kt index dc0bfb0..272e11e 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/profile/ProfileViewModel.kt @@ -34,8 +34,14 @@ class ProfileViewModel( private suspend fun observeSessionForLoggedInUser() { observeSessionStateUseCase() - .map { if (it is SessionState.LoggedIn) it.user else null } - .collect { setState { copy(loggedInUser = it) } } + .map { + Timber.d("Session State was $it") + if (it is SessionState.LoggedIn) it.user else null + } + .collect { + Timber.d("Updating LoggedInUser to $it") + setState { copy(loggedInUser = it) } + } } private fun setState(mutator: ProfileViewState.() -> ProfileViewState) { diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/util/UiState.kt b/app/src/main/java/com/kshitijpatil/tazabazar/util/UiState.kt index 0034ac3..d6c0b9a 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/util/UiState.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/util/UiState.kt @@ -7,4 +7,6 @@ sealed class UiState { data class Success(val value: T) : UiState() object Error : UiState() data class Loading(@StringRes val msgResId: Int? = null) : UiState() -} \ No newline at end of file +} + +val UiState<*>.enableActionButton get() = this is UiState.Error || this is UiState.Idle \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/util/UiUtils.kt b/app/src/main/java/com/kshitijpatil/tazabazar/util/UiUtils.kt index db3ca34..e8e0403 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/util/UiUtils.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/util/UiUtils.kt @@ -1,10 +1,18 @@ package com.kshitijpatil.tazabazar.util +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.textfield.TextInputLayout +import com.kshitijpatil.tazabazar.R import com.kshitijpatil.tazabazar.TazaBazarApplication import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted @@ -72,4 +80,38 @@ inline fun CoroutineScope.launchTextInputLayoutObservers( } } -val Fragment.tazabazarApplication get() = requireActivity().application as TazaBazarApplication \ No newline at end of file +val Fragment.tazabazarApplication get() = requireActivity().application as TazaBazarApplication + +/** + * Will set a Spannable String on TextView in the following format + * Login + * for instance, 'Login to place your Order' + * Where the word 'Login' will be clickable and invoke the [onLogin] callback + * @param promptStringResId String Resource Id of your prompt + * @param loginTextModifier Define your customization for Login's Text Appearance here + * @param onLogin A callback method to be invoked when 'Login' gets clicked + */ +internal fun TextView.setLoginToPerformActionPrompt( + promptStringResId: Int, + loginTextModifier: TextPaint.() -> Unit = {}, + onLogin: () -> Unit +) { + val loginTextSpan = object : ClickableSpan() { + override fun onClick(view: View) { + onLogin() + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.apply(loginTextModifier) + } + } + val loginText = context.getString(R.string.label_login) + val promptText = context.getString(promptStringResId) + val loginStart = 0 + val targetSpannableString = SpannableString("$loginText $promptText").apply { + setSpan(loginTextSpan, loginStart, loginText.length, Spanned.SPAN_POINT_MARK) + } + text = targetSpannableString + movementMethod = LinkMovementMethod.getInstance() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_square.xml b/app/src/main/res/drawable/ic_check_square.xml new file mode 100644 index 0000000..a70e094 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_square.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/layout/bottomsheet_order_details.xml b/app/src/main/res/layout/bottomsheet_order_details.xml new file mode 100644 index 0000000..8b6bd5c --- /dev/null +++ b/app/src/main/res/layout/bottomsheet_order_details.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cart_cost_view.xml b/app/src/main/res/layout/cart_cost_view.xml deleted file mode 100644 index 632e919..0000000 --- a/app/src/main/res/layout/cart_cost_view.xml +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/cart_footer_view.xml b/app/src/main/res/layout/cart_footer_view.xml new file mode 100644 index 0000000..6e211dc --- /dev/null +++ b/app/src/main/res/layout/cart_footer_view.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_favorite_products.xml b/app/src/main/res/layout/fragment_favorite_products.xml index deb1988..a9a3974 100644 --- a/app/src/main/res/layout/fragment_favorite_products.xml +++ b/app/src/main/res/layout/fragment_favorite_products.xml @@ -74,14 +74,14 @@ + app:cornerRadius="@dimen/corner_radius_medium" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_order_success.xml b/app/src/main/res/layout/fragment_order_success.xml new file mode 100644 index 0000000..27321c0 --- /dev/null +++ b/app/src/main/res/layout/fragment_order_success.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_orders.xml b/app/src/main/res/layout/fragment_orders.xml index 0911885..bc98654 100644 --- a/app/src/main/res/layout/fragment_orders.xml +++ b/app/src/main/res/layout/fragment_orders.xml @@ -1,10 +1,65 @@ + android:layout_height="match_parent" + android:background="@color/tzb_gray_100"> + + android:layout_marginVertical="20dp" + android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + tools:text="Login to view your order history" /> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/order_history_item_view.xml b/app/src/main/res/layout/order_history_item_view.xml new file mode 100644 index 0000000..c020484 --- /dev/null +++ b/app/src/main/res/layout/order_history_item_view.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation_dashboard.xml b/app/src/main/res/navigation/navigation_dashboard.xml index 3791d0a..79d0a32 100644 --- a/app/src/main/res/navigation/navigation_dashboard.xml +++ b/app/src/main/res/navigation/navigation_dashboard.xml @@ -30,7 +30,11 @@ + android:label="OrdersFragment"> + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation_main.xml b/app/src/main/res/navigation/navigation_main.xml index 24c4d4d..a6a31b0 100644 --- a/app/src/main/res/navigation/navigation_main.xml +++ b/app/src/main/res/navigation/navigation_main.xml @@ -24,4 +24,15 @@ app:destination="@id/navigation_auth" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index ecba7dd..6292374 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,4 +12,8 @@ 32dp 28dp 8dp + 70dp + 56dp + 12dp + 8dp \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 5b17db4..c294058 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -7,7 +7,9 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61b8edd..38c9401 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,4 +56,29 @@ Phone Number An account with this email address already exists An account with this phone number already exists + Place Order + Failed to place your order! + Retry + Preparing your order + Your order will be prepared and will come soon + Track My Order + Check your order status to know more about next steps information + %1$s, your order has been successful + Your order has been successful + to place your order now! + to view your order history + Order: %1$s + Order details not found + Details + Order Details + Ordered + Order ID + Status + Order History + Accepted + Pending + Dispatched + Delivered + Canceled + No Previous orders \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 8c991f6..de24307 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,6 +5,7 @@ @color/tzb_green @color/tzb_green_darker @color/white + ?colorPrimary @color/teal_200 @color/teal_700 @@ -89,6 +90,11 @@ 24dp + +