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:
- The latest stable version of Node.js
- Yarn, but you can use npm or pnpm as alternatives to yarn if you prefer.
- Android Studio
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.kt
file 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:
- Add a “region switcher" for customers’ region selection.
- Use product variants to provide product options like colors on the details view.
- Implement authentication flow allowing customers to sign up with their email or log in via social media.
- Implement the full checkout experience when customers confirm the payment.
- Integrate payment methods such as Stripe.
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.