Skip to content

Commit

Permalink
Merge pull request #6 from Kshitij09/feature/place-order
Browse files Browse the repository at this point in the history
* Support Placing an Order when you're logged-in and have added some items in the Cart
* You can view your Order History when logged-in
* Any action requiring LogIn will prompt you with an easy link to complete your authentication and will bring you right back where you left
* Support viewing order details when clicked on the 'details' from the `OrdersFragment`. Currently this uses all the local details to populate the view, but this could further be improvised by making an actual API call to get the fresh, consistent information from the remote server
* Order details retrieved from the remote server are mapped with local inventory details and thus total of an order is calculated completely offline. If an Order contains some inventory-ids that are not currently in the local database, we **Skip** that order from the final result. This should later be changed by making API calls that fetch respective inventory details
  • Loading branch information
Kshitij09 authored Oct 24, 2021
2 parents b125d74 + 8b51777 commit 964920a
Show file tree
Hide file tree
Showing 55 changed files with 2,011 additions and 236 deletions.
1 change: 1 addition & 0 deletions api-client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ test {
if (project.hasProperty('prod')) {
filter {
excludeTestsMatching "com.kshitijpatil.tazabazar.api.TestAuthApi.register*"
excludeTestsMatching "com.kshitijpatil.tazabazar.api.TestOrderApi.*"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.kshitijpatil.tazabazar.api

import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
Expand All @@ -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()
Expand All @@ -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()
}
}

Original file line number Diff line number Diff line change
@@ -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<OrderLineDto>): 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<OrderResponse>
}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<OrderLineDto> = emptyList(),
@Json(name = "status")
val status: String,
@Json(name = "username")
val username: String
)
Original file line number Diff line number Diff line change
@@ -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("[email protected]", "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)
}
}
10 changes: 8 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.TazaBazar"
tools:targetApi="n">
<!-- disables android.startup altogether, change this if app-startup is used in the future -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
android:exported="false"
tools:node="merge">
<!-- If you are using androidx.startup to initialize other components -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".ui.MainActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,40 +29,57 @@ interface CartRepository {
suspend fun getCartConfiguration(): CartConfiguration

fun observeCartItemCount(): Flow<Int>

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<CartItem> {
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<Int> {
return cartItemDao.observeCartItemCount()
return cartItemDao.observeCartItemCount().flowOn(dispatchers.io)
}

override suspend fun clearCart() {
withContext(dispatchers.io) {
cartItemDao.deleteAll()
}
}

}
119 changes: 119 additions & 0 deletions app/src/main/java/com/kshitijpatil/tazabazar/data/OrderRepository.kt
Original file line number Diff line number Diff line change
@@ -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<CartItem>)
abstract suspend fun getOrdersOfCurrentUser(): List<Order>
}

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<CartItem>) {
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<Order> {
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<Order, List<InventoryEntity>> {
val invIds = order.orderLines.map(OrderLine::inventoryId)
val inventories = inventoryDao.getInventoriesByIds(invIds)
return Pair(order, inventories)
}

private fun skipOrderWithMissingInventories(orderInvPair: Pair<Order, List<InventoryEntity>>): 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, List<InventoryEntity>>): 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
)
}
Loading

0 comments on commit 964920a

Please sign in to comment.