Skip to content

Commit

Permalink
Migrate to CoinMarketCap professional API.
Browse files Browse the repository at this point in the history
Update README to reflect that the project is no longer
under active development.
Fix wrong title being displayed in the selection dialog.
  • Loading branch information
cjurjiu committed Dec 29, 2018
1 parent 448ced2 commit 2a27bce
Show file tree
Hide file tree
Showing 21 changed files with 99 additions and 54 deletions.
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,35 @@ Open Source cryptocurrency viewer for Android, written in Kotlin. MVP architectu

## Purpose

This project is mainly used as a playground to validate architecture concepts, to learn working with new libraries & frameworks and to play with various ideas & widgets.
This project was mainly used as a playground outside work to validate architecture concepts, to learn working with new libraries and to explore various ideas & widgets. Currently the project is no longer under active development, as it achieved its initial purpose.

Currently the project is not published to the Google Play store, though it will certainly be published at some point.

New features will be added as time progresses.
It is not published to the Google Play store, and there are no plans to publish it anytime soon.

## Tech stack

The project is written fully in Kotlin and is structured in 3 layers: presentation, business & data. Eacy layer belongs to its own Gradle module.
The project is written fully in Kotlin and is structured in 3 layers: presentation, business & data. Each layer belongs to its own Gradle module.

The interactions between layers respects Bob C. Martin's [dependency rule](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html#the-dependency-rule). Namely, "inner" layers know nothing about any of the outer layers (for instance, the data layer knows nothing about the business layer).

The presentation layer uses the MVP architecture. Presenters are persisted across configuration changes & are re-attached to the new view instance after a configuration change occurs.

Data is fetched from CoinMarketCap's [public API](https://coinmarketcap.com/api/) using Retrofit.
Certain things have been left out intentionally (such as proper error handling).

## Data

Retrofit is used to fetch cryptocurrency data from CoinMarketCap's [professional API](https://coinmarketcap.com/api/). Only free to use endpoints are used, but an API key is required. Once you obtain your API key, add it to `local.properties`:

```
coinMarketCapApiKey="<your key here>"
```

A read-to-use apk (for demo purposes) which uses a valid API-key can be found in the latest release.

Cryptocurrency icons are fetched via Glide from [here](https://github.com/cjurjiu/cryptocurrency-icons).

## Tools:

Tools used:
The following tools/tech is used in KairosCrypto:
- [Kotlin](https://kotlinlang.org/)
- [RxJava2](https://github.com/ReactiveX/RxJava) (with RxJava's Android extensions)
- [RxRelay](https://github.com/JakeWharton/RxRelay)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,15 @@ class CoinDisplayOptionsToolbar @JvmOverloads constructor(context: Context?,
override fun openChangeCurrencyDialog(selectionItems: List<SelectionItem>) {
SelectionDialog.showCancelable(dialogIdentifier = CoinListSelectionDialogType.ChangeCurrency,
fragmentManager = fragmentManager,
title = context.getString(R.string.dialog_pick_currency),
data = selectionItems,
listenerFactory = ::getListenerForDialogType)
}

override fun openSelectSnapshotDialog(selectionItems: List<SelectionItem>) {
SelectionDialog.showCancelable(dialogIdentifier = CoinListSelectionDialogType.SelectSnapshot,
fragmentManager = fragmentManager,
title = context.getString(R.string.dialog_pick_period),
data = selectionItems,
listenerFactory = ::getListenerForDialogType)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class SelectionDialog : DialogFragment() {
.setView(view)
.setCancelable(isDialogCancelable)
.create()
view.dialog_title.text = arguments?.getString(KEY_TITLE).orEmpty()
initRecyclerView(view = view)
initCancelButton(view = view)
return dialog
Expand Down Expand Up @@ -123,8 +124,14 @@ class SelectionDialog : DialogFragment() {
class Builder {
private var selectionListener: OnItemSelectedListener? = null
private var data: ArrayList<ParcelableSelectionItem>? = null
private var dialogTitle: String = ""
private var isCancelable: Boolean = true

fun title(title: String): Builder {
this.dialogTitle = title
return this
}

fun data(data: List<SelectionItem>): Builder {
val parcelableData = ArrayList(data.map {
it.toParcelableSelectionItem()
Expand All @@ -148,8 +155,9 @@ class SelectionDialog : DialogFragment() {
val arguments = Bundle()
data?.let {
arguments.putParcelableArrayList(KEY_DATA, data)
arguments.putBoolean(KEY_CANCELABLE, isCancelable)
}
arguments.putBoolean(KEY_CANCELABLE, isCancelable)
arguments.putString(KEY_TITLE, dialogTitle)
selectionListener?.let {
dialog.selectionListener = selectionListener
}
Expand All @@ -159,25 +167,30 @@ class SelectionDialog : DialogFragment() {
}

companion object {
private const val KEY_TITLE = "SelectionDialog::Arguments::title"
private const val KEY_DATA = "SelectionDialog::Arguments::data"
private const val KEY_CANCELABLE = "SelectionDialog::Arguments::isCancelable"

fun <T : SelectionDialogIdentifier> showCancelable(dialogIdentifier: T,
fragmentManager: FragmentManager?,
title: String = "",
data: List<SelectionItem>,
listenerFactory: ListenerFactory<T>) =
SelectionDialog.Builder()
.selectionListener(selectionListener = listenerFactory.invoke(dialogIdentifier))
.title(title = title)
.data(data = data)
.build()
.show(fragmentManager, dialogIdentifier.identifier)

fun <T : SelectionDialogIdentifier> showNonCancelable(dialogIdentifier: T,
fragmentManager: FragmentManager?,
title: String = "",
data: List<SelectionItem>,
listenerFactory: ListenerFactory<T>) =
SelectionDialog.Builder()
.selectionListener(selectionListener = listenerFactory.invoke(dialogIdentifier))
.title(title = title)
.data(data = data)
.cancelable(cancelable = false)
.build()
Expand Down
5 changes: 2 additions & 3 deletions app/src/main/res/layout/layout_selection_dialog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
style="@style/KairosCrypto.Style.Dialog.TitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dialog_pick_transform_type"
tools:text="@string/dialog_pick_transform_type" />
tools:text="@string/dialog_pick_currency" />

<com.catalinjurjiu.kairoscrypto.presentationlayer.common.view.custom.MaxHeightRecyclerView
android:id="@+id/dialog_selection_list"
Expand All @@ -26,8 +25,8 @@
style="@style/KairosCrypto.Style.Button.Flat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_below="@id/dialog_selection_list"
android:layout_alignParentEnd="true"
android:layout_marginEnd="8dp"
android:text="@string/dialog_action_text_negative" />

Expand Down
3 changes: 2 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
<string name="bookmarks">Bookmarks</string>
<string name="coin_list">List</string>
<string name="settings">Settings</string>
<string name="dialog_pick_transform_type">Pick a currency</string>
<string name="dialog_pick_currency">Pick a currency</string>
<string name="dialog_pick_period">Pick a period</string>
<string name="dialog_action_text_positive">Done</string>
<string name="dialog_action_text_negative">Cancel</string>
<!--change currency dialog-->
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

apply from: 'config/config.gradle'

ext.localProperties = new Properties()
localProperties.load(project.rootProject.file('local.properties').newDataInputStream())

subprojects {
buildscript {
repositories {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,4 @@ private val EmptyPriceData = PriceData(
percentChange1h = 0F,
percentChange24h = 0F,
percentChange7d = 0F,
lastUpdated = 0L)
lastUpdated = "")
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ data class BookmarksCoin(val id: String,
val totalSupply: Double,
val maxSupply: Double,
var priceData: Map<String, PriceData>,
val lastUpdated: Long,
val lastUpdated: String,
val isLoading: Boolean)

inline fun CryptoCoin.toBookmarksCoin(isLoading: Boolean = false): BookmarksCoin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ data class CryptoCoin(val id: String,
val totalSupply: Double,
val maxSupply: Double,
var priceData: Map<String, PriceData>,
val lastUpdated: Long)
val lastUpdated: String)
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ data class CryptoCoinDetails(val id: String,
val totalSupply: Double,
val maxSupply: Double,
var priceData: Map<String, PriceData>,
val lastUpdated: Long)
val lastUpdated: String)
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ data class PriceData(val currency: String,
val percentChange1h: Float,
val percentChange24h: Float,
val percentChange7d: Float,
val lastUpdated: Long = -1)
val lastUpdated: String = "")
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class CoinMarketCapBookmarksRepository(private val kairosCryptoDb: KairosCryptoD
requestsObservable.subscribe({ request ->
//onNext
request.response.subscribe { coinDetailsResponse ->
val coinDetails = coinDetailsResponse.data
val coinDetails = coinDetailsResponse.data.entries.first().value
val coinId = kairosCryptoDb.getPlainCryptoCoinDao().insert(coinDetails.toDataLayerCoin())
val priceDetailsIds = kairosCryptoDb.getCoinMarketCapPriceDataDao().insert(coinDetails.toDataLayerPriceData())
Log.d(TAG, "Repo refreshBookmarks response. inserted coin with id: " +
Expand Down Expand Up @@ -135,7 +135,7 @@ class CoinMarketCapBookmarksRepository(private val kairosCryptoDb: KairosCryptoD
//setup
request.response.subscribe { coinDetailsResponse ->
//onNext
val coinDetails = coinDetailsResponse.data
val coinDetails = coinDetailsResponse.data.entries.first().value
//also remove the coin from the list of loading coins before inserting into
//DB, to prevent db change notifications before the list is updated
loadingCoinsList.remove(coin.symbol)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,16 @@ class CoinMarketCapCoinsRepository(private val kairosCryptoDb: KairosCryptoDb,
coinMarketCapApiService = coinMarketCapApiService)
val resultSubject: PublishSubject<Int> = PublishSubject.create()

apiRequest.response.observeOn(Schedulers.io()).subscribe {
apiRequest.response.observeOn(Schedulers.io()).subscribe { response ->
//network coins from the response
val networkCoins: List<CoinMarketCapCryptoCoin> = it.data.map { coin -> coin.value }
val networkCoins: List<CoinMarketCapCryptoCoin> = response.data
//db coins & insert
val dbCoins: List<DbPartialCryptoCoin> = networkCoins.map { coin -> coin.toDataLayerCoin() }
kairosCryptoDb.getPlainCryptoCoinDao().insert(dbCoins)
//price data
val coinsPriceData = networkCoins.flatMap { it.toDataLayerPriceData() }
val insertedDataIds = kairosCryptoDb.getCoinMarketCapPriceDataDao().insert(coinsPriceData)
Log.d(TAG, "Repo getFreshCoins response AFTER do next coins size:" + it.data.size + "" +
Log.d(TAG, "Repo getFreshCoins response AFTER do next coins size:" + response.data.size + "" +
"inserted ids:" + insertedDataIds)
resultSubject.onNext(pageIndex + numberOfPages - 1)
resultSubject.onComplete()
Expand Down Expand Up @@ -162,7 +162,7 @@ class CoinMarketCapCoinsRepository(private val kairosCryptoDb: KairosCryptoDb,
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe { coinResponse ->
val coin = coinResponse.data
val coin = coinResponse.data.entries.first().value
val dbCoin: DbPartialCryptoCoin = coin.toDataLayerCoin()
kairosCryptoDb.getPlainCryptoCoinDao().insert(dbCoin)
val dbPriceData = coin.toDataLayerPriceData()
Expand Down
4 changes: 3 additions & 1 deletion datalayer/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ android {
targetSdkVersion 27
versionCode 1
versionName "1.0"

testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
//add the api key
buildConfigField("String", "COINMARKETCAP_API_KEY", localProperties.getProperty("coinMarketCapApiKey"))
}

sourceSets {
Expand Down Expand Up @@ -58,6 +59,7 @@ dependencies {

//jsoup
implementation "org.jsoup:jsoup:$config.lib_versions.jsoup"

//testing stuff
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ data class DbPartialCryptoCoin(
@ColumnInfo(name = ColumnNames.MAX_SUPPLY)
val maxSupply: Double,
@ColumnInfo(name = ColumnNames.LAST_UPDATED)
val lastUpdated: Long) {
val lastUpdated: String) {

companion object {
const val COIN_TABLE_NAME: String = "coins"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ data class DbPriceData(
@ColumnInfo(name = ColumnNames.PERCENT_CHANGE_7D)
val percentChange7d: Float,
@ColumnInfo(name = ColumnNames.LAST_UPDATED)
val lastUpdated: Long = -1) {
val lastUpdated: String = "") {

companion object {
const val PRICE_DATA_TABLE_NAME = "price_data"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.catalinjurjiu.kairoscrypto.datalayer.network

import com.catalinjurjiu.kairoscrypto.datalayer.BuildConfig
import com.catalinjurjiu.kairoscrypto.datalayer.network.coinmarketcap.CoinMarketCapApiService
import com.catalinjurjiu.kairoscrypto.datalayer.network.coinmarketcap.CoinMarketCapHtmlService
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
Expand All @@ -11,20 +13,34 @@ import retrofit2.converter.scalars.ScalarsConverterFactory

object RestServiceFactory {

private val retrofit: Retrofit = RetrofitFactory(baseUrl = CoinMarketCapApiService.BASE_URL,
okHttpClient = OkHttpFactory.okHttpClient,
converterFactory = GsonConverterFactory.create())
.build()
private val retrofit2: Retrofit

init {
val httpClient: OkHttpClient.Builder = OkHttpClient.Builder()
httpClient.addInterceptor { chain ->
val original = chain.request()
val request: Request = original.newBuilder()
.header(CoinMarketCapApiService.API_KEY_HEADER, BuildConfig.COINMARKETCAP_API_KEY)
.method(original.method(), original.body())
.build()
chain.proceed(request)
}

retrofit2 = RetrofitFactory(baseUrl = CoinMarketCapApiService.BASE_URL,
okHttpClient = httpClient.build(),
converterFactory = GsonConverterFactory.create())
.build()
}

fun getCoinsRestServiceApi(): CoinMarketCapApiService {
return retrofit.create(CoinMarketCapApiService::class.java)
return retrofit2.create(CoinMarketCapApiService::class.java)
}
}

object HtmlServiceFactory {

private val retrofit: Retrofit = RetrofitFactory(baseUrl = CoinMarketCapHtmlService.BASE_URL,
okHttpClient = OkHttpFactory.okHttpClient,
okHttpClient = OkHttpClient.Builder().build(),
converterFactory = ScalarsConverterFactory.create())
.build()

Expand All @@ -33,10 +49,6 @@ object HtmlServiceFactory {
}
}

object OkHttpFactory {
val okHttpClient = OkHttpClient.Builder().build()
}

private class RetrofitFactory(private val baseUrl: String,
private val okHttpClient: OkHttpClient,
private val converterFactory: Converter.Factory) {
Expand All @@ -47,5 +59,4 @@ private class RetrofitFactory(private val baseUrl: String,
.addConverterFactory(converterFactory)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()

}
Loading

0 comments on commit 2a27bce

Please sign in to comment.