Build an Android Ecommerce App with Medusa

Build an Android Ecommerce App with Medusa

In addition to ecommerce websites, many ecommerce startups now require mobile applications to enhance the shopping experience for users with features such as push notifications, personalization, less friction, and more.

Medusa is a set of ecommerce building blocks that gives developers full control over building an ecommerce application. Its modules make up a headless backend that any frontend, storefront, app, or admin dashboard, can connect to through REST APIs. “Headless” in this case means that the backend and frontend are separate.

This article explains how to build an Android ecommerce app with Medusa. The code source of the Android client is available on GitHub.

Here is a preview of what your application should look like at the end of this tutorial.

Prerequisites

To follow this guide, it's essential to have the following:

Set Up Medusa Server

Install the Medusa CLI app with the following command:

yarn global add @medusajs/medusa-cli

Create a new Medusa project:

medusa new my-medusa-server --seed

By default, it will use SQLite database. You can configure it to use PostgreSQL database. Refer to the documentation for the details.

Start the server with the commands below:

cd my-medusa-server
medusa develop

Your server runs at port 9000. To verify that the server is working properly, you can open the URL http://localhost:9000/store/products with your browser. You should get the JSON data of products available on your server.

Set Up the Android Ecommerce Project

Begin by launching Android Studio and create a new project. In the New Project window, choose Empty Compose Activity.

In the following screen, choose API 33: Android Tiramisu in the Minimum SDK field.

You can name your application and your package as you wish. In this tutorial, the application's name is Medusa Android Application and the package name is com.medusajs.android.medusaandroidapplication.

Install Dependencies

Once the project is ready, edit the application build.gradle and add these libraries in the dependencies block:

dependencies {
        ...

        // Add the new dependencies here
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.github.skydoves:landscapist-glide:2.1.0'
    implementation 'androidx.navigation:navigation-compose:2.5.3'
}

Sync the Gradle build file by clicking the Sync Now button. The Retrofit library allows you to connect to API with objects, while the Glide library facilitates the loading of remote images. The navigation connects screens in your Android application.

Create an XML file named network_security_config.xml inside app/res/xml and add the following content:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:android="http://schemas.android.com/apk/res/android">
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
</network-security-config>

Add this network rule to connect to your localhost in the Android emulator without https. The Android emulator recognizes localhost as 10.0.2.2.

Edit AndroidManifest.xml in app/manifests. Add these lines inside the manifest node:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- Add them here -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.INTERNET" />

        <application> ... </application>
</manifest>

Add this line as an attribute in the application node:

android:networkSecurityConfig="@xml/network_security_config"

Connect the Android Project to the Medusa Server

You have to model the object that represents the API in the Medusa Server. Create the package model in app/java/com/medusajs/android/medusaandroidapplication. Inside the package, create the Response class, and replace the content with the following code:

package com.medusajs.android.medusaandroidapplication.model

data class ProductsResult(val products: List<Product>)

data class ProductResult(val product: Product)

data class CartResult(val cart: Cart)

data class CartRequest(val items: List<Item>)

data class Product(
  val id: String?,
  val title: String?,
  val thumbnail: String?,
  val variants: List<Variant>
)

data class Variant(
  val id: String?,
  val prices: List<Price>
)

class Price {
  var currency_code: String = ""
  var amount: Int = 0

  fun getPrice() : String {
    return "$currency_code $amount"
  }
}

data class Item(
  val variant_id: String,
  val title: String?,
  val thumbnail: String?,
  val quantity: Int
)

data class Cart(
  val id: String?,
  val items: List<Item>
)

data class LineItem(
  val variant_id: String,
  val quantity: Int
)

Each data class represents the JSON data structure you get from or send to the Medusa server. You can take a look at the complete API reference of the Medusa server in this store API reference.

Check if the result of an API call has this structure:

{
  products: {
    {
      id: ...,
      title: ...
    },
    {
      id: ...,
      title: ...
    }
  }
}

If that is the case, then the correspondence data class are these classes:

data class ProductsResult(val products: List<Product>)

data class Product(
  val id: String?,
  val title: String?
)

You need to access the API endpoints. Create the MedusaService interface in app/java/com/medusajs/android/medusaandroidapplication/``model and replace the content with the following code:

package com.medusajs.android.medusaandroidapplication.model

import retrofit2.Call
import retrofit2.http.*

interface MedusaService {

  @GET("/store/products")
  fun retrieveProducts(): Call<ProductsResult>

  @GET("/store/products/{id}")
  fun getProduct(@Path("id") id: String) : Call<ProductResult>

  @Headers("Content-Type: application/json")
  @POST("/store/carts")
  fun createCart(@Body cart: CartRequest): Call<CartResult>

  @Headers("Content-Type: application/json")
  @POST("/store/carts/{id}/line-items")
  fun addProductToCart(@Path("id") id: String, @Body lineItem: LineItem): Call<CartResult>

  @GET("/store/carts/{id}")
  fun getCart(@Path("id") id: String) : Call<CartResult>
}

As you can see, there are two annotations. One is for the GET request and the other is for the POST request. The argument for the annotation is the API endpoint in the Medusa server. The argument /store/products mean http://``localhost:9000``/store/products. The return result represents the result type you will get.

The @Body represents the JSON data you send to the API endpoint. The @Path represents the parameter of the API endpoint.

This is only an interface. You need to implement it. Create the ProductsRetriever class inside the app/java/com/medusajs/android/medusaandroidapplication/model package and replace the content with the following code:

package com.medusajs.android.medusaandroidapplication.model

import retrofit2.Callback
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class ProductsRetriever {
  private val service: MedusaService

  companion object {
    const val BASE_URL = "http://10.0.2.2:9000"
  }

  init {
    val retrofit = Retrofit.Builder()
      .baseUrl(BASE_URL)
      .addConverterFactory(GsonConverterFactory.create())
      .build()

    service = retrofit.create(MedusaService::class.java)
  }

  fun getProducts(callback: Callback<ProductsResult>) {
    val call = service.retrieveProducts()
    call.enqueue(callback)
  }

  fun getProduct(productId: String, callback: Callback<ProductResult>) {
    val call = service.getProduct(productId)
    call.enqueue(callback)
  }

  fun createCart(cartId: String, variantId: String, callback: Callback<CartResult>) {
    if (cartId.isNotEmpty()) {
      val lineItemRequest = LineItem(variantId, 1)
      val call = service.addProductToCart(cartId, lineItemRequest)
      call.enqueue(callback)
    } else {
      val items = listOf(
        Item(variant_id=variantId, quantity=1, thumbnail=null, title=null)
      )
      val cartRequest = CartRequest(items)
      val call = service.createCart(cartRequest)
      call.enqueue(callback)
    }
  }

  fun getCart(cartId: String, callback: Callback<CartResult>) {
    if (cartId.isNotEmpty()) {
      val call = service.getCart(cartId)
      call.enqueue(callback)
    }
  }
}

In this class, you initialize the retrofit variable with the base URL http://10.0.2.2:9000 and the Gson converter to handle JSON. Remember that the emulator knows the localhost as 10.0.2.2. Then you create a service with the create method from the retrofit variable with MedusaService.

You use this service variable within the implemented methods. You execute the method defined in the interface, resulting in the call object. Then you call the enqueue method from this object with the callback argument. After the API endpoint is called, this callback will be executed.

Products List Screen

Now that you can connect to API endpoints, you can create a UI interface for the products list. Create a new file called ProductsList.kt in app/java/com/medusajs/android/``medusaandroidapplication``ui and replace the content with the following code:

package com.medusajs.android.medusaandroidapplication.ui

import androidx.compose.foundation.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.medusajs.android.medusaandroidapplication.model.Product
import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.glide.GlideImage

@Composable
fun ProductsList(products: List<Product>,
                 onItemSelected: (String) -> Unit,
                 onCartButtonClick: () -> Unit) {
  Column(modifier = Modifier
    .verticalScroll(rememberScrollState())
    .fillMaxSize()
    .padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally
  ) {
    Button(onClick = {
      onCartButtonClick()
    }) {
      Text("My Cart")
    }
    products.forEach { product ->
      Column(
        modifier = Modifier
          .padding(8.dp)
          .border(BorderStroke(2.dp, Color.Gray))
          .clickable {
            onItemSelected(product.id!!)
          }
        ,
        horizontalAlignment = Alignment.CenterHorizontally
      ) {
        GlideImage(
          imageModel = { product.thumbnail!! },
          imageOptions = ImageOptions(
            contentScale = ContentScale.Crop,
            requestSize = IntSize(400,600),
            alignment = Alignment.Center
          )
        )
        Text(product.title!!, fontWeight = FontWeight.Bold)
        Text(product.variants[0].prices[0].getPrice())
      }
    }

  }
}

In this ProductsList function, you create a button to go to the cart screen and a list of products. Each card is composed of a remote image, a title of the product, and the price.

Each product in Medusa has more than one variant. Each variant can have many prices (depending on which region). For this mobile ecommerce tutorial, each product uses the first variant and the first price from the variant.

The purpose of this decision is to make the tutorial simple. But in production, you can make a different architecture, such as displaying products with their variants.

Product Info Screen

Now it’s time to create a detail page. On that page, a user can add the product to their shopping cart.

Create the ProductItem.ktfile inside app/java/com/medusajs/androidmedusaandroidapplication/ui and replace the content with the following code:

package com.medusajs.android.<your application name>.ui

import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medusajs.android.medusaandroidapplication.model.CartResult
import com.medusajs.android.medusaandroidapplication.model.Product
import com.medusajs.android.medusaandroidapplication.model.ProductsRetriever
import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.glide.GlideImage
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response


@Composable
fun ProductItem(product: Product,
                cartId: String,
                onCartChange: (String) -> Unit) {
  Column(
    modifier = Modifier
      .padding(8.dp)
      .fillMaxSize(),
    horizontalAlignment = Alignment.CenterHorizontally
  ) {
    GlideImage(
      imageModel = { product.thumbnail!! },
      imageOptions = ImageOptions(
        alignment = Alignment.Center,
        requestSize = IntSize(800,1200)
      )
    )
    Text(product.title!!, fontSize = 30.sp)
    Text(product.variants[0].prices[0].getPrice())
    Button(onClick = {
      val productsRetriever = ProductsRetriever()
      val callback = object : Callback<CartResult> {
        override fun onFailure(call: Call<CartResult>, t: Throwable) {
          Log.e("MainActivity", t.message!!)
        }

        override fun onResponse(call: Call<CartResult>, response: Response<CartResult>) {
          response.isSuccessful.let {
            response.body()?.let { c ->
              onCartChange(c.cart.id!!)
            }
          }
        }
      }
      productsRetriever.createCart(cartId, product.variants[0].id!!, callback)
    }) {
      Text("Add 1 Item to Cart")
    }
  }
}

In the button's callback, you create an object of Callback<CartResult> with two methods: onFailure and onResponse. If the API call is successful, the onReponse method will be triggered. You call onCartChange with the cart id you receive from the server. The method saves the cart id to a variable so you can reuse the cart id later. If the API call is unsuccessful, the onFailure method will be called. In this case, you just log the error.

You call the createCart method with the variant id of the product. To simplify the tutorial, you just send the first variant of the product. Then you can only add one piece of the product.

If you want to add two product units, you must press the button twice. You also send the cart ID. Initially, the cart ID will be 'null’, in which case the server will create a cart for you. However, if it is not null, you will reuse the existing cart, allowing you to add the product.

To create a cart, you call the createCart method with the CartRequest object. To add an item to the cart, call the addProductToCart method with the LineItem object.

You can verify the JSON data the Medusa server requires in the API documentation.

For example, to add a line item, the expected payload is:

{
    "variant_id": "string",
    "quantity": 0,
    "metadata": {}
}

Notice the JSON fields (variant_id and quantity) are the same as the fields in the LineItem data class.

Create a Cart

You need to create the ViewModel. Create CartViewModel in app/java/com/medusajs/android/``m``edusaandroidapplication/model:

package com.medusajs.android.medusaandroidapplication.model

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow


data class CartModel (
  val title: String,
  val quantity: Int,
  val thumbnail: String,
)

class CartViewModel : ViewModel() {

  private val _cartState = MutableStateFlow(emptyList<CartModel>())
  val cartState: StateFlow<List<CartModel>> = _cartState.asStateFlow()

  fun setCart(cartModels: List<CartModel>) {
    _cartState.value = cartModels
  }
}

The ViewModel has _cartState that is a MutableStateFlow holding CartModel.

Users might want to see what products they have added to their shopping cart. You need to create a screen for the cart. Create CartCompose in app/java/com/medusajs/android/<your application name>/ui and replace its content with the following code:

package com.raywenderlich.android.medusaandroidapplication.ui

import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.medusajs.android.medusaandroidapplication.model.CartModel
import com.medusajs.android.medusaandroidapplication.model.CartResult
import com.medusajs.android.medusaandroidapplication.model.CartViewModel
import com.medusajs.android.medusaandroidapplication.model.ProductsRetriever
import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.glide.GlideImage
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response


@Composable
fun CartCompose(cartId: String,
                cartViewModel: CartViewModel = viewModel()
) {
  val cartStates by cartViewModel.cartState.collectAsState()

  val callback = object : Callback<CartResult> {
    override fun onFailure(call: Call<CartResult>, t: Throwable) {
      Log.e("MainActivity", t.message!!)
    }

    override fun onResponse(call: Call<CartResult>, response: Response<CartResult>) {
      response.isSuccessful.let {
        response.body()?.let { c ->
          val cartModels : MutableList<CartModel> = mutableListOf()
          c.cart.items.forEach { item ->
            val title = item.title!!
            val thumbnail = item.thumbnail!!
            val quantity = item.quantity
            val cartModel = CartModel(title, quantity, thumbnail)
            cartModels.add(cartModel)
          }
          cartViewModel.setCart(cartModels.toList())
        }
      }
    }
  }
  val productsRetriever = ProductsRetriever()
  productsRetriever.getCart(cartId, callback)
  Column(modifier = Modifier
    .verticalScroll(rememberScrollState())
    .fillMaxSize()
    .padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally
  ) {
    Text("My Cart")
    cartStates.forEach { cartState ->
      Row(modifier = Modifier.fillMaxWidth().padding(16.dp),
      horizontalArrangement = Arrangement.SpaceBetween) {
        GlideImage(
          imageModel = { cartState.thumbnail },
          imageOptions = ImageOptions(
            alignment = Alignment.Center,
            requestSize = IntSize(200,300)
          )
        )
        Text(cartState.title)
        Text("${cartState.quantity} pcs")
      }
    }
  }
}

You use cartViewModel and a ViewModel to hold the products you added to the cart.

In the onResponse method, after a successful API call, you set the ViewModel with the cart JSON object from the server.

You call the getCart method with the cart id argument to get the information about the cart.

Set up the Navigation

All that’s left is to create the navigation that connects the screens. Create MainAppCompose inside app/java/com/medusajs/ui and replace the content with the following code:

package com.medusajs.android.medusaandroidapplication.ui

import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.medusajs.android.medusaandroidapplication.model.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

@Composable
fun MainApp(products: List<Product>) {
  var cartId by rememberSaveable { mutableStateOf("") }

  val navController = rememberNavController()
  var product : Product? = null

  NavHost(navController = navController, startDestination = "list") {
    composable("list") {
      ProductsList(products,
        onItemSelected = { product_id ->
          navController.navigate("item/$product_id")
        },
        onCartButtonClick = {
          navController.navigate("cart")
        }
      )
    }
    composable(
      "item/{product_id}",
            arguments =  listOf(navArgument("product_id") { type = NavType.StringType})) { it ->
      val productId = it.arguments?.getString("product_id")!!
      val callback = object : Callback<ProductResult> {
        override fun onFailure(call: Call<ProductResult>, t: Throwable) {
          Log.e("MainActivity", t.message!!)
        }

        override fun onResponse(call: Call<ProductResult>, response: Response<ProductResult>) {
          response.isSuccessful.let {
            response.body()?.let { p ->
              product = p.product
            }
          }
        }
      }
      val productsRetriever = ProductsRetriever()
      productsRetriever.getProduct(productId, callback)
      product?.let { ProductItem(it, cartId, onCartChange = { cartId = it }) }
    }

    composable("cart") {
      CartCompose(cartId)
    }
  }
}

You created NavHost, which is composed of three compose functions or screens. You navigate from one screen to another screen with the navigate method of the navController object.

Finally, edit MainActivity that is inside the app/java/com/medusajs/android/medusaandroidapplication package to call the MainApp that connects the screens. Replace the content with the following code:

package com.medusajs.android.medusaandroidapplication

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.*
import com.medusajs.android.medusaandroidapplication.model.Product
import com.medusajs.android.medusaandroidapplication.model.ProductsResult
import com.medusajs.android.medusaandroidapplication.model.ProductsRetriever
import com.medusajs.android.medusaandroidapplication.ui.MainApp
import com.medusajs.android.medusaandroidapplication.ui.theme.MedusaAndroidApplicationTheme
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response


class MainActivity : ComponentActivity() {

  private val productsRetriever = ProductsRetriever()

  private var products : List<Product> = emptyList()

  private val callback = object : Callback<ProductsResult> {
    override fun onFailure(call: Call<ProductsResult>, t: Throwable) {
      Log.e("MainActivity", t.message!!)
    }

    override fun onResponse(call: Call<ProductsResult>, response: Response<ProductsResult>) {
      response.isSuccessful.let {
        products = response.body()?.products ?: emptyList()
        setContentWithProducts()
      }
    }
  }

  fun setContentWithProducts() {
    setContent {
      MedusaAndroidApplicationTheme {
        Scaffold(
          topBar = { TopAppBar(title = { Text(text = "Medusa App") }) }
        ) {
          MainApp(products = products)
        }
      }
    }
  }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    productsRetriever.getProducts(callback)
  }
}

Test the Application

Now that you have written the code, it's time to build the application. Make sure the Medusa server is running and run the following command:

cd my-medusa-server
medusa develop

Build and run the application in the Android emulator. But first, you need to create a device in Device Manager. You will get the screen of the products list.

Click one of the products, then you'll see the product detail page.

Add some products to your shopping cart in the products list screen; click the button to view the cart.

Conclusion

This tutorial provides an overview of how to build an Android ecommerce application with Medusa. This application provides basic functionalities, but you can implement more functionalities to enhance it:

Visit the Medusa store API reference to discover all the possibilities to build your ecommerce application.

Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.