Unicorn Coding Bootcamp — The Unicorn Fullstack Professional Developer Guide to Modern Android Development — Part 1

Building a Modern Android App using UnicornStack for Fun and Science

Arunabh Das
Developers Inc
13 min readFeb 8, 2023

--

UnicornStack Android App Mocks

In this tutorial, we shall build an Android App using Modern Android Development best practices.

In order to implement this amazing Unicorn Stack Platform for financial management, we may get our friendly neighbourhood designer to create the mocks for the UnicornStack Android App

Step 1 — Scaffold

We may start implementing by scaffolding the app by launching the latest version of Android Studio (Giraffe 2022.3.1Canary 2) at the time of going to press with this post) and use the EmptyActivity template to scaffold the Unicorn Stack app as below

Giraffe 2022.3.1Canary 2
Empty Activity Scaffold

It is probably a good idea to select API 23 or higher as the min SDK level as that enables optimal compatibility with the newest APIs that the Android Framework provides.

Ensure that the gradle JDK version is 17 and the Kotlin Compiler Target JVM version is set to 17 as seen below

Gradle JDK 17
Kotlin Compiler Target JVM version

Step 2— Architecture

We now need to come up with an architecture for the Unicorn Stack

We use the graphviz and python diagramming tool diagram and the following code to come up with the code for the architecture diagram

from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS
from diagrams.aws.database import ElastiCache, RDS
from diagrams.aws.network import ELB
from diagrams.aws.network import Route53

with Diagram("Unicorn Stack Architecture", show=False):
apps = ELB("android app")

with Cluster("UnicornStack Services"):
svc_group = [ECS("service1"),
ECS("service2"),
ECS("service3")]

with Cluster("Database Layer"):
db_primary = RDS("auth")
db_primary - [RDS("services")]

memcached = ElastiCache("memcached")

apps >> svc_group
svc_group >> db_primary
svc_group >> memcached

This gives us the following diagram for the UnicornStack architecture

UnicornStack Architecture First Draft

We may clone the contents of the scaffold from the main branch of the following repo folder —

Running the Unicorn app gives us the following results —

We need to have a strong foundation in order to have a robust architecture for our app and therefore, we need to keep the project structure simple as we add features.

Accordingly, we shall refactor the project to have data, domain, presentation and viewmodel packages as seen in the following project structure

Project Structure Setup Part 1

Under the data, domain and presentation folders, we may go ahead and add the following folder structure

Project Structure Setup Part 2

Step 3 — Add Retrofit

Add Retrofit by adding the retrofit references in app level build.gradle

    // Retrofit & OkHttp
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'

Step 4 — Secure Your API_KEY

To hide the API_KEY used for accessing the stocks API, we need to hide the API_KEY as follows

First make sure both build/ and local.properties are added to your .gitignore

build/
# Local configuration file (sdk path, etc)
local.properties

Add your APIKEY xyz123 to your local.properties as follows

apiKey="xyz123"

Next add the following block to the root level of your app build.gradle

def localPropertiesFile = rootProject.file("local.properties")
def localProperties = new Properties()
localProperties.load(new FileInputStream(localPropertiesFile))

Add the following line to the android block of your app build.gradle

buildConfigField("String", "API_KEY", localProperties['apiKey'])

Step5 — Create StockApi Retrofit Interface

Then, we may call the BuildConfig.API_KEY constant in our $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/remote/StocksApi.kt as follows

package app.unicornapp.mobile.android.unicorn.data.remote

import app.unicornapp.mobile.android.unicorn.BuildConfig
import okhttp3.ResponseBody
import retrofit2.http.Query

/**
* StockApi
*/
interface StocksApi {
@GET("query?function=LISTING_STATUS")
suspend fun getListings(
@Query("apikey") apiKey: String = API_KEY
): ResponseBody

companion object {
val API_KEY = BuildConfig.API_KEY
val STOCKS_BASE_URL = BuildConfig.STOCKS_BASE_URL
}
}

Step 6 — Create CompanyListingEntity model class

We may now create our CompanyListingEntity class in

$PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/local/CompanyListingEntity.kt as follows

package app.unicornapp.mobile.android.unicorn.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

/**
* CompanyListingEntity
*/
@Entity
data class CompanyListingEntity (
val name: String,
val symbol: String,
val exchange: String,
@PrimaryKey val id: Int? = null

)

Step 7— Create CompanyListing model class

We may now create our CompanyListing class in

$PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/domain/model/CompanyListing.kt as follows

package app.unicornapp.mobile.android.unicorn.domain.model

import androidx.room.Entity

/**
* CompanyListing
*/
@Entity
data class CompanyListing (
val name: String,
val symbol: String,
val exchange: String,
)

Step 8 — Create StockMapper.kt

We may now create StockMapper.kt in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/mapper/StockMapper.kt as follows

package app.unicornapp.mobile.android.unicorn.data.mapper

import app.unicornapp.mobile.android.unicorn.data.local.CompanyListingEntity
import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing

/**
* StockMapper
*/

fun CompanyListingEntity.toCompanyListing(): CompanyListing {
return CompanyListing(
name = name,
symbol = symbol,
exchange = exchange
)
}

fun CompanyListing.toCompanyListingEntity(): CompanyListingEntity {
return CompanyListingEntity(
name = name,
symbol = symbol,
exchange = exchange
)
}

Step 9 — Create StockDao.kt

We may create a StockDao class in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/local/StockDao.kt as follows

package app.unicornapp.mobile.android.unicorn.data.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query

/**
* StockDao
*/
@Dao
interface StockDao {

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConpanyListings(
companyListingEntities: List<CompanyListingEntity>
)

@Query("DELETE FROM companylistingentity")
suspend fun clearCompanyListings()

@Query(
"""
SELECT *
FROM companylistingentity
WHERE LOWER(name) LIKE '%' || LOWER(:query) || '%' OR
UPPER(:query) == symbol
"""
)
suspend fun searchCompanyListing(query: String): List<CompanyListingEntity>

}

Step 10 — Create StockDatabase.kt

We may now create a StockDatabase class in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/local/StockDatabase.kt as follows

package app.unicornapp.mobile.android.unicorn.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

/**
* StockDatabase
*/

@Database(
entities = [CompanyListingEntity::class],
version = 1

)
abstract class StockDatabase: RoomDatabase() {
abstract val dao: StockDao
}

Step 11 — Create Resource class

We now need a generic Resource class to handle success, error and loading states

package app.unicornapp.mobile.android.unicorn.util

/**
* Resource
*/
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Succes<T>(data: T?): Resource<T>(data)
class Error<T>(message: String, data: T? = null): Resource<T>(data, message)
class Loading<T>(val isLoading: Boolean = true) : Resource<T>(null)
}

A Kotlin Flow is a coroutine that emit multiple values over time.

Step 12 — Create domain layer StockRepository interface

We may now go ahead and create the domain layer StockRepository as follows

package app.unicornapp.mobile.android.unicorn.domain.repository

import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing
import app.unicornapp.mobile.android.unicorn.util.Resource
import kotlinx.coroutines.flow.Flow

/**
* StockRepository
*/
interface StockRepository {
suspend fun getCompanyListings(
fetchFromRemote: Boolean,
query: String
) : Flow<Resource<List<CompanyListing>>>
}

Step 13 — Add Dagger Hilt Dependencies for Dependency Injection

Add Dagger Hilt dependencies as described here

First, add the hilt-android-gradle-plugin plugin to your project's root build.gradle file:

plugins {
...
id 'com.google.dagger.hilt.android' version '2.44' apply false
}

Then, apply the Gradle plugin and add these dependencies in your app/build.gradle file:

...
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}

android {
...
}

dependencies {
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
}

// Allow references to generated code
kapt {
correctErrorTypes true
}

Hilt uses Java 8 features. To enable Java 8 in your project, add the following to the app/build.gradle file:

android {
...
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}

Step 14 — Create StockRepositoryImpl

Create StockRepositoryImpl in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/repository/StockRepositoryImpl as follows

package app.unicornapp.mobile.android.unicorn.data.repository

import app.unicornapp.mobile.android.unicorn.data.local.StockDatabase
import app.unicornapp.mobile.android.unicorn.data.mapper.toCompanyListing
import app.unicornapp.mobile.android.unicorn.data.mapper.toCompanyListingEntity
import app.unicornapp.mobile.android.unicorn.data.parser.CsvParser
import app.unicornapp.mobile.android.unicorn.data.remote.StocksApi
import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing
import app.unicornapp.mobile.android.unicorn.domain.repository.StockRepository
import app.unicornapp.mobile.android.unicorn.util.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/**
* StockRepositoryImpl
*/
@Singleton
class StockRepositoryImpl @Inject constructor(
private val stockApi: StocksApi,
private val db: StockDatabase,
private val companyListingsParser: CsvParser<CompanyListing>
): StockRepository {
private val dao = db.dao
override suspend fun getCompanyListings(
fetchFromRemote: Boolean,
query: String
): Flow<Resource<List<CompanyListing>>> {
return flow {
emit(Resource.Loading(true))
val localListings = dao.searchCompanyListing(query)
emit(Resource.Succes(
data = localListings.map { entity ->
entity.toCompanyListing()
}
))
val isDbEmpty = localListings.isEmpty() && query.isBlank()
val shouldJustLoadFromCache = !isDbEmpty && !fetchFromRemote

if (shouldJustLoadFromCache) {
emit(Resource.Loading(false))
return@flow
}
val remoteListings = try {
val response = stockApi.getListings()
companyListingsParser.parse(response.byteStream())
} catch (e: IOException) {
e.printStackTrace()
emit(Resource.Error("Could not load data"))
null
} catch (e: HttpException) {
e.printStackTrace()
emit(Resource.Error("Could not load data"))
null
}

remoteListings?.let {listings ->
dao.clearCompanyListings()
dao.insertConpanyListings(
listings.map { listing ->
listing.toCompanyListingEntity()
}
)
emit(Resource.Succes(
data = dao.searchCompanyListing("")
.map { entity ->
entity.toCompanyListing()
}
))
emit(Resource.Loading(false))
}
}
}
}

Step 15 — Create CsvParser

Create CsvParser in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/parser/CsvParser

package app.unicornapp.mobile.android.unicorn.data.parser

import java.io.InputStream

/**
* CsvParser
*/
interface CsvParser<T> {
suspend fun parse(stream: InputStream): List<T>
}

Step 16 — Create CompanyListingParser

Create CompanyListingParser in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/parser/CompanyListingsParser. Make sure to annotate with @Singletonfor DI

package app.unicornapp.mobile.android.unicorn.data.parser

import java.io.InputStream
import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing
import com.opencsv.CSVReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStreamReader
import javax.inject.Inject
import javax.inject.Singleton


/**
* CompanyListingsParser
*/
@Singleton
class CompanyListingsParser @Inject constructor(): CsvParser<CompanyListing> {
override suspend fun parse(stream: InputStream): List<CompanyListing> {
val csvReader = CSVReader(InputStreamReader(stream))
return withContext(Dispatchers.IO) {
csvReader.readAll()
.drop(1)
.mapNotNull { line ->
val symbol = line.getOrNull(0)
val name = line.getOrNull(1)
val exchange = line.getOrNull(2)
CompanyListing(
name = name ?: return@mapNotNull null,
symbol = symbol ?: return@mapNotNull null,
exchange = exchange ?: return@mapNotNull null
)
}
.also {
csvReader.close()
}
}
}
}

Step 17 — Update StockRepositoryImpl

Update StockRepositoryImpl in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/data/repository/StockRepositoryImpl to include the stockListingsParser as below

package app.unicornapp.mobile.android.unicorn.data.repository

import app.unicornapp.mobile.android.unicorn.data.local.StockDatabase
import app.unicornapp.mobile.android.unicorn.data.mapper.toStockListing
import app.unicornapp.mobile.android.unicorn.data.mapper.toStockListingEntity
import app.unicornapp.mobile.android.unicorn.data.parser.CsvParser
import app.unicornapp.mobile.android.unicorn.data.remote.StocksApi
import app.unicornapp.mobile.android.unicorn.domain.model.StockListing
import app.unicornapp.mobile.android.unicorn.domain.repository.StockRepository
import app.unicornapp.mobile.android.unicorn.util.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/**
* StockRepositoryImpl
*/
@Singleton
class StockRepositoryImpl @Inject constructor(
val stockApi: StocksApi,
val db: StockDatabase,
val stockListingsParser: CsvParser<StockListing>
): StockRepository {
private val dao = db.dao
override suspend fun getStockListings(
fetchFromRemote: Boolean,
query: String
): Flow<Resource<List<StockListing>>> {
return flow {
emit(Resource.Loading(true))
val localListings = dao.searchStockListing(query)
emit(Resource.Succes(
data = localListings.map { stockListingEntity ->
stockListingEntity.toStockListing()
}
))
val isDbEmpty = localListings.isEmpty() && query.isBlank()
val shouldJustLoadFromCache = !isDbEmpty && !fetchFromRemote

if (shouldJustLoadFromCache) {
emit(Resource.Loading(false))
return@flow
}
val remoteListings = try {
val response = stockApi.getListings()
stockListingsParser.parse(response.byteStream())
} catch (e: IOException) {
e.printStackTrace()
emit(Resource.Error("Could not load data"))
null
} catch (e: HttpException) {
e.printStackTrace()
emit(Resource.Error("Could not load data"))
null
}

remoteListings?.let {listings ->
dao.clearStockListings()
dao.insertStockListings(
listings.map { stockListing ->
stockListing.toStockListingEntity()
}
)
emit(Resource.Succes(
data = dao.searchStockListing("")
.map { it.toStockListing() }
))
emit(Resource.Loading(false))
}
}
}
}

Step 18 — Create CompanyListingsEvent

Create CompanyListingsEvent in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/presentation/company_listings/CompanyListingsEvent.kt as follows

package app.unicornapp.mobile.android.unicorn.presentation.company_listings

/**
* CompanyListingsEvent
*/
sealed class CompanyListingsEvent {
object Refresh: CompanyListingsEvent()
data class OnSearchQueryChange(val query: String): CompanyListingsEvent()
}

Step 18 — Create CompanyListingsState

Create CompanyListingsState in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/presentation/company_listings/CompanyListingsState.kt as follows

package app.unicornapp.mobile.android.unicorn.presentation.company_listings

import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing

/**
* CompanyListingsState
*/
data class CompanyListingsState(
val companies: List<CompanyListing> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val searchQuery: String = ""
)

Step 19 — Create CompanyListingsViewModel

Create CompanyListingsViewModel in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/presentation/CompanyListingsViewModel.kt as follows

package app.unicornapp.mobile.android.unicorn.presentation.company_listings

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.unicornapp.mobile.android.unicorn.domain.repository.StockRepository
import app.unicornapp.mobile.android.unicorn.util.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject

/**
* CompanyListingsViewModel
*/
@HiltViewModel
class CompanyListingsViewModel @Inject constructor(
private val repository: StockRepository
): ViewModel() {
var uiState by mutableStateOf(CompanyListingsState())

private var searchJob: Job? = null

init {
getCompanyListings()
}

fun onEvent(event: CompanyListingsEvent) {
when(event) {
is CompanyListingsEvent.Refresh -> {
getCompanyListings(fetchFromRemote = true)
}
is CompanyListingsEvent.OnSearchQueryChange -> {
uiState = uiState.copy(searchQuery = event.query)
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(500L)
getCompanyListings()
}
}
}
}

private fun getCompanyListings(
query: String = uiState.searchQuery.lowercase(),
fetchFromRemote: Boolean = false
) {
viewModelScope.launch {
repository
.getCompanyListings(fetchFromRemote, query)
.collect { result ->
when (result) {
is Resource.Succes -> {
result.data?.let { listings ->
uiState = uiState.copy(
companies = listings
)
}
}
is Resource.Error -> {

}
is Resource.Loading -> {
uiState = uiState.copy(isLoading = result.isLoading)
}

}
}
}
}

}

Step 20 — Build and Run

Build and Run to check that the project builds and runs without any errors before continuing on to implement the UI in Jetpack Compose

Step 21 — Create CompanyItem Composable

Create CompanyItem composable in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/presentation/company_listings as follows

package app.unicornapp.mobile.android.unicorn.presentation.company_listings

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing
import app.unicornapp.mobile.android.unicorn.presentation.theme.TextWhite

/**
* CompanyItem
*/
@Composable
fun CompanyItem (
company: CompanyListing,
modifier: Modifier = Modifier
){
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = company.name,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = TextWhite,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = company.exchange,
fontWeight = FontWeight.Light,
color = TextWhite
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "(${company.symbol}",
fontStyle = FontStyle.Italic,
color = TextWhite
)
}
}
}

@Preview(name = "yellow square", group = "square")
@Composable
fun PreviewCompanyItem() {
CompanyItem(CompanyListing("Google", "GOOG", "NASDAQ") )
}

Step 22 — Create CompanyListingsScreen

Create CompanyListingsScreen composable in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/presentation/company_listings as follows

package app.unicornapp.mobile.android.unicorn.presentation.company_listings

import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.ui.Modifier
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import app.unicornapp.mobile.android.unicorn.R
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
* CompanyListingsScreen
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CompanyListingsScreen(
navController: NavController,
viewModel: CompanyListingsViewModel = hiltViewModel()
) {
val swipeRefreshState = rememberSwipeRefreshState(
isRefreshing = viewModel.uiState.isRefreshing
)
val state = viewModel.uiState
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Image(
painterResource(
id = R.drawable.banner_bg_2
),
contentDescription = "",
contentScale = ContentScale.FillBounds,
modifier = Modifier.matchParentSize()
)
Column(
modifier = Modifier.fillMaxSize()

) {
OutlinedTextField(
value = state.searchQuery,
onValueChange = { newQuery ->
viewModel.onEvent(
CompanyListingsEvent.OnSearchQueryChange(newQuery)
)
},
modifier = Modifier
.padding(48.dp)
.fillMaxWidth(),
placeholder = {
Text(
text = "Search...",
color = Color.White,
fontSize = MaterialTheme.typography.titleSmall.fontSize,
fontWeight = FontWeight.Bold
)
},
colors = TextFieldDefaults.outlinedTextFieldColors(
unfocusedLabelColor = MaterialTheme.colorScheme.inversePrimary,
textColor = Color.White
),
maxLines = 1,
singleLine = true
)
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
viewModel.onEvent(CompanyListingsEvent.Refresh)
}
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(state.companies.size) {i ->
val company = state.companies[i]
CompanyItem(
company = company,
modifier = Modifier
.fillMaxWidth()
.clickable {
// TODO-FIXME Navigate to detail screen
}
.padding(16.dp)
)
if (i < state.companies.size) {
Divider(modifier = Modifier.padding(
horizontal = 16.dp
))
}
}
}
}
}
}

}
@Preview(showBackground = true)
@Composable
fun PreviewCompanyListingsScreen(
) {
CompanyListingsScreen(
navController = rememberNavController(),
viewModel = hiltViewModel()
)
}

Step 23 — Create UnicornApplication class

Create UnicornApplication class in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/UnicornApplication and annotate with @HiltAndroidApp as below

package app.unicornapp.mobile.android.unicorn

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

/**
* UnicornApplication
*/
@HiltAndroidApp
class UnicornApplication: Application()

Step 24 — Modify AndroidManifest.xml

Modify AndroidManifest.xml in $PROJECT_ROOT/app/src/main/AndroidManifest.xml to include UnicornApplication as below

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

<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name=".UnicornApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.App.Launch"
android:enableOnBackInvokedCallback="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.App.Launch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

Step 25 — Create AppModule class for dependency injection

Create AppModule class in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/di/AppModule.kt to provide StockApi and StockDatabase for DI as below

package app.unicornapp.mobile.android.unicorn.di

import android.app.Application
import androidx.room.Room
import app.unicornapp.mobile.android.unicorn.data.local.StockDatabase
import app.unicornapp.mobile.android.unicorn.data.remote.StocksApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create
import javax.inject.Singleton

/**
* AppModule
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun providesStockApi(): StocksApi {
return Retrofit.Builder()
.baseUrl(StocksApi.STOCKS_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create()
}

@Provides
@Singleton
fun providesStockDatabase(app: Application): StockDatabase {
return Room.databaseBuilder(
app,
StockDatabase::class.java,
"stockdb.db"
).build()
}
}

Step 26 — Create RepositoryModule class for dependency injection

Create RepositoryModule in $PROJECT_ROOT/app/src/main/java/app/unicornapp/android/unicorn/di/RepositoryModule.kt to provide concrete implementations of parser and repository as dependency injection dependencies as below

package app.unicornapp.mobile.android.unicorn.di

import app.unicornapp.mobile.android.unicorn.data.parser.CompanyListingsParser
import app.unicornapp.mobile.android.unicorn.data.parser.CsvParser
import app.unicornapp.mobile.android.unicorn.data.repository.StockRepositoryImpl
import app.unicornapp.mobile.android.unicorn.domain.model.CompanyListing
import app.unicornapp.mobile.android.unicorn.domain.repository.StockRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* RepositoryModule
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindCompanyListingsParser(
companyListingsParser: CompanyListingsParser
): CsvParser<CompanyListing>

@Binds
@Singleton
abstract fun bindStockRepository(
stockRepositoryImpl: StockRepositoryImpl
): StockRepository
}

Step 27 — Ensure that MainActivity is annotated with @AndroidEntryPoint

Ensure that MainActivity is annotated with @AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
lateinit var navController: NavController
private val viewModel: UnicornViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
installSplashScreen().apply {
setKeepOnScreenCondition {
viewModel.isLoading.value
}
}
setContent {
UnicornTheme {
navController = rememberNavController()
MyApp(navController)
}
}
}

override fun onBackPressed() {
super.onBackPressed()
}
}

Step 28 — Build and Run

Build and Run and navigate to Company Listings screen and test the search functionality

App/Build.gradle — Double check versions in app/build.gradle

Double check versions in app/build.gradle to match the below versions

plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}

def localPropertiesFile = rootProject.file("local.properties")
def localProperties = new Properties()
localProperties.load(new FileInputStream(localPropertiesFile))

android {
namespace 'app.unicornapp.mobile.android.unicorn'
compileSdk 33

defaultConfig {
applicationId "app.unicornapp.mobile.android.unicorn"
minSdk 23
targetSdk 33
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
buildConfigField("String", "API_KEY", localProperties['apiKey'])
buildConfigField("String", "STOCKS_BASE_URL", localProperties['stocksBaseUrl'])
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
allWarningsAsErrors = false
freeCompilerArgs += [
'-opt-in=androidx.compose.material3.ExperimentalMaterial3Api'
]
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.2'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}

dependencies {
// Import the Compose BOM
implementation platform('androidx.compose:compose-bom:2022.12.00')

// Core
implementation 'androidx.core:core-ktx:1.8.0'

// Compose dependencies
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-tooling-preview"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose'

// Splash
implementation 'androidx.core:core-splashscreen:1.0.0'

// Compose Navigation
implementation 'androidx.navigation:navigation-runtime-ktx:2.5.3'
implementation "androidx.navigation:navigation-compose:2.5.3"

// Material and Material Extended
implementation 'androidx.compose.material:material:1.3.1'
implementation "androidx.compose.material3:material3"
implementation 'androidx.compose.material:material-icons-extended:1.3.1'

// SwipeRefresh
implementation "com.google.accompanist:accompanist-swiperefresh:0.25.1"

// Open CSV
implementation 'com.opencsv:opencsv:5.5.2'

//moshi
implementation "com.squareup.moshi:moshi-kotlin:1.14.0"
implementation 'com.squareup.moshi:moshi:1.14.0'
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.14.0"

// Retrofit & OkHttp
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'androidx.room:room-common:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation("com.squareup.okhttp3:okhttp:4.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.0")

// Dagger Hilt
implementation "com.google.dagger:hilt-android:2.44"
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
kapt "com.google.dagger:hilt-compiler:2.44"

// Testing and Instrumentation
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4"
androidTestImplementation "androidx.compose.ui:ui-test-junit4"
debugImplementation "androidx.compose.ui:ui-tooling"
debugImplementation "androidx.compose.ui:ui-tooling"
debugImplementation "androidx.compose.ui:ui-test-manifest"

}

// Allow references to generated code
kapt {
correctErrorTypes true
}

In Part 2 we shall continue implementing the detail screens.

Below are some of the reference files used for this project.

Root build.gradle — Double check project level build.gradle

Double check project level build.gradle to match the below versions

buildscript {
ext {
compose_version = '1.3.0'
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.

// Compatibility Map
// https://developer.android.com/jetpack/androidx/releases/compose-kotlin
// Kotlin 1.8.10 is compatible with Compose Compiler Version 1.4.2
plugins {
id 'com.android.application' version '8.1.0-alpha03' apply false
id 'com.android.library' version '8.1.0-alpha03' apply false
id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
id 'com.google.dagger.hilt.android' version '2.44' apply false
}

Step — Create REST API

We may go ahead and run our REST API locally and create the model classes as below


cd unicornstack-api

npm run strapi develop

Browse to http://localhost:1337/admin to create FaveStock API

http://localhost:1337/admin

The full source code for this tutorial can be found below —

--

--

Arunabh Das
Developers Inc

Sort of an executive-officer-of-the-week of a-techno-syndicalist commune. Cypherpunk, techno-idealist, peacenik, spiritual, humanist