diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 7643783..4bec4ea 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,8 +1,5 @@
-
-
-
@@ -116,8 +113,5 @@
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 79ee123..a55e7a1 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index fb7f4a8..a601bba 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,9 @@
-
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..ebc2cd3
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index c83a369..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
index e34606c..d2ce72d 100644
--- a/.idea/jarRepositories.xml
+++ b/.idea/jarRepositories.xml
@@ -12,19 +12,14 @@
-
-
-
+
+
+
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinScripting.xml b/.idea/kotlinScripting.xml
deleted file mode 100644
index a6fe551..0000000
--- a/.idea/kotlinScripting.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 1c3230b..e164b18 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,5 +1,16 @@
+
+
+
diff --git a/README.md b/README.md
index ab76cf0..6716b0e 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
-# android-template
+# RaCooin
-Template for Android Projects.
-# TODO
+RaCooin (a mix of **Ra**te, **Coin** and the cute animal)
+is a minimalistic app for tracking the current fiat money value of one's crypto currencies.
+
+# TODO
- Add CI Workflow to publish to google play
-- Add Jetpack Compose once stable
diff --git a/apiclient/.gitignore b/apiclient/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/apiclient/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/apiclient/build.gradle.kts b/apiclient/build.gradle.kts
new file mode 100644
index 0000000..252120b
--- /dev/null
+++ b/apiclient/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ id(Plugins.LIB_JAVA)
+ id(Plugins.LIB_KOTLIN)
+ kotlin(Plugins.SERIALIZATION) version BuildPluginsVersions.KOTLIN
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+dependencies {
+ implementation(project(mapOf("path" to ":core")))
+
+ implementation(Core.KOTLINX_COROUTINES) // TODO: remove?
+
+ implementation(Utils.KOTLIN_SERIALIZATION)
+
+ implementation(Server.KTOR_CLIENT_CORE)
+ implementation(Server.KTOR_CLIENT_ANDROID)
+ implementation(Server.KTOR_CLIENT_SERIALIZATION)
+ implementation(Server.KTOR_CLIENT_LOGGING)
+ implementation(Server.LOGBACK)
+
+ testImplementation(Testing.KOTEST_RUNNER)
+ testImplementation(Testing.KOTEST_JUNIT_RUNNER)
+ testImplementation(Testing.KOTEST_ASSERTIONS)
+ testImplementation(Testing.KOTEST_EXTENSIONS_ARROW)
+ testImplementation(Testing.KOTEST_PROPERTIES)
+ testImplementation(Testing.MOCKK)
+ testImplementation(Testing.KOTLINX_COROUTINES_TEST)
+}
diff --git a/apiclient/src/main/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClient.kt b/apiclient/src/main/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClient.kt
new file mode 100644
index 0000000..7917723
--- /dev/null
+++ b/apiclient/src/main/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClient.kt
@@ -0,0 +1,85 @@
+package com.greyhairredbear.racooin.apiclient
+
+import arrow.core.Either
+import com.greyhairredbear.racooin.core.interfaces.ApiClient
+import com.greyhairredbear.racooin.core.model.ApiCallFailed
+import com.greyhairredbear.racooin.core.model.ApiClientError
+import com.greyhairredbear.racooin.core.model.CryptoCurrency
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+import com.greyhairredbear.racooin.core.model.FiatBalance
+import com.greyhairredbear.racooin.core.model.FiatCurrency
+import io.ktor.client.HttpClient
+import io.ktor.client.features.defaultRequest
+import io.ktor.client.features.json.JsonFeature
+import io.ktor.client.features.json.serializer.KotlinxSerializer
+import io.ktor.client.features.logging.DEFAULT
+import io.ktor.client.features.logging.LogLevel
+import io.ktor.client.features.logging.Logger
+import io.ktor.client.features.logging.Logging
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.request.host
+import io.ktor.client.request.parameter
+import io.ktor.http.ContentType
+import io.ktor.http.URLProtocol
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CurrencyRatesResponse(
+ val bitcoin: FiatBalanceResponse,
+ val ethereum: FiatBalanceResponse,
+ val dogecoin: FiatBalanceResponse,
+ val litecoin: FiatBalanceResponse,
+ val ripple: FiatBalanceResponse,
+) {
+ fun toCurrencyRates(): List =
+ mapOf(
+ CryptoCurrency.BITCOIN to bitcoin,
+ CryptoCurrency.ETHEREUM to ethereum,
+ CryptoCurrency.DOGECOIN to dogecoin,
+ CryptoCurrency.LITECOIN to litecoin,
+ CryptoCurrency.RIPPLE to ripple,
+ ).flatMap {
+ listOf(
+ CryptoCurrencyRate(it.key, FiatBalance(FiatCurrency.EURO, it.value.eur)),
+ CryptoCurrencyRate(it.key, FiatBalance(FiatCurrency.US_DOLLAR, it.value.usd)),
+ )
+ }
+}
+
+@Serializable
+data class FiatBalanceResponse(val eur: Double, val usd: Double)
+
+// TODO extract client setup etc
+class CoingeckoApiClient : ApiClient {
+ private val client = HttpClient {
+
+ install(Logging) {
+ logger = Logger.DEFAULT
+ level = LogLevel.ALL
+ }
+
+ install(JsonFeature) {
+ serializer = KotlinxSerializer()
+ }
+
+ defaultRequest {
+ host = "api.coingecko.com/api/v3/simple"
+ url {
+ protocol = URLProtocol.HTTPS
+ }
+ accept(ContentType.Application.Json)
+ }
+
+ }
+
+ override suspend fun fetchCurrencyRates(): Either> =
+ Either.catch {
+ val result = client.get {
+ url { encodedPath = "/price" }
+ parameter("ids", "ethereum,bitcoin,dogecoin,litecoin,ripple")
+ parameter("vs_currencies", "eur,usd")
+ }
+ result.toCurrencyRates()
+ }.mapLeft { ApiCallFailed }
+}
diff --git a/apiclient/src/test/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClientTest.kt b/apiclient/src/test/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClientTest.kt
new file mode 100644
index 0000000..76309b0
--- /dev/null
+++ b/apiclient/src/test/java/com/greyhairredbear/racooin/apiclient/CoingeckoApiClientTest.kt
@@ -0,0 +1,31 @@
+package com.greyhairredbear.racooin.apiclient
+
+import com.greyhairredbear.racooin.core.model.CryptoCurrency
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+import com.greyhairredbear.racooin.core.model.FiatCurrency
+import io.kotest.assertions.arrow.core.shouldBeRight
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainAll
+import io.kotest.matchers.collections.shouldHaveSize
+
+val sut = CoingeckoApiClient()
+
+class ApiClientTest : FunSpec({
+ test("should fetch currencies from api") {
+ val result = sut.fetchCurrencyRates()
+ .shouldBeRight()
+
+ result.shouldHaveSize(10)
+ result.shouldContainAllCurrencyCombinations()
+ }
+})
+
+private fun List.shouldContainAllCurrencyCombinations() {
+ val expectedCombinations = CryptoCurrency.values()
+ .flatMap { crypto -> FiatCurrency.values().map { crypto to it } }
+
+ currencyCombinations().shouldContainAll(expectedCombinations)
+}
+
+private fun List.currencyCombinations() =
+ map { it.cryptoCurrency to it.fiatBalance.fiatCurrency }
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 16eacbb..9e28a63 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,14 @@
+import com.google.protobuf.gradle.generateProtoTasks
+import com.google.protobuf.gradle.plugins
+import com.google.protobuf.gradle.protobuf
+import com.google.protobuf.gradle.protoc
+
plugins {
+ kotlin(Plugins.KAPT)
id(Plugins.ANDROID_APPLICATION)
id(Plugins.KOTLIN_ANDROID)
+ id(Plugins.PROTOBUF) version BuildPluginsVersions.PROTOBUF
+ id(Plugins.HILT)
}
val installGitHooks by rootProject.tasks.existing
@@ -39,7 +47,6 @@ android {
}
composeOptions {
- kotlinCompilerVersion = BuildPluginsVersions.KOTLIN
kotlinCompilerExtensionVersion = Versions.COMPOSE_VERSION
}
@@ -50,26 +57,32 @@ android {
}
dependencies {
- implementation(Core.STD_LIB)
- implementation(Core.KOTLINX_COROUTINES)
+ implementation(project(mapOf("path" to ":core")))
+ implementation(project(mapOf("path" to ":apiclient")))
- implementation(Server.KTOR_CLIENT_CORE)
- implementation(Server.KTOR_CLIENT_ANDROID)
- implementation(Server.KTOR_CLIENT_SERIALIATION)
+ implementation(Core.KOTLINX_COROUTINES)
implementation(Compose.COMPOSE_UI)
implementation(Compose.COMPOSE_MATERIAL)
implementation(Compose.COMPOSE_UI_TOOLING_PREVIEW)
implementation(Compose.COMPOSE_FOUNDATION)
+ implementation(Android.ANDROIDX_LIFECYCLE_VIEWMODEL_COMPOSE)
+
+ implementation(Protobuf.PROTOBUF_JAVA_LITE)
+ implementation(Android.ANDROIDX_DATASTORE)
- implementation(SupportLibs.ANDROIDX_APPCOMPAT)
- implementation(SupportLibs.ANDROIDX_CORE_KTX)
- implementation(SupportLibs.ANDROIDX_ACTIVITY)
+ implementation(Android.ANDROIDX_APPCOMPAT)
+ implementation(Android.ANDROIDX_CORE_KTX)
+ implementation(Android.ANDROIDX_ACTIVITY)
implementation (GoogleLibs.ANDROID_MATERIAL)
+ implementation(Android.HILT_ANDROID)
+ kapt(Android.HILT_ANDROID_COMPILER)
+
testImplementation(Testing.KOTEST_RUNNER)
testImplementation(Testing.KOTEST_JUNIT_RUNNER)
testImplementation(Testing.KOTEST_ASSERTIONS)
+ testImplementation(Testing.KOTEST_EXTENSIONS_ARROW)
testImplementation(Testing.KOTEST_PROPERTIES)
testImplementation(Testing.MOCKK)
testImplementation(Testing.KOTLINX_COROUTINES_TEST)
@@ -80,4 +93,27 @@ dependencies {
androidTestImplementation(AndroidTesting.ESPRESSO_CORE)
debugImplementation(Compose.COMPOSE_UI_TOOLING)
-}
\ No newline at end of file
+}
+
+kapt {
+ correctErrorTypes = true
+}
+
+protobuf {
+ protoc {
+ artifact = Protobuf.PROTOBUF_PROTOC
+ }
+
+ // Generates the java Protobuf-lite code for the Protobufs in this project. See
+ // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
+ // for more information.
+ generateProtoTasks {
+ all().forEach { task ->
+ task.plugins {
+ create("java") {
+ option("lite")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/greyhairredbear/androidtemplate/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/greyhairredbear/racooin/ExampleInstrumentedTest.kt
similarity index 77%
rename from app/src/androidTest/java/com/greyhairredbear/androidtemplate/ExampleInstrumentedTest.kt
rename to app/src/androidTest/java/com/greyhairredbear/racooin/ExampleInstrumentedTest.kt
index 106cae2..5f7cb73 100644
--- a/app/src/androidTest/java/com/greyhairredbear/androidtemplate/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/greyhairredbear/racooin/ExampleInstrumentedTest.kt
@@ -1,13 +1,11 @@
-package com.greyhairredbear.androidtemplate
+package com.greyhairredbear.racooin
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.Assert.*
-
/**
* Instrumented test, which will execute on an Android device.
*
@@ -19,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.greyhairredbear.androidtemplate", appContext.packageName)
+ assertEquals("com.greyhairredbear.racooin", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1a7a400..5498cb0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,14 +1,18 @@
+ package="com.greyhairredbear.racooin">
+
+
+
@@ -20,4 +24,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..324f78a
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/greyhairredbear/androidtemplate/MainActivity.kt b/app/src/main/java/com/greyhairredbear/androidtemplate/MainActivity.kt
deleted file mode 100644
index 0e77a67..0000000
--- a/app/src/main/java/com/greyhairredbear/androidtemplate/MainActivity.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.greyhairredbear.androidtemplate
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.compose.material.Text
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContent {
- Text("Hello there")
- }
- }
-}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/application/RacooinApplication.kt b/app/src/main/java/com/greyhairredbear/racooin/application/RacooinApplication.kt
new file mode 100644
index 0000000..27a3640
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/application/RacooinApplication.kt
@@ -0,0 +1,7 @@
+package com.greyhairredbear.racooin.application
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class RacooinApplication : Application()
diff --git a/app/src/main/java/com/greyhairredbear/racooin/application/di/HiltModule.kt b/app/src/main/java/com/greyhairredbear/racooin/application/di/HiltModule.kt
new file mode 100644
index 0000000..4165977
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/application/di/HiltModule.kt
@@ -0,0 +1,23 @@
+package com.greyhairredbear.racooin.application.di
+
+import android.content.Context
+import com.greyhairredbear.racooin.apiclient.CoingeckoApiClient
+import com.greyhairredbear.racooin.core.interfaces.ApiClient
+import com.greyhairredbear.racooin.core.interfaces.Persistence
+import com.greyhairredbear.racooin.persistence.DataStorePersistence
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+object HiltModule {
+ @Provides
+ fun provideApiClient(): ApiClient = CoingeckoApiClient()
+
+ @Provides
+ fun providePersistence(@ApplicationContext applicationContext: Context): Persistence =
+ DataStorePersistence(applicationContext)
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/DataStorePersistence.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/DataStorePersistence.kt
new file mode 100644
index 0000000..3ebe3d0
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/DataStorePersistence.kt
@@ -0,0 +1,148 @@
+package com.greyhairredbear.racooin.persistence
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.dataStore
+import arrow.core.Either
+import arrow.core.Either.Companion.catch
+import com.google.protobuf.Timestamp
+import com.greyhairredbear.racooin.core.interfaces.Persistence
+import com.greyhairredbear.racooin.core.model.CryptoBalance
+import com.greyhairredbear.racooin.core.model.CryptoCurrency
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+import com.greyhairredbear.racooin.core.model.FiatBalance
+import com.greyhairredbear.racooin.core.model.FiatCurrency
+import com.greyhairredbear.racooin.core.model.PersistenceError
+import com.greyhairredbear.racooin.persistence.conversion.toCoreModel
+import com.greyhairredbear.racooin.persistence.conversion.toPersistenceBalance
+import com.greyhairredbear.racooin.persistence.conversion.toPersistenceModel
+import com.greyhairredbear.racooin.persistence.serializer.CryptoBalanceSerializer
+import com.greyhairredbear.racooin.persistence.serializer.CryptoCurrencyRateSerializer
+import com.greyhairredbear.racooin.persistence.serializer.InvestSerializer
+import kotlinx.coroutines.flow.firstOrNull
+import com.greyhairredbear.racooin.persistence.CryptoBalance as PersistenceCryptoBalance
+import com.greyhairredbear.racooin.persistence.CryptoCurrency as PersistenceCryptoCurrency
+import com.greyhairredbear.racooin.persistence.FiatBalance as PersistenceFiatBalance
+
+class DataStorePersistence(applicationContext: Context) : Persistence {
+ private val cryptoBalancesStore = applicationContext.cryptoBalancesStore
+ private val investsStore = applicationContext.investsStore
+ private val ratesStore = applicationContext.ratesStore
+
+ override suspend fun persistCryptoBalance(balance: CryptoBalance): Either =
+ catchAsPersistenceError {
+ cryptoBalancesStore.updateData { it.with(balance.toPersistenceBalance()) }
+ }
+
+ override suspend fun fetchCryptoBalance(currency: CryptoCurrency): Either =
+ catchAsPersistenceError {
+ cryptoBalancesStore.data.firstOrNull()
+ ?.balancesList
+ ?.firstOrNull { it.cryptoCurrency == currency.toPersistenceModel() }
+ ?.let { CryptoBalance(it.cryptoCurrency.toCoreModel(), it.balance) }
+ ?: CryptoBalance(currency, 0.0)
+ }
+
+ override suspend fun fetchAllCryptoBalances(): Either> =
+ catchAsPersistenceError {
+ cryptoBalancesStore.data.firstOrNull()
+ ?.balancesList
+ ?.map { it.toCoreModel() }
+ ?: listOf()
+ }
+
+ override suspend fun persistInvest(
+ currency: CryptoCurrency,
+ balance: FiatBalance
+ ): Either =
+ catchAsPersistenceError {
+ investsStore.updateData {
+ it.with(
+ currency.toPersistenceModel(),
+ balance.toPersistenceModel()
+ )
+ }
+ }
+
+ override suspend fun fetchInvest(currency: CryptoCurrency): Either =
+ catchAsPersistenceError {
+ investsStore.data.firstOrNull()
+ ?.investsList
+ ?.firstOrNull { it.cryptoCurrency == currency.toPersistenceModel() }
+ ?.fiatBalance?.toCoreModel()
+ ?: FiatBalance(
+ FiatCurrency.EURO,
+ 0.0
+ ) // TODO: support multiple fiat currencies here
+ }
+
+ override suspend fun fetchAllInvests(): Either> =
+ catchAsPersistenceError {
+ investsStore.data.firstOrNull()
+ ?.investsList
+ ?.map { it.fiatBalance.toCoreModel() }
+ ?: listOf()
+ }
+
+ override suspend fun persistCurrencyRates(rates: List): Either =
+ catchAsPersistenceError {
+ ratesStore.updateData {
+ CryptoCurrencyRates.newBuilder()
+ .addAllRates(rates.map { it.toPersistenceModel() })
+ .build()
+ }
+ }
+
+ override suspend fun fetchCurrencyRates(): Either> =
+ catchAsPersistenceError {
+ ratesStore.data.firstOrNull()
+ ?.ratesList
+ ?.map { it.toCoreModel() }
+ ?: listOf()
+ }
+}
+
+private fun CryptoBalances.with(newBalance: PersistenceCryptoBalance): CryptoBalances =
+ CryptoBalances.newBuilder().addAllBalances(
+ balancesList.toMutableList().apply {
+ removeAll { it.cryptoCurrency == newBalance.cryptoCurrency }
+ add(newBalance)
+ }
+ ).build()
+
+private fun Invests.with(
+ cryptoCurrency: PersistenceCryptoCurrency,
+ newInvest: PersistenceFiatBalance
+): Invests =
+ Invests.newBuilder().addAllInvests(
+ investsList.apply {
+ removeAll { it.cryptoCurrency == cryptoCurrency } // TODO support multiple fiat currencies
+ add(
+ Invest.newBuilder()
+ .setCryptoCurrency(cryptoCurrency)
+ .setFiatBalance(newInvest)
+ .build()
+ )
+ }
+ ).build()
+
+private suspend fun catchAsPersistenceError(block: suspend () -> T): Either =
+ catch({ PersistenceError }, { block() })
+
+private const val DATA_STORE_FILENAME_BALANCES = "balances.pb"
+private val Context.cryptoBalancesStore: DataStore by dataStore(
+ fileName = DATA_STORE_FILENAME_BALANCES,
+ serializer = CryptoBalanceSerializer
+)
+
+private const val DATA_STORE_FILENAME_INVESTS = "invests.pb"
+private val Context.investsStore: DataStore by dataStore(
+ fileName = DATA_STORE_FILENAME_INVESTS,
+ serializer = InvestSerializer
+)
+
+private const val DATA_STORE_FILENAME_RATES = "rates.pb"
+private val Context.ratesStore: DataStore by dataStore(
+ fileName = DATA_STORE_FILENAME_RATES,
+ serializer = CryptoCurrencyRateSerializer
+)
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoBalance.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoBalance.kt
new file mode 100644
index 0000000..0637916
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoBalance.kt
@@ -0,0 +1,13 @@
+package com.greyhairredbear.racooin.persistence.conversion
+
+import com.greyhairredbear.racooin.core.model.CryptoBalance
+import com.greyhairredbear.racooin.persistence.CryptoBalance as PersistenceCryptoBalance
+
+fun CryptoBalance.toPersistenceBalance(): PersistenceCryptoBalance =
+ PersistenceCryptoBalance.newBuilder()
+ .setCryptoCurrency(cryptoCurrency.toPersistenceModel())
+ .setBalance(balance)
+ .build()
+
+fun PersistenceCryptoBalance.toCoreModel(): CryptoBalance =
+ CryptoBalance(cryptoCurrency.toCoreModel(), balance)
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrency.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrency.kt
new file mode 100644
index 0000000..55aaa96
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrency.kt
@@ -0,0 +1,26 @@
+package com.greyhairredbear.racooin.persistence.conversion
+
+import com.greyhairredbear.racooin.core.model.CryptoCurrency
+import java.io.UnsupportedEncodingException
+import com.greyhairredbear.racooin.persistence.CryptoCurrency as PersistenceCryptoCurrency
+
+fun CryptoCurrency.toPersistenceModel(): PersistenceCryptoCurrency =
+ when (this) {
+ CryptoCurrency.BITCOIN -> PersistenceCryptoCurrency.BITCOIN
+ CryptoCurrency.ETHEREUM -> PersistenceCryptoCurrency.ETHEREUM
+ CryptoCurrency.DOGECOIN -> PersistenceCryptoCurrency.DOGECOIN
+ CryptoCurrency.LITECOIN -> PersistenceCryptoCurrency.LITECOIN
+ CryptoCurrency.RIPPLE -> PersistenceCryptoCurrency.RIPPLE
+ }
+
+fun PersistenceCryptoCurrency.toCoreModel(): CryptoCurrency =
+ when (this) {
+ PersistenceCryptoCurrency.BITCOIN -> CryptoCurrency.BITCOIN
+ PersistenceCryptoCurrency.ETHEREUM -> CryptoCurrency.ETHEREUM
+ PersistenceCryptoCurrency.DOGECOIN -> CryptoCurrency.DOGECOIN
+ PersistenceCryptoCurrency.LITECOIN -> CryptoCurrency.LITECOIN
+ PersistenceCryptoCurrency.RIPPLE -> CryptoCurrency.RIPPLE
+ PersistenceCryptoCurrency.UNRECOGNIZED -> {
+ throw UnsupportedEncodingException() // TODO
+ }
+ }
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrencyRate.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrencyRate.kt
new file mode 100644
index 0000000..39fe9c8
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/CryptoCurrencyRate.kt
@@ -0,0 +1,13 @@
+package com.greyhairredbear.racooin.persistence.conversion
+
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+import com.greyhairredbear.racooin.persistence.CryptoCurrencyRate as PersistenceCryptoCurrencyRate
+
+fun CryptoCurrencyRate.toPersistenceModel(): PersistenceCryptoCurrencyRate =
+ PersistenceCryptoCurrencyRate.newBuilder()
+ .setCryptoCurrency(cryptoCurrency.toPersistenceModel())
+ .setFiatBalance(fiatBalance.toPersistenceModel())
+ .build()
+
+fun PersistenceCryptoCurrencyRate.toCoreModel(): CryptoCurrencyRate =
+ CryptoCurrencyRate(cryptoCurrency.toCoreModel(), fiatBalance.toCoreModel())
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatBalance.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatBalance.kt
new file mode 100644
index 0000000..4edab42
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatBalance.kt
@@ -0,0 +1,13 @@
+package com.greyhairredbear.racooin.persistence.conversion
+
+import com.greyhairredbear.racooin.core.model.FiatBalance
+import com.greyhairredbear.racooin.persistence.FiatBalance as PersistenceFiatBalance
+
+fun FiatBalance.toPersistenceModel(): PersistenceFiatBalance =
+ PersistenceFiatBalance.newBuilder()
+ .setCurrency(fiatCurrency.toPersistenceModel())
+ .setBalance(balance)
+ .build()
+
+fun PersistenceFiatBalance.toCoreModel(): FiatBalance =
+ FiatBalance(currency.toCoreModel(), balance)
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatCurrency.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatCurrency.kt
new file mode 100644
index 0000000..3ddc88c
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/conversion/FiatCurrency.kt
@@ -0,0 +1,20 @@
+package com.greyhairredbear.racooin.persistence.conversion
+
+import com.greyhairredbear.racooin.core.model.FiatCurrency
+import java.io.UnsupportedEncodingException
+import com.greyhairredbear.racooin.persistence.FiatCurrency as PersistenceFiatCurrency
+
+fun FiatCurrency.toPersistenceModel(): PersistenceFiatCurrency =
+ when (this) {
+ FiatCurrency.EURO -> PersistenceFiatCurrency.EURO
+ FiatCurrency.US_DOLLAR -> PersistenceFiatCurrency.US_DOLLAR
+ }
+
+fun PersistenceFiatCurrency.toCoreModel(): FiatCurrency =
+ when (this) {
+ PersistenceFiatCurrency.EURO -> FiatCurrency.EURO
+ PersistenceFiatCurrency.US_DOLLAR -> FiatCurrency.US_DOLLAR
+ PersistenceFiatCurrency.UNRECOGNIZED -> {
+ throw UnsupportedEncodingException() // TODO
+ }
+ }
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoBalanceSerializer.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoBalanceSerializer.kt
new file mode 100644
index 0000000..85455ff
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoBalanceSerializer.kt
@@ -0,0 +1,22 @@
+package com.greyhairredbear.racooin.persistence.serializer
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.google.protobuf.InvalidProtocolBufferException
+import com.greyhairredbear.racooin.persistence.CryptoBalances
+import java.io.InputStream
+import java.io.OutputStream
+
+object CryptoBalanceSerializer: Serializer {
+ override val defaultValue: CryptoBalances = CryptoBalances.getDefaultInstance()
+
+ override suspend fun readFrom(input: InputStream): CryptoBalances {
+ try {
+ return CryptoBalances.parseFrom(input)
+ } catch (exception: InvalidProtocolBufferException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+ }
+
+ override suspend fun writeTo(t: CryptoBalances, output: OutputStream) = t.writeTo(output)
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoCurrencyRateSerializer.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoCurrencyRateSerializer.kt
new file mode 100644
index 0000000..953f570
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/CryptoCurrencyRateSerializer.kt
@@ -0,0 +1,22 @@
+package com.greyhairredbear.racooin.persistence.serializer
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.google.protobuf.InvalidProtocolBufferException
+import com.greyhairredbear.racooin.persistence.CryptoCurrencyRates
+import java.io.InputStream
+import java.io.OutputStream
+
+object CryptoCurrencyRateSerializer : Serializer {
+ override val defaultValue: CryptoCurrencyRates = CryptoCurrencyRates.getDefaultInstance()
+
+ override suspend fun readFrom(input: InputStream): CryptoCurrencyRates {
+ try {
+ return CryptoCurrencyRates.parseFrom(input)
+ } catch (exception: InvalidProtocolBufferException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+ }
+
+ override suspend fun writeTo(t: CryptoCurrencyRates, output: OutputStream) = t.writeTo(output)
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/InvestSerializer.kt b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/InvestSerializer.kt
new file mode 100644
index 0000000..c0b4462
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/persistence/serializer/InvestSerializer.kt
@@ -0,0 +1,22 @@
+package com.greyhairredbear.racooin.persistence.serializer
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.google.protobuf.InvalidProtocolBufferException
+import com.greyhairredbear.racooin.persistence.Invests
+import java.io.InputStream
+import java.io.OutputStream
+
+object InvestSerializer: Serializer {
+ override val defaultValue: Invests = Invests.getDefaultInstance()
+
+ override suspend fun readFrom(input: InputStream): Invests {
+ try {
+ return Invests.parseFrom(input)
+ } catch (exception: InvalidProtocolBufferException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+ }
+
+ override suspend fun writeTo(t: Invests, output: OutputStream) = t.writeTo(output)
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/ui/MainActivity.kt b/app/src/main/java/com/greyhairredbear/racooin/ui/MainActivity.kt
new file mode 100644
index 0000000..377b4a0
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/ui/MainActivity.kt
@@ -0,0 +1,45 @@
+package com.greyhairredbear.racooin
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.ui.Alignment
+import androidx.lifecycle.lifecycleScope
+import com.greyhairredbear.racooin.ui.MainScreen
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ MainScreen()
+ }
+ }
+
+ lifecycleScope.launchWhenResumed {
+ withContext(Dispatchers.IO) {
+ val ignored = ""
+ println(ignored)
+ }
+ }
+ }
+
+ // TODO:
+ // onCreate:
+ // - calculate balances
+ // -- load rates
+ // --- store rates in persistence
+ // (only call api on force refresh or when older than 1h)
+ // -- load persisted balances
+ // -- multiply rates with balances
+ // -- return balance
+ // onRefresh:
+ // - calculate balances
+ // onChangeCryptoCurrency
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/ui/MainScreen.kt b/app/src/main/java/com/greyhairredbear/racooin/ui/MainScreen.kt
new file mode 100644
index 0000000..eff10d2
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/ui/MainScreen.kt
@@ -0,0 +1,33 @@
+package com.greyhairredbear.racooin.ui
+
+import androidx.compose.foundation.layout.Column
+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.lifecycle.viewmodel.compose.viewModel
+import com.greyhairredbear.racooin.core.interfaces.Resource
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+
+@Composable
+fun MainScreen(
+ screenViewModel: MainViewModel = viewModel()
+) {
+ val uiState by screenViewModel.uiState.collectAsState()
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ when (uiState) {
+ is Resource.Success -> {
+ (uiState as Resource.Success).data.forEach {
+ Text(text = "1 ${it.cryptoCurrency.name} = ${it.fiatBalance.balance} ${it.fiatBalance.fiatCurrency}")
+ }
+ }
+ is Resource.Error -> {
+ Text(text = "Error")
+ }
+ is Resource.Loading -> {
+ Text(text = "Loading")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/greyhairredbear/racooin/ui/MainViewModel.kt b/app/src/main/java/com/greyhairredbear/racooin/ui/MainViewModel.kt
new file mode 100644
index 0000000..781f2d7
--- /dev/null
+++ b/app/src/main/java/com/greyhairredbear/racooin/ui/MainViewModel.kt
@@ -0,0 +1,40 @@
+package com.greyhairredbear.racooin.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import arrow.core.computations.either
+import com.greyhairredbear.racooin.core.interfaces.ApiClient
+import com.greyhairredbear.racooin.core.interfaces.Persistence
+import com.greyhairredbear.racooin.core.interfaces.Resource
+import com.greyhairredbear.racooin.core.model.ApiClientError
+import com.greyhairredbear.racooin.core.model.CryptoCurrencyRate
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val apiClient: ApiClient,
+ private val persistence: Persistence,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow>>(Resource.Loading)
+ val uiState: StateFlow>> = _uiState
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ val test = either> {
+ val currencyRateResult = apiClient.fetchCurrencyRates().bind()
+ currencyRateResult
+ }
+
+ test.fold(
+ ifRight = { _uiState.value = Resource.Success(it) },
+ ifLeft = { _uiState.value = Resource.Error("failed api call") },
+ )
+ }
+ }
+}
diff --git a/app/src/main/proto/datastore-persistence-schema.proto b/app/src/main/proto/datastore-persistence-schema.proto
new file mode 100644
index 0000000..d1d87c4
--- /dev/null
+++ b/app/src/main/proto/datastore-persistence-schema.proto
@@ -0,0 +1,52 @@
+syntax = "proto3";
+
+import "google/protobuf/timestamp.proto";
+
+option java_package = "com.greyhairredbear.racooin.persistence";
+option java_multiple_files = true;
+
+enum CryptoCurrency {
+ BITCOIN = 0;
+ ETHEREUM = 1;
+ DOGECOIN = 2;
+ LITECOIN = 3;
+ RIPPLE = 4;
+}
+
+enum FiatCurrency {
+ EURO = 0;
+ US_DOLLAR = 1;
+}
+
+message FiatBalance {
+ FiatCurrency currency = 1;
+ double balance = 2;
+}
+
+message CryptoBalance {
+ CryptoCurrency cryptoCurrency = 1;
+ double balance = 2;
+}
+
+message Invest {
+ CryptoCurrency cryptoCurrency = 1;
+ FiatBalance fiatBalance = 2;
+}
+
+message CryptoBalances {
+ repeated CryptoBalance balances = 1;
+}
+
+message Invests {
+ repeated Invest invests = 1;
+}
+
+message CryptoCurrencyRate {
+ CryptoCurrency cryptoCurrency = 1;
+ FiatBalance fiatBalance = 2;
+}
+
+message CryptoCurrencyRates {
+ google.protobuf.Timestamp timeOfFetching = 1;
+ repeated CryptoCurrencyRate rates = 2;
+}
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
deleted file mode 100644
index 2b068d1..0000000
--- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
deleted file mode 100644
index 07d5da9..0000000
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..d1244c9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index eca70cf..7353dbd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,5 @@
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index eca70cf..7353dbd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,5 @@
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index a571e60..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 61da551..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index c41dd28..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index db5080a..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 6dba46d..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index da31a87..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 15ac681..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index b216f2d..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index f25a419..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index e96783c..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index f9bebc1..c84da4f 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,6 +1,6 @@
-