├── app ├── .gitignore ├── src │ ├── main │ │ ├── ic_logo-web.png │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_logo.png │ │ │ │ └── ic_logo_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_logo.png │ │ │ │ └── ic_logo_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_logo.png │ │ │ │ └── ic_logo_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_logo.png │ │ │ │ └── ic_logo_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_logo.png │ │ │ │ └── ic_logo_round.png │ │ │ ├── values │ │ │ │ ├── boolean.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── values-v23 │ │ │ │ └── boolean.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_logo.xml │ │ │ │ └── ic_logo_round.xml │ │ │ ├── drawable │ │ │ │ ├── ripple.xml │ │ │ │ ├── ripple_dark.xml │ │ │ │ ├── ic_swap_button.xml │ │ │ │ ├── ic_logo_background.xml │ │ │ │ └── ic_logo_foreground.xml │ │ │ ├── layout │ │ │ │ ├── activity_picker.xml │ │ │ │ ├── row_picker.xml │ │ │ │ ├── activity_main.xml │ │ │ │ └── numpad.xml │ │ │ └── layout-land │ │ │ │ └── activity_main.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── worker8 │ │ │ │ └── simplecurrency │ │ │ │ ├── di │ │ │ │ ├── scope │ │ │ │ │ ├── PerActivityScope.kt │ │ │ │ │ └── ScopeConstant.kt │ │ │ │ ├── module │ │ │ │ │ ├── RepoModule.kt │ │ │ │ │ ├── ActivityModule.kt │ │ │ │ │ └── AppModule.kt │ │ │ │ └── AppComponent.kt │ │ │ │ ├── db │ │ │ │ ├── entity │ │ │ │ │ ├── RoomUpdatedTimeStamp.kt │ │ │ │ │ └── RoomConversionRate.kt │ │ │ │ ├── dao │ │ │ │ │ ├── RoomUpdatedTimeStampDao.kt │ │ │ │ │ ├── BaseDao.kt │ │ │ │ │ └── RoomConversionRateDao.kt │ │ │ │ └── SimpleCurrencyDatabase.kt │ │ │ │ ├── common │ │ │ │ ├── extension │ │ │ │ │ ├── RxExtension.kt │ │ │ │ │ └── DoubleExtension.kt │ │ │ │ ├── util │ │ │ │ │ └── NumberFormatterUtil.kt │ │ │ │ └── sharedPreference │ │ │ │ │ ├── MainPreference.kt │ │ │ │ │ └── PreferenceUtil.kt │ │ │ │ ├── ui │ │ │ │ ├── picker │ │ │ │ │ ├── PickerContract.kt │ │ │ │ │ ├── PickerViewHolder.kt │ │ │ │ │ ├── PickerAdapter.kt │ │ │ │ │ ├── PickerRepo.kt │ │ │ │ │ ├── PickerActivity.kt │ │ │ │ │ └── PickerViewModel.kt │ │ │ │ └── main │ │ │ │ │ ├── MainRepoInterface.kt │ │ │ │ │ ├── event │ │ │ │ │ ├── BackSpaceInputEvent.kt │ │ │ │ │ ├── NewNumberInputEvent.kt │ │ │ │ │ └── CalculateConversionRateEvent.kt │ │ │ │ │ ├── MainContract.kt │ │ │ │ │ ├── MainRepo.kt │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ └── MainViewModel.kt │ │ │ │ ├── SimpleCurrencyApplication.kt │ │ │ │ └── worker │ │ │ │ └── UpdateCurrencyWorker.kt │ │ └── AndroidManifest.xml │ ├── release │ │ └── java │ │ │ └── com │ │ │ └── worker8 │ │ │ └── simplecurrency │ │ │ ├── DebugSetting.kt │ │ │ └── OkHttpClientProvider.kt │ ├── debug │ │ └── java │ │ │ └── com │ │ │ └── worker8 │ │ │ └── simplecurrency │ │ │ ├── OkHttpClientProvider.kt │ │ │ └── DebugSetting.kt │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── worker8 │ │ │ └── simplecurrency │ │ │ └── RoomConversionRateDaoTest.kt │ └── test │ │ └── java │ │ └── com │ │ └── worker8 │ │ └── simplecurrency │ │ └── MainViewModelTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── fixerio ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ └── values │ │ │ │ └── strings.xml │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── worker8 │ │ │ │ └── fixerio │ │ │ │ ├── model │ │ │ │ ├── Rates.kt │ │ │ │ ├── Quotes.kt │ │ │ │ ├── ConversionRate.kt │ │ │ │ └── Currency.kt │ │ │ │ ├── response │ │ │ │ └── EuroCurrencyResponse.kt │ │ │ │ ├── network │ │ │ │ ├── FixerIOMoshi.kt │ │ │ │ ├── FixerIOLiveService.kt │ │ │ │ ├── SeedFixerIOLiveService.kt │ │ │ │ └── FixerIORetrofit.kt │ │ │ │ └── adapter │ │ │ │ ├── QuotesCustomAdapter.kt │ │ │ │ └── RatesCustomAdapter.kt │ │ └── resources │ │ │ └── usd_based_currencies_fixerio.json │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── worker8 │ │ │ └── fixerio │ │ │ └── ExampleInstrumentedTest.java │ └── test │ │ └── java │ │ └── com │ │ └── worker8 │ │ └── fixerio │ │ ├── FixerIOLiveServiceTest.kt │ │ └── FixerIOMoshiTest.kt ├── build.gradle.kts └── proguard-rules.pro ├── settings.gradle ├── logo.sketch ├── feature_image.sketch ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .editorconfig ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── privacy_policy.md ├── gradlew └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /fixerio/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ':fixerio' 2 | -------------------------------------------------------------------------------- /logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/logo.sketch -------------------------------------------------------------------------------- /feature_image.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/feature_image.sketch -------------------------------------------------------------------------------- /app/src/main/ic_logo-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/ic_logo-web.png -------------------------------------------------------------------------------- /fixerio/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | FixerIO 3 | 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-hdpi/ic_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-mdpi/ic_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xhdpi/ic_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xxhdpi/ic_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-hdpi/ic_logo_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-mdpi/ic_logo_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xhdpi/ic_logo_round.png -------------------------------------------------------------------------------- /fixerio/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xxhdpi/ic_logo_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_logo_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worker8/SimpleCurrency/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_logo_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/boolean.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values-v23/boolean.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/model/Rates.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.model 2 | 3 | class Rates { 4 | var conversionRates = listOf() 5 | } 6 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/model/Quotes.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.model 2 | 3 | class Quotes { 4 | var conversionRates = listOf() 5 | } 6 | -------------------------------------------------------------------------------- /app/src/release/java/com/worker8/simplecurrency/DebugSetting.kt: -------------------------------------------------------------------------------- 1 | import android.content.Context 2 | 3 | class DebugSetting { 4 | companion object { 5 | fun init(context: Context) { 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/src/release/java/com/worker8/simplecurrency/OkHttpClientProvider.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency 2 | 3 | import okhttp3.OkHttpClient 4 | 5 | fun provideOkHttpClient() = 6 | OkHttpClient.Builder() 7 | .build() 8 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/model/ConversionRate.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.model 2 | 3 | data class ConversionRate(val code: String, val rate: Double) { 4 | // fun getCodeWithoutUSD(): String { 5 | // return code.substring(3) 6 | // } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/scope/PerActivityScope.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di.scope 2 | 3 | import javax.inject.Scope 4 | 5 | @Retention(value = AnnotationRetention.RUNTIME) 6 | @MustBeDocumented 7 | @Scope 8 | annotation class PerActivityScope 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/scope/ScopeConstant.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di.scope 2 | 3 | object ScopeConstant { 4 | const val MainThreadScheduler = "MainThreadScheduler" 5 | const val BackgroundThreadScheduler = "BackgroundThreadScheduler" 6 | } 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Sep 04 10:40:41 JST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_logo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_logo_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/debug/java/com/worker8/simplecurrency/OkHttpClientProvider.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency 2 | 3 | import okhttp3.OkHttpClient 4 | import com.facebook.stetho.okhttp3.StethoInterceptor 5 | 6 | fun provideOkHttpClient() = 7 | OkHttpClient.Builder() 8 | .addNetworkInterceptor(StethoInterceptor()) 9 | .build() 10 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/response/EuroCurrencyResponse.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.response 2 | 3 | import com.worker8.fixerio.model.Rates 4 | 5 | data class EuroCurrencyResponse( 6 | val success: Boolean, 7 | val timestamp: Long, 8 | val base: String, 9 | val date: String, 10 | val rates: Rates 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #303F9F 4 | #1A237E 5 | #EF6C00 6 | #48FFFFFF 7 | #f5f5f5 8 | #BDBDBD 9 | 10 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/network/FixerIOMoshi.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.network 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.worker8.fixerio.adapter.ConversionsFactory 5 | 6 | class FixerIOMoshi { 7 | companion object { 8 | fun build(): Moshi = Moshi 9 | .Builder() 10 | .add(ConversionsFactory()) 11 | .build() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/entity/RoomUpdatedTimeStamp.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class RoomUpdatedTimeStamp( 8 | @PrimaryKey val id: String, 9 | /* TimeStamp from the API*/ 10 | val timeStamp: Long, 11 | val updatedAt: Long = System.currentTimeMillis() / 1000 12 | ) 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.{gradle,java,kt,kts,scala,rs,xml}] 14 | indent_size = 4 15 | 16 | [{Makefile,*.go}] 17 | indent_style = tab 18 | indent_size = 4 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Simple Currency 3 | Search currencies by name or code 4 | Last Updated 5 | FROM 6 | TO 7 | Ops, there is an error, please restart the app… 8 | 9 | -------------------------------------------------------------------------------- /app/src/debug/java/com/worker8/simplecurrency/DebugSetting.kt: -------------------------------------------------------------------------------- 1 | import android.content.Context 2 | import com.facebook.stetho.Stetho 3 | import com.worker8.simplecurrency.BuildConfig 4 | 5 | class DebugSetting { 6 | companion object { 7 | fun init(context: Context) { 8 | if (BuildConfig.DEBUG) { 9 | Stetho.initializeWithDefaults(context) 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/module/RepoModule.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di.module 2 | 3 | import com.worker8.simplecurrency.ui.main.MainRepo 4 | import com.worker8.simplecurrency.ui.main.MainRepoInterface 5 | import dagger.Binds 6 | import dagger.Module 7 | 8 | @Module 9 | abstract class RepoModule { 10 | @Binds 11 | abstract fun provideMainRepoInterface(mainRepo: MainRepo): MainRepoInterface 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/dao/RoomUpdatedTimeStampDao.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Query 5 | import com.worker8.simplecurrency.db.entity.RoomUpdatedTimeStamp 6 | 7 | @Dao 8 | interface RoomUpdatedTimeStampDao : BaseDao { 9 | @Query("SELECT * FROM RoomUpdatedTimeStamp WHERE id='1'") 10 | fun findLatestTimeStamp(): List 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/common/extension/RxExtension.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.common 2 | 3 | import io.reactivex.disposables.CompositeDisposable 4 | import io.reactivex.disposables.Disposable 5 | import io.reactivex.subjects.BehaviorSubject 6 | 7 | fun Disposable.addTo(composite: CompositeDisposable) = composite.add(this) 8 | 9 | // convenience value to avoid using !! everywhere 10 | val BehaviorSubject.realValue: T 11 | get() = value!! 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/dao/BaseDao.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db.dao 2 | 3 | import androidx.room.Delete 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Update 7 | 8 | interface BaseDao { 9 | @Insert(onConflict = OnConflictStrategy.REPLACE) 10 | fun insert(obj: T) 11 | 12 | @Insert 13 | fun insert(vararg obj: T) 14 | 15 | @Update 16 | fun update(obj: T) 17 | 18 | @Delete 19 | fun delete(obj: T) 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | local.properties 3 | api_keys.properties 4 | 5 | */build/ 6 | secret.gradle 7 | proguard.map 8 | 9 | *~ 10 | *.swp 11 | .DS_Store 12 | build 13 | 14 | # Android Studio 15 | .idea/* 16 | !.idea/codeStyles/Project.xml 17 | !.idea/codeStyles/codeStyleConfig.xml 18 | *.iml 19 | *.ipr 20 | *.iws 21 | 22 | # Keystore 23 | #signing.gradle 24 | 25 | # Crashlytics 26 | com_crashlytics_export_strings.xml 27 | crashlytics-build.properties 28 | crashlytics.properties 29 | 30 | # integration tests 31 | artifacts 32 | 33 | # Documentations 34 | design/ 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/common/util/NumberFormatterUtil.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.common.util 2 | 3 | import com.worker8.simplecurrency.common.extension.toComma 4 | 5 | class NumberFormatterUtil { 6 | companion object { 7 | fun addComma(s: String): String { 8 | val dotIndex = s.indexOf('.') 9 | return if (dotIndex == -1) { 10 | s.toDouble().toComma() 11 | } else { 12 | s.substring(0, dotIndex).toDouble().toComma() + s.substring(dotIndex) 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_dark.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/network/FixerIOLiveService.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.network 2 | 3 | import com.worker8.fixerio.BuildConfig 4 | import com.worker8.fixerio.response.EuroCurrencyResponse 5 | import io.reactivex.Single 6 | import retrofit2.http.GET 7 | import retrofit2.http.Query 8 | 9 | interface FixerIOLiveService { 10 | @GET(BuildConfig.CURRENCY_API_URL_PATH) 11 | fun getCurrencies( 12 | @Query("access_key") accessKey: String = BuildConfig.FIXER_IO_ACCCES_KEY, 13 | @Query("base") source: String = "EUR", 14 | @Query("format") format: Int = 1 15 | ): Single 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerContract.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import io.reactivex.Observable 4 | 5 | 6 | class PickerContract { 7 | interface Input { 8 | val isBase: Boolean 9 | val onFilterTextChanged: Observable 10 | val inputAmount: Double 11 | } 12 | 13 | interface ViewAction { 14 | fun showTerminalError() 15 | } 16 | 17 | data class ScreenState( 18 | val currencyList: LinkedHashSet, 19 | val rateDetailVisibility: Boolean, 20 | val latestUpdatedString: String 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /fixerio/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.Tool.kotlin}") 3 | implementation("com.squareup.retrofit2:retrofit:${Versions.App.retrofit}") 4 | implementation("com.squareup.retrofit2:converter-moshi:${Versions.App.retrofit}") 5 | implementation("com.squareup.retrofit2:adapter-rxjava2:${Versions.App.retrofit}") 6 | implementation("com.squareup.moshi:moshi-kotlin:${Versions.App.moshi}") 7 | kapt("com.squareup.moshi:moshi-kotlin-codegen:${Versions.App.moshi}") 8 | 9 | testImplementation("junit:junit:${Versions.Test.junit}") 10 | androidTestImplementation("androidx.test:runner:${Versions.Test.runner}") 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/module/ActivityModule.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di.module 2 | 3 | import com.worker8.simplecurrency.di.scope.PerActivityScope 4 | import com.worker8.simplecurrency.ui.main.MainActivity 5 | import com.worker8.simplecurrency.ui.picker.PickerActivity 6 | import dagger.Module 7 | import dagger.android.ContributesAndroidInjector 8 | 9 | @Module 10 | abstract class ActivityModule { 11 | 12 | @PerActivityScope 13 | @ContributesAndroidInjector 14 | abstract fun contributeMainActivity(): MainActivity 15 | 16 | @PerActivityScope 17 | @ContributesAndroidInjector 18 | abstract fun contributePickerActivity(): PickerActivity 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_swap_button.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/MainRepoInterface.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main 2 | 3 | import io.reactivex.Flowable 4 | import io.reactivex.Observable 5 | import io.reactivex.Scheduler 6 | 7 | interface MainRepoInterface { 8 | val mainThread: Scheduler 9 | val backgroundThread: Scheduler 10 | fun populateDbIfFirstTime(): Observable 11 | fun getSelectedBaseCurrencyCode(): String 12 | fun getSelectedTargetCurrencyCode(): String 13 | fun setSelectedBaseCurrencyCode(currencyCode: String): Boolean 14 | fun setSelectedTargetCurrencyCode(currencyCode: String): Boolean 15 | fun getLatestSelectedRateFlowable(): Flowable 16 | fun setupPeriodicUpdate() 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/SimpleCurrencyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency 2 | 3 | import com.jakewharton.threetenabp.AndroidThreeTen 4 | import com.worker8.simplecurrency.di.DaggerAppComponent 5 | import dagger.android.AndroidInjector 6 | import dagger.android.DaggerApplication 7 | 8 | class SimpleCurrencyApplication : DaggerApplication() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | 12 | AndroidThreeTen.init(this) 13 | DebugSetting.init(this) 14 | } 15 | 16 | override fun applicationInjector(): AndroidInjector { 17 | return DaggerAppComponent 18 | .builder() 19 | .application(this) 20 | .build() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/entity/RoomConversionRate.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import com.worker8.fixerio.model.ConversionRate 6 | import com.worker8.fixerio.model.Currency 7 | 8 | @Entity 9 | data class RoomConversionRate( 10 | @PrimaryKey 11 | val code: String, val rate: Double, val name: String 12 | ) { 13 | companion object { 14 | fun fromConversionRate(conversionRate: ConversionRate): RoomConversionRate { 15 | return RoomConversionRate( 16 | conversionRate.code, 17 | conversionRate.rate, 18 | Currency.ALL[conversionRate.code] ?: "" 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /fixerio/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/SimpleCurrencyDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.worker8.simplecurrency.db.dao.RoomConversionRateDao 6 | import com.worker8.simplecurrency.db.dao.RoomUpdatedTimeStampDao 7 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 8 | import com.worker8.simplecurrency.db.entity.RoomUpdatedTimeStamp 9 | 10 | @Database( 11 | entities = [RoomConversionRate::class, RoomUpdatedTimeStamp::class], 12 | version = 1, 13 | exportSchema = false /* TODO: export schema when the app is launched */ 14 | ) 15 | abstract class SimpleCurrencyDatabase : RoomDatabase() { 16 | abstract fun roomConversionRateDao(): RoomConversionRateDao 17 | abstract fun roomUpdatedTimeStampDao(): RoomUpdatedTimeStampDao 18 | } 19 | -------------------------------------------------------------------------------- /fixerio/src/androidTest/java/com/worker8/fixerio/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.runner.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumented test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getTargetContext(); 23 | 24 | assertEquals("com.worker8.fixerio.test", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/network/SeedFixerIOLiveService.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.network 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.worker8.fixerio.model.Rates 5 | import com.worker8.fixerio.response.EuroCurrencyResponse 6 | import java.io.BufferedReader 7 | 8 | class SeedFixerIOLiveService(val moshi: Moshi) { 9 | private val json: String by lazy { 10 | val inputStream = 11 | javaClass.classLoader.getResourceAsStream("usd_based_currencies_fixerio.json") 12 | inputStream.bufferedReader().use(BufferedReader::readText) 13 | } 14 | 15 | fun getSeedCurrencies(): Pair { 16 | val jsonAdapter = moshi.adapter(EuroCurrencyResponse::class.java) 17 | val response = jsonAdapter.fromJson(json) 18 | val rates = response?.rates ?: Rates() 19 | val timestamp = response?.timestamp ?: 0 20 | return rates to timestamp 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # Kotlin code style for this project: "official" or "obsolete": 15 | kotlin.code.style=official 16 | android.enableJetifier=true 17 | android.useAndroidX=true 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_logo_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /fixerio/src/test/java/com/worker8/fixerio/FixerIOLiveServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio 2 | 3 | import com.worker8.fixerio.network.FixerIOLiveService 4 | import com.worker8.fixerio.network.FixerIORetrofit 5 | import org.junit.Assert 6 | import org.junit.Before 7 | import org.junit.Test 8 | import retrofit2.Retrofit 9 | 10 | class FixerIOLiveServiceTest { 11 | private lateinit var service: FixerIOLiveService 12 | private lateinit var retrofit: Retrofit 13 | 14 | @Before 15 | fun setup() { 16 | retrofit = FixerIORetrofit.build() 17 | service = retrofit.create(FixerIOLiveService::class.java) 18 | } 19 | 20 | @Test 21 | fun testCall() { 22 | val response = service.getCurrencies().blockingGet() 23 | response?.let { 24 | System.out.println(it) 25 | it.rates.conversionRates.forEach { 26 | System.out.println(it) 27 | } 28 | } 29 | Assert.assertNotNull(response) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/network/FixerIORetrofit.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.network 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.worker8.fixerio.BuildConfig 5 | import okhttp3.OkHttpClient 6 | import retrofit2.Retrofit 7 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 8 | import retrofit2.converter.moshi.MoshiConverterFactory 9 | 10 | 11 | class FixerIORetrofit { 12 | companion object { 13 | private const val BaseUrl = BuildConfig.CURRENCY_API_URL; 14 | 15 | fun build( 16 | moshi: Moshi = FixerIOMoshi.build(), 17 | okHttpClient: OkHttpClient = 18 | OkHttpClient.Builder().build() 19 | ) = 20 | Retrofit.Builder() 21 | .baseUrl(BaseUrl) 22 | .client(okHttpClient) 23 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 24 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 25 | .build() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di 2 | 3 | import com.worker8.simplecurrency.SimpleCurrencyApplication 4 | import com.worker8.simplecurrency.di.module.ActivityModule 5 | import com.worker8.simplecurrency.di.module.AppModule 6 | import com.worker8.simplecurrency.di.module.RepoModule 7 | import com.worker8.simplecurrency.worker.UpdateCurrencyWorker 8 | import dagger.BindsInstance 9 | import dagger.Component 10 | import dagger.android.AndroidInjector 11 | import dagger.android.support.AndroidSupportInjectionModule 12 | import javax.inject.Singleton 13 | 14 | @Singleton 15 | @Component( 16 | modules = [ 17 | AndroidSupportInjectionModule::class, 18 | AppModule::class, 19 | ActivityModule::class, 20 | RepoModule::class 21 | ] 22 | ) 23 | interface AppComponent : AndroidInjector { 24 | fun inject(updateCurrencyWorker: UpdateCurrencyWorker) 25 | 26 | @Component.Builder 27 | abstract class Builder { 28 | @BindsInstance 29 | abstract fun application(application: SimpleCurrencyApplication): Builder 30 | 31 | abstract fun build(): AppComponent 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/common/extension/DoubleExtension.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.common.extension 2 | 3 | import java.math.RoundingMode 4 | import java.text.DecimalFormat 5 | import java.text.DecimalFormatSymbols 6 | import java.util.* 7 | 8 | fun Double.toComma(): String { 9 | val decimalFormat = DecimalFormat("#,###.#######################") 10 | decimalFormat.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) 11 | val bigDecimal = this.toBigDecimal() 12 | return decimalFormat.format(bigDecimal) 13 | } 14 | 15 | fun Double.toTwoDecimalWithComma(): String { 16 | val decimalFormat = DecimalFormat("#,###.##") 17 | decimalFormat.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) 18 | val bigDecimal = this.toBigDecimal().setScale(2, RoundingMode.HALF_UP) 19 | return decimalFormat.format(bigDecimal) 20 | } 21 | 22 | fun Double.toThreeDecimalWithComma(): String { 23 | val decimalFormat = DecimalFormat("#,###.###") 24 | decimalFormat.decimalFormatSymbols = DecimalFormatSymbols(Locale.getDefault()) 25 | val bigDecimal = this.toBigDecimal().setScale(3, RoundingMode.HALF_UP) 26 | return decimalFormat.format(bigDecimal) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/event/BackSpaceInputEvent.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main.event 2 | 3 | import com.worker8.simplecurrency.common.realValue 4 | import com.worker8.simplecurrency.ui.main.MainContract 5 | import io.reactivex.Observable 6 | import io.reactivex.subjects.BehaviorSubject 7 | 8 | class BackSpaceInputEvent( 9 | private val input: MainContract.Input, 10 | private val screenStateSubject: BehaviorSubject 11 | ) { 12 | private val currentScreenState get() = screenStateSubject.realValue 13 | fun process(): Observable { 14 | return input.backSpaceClick.map { 15 | if (currentInputString().isEmpty() || currentInputString() == "0") { 16 | currentInputString() 17 | } else if (currentInputString().length == 1) { 18 | "0" 19 | } else { 20 | currentInputString().removeRange( 21 | currentInputString().length - 1, 22 | currentInputString().length 23 | ) 24 | } 25 | }.share() 26 | } 27 | 28 | private fun currentInputString(): String { 29 | return currentScreenState.inputNumberStringState 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/common/sharedPreference/MainPreference.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.common.sharedPreference 2 | 3 | import android.content.Context 4 | 5 | class MainPreference { 6 | companion object { 7 | private const val FIRST_TIME = "FIRST_TIME" 8 | private const val BASE_CURRENCY = "BASE_CURRENCY" 9 | private const val TARGET_CURRENCY = "TARGET_CURRENCY" 10 | 11 | fun setFirstTimeFalse(context: Context) = 12 | context.defaultPrefs().save(FIRST_TIME, false) 13 | 14 | fun getFirstTime(context: Context) = 15 | context.defaultPrefs().get(FIRST_TIME, true) 16 | 17 | fun setSelectedBaseCurrencyCode(context: Context, currencyCode: String) = 18 | context.defaultPrefs().save(BASE_CURRENCY, currencyCode) 19 | 20 | fun getSelectedBaseCurrencyCode(context: Context) = 21 | context.defaultPrefs().get(BASE_CURRENCY, "JPY") 22 | 23 | fun setSelectedTargetCurrencyCode(context: Context, currencyCode: String) = 24 | context.defaultPrefs().save(TARGET_CURRENCY, currencyCode) 25 | 26 | fun getSelectedTargetCurrencyCode(context: Context) = 27 | context.defaultPrefs().get(TARGET_CURRENCY, "EUR") 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/db/dao/RoomConversionRateDao.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.db.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 8 | import io.reactivex.Flowable 9 | 10 | @Dao 11 | interface RoomConversionRateDao : BaseDao { 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | fun insert(list: List) 14 | 15 | @Query("SELECT * FROM RoomConversionRate WHERE code=:currencyCode ORDER BY code ASC") 16 | fun findConversionRate(currencyCode: String): List 17 | 18 | @Query("SELECT * FROM RoomConversionRate") 19 | fun getRoomConversionRateList(): List 20 | 21 | @Query("SELECT * FROM RoomConversionRate WHERE code like :currencyCode OR name like :currenyName ORDER BY code ASC") 22 | fun findRoomConversionRateFlowable( 23 | currencyCode: String, 24 | currenyName: String 25 | ): Flowable> 26 | 27 | @Query("SELECT * FROM RoomConversionRate WHERE code=:currencyCode ORDER BY code ASC") 28 | fun findConversionRateFlowable(currencyCode: String): Flowable> 29 | } 30 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/adapter/QuotesCustomAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.adapter 2 | 3 | import com.squareup.moshi.* 4 | import com.worker8.fixerio.model.ConversionRate 5 | import com.worker8.fixerio.model.Quotes 6 | 7 | class QuotesCustomAdapter : JsonAdapter() { 8 | override fun fromJson(reader: JsonReader): Quotes? { 9 | val list = mutableListOf() 10 | 11 | reader.beginObject() 12 | while (reader.hasNext()) { 13 | list.add( 14 | ConversionRate( 15 | code = reader.nextName(), 16 | rate = reader.nextString().toDouble() 17 | ) 18 | ) 19 | } 20 | reader.endObject() 21 | return Quotes().apply { 22 | conversionRates = list 23 | } 24 | } 25 | 26 | override fun toJson(writer: JsonWriter, value: Quotes?) { 27 | //TODO: write this if needed 28 | } 29 | } 30 | 31 | //class ConversionsFactory : JsonAdapter.Factory { 32 | // override fun create(type: Type, annotations: MutableSet, moshi: Moshi): JsonAdapter<*>? { 33 | // if (Types.getRawType(type).isAssignableFrom(Quotes::class.java)) { 34 | // return QuotesCustomAdapter() 35 | // } 36 | // return null 37 | // } 38 | // 39 | //} 40 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/adapter/RatesCustomAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.adapter 2 | 3 | import com.squareup.moshi.* 4 | import com.worker8.fixerio.model.ConversionRate 5 | import com.worker8.fixerio.model.Rates 6 | import java.lang.reflect.Type 7 | 8 | class RatesCustomAdapter : JsonAdapter() { 9 | override fun fromJson(reader: JsonReader): Rates? { 10 | val list = mutableListOf() 11 | 12 | reader.beginObject() 13 | while (reader.hasNext()) { 14 | list.add( 15 | ConversionRate( 16 | code = reader.nextName(), 17 | rate = reader.nextString().toDouble() 18 | ) 19 | ) 20 | } 21 | reader.endObject() 22 | return Rates().apply { 23 | conversionRates = list 24 | } 25 | } 26 | 27 | override fun toJson(writer: JsonWriter, value: Rates?) { 28 | //TODO: write this if needed 29 | } 30 | } 31 | 32 | class ConversionsFactory : JsonAdapter.Factory { 33 | override fun create( 34 | type: Type, 35 | annotations: MutableSet, 36 | moshi: Moshi 37 | ): JsonAdapter<*>? { 38 | if (Types.getRawType(type).isAssignableFrom(Rates::class.java)) { 39 | return RatesCustomAdapter() 40 | } 41 | return null 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/worker/UpdateCurrencyWorker.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.worker 2 | 3 | import android.content.Context 4 | import androidx.work.Worker 5 | import androidx.work.WorkerParameters 6 | import com.worker8.fixerio.network.FixerIOLiveService 7 | import com.worker8.simplecurrency.SimpleCurrencyApplication 8 | import com.worker8.simplecurrency.db.SimpleCurrencyDatabase 9 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 10 | import com.worker8.simplecurrency.db.entity.RoomUpdatedTimeStamp 11 | import com.worker8.simplecurrency.di.DaggerAppComponent 12 | import javax.inject.Inject 13 | 14 | class UpdateCurrencyWorker(appContext: Context, workerParams: WorkerParameters) : 15 | Worker(appContext, workerParams) { 16 | @Inject 17 | lateinit var service: FixerIOLiveService 18 | @Inject 19 | lateinit var db: SimpleCurrencyDatabase 20 | 21 | override fun doWork(): Result { 22 | DaggerAppComponent.builder() 23 | .application(applicationContext as SimpleCurrencyApplication) 24 | .build() 25 | .inject(this) 26 | 27 | val response = service.getCurrencies() 28 | .blockingGet() 29 | val roomConversionRateList = response.rates.conversionRates.map { 30 | RoomConversionRate.fromConversionRate(it) 31 | } 32 | db.roomConversionRateDao().insert(roomConversionRateList) 33 | db.roomUpdatedTimeStampDao() 34 | .insert(RoomUpdatedTimeStamp("1", response.timestamp)) 35 | 36 | return Result.success() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.worker8.simplecurrency.R 8 | import kotlinx.android.synthetic.main.row_picker.view.* 9 | 10 | class PickerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 11 | 12 | fun bind( 13 | pickerRowType: PickerAdapter.PickerRowType, 14 | isBase: Boolean, 15 | callback: (String) -> Unit 16 | ) { 17 | itemView.apply { 18 | pickerCurrencyName.text = pickerRowType.currencyName 19 | pickerCurrencyRate.text = pickerRowType.currencyRate 20 | pickerCurrencyCode.text = pickerRowType.currencyCode 21 | pickerCurrencyRateCalculated.text = pickerRowType.currencyRateCalculated 22 | val rateVisibility = if (isBase) { 23 | View.GONE 24 | } else { 25 | View.VISIBLE 26 | } 27 | pickerCurrencyRate.visibility = rateVisibility 28 | pickerCurrencyRateCalculated.visibility = rateVisibility 29 | setOnClickListener { callback.invoke(pickerRowType.currencyCode) } 30 | } 31 | } 32 | 33 | companion object { 34 | fun create(parent: ViewGroup) = 35 | LayoutInflater.from(parent.context) 36 | .inflate(R.layout.row_picker, parent, false) 37 | .let { PickerViewHolder(it) } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/MainContract.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main 2 | 3 | import io.reactivex.Observable 4 | 5 | class MainContract { 6 | interface Input { 7 | val onNumpad0Click: Observable 8 | val onNumpad1Click: Observable 9 | val onNumpad2Click: Observable 10 | val onNumpad3Click: Observable 11 | val onNumpad4Click: Observable 12 | val onNumpad5Click: Observable 13 | val onNumpad6Click: Observable 14 | val onNumpad7Click: Observable 15 | val onNumpad8Click: Observable 16 | val onNumpad9Click: Observable 17 | val backSpaceClick: Observable 18 | val backSpaceLongClick: Observable 19 | val swapButtonClick: Observable 20 | val dotClick: Observable 21 | val onBaseCurrencyChanged: Observable 22 | val onTargetCurrencyChanged: Observable 23 | val onTargetCurrencyClicked: Observable 24 | } 25 | 26 | interface ViewAction { 27 | fun navigateToSelectTargetCurrency(inputAmount: Double) 28 | /* show this in a case of unrecoverable error */ 29 | fun showTerminalError() 30 | } 31 | 32 | data class ScreenState( 33 | val inputNumberStringState: String = "0", 34 | val inputNumberString: String = "0", 35 | val outputNumberString: String = "0", 36 | val baseCurrencyCode: String = "", 37 | val targetCurrencyCode: String = "", 38 | val isEnableDot: Boolean = true 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /fixerio/src/test/java/com/worker8/fixerio/FixerIOMoshiTest.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.worker8.fixerio.model.Currency 5 | import com.worker8.fixerio.network.FixerIOMoshi 6 | import com.worker8.fixerio.response.EuroCurrencyResponse 7 | import org.junit.Assert 8 | import org.junit.Before 9 | import org.junit.Test 10 | import java.io.BufferedReader 11 | 12 | class FixerIOMoshiTest { 13 | private lateinit var moshi: Moshi 14 | private val json: String by lazy { 15 | val inputStream = 16 | javaClass.classLoader.getResourceAsStream("usd_based_currencies_fixerio.json") 17 | inputStream.bufferedReader().use(BufferedReader::readText) 18 | } 19 | 20 | @Before 21 | fun setup() { 22 | moshi = FixerIOMoshi.build() 23 | } 24 | 25 | @Test 26 | fun testCall() { 27 | val jsonAdapter = moshi.adapter(EuroCurrencyResponse::class.java) 28 | val response = jsonAdapter.fromJson(json) 29 | response?.let { 30 | System.out.println(it) 31 | it.rates.conversionRates.forEach { 32 | System.out.println(it) 33 | } 34 | } 35 | Assert.assertNotNull(response) 36 | } 37 | 38 | @Test 39 | fun testTest() { 40 | System.out.println("Currency.ALL.count: ${Currency.ALL.count()}") 41 | System.out.println("Currency.ALL.TEMP: ${Currency.TEMP.count()}") 42 | var count = 0 43 | Currency.ALL.forEach { (code, name) -> 44 | if (Currency.TEMP.contains(code)) { 45 | count++ 46 | } 47 | } 48 | 49 | System.out.println("Total match: ${count}") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/common/sharedPreference/PreferenceUtil.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.common.sharedPreference 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.preference.PreferenceManager 6 | 7 | fun Context.defaultPrefs(): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) 8 | fun Context.customPrefs(name: String): SharedPreferences = getSharedPreferences(name, Context.MODE_PRIVATE) 9 | 10 | fun SharedPreferences.save(key: String, value: Any): Boolean { 11 | return edit().apply { 12 | when (value) { 13 | is String -> putString(key, value) 14 | is Int -> putInt(key, value) 15 | is Boolean -> putBoolean(key, value) 16 | is Float -> putFloat(key, value) 17 | is Long -> putLong(key, value) 18 | else -> throw UnsupportedOperationException("Unsupported Type") 19 | } 20 | }.commit() 21 | } 22 | 23 | inline fun SharedPreferences.get(key: String, defaultValue: T): T { 24 | defaultValue.ofType { 25 | return getString(key, it) as T 26 | } 27 | 28 | defaultValue.ofType { 29 | return getInt(key, it) as T 30 | } 31 | 32 | defaultValue.ofType { 33 | return getBoolean(key, it) as T 34 | } 35 | 36 | defaultValue.ofType { 37 | return getFloat(key, it) as T 38 | } 39 | 40 | defaultValue.ofType { 41 | return getLong(key, it) as T 42 | } 43 | 44 | throw UnsupportedOperationException("Unsupported Type") 45 | } 46 | 47 | inline fun Any.ofType(block: (T) -> Unit) { 48 | if (this is T) { 49 | block(this as T) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_logo_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 19 | 25 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.DiffUtil 5 | import androidx.recyclerview.widget.ListAdapter 6 | import androidx.recyclerview.widget.RecyclerView 7 | import io.reactivex.Observable 8 | import io.reactivex.subjects.PublishSubject 9 | 10 | class PickerAdapter(private val isBase: Boolean) : 11 | ListAdapter(comparator) { 12 | private val clickSubject: PublishSubject = PublishSubject.create() 13 | val selectedCurrencyCode: Observable = clickSubject.hide() 14 | 15 | init { 16 | setHasStableIds(true) 17 | } 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 20 | return PickerViewHolder.create(parent) 21 | } 22 | 23 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 24 | val pickerRowType = getItem(position) 25 | (holder as PickerViewHolder).bind(pickerRowType, isBase) { 26 | clickSubject.onNext(it) 27 | } 28 | } 29 | 30 | override fun getItemId(position: Int) = getItem(position).currencyCode.hashCode().toLong() 31 | 32 | companion object { 33 | val comparator = object : DiffUtil.ItemCallback() { 34 | override fun areItemsTheSame(oldItem: PickerRowType, newItem: PickerRowType): Boolean { 35 | return oldItem.currencyCode == newItem.currencyCode 36 | } 37 | 38 | override fun areContentsTheSame( 39 | oldItem: PickerRowType, 40 | newItem: PickerRowType 41 | ): Boolean { 42 | return oldItem == newItem 43 | } 44 | } 45 | } 46 | 47 | data class PickerRowType( 48 | val currencyCode: String, 49 | val currencyName: String, 50 | val currencyRate: String, 51 | val currencyRateCalculated: String 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/event/NewNumberInputEvent.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main.event 2 | 3 | import com.worker8.simplecurrency.common.realValue 4 | import com.worker8.simplecurrency.ui.main.MainContract 5 | import io.reactivex.Observable 6 | import io.reactivex.subjects.BehaviorSubject 7 | 8 | class NewNumberInputEvent( 9 | private val input: MainContract.Input, 10 | private val screenStateSubject: BehaviorSubject 11 | ) { 12 | private val currentScreenState get() = screenStateSubject.realValue 13 | fun process(): Observable { 14 | return Observable.merge( 15 | arrayListOf( 16 | input.onNumpad0Click, 17 | input.onNumpad1Click, 18 | input.onNumpad2Click, 19 | input.onNumpad3Click, 20 | input.onNumpad4Click, 21 | input.onNumpad5Click, 22 | input.onNumpad6Click, 23 | input.onNumpad7Click, 24 | input.onNumpad8Click, 25 | input.onNumpad9Click, 26 | input.dotClick 27 | ) 28 | ) 29 | .map { newChar -> 30 | if (newChar == '.') { 31 | // handling '.' as input 32 | // before: "0" --> after: "0." 33 | // before: "123 --> after: "123. 34 | currentInputString() + newChar 35 | } else if (currentInputString().length == 1 && currentInputString() == "0") { 36 | // handle case when input is "0" (beginning) 37 | // before: "0" --> after: "2" 38 | newChar.toString() 39 | } else { 40 | // handle normal case 41 | // before "123", after "1234" 42 | currentInputString() + newChar 43 | } 44 | } 45 | .share() 46 | } 47 | 48 | private fun currentInputString(): String { 49 | return currentScreenState.inputNumberStringState 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/event/CalculateConversionRateEvent.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main.event 2 | 3 | import com.worker8.simplecurrency.ui.main.MainRepoInterface 4 | import io.reactivex.Observable 5 | import io.reactivex.functions.BiFunction 6 | import io.reactivex.subjects.PublishSubject 7 | 8 | class CalculateConversionRateEvent( 9 | private val newNumberInputSharedObservable: Observable, 10 | private val backSpaceInputEventSharedObservable: Observable, 11 | private val backSpaceLongClickSharedObservable: Observable, 12 | private val triggerCalculateSubject: PublishSubject = PublishSubject.create(), 13 | private val seedDatabaseSharedObservable: Observable, 14 | private val repo: MainRepoInterface 15 | ) { 16 | fun process(): Observable>> { 17 | return Observable.combineLatest( 18 | Observable.merge( 19 | newNumberInputSharedObservable, 20 | backSpaceInputEventSharedObservable, 21 | triggerCalculateSubject, 22 | backSpaceLongClickSharedObservable 23 | ), 24 | seedDatabaseSharedObservable.subscribeOn(repo.backgroundThread) 25 | .flatMap { repo.getLatestSelectedRateFlowable().toObservable() }, 26 | BiFunction>> { numberString, rate -> 27 | val dotRemoved = if (numberString.isNotEmpty() && numberString.last() == '.') { 28 | numberString.removeRange( 29 | numberString.length - 1, 30 | numberString.length 31 | ) 32 | } else { 33 | numberString 34 | } 35 | val input = dotRemoved.toDoubleOrNull() 36 | return@BiFunction if (input != null) { 37 | Result.success(input to rate) 38 | } else { 39 | Result.failure(Exception()) 40 | } 41 | }).share() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_picker.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | 21 | 32 | 33 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/worker8/simplecurrency/RoomConversionRateDaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.test.core.app.ApplicationProvider 6 | import androidx.test.ext.junit.runners.AndroidJUnit4 7 | import com.worker8.simplecurrency.db.SimpleCurrencyDatabase 8 | import com.worker8.simplecurrency.db.dao.RoomConversionRateDao 9 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 10 | import org.junit.Assert 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | 15 | @RunWith(AndroidJUnit4::class) 16 | class RoomConversionRateDaoTest { 17 | private lateinit var roomConversionRateDao: RoomConversionRateDao 18 | private lateinit var db: SimpleCurrencyDatabase 19 | 20 | @Before 21 | fun createDb() { 22 | val context = ApplicationProvider.getApplicationContext() 23 | db = Room.inMemoryDatabaseBuilder(context, SimpleCurrencyDatabase::class.java) 24 | .allowMainThreadQueries() 25 | .build() 26 | roomConversionRateDao = db.roomConversionRateDao() 27 | } 28 | 29 | @Test 30 | fun crudTest() { 31 | roomConversionRateDao.insert( 32 | RoomConversionRate("USDJPY", 106.25984, "Japanese Yen"), 33 | RoomConversionRate("USDKES", 103.910315, "Kenyan Shilling"), 34 | RoomConversionRate("USDKGS", 69.85001, "Kyrgystani Som"), 35 | RoomConversionRate("USDKHR", 4089.999831, "Cambodian Riel"), 36 | RoomConversionRate("USDKMF", 447.050295, "Comorian Franc") 37 | ) 38 | 39 | val list = roomConversionRateDao.getRoomConversionRateList() 40 | Assert.assertNotNull(list) 41 | Assert.assertEquals(5, list.size) 42 | 43 | roomConversionRateDao.delete(RoomConversionRate("USDJPY", 106.25984, "Japanese Yen")) 44 | val deletedList = roomConversionRateDao.getRoomConversionRateList() 45 | Assert.assertEquals(4, deletedList.size) 46 | 47 | val foundList = roomConversionRateDao.findConversionRate("USDKMF") 48 | Assert.assertEquals(1, foundList.size) 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerRepo.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import android.content.Context 4 | import com.worker8.simplecurrency.common.sharedPreference.MainPreference 5 | import com.worker8.simplecurrency.db.SimpleCurrencyDatabase 6 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 7 | import com.worker8.simplecurrency.di.scope.PerActivityScope 8 | import com.worker8.simplecurrency.di.scope.ScopeConstant 9 | import io.reactivex.Observable 10 | import io.reactivex.Scheduler 11 | import org.threeten.bp.Instant 12 | import org.threeten.bp.ZoneId 13 | import org.threeten.bp.ZonedDateTime 14 | import org.threeten.bp.format.DateTimeFormatter 15 | import javax.inject.Inject 16 | import javax.inject.Named 17 | 18 | @PerActivityScope 19 | class PickerRepo @Inject constructor( 20 | private val context: Context, 21 | val db: SimpleCurrencyDatabase, 22 | @Named(ScopeConstant.MainThreadScheduler) 23 | val mainThread: Scheduler, 24 | @Named(ScopeConstant.BackgroundThreadScheduler) 25 | val backgroundThread: Scheduler 26 | ) { 27 | fun getAllCurrenciesFromDb(searchText: String): Observable> = 28 | db.roomConversionRateDao().findRoomConversionRateFlowable( 29 | "%$searchText%", 30 | "%$searchText%" 31 | ).toObservable() 32 | 33 | fun getBaseRate(): List { 34 | val baseCurrency = getSelectedBaseCurrencyCode() 35 | return db.roomConversionRateDao().findConversionRate("$baseCurrency") 36 | } 37 | 38 | fun getLatestUpdatedDate(): Observable { 39 | return Observable.fromCallable { 40 | val foundList = db.roomUpdatedTimeStampDao().findLatestTimeStamp() 41 | 42 | if (foundList.isNotEmpty()) { 43 | val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm a") 44 | val zonedDateTime = ZonedDateTime.ofInstant( 45 | Instant.ofEpochSecond(foundList[0].timeStamp), 46 | ZoneId.systemDefault() 47 | ) 48 | zonedDateTime.format(formatter) 49 | } else "" 50 | } 51 | } 52 | 53 | fun getSelectedBaseCurrencyCode() = 54 | MainPreference.getSelectedBaseCurrencyCode(context) 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/res/layout/row_picker.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | 21 | 31 | 32 | 41 | 42 | 51 | 52 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/di/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.di.module 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.work.WorkManager 6 | import com.squareup.moshi.Moshi 7 | import com.worker8.fixerio.network.FixerIOLiveService 8 | import com.worker8.fixerio.network.FixerIOMoshi 9 | import com.worker8.fixerio.network.FixerIORetrofit 10 | import com.worker8.simplecurrency.SimpleCurrencyApplication 11 | import com.worker8.simplecurrency.db.SimpleCurrencyDatabase 12 | import com.worker8.simplecurrency.di.scope.ScopeConstant 13 | import com.worker8.simplecurrency.provideOkHttpClient 14 | import dagger.Binds 15 | import dagger.Module 16 | import dagger.Provides 17 | import io.reactivex.Scheduler 18 | import io.reactivex.android.schedulers.AndroidSchedulers 19 | import io.reactivex.schedulers.Schedulers 20 | import okhttp3.OkHttpClient 21 | import retrofit2.Retrofit 22 | import javax.inject.Named 23 | import javax.inject.Singleton 24 | 25 | @Module(includes = [AppModule.AppModuleInterface::class]) 26 | class AppModule { 27 | 28 | @Singleton 29 | @Provides 30 | fun provideDatabase(context: Context): SimpleCurrencyDatabase { 31 | return Room.databaseBuilder( 32 | context, 33 | SimpleCurrencyDatabase::class.java, 34 | "SimpleCurrencyDatabase" 35 | ).build() 36 | } 37 | 38 | @Singleton 39 | @Provides 40 | fun provideMoshi(): Moshi { 41 | return FixerIOMoshi.build() 42 | } 43 | 44 | @Singleton 45 | @Provides 46 | fun provideOKHttp3(): OkHttpClient { 47 | return provideOkHttpClient() 48 | } 49 | 50 | @Singleton 51 | @Provides 52 | fun provideRetrofit(moshi: Moshi, okHttpClient: OkHttpClient): Retrofit { 53 | return FixerIORetrofit.build(moshi, okHttpClient) 54 | } 55 | 56 | @Singleton 57 | @Provides 58 | fun provideWorkManager(context: Context): WorkManager { 59 | return WorkManager.getInstance(context) 60 | } 61 | 62 | @Singleton 63 | @Provides 64 | fun provideFixerIOLiveService(retrofit: Retrofit): FixerIOLiveService { 65 | return retrofit.create(FixerIOLiveService::class.java) 66 | } 67 | 68 | @Named(ScopeConstant.MainThreadScheduler) 69 | @Provides 70 | fun provideMainThreadScheduler(): Scheduler { 71 | return AndroidSchedulers.mainThread() 72 | } 73 | 74 | @Named(ScopeConstant.BackgroundThreadScheduler) 75 | @Provides 76 | fun provideBackgroundThreadScheduler(): Scheduler { 77 | return Schedulers.io() 78 | } 79 | 80 | @Module 81 | interface AppModuleInterface { 82 | @Binds 83 | fun provideContext(application: SimpleCurrencyApplication): Context 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | android { 2 | defaultConfig { 3 | applicationId = "com.worker8.simplecurrency" 4 | } 5 | dependencies { 6 | implementation(project(":fixerio")) 7 | 8 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.Tool.kotlin}") 9 | 10 | /* Architecture */ 11 | implementation("androidx.appcompat:appcompat:${Versions.App.androidX}") 12 | implementation("androidx.lifecycle:lifecycle-extensions:${Versions.App.viewModel}") 13 | implementation("androidx.lifecycle:lifecycle-viewmodel:${Versions.App.viewModel}") 14 | implementation("androidx.work:work-runtime:${Versions.App.workManager}") 15 | implementation("io.reactivex.rxjava2:rxjava:${Versions.App.rxJava}") 16 | implementation("io.reactivex.rxjava2:rxandroid:${Versions.App.rxAndroid}") 17 | implementation("com.jakewharton.rxbinding3:rxbinding:${Versions.App.rxBinding}") 18 | implementation("com.jakewharton.threetenabp:threetenabp:${Versions.App.threeTenABP}") 19 | 20 | /* UI */ 21 | implementation("androidx.constraintlayout:constraintlayout:${Versions.App.constraintLayout}") 22 | implementation("com.google.android.material:material:${Versions.App.material}") 23 | 24 | /* Network & Data Layer */ 25 | implementation("com.squareup.retrofit2:retrofit:${Versions.App.retrofit}") 26 | implementation("com.squareup.retrofit2:converter-moshi:${Versions.App.retrofit}") 27 | implementation("androidx.room:room-runtime:${Versions.App.room}") 28 | implementation("androidx.room:room-rxjava2:${Versions.App.room}") 29 | annotationProcessor("androidx.room:room-compiler:${Versions.App.room}") 30 | kapt("androidx.room:room-compiler:${Versions.App.room}") 31 | 32 | /* DI */ 33 | implementation("com.google.dagger:dagger:${Versions.App.dagger}") 34 | kapt("com.google.dagger:dagger-compiler:${Versions.App.dagger}") 35 | implementation("com.google.dagger:dagger-android:${Versions.App.dagger}") 36 | implementation("com.google.dagger:dagger-android-support:${Versions.App.dagger}") 37 | kapt("com.google.dagger:dagger-android-processor:${Versions.App.dagger}") 38 | 39 | /* Debug */ 40 | debugImplementation("com.facebook.stetho:stetho:${Versions.Debug.stetho}") 41 | debugImplementation("com.facebook.stetho:stetho-okhttp3:${Versions.Debug.stetho}") 42 | 43 | /* Test */ 44 | testImplementation("junit:junit:${Versions.Test.junit}") 45 | testImplementation("io.mockk:mockk:${Versions.Test.mockk}") 46 | androidTestImplementation("androidx.test.ext:junit:${Versions.Test.junitExt}") 47 | androidTestImplementation("androidx.test:core:${Versions.Test.core}") 48 | androidTestImplementation("androidx.test:runner:${Versions.Test.runner}") 49 | androidTestImplementation("androidx.test.espresso:espresso-core:${Versions.Test.espresso}") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerActivity.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.lifecycle.ViewModelProviders 6 | import com.google.android.material.snackbar.Snackbar 7 | import com.jakewharton.rxbinding3.widget.textChanges 8 | import com.worker8.simplecurrency.R 9 | import com.worker8.simplecurrency.common.addTo 10 | import dagger.android.support.DaggerAppCompatActivity 11 | import io.reactivex.disposables.CompositeDisposable 12 | import kotlinx.android.synthetic.main.activity_main.* 13 | import kotlinx.android.synthetic.main.activity_picker.* 14 | import javax.inject.Inject 15 | 16 | class PickerActivity : DaggerAppCompatActivity() { 17 | @Inject 18 | lateinit var repo: PickerRepo 19 | 20 | private val adapter by lazy { PickerAdapter(isBase) } 21 | private val disposableBag = CompositeDisposable() 22 | 23 | val isBase get() = intent.getBooleanExtra(BASE_OR_TARGET_KEY, true) 24 | val inputAmount get() = intent.getDoubleExtra(INPUT_AMOUNT, 0.0) 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | setContentView(R.layout.activity_picker) 29 | 30 | val inputLocal = object : PickerContract.Input { 31 | override val inputAmount = this@PickerActivity.inputAmount 32 | override val onFilterTextChanged = 33 | pickerInput.textChanges().map { it.toString() } 34 | override val isBase = this@PickerActivity.isBase 35 | } 36 | val viewActionLocal = object : PickerContract.ViewAction { 37 | override fun showTerminalError() { 38 | Snackbar.make(pickerContainer, R.string.error_message, Snackbar.LENGTH_INDEFINITE) 39 | .show() 40 | } 41 | } 42 | pickerRecyclerView.adapter = adapter 43 | val viewModel = 44 | ViewModelProviders.of(this, PickerViewModel.PickerViewModelFactory(repo)) 45 | .get(PickerViewModel::class.java) 46 | .apply { 47 | input = inputLocal 48 | viewAction = viewActionLocal 49 | } 50 | 51 | viewModel.screenState 52 | .distinctUntilChanged() 53 | .observeOn(repo.mainThread) 54 | .subscribe({ screenState -> 55 | screenState.apply { 56 | adapter.submitList(currencyList.toList()) 57 | pickerLastUpdatedMessage.text = 58 | "${getString(R.string.last_updated)} ${latestUpdatedString}" 59 | } 60 | }, { 61 | viewActionLocal.showTerminalError() 62 | }) 63 | .addTo(disposableBag) 64 | 65 | adapter.selectedCurrencyCode 66 | .subscribe({ currencyCode -> 67 | setResult(RESULT_OK, Intent().apply { putExtra(RESULT_KEY, currencyCode) }) 68 | finish() 69 | } 70 | , { 71 | viewActionLocal.showTerminalError() 72 | }) 73 | .addTo(disposableBag) 74 | lifecycle.addObserver(viewModel) 75 | } 76 | 77 | override fun onDestroy() { 78 | super.onDestroy() 79 | disposableBag.dispose() 80 | } 81 | 82 | companion object { 83 | const val INPUT_AMOUNT = "INPUT_AMOUNT" 84 | const val BASE_OR_TARGET_KEY = "BASE_OR_TARGET_KEY" 85 | const val RESULT_KEY = "RESULT_KEY" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/picker/PickerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.picker 2 | 3 | import androidx.lifecycle.* 4 | import com.worker8.fixerio.model.Currency 5 | import com.worker8.simplecurrency.common.addTo 6 | import com.worker8.simplecurrency.common.extension.toThreeDecimalWithComma 7 | import com.worker8.simplecurrency.common.extension.toTwoDecimalWithComma 8 | import com.worker8.simplecurrency.common.realValue 9 | import io.reactivex.Observable 10 | import io.reactivex.disposables.CompositeDisposable 11 | import io.reactivex.subjects.BehaviorSubject 12 | import java.util.concurrent.TimeUnit 13 | 14 | class PickerViewModel(private val repo: PickerRepo) : 15 | ViewModel(), LifecycleObserver { 16 | private val screenStateSubject = 17 | BehaviorSubject.createDefault(PickerContract.ScreenState(linkedSetOf(), false, "")) 18 | private val currentScreenState: PickerContract.ScreenState get() = screenStateSubject.realValue 19 | var screenState: Observable = 20 | screenStateSubject.hide().observeOn(repo.mainThread) 21 | private val disposableBag = CompositeDisposable() 22 | lateinit var input: PickerContract.Input 23 | lateinit var viewAction: PickerContract.ViewAction 24 | @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) 25 | fun onCreate() { 26 | input.apply { 27 | Observable.merge( 28 | Observable.just(""), 29 | onFilterTextChanged.debounce(300, TimeUnit.MILLISECONDS) 30 | ) 31 | .flatMap { repo.getAllCurrenciesFromDb(it) } 32 | .subscribeOn(repo.backgroundThread) 33 | .observeOn(repo.backgroundThread) 34 | .map { it to repo.getBaseRate() } 35 | .map { (filteredCurrencyRates, baseCurrencyList) -> 36 | val baseCurrency = baseCurrencyList[0].rate 37 | val resultSet = linkedSetOf() 38 | filteredCurrencyRates.forEach { roomConversionRate -> 39 | resultSet.add(roomConversionRate.run { 40 | val baseToTargetRate = (rate / baseCurrency) 41 | PickerAdapter.PickerRowType( 42 | currencyName = Currency.ALL[code] ?: "", 43 | currencyRate = "1 ${repo.getSelectedBaseCurrencyCode()} = ${baseToTargetRate.toThreeDecimalWithComma()} ${code}", 44 | currencyRateCalculated = "${inputAmount.toTwoDecimalWithComma()} ${repo.getSelectedBaseCurrencyCode()} = ${(inputAmount * baseToTargetRate).toTwoDecimalWithComma()} ${code}", 45 | currencyCode = code 46 | ) 47 | }) 48 | } 49 | resultSet 50 | } 51 | .subscribe({ 52 | dispatch(currentScreenState.copy(currencyList = it)) 53 | }, { 54 | it.printStackTrace() 55 | viewAction.showTerminalError() 56 | }) 57 | .addTo(disposableBag) 58 | 59 | repo.getLatestUpdatedDate() 60 | .subscribeOn(repo.backgroundThread) 61 | .observeOn(repo.mainThread) 62 | .subscribe({ 63 | dispatch( 64 | currentScreenState.copy( 65 | rateDetailVisibility = !isBase, 66 | latestUpdatedString = it 67 | ) 68 | ) 69 | }, { 70 | viewAction.showTerminalError() 71 | }) 72 | .addTo(disposableBag) 73 | 74 | } 75 | } 76 | 77 | private fun dispatch(screenState: PickerContract.ScreenState) { 78 | screenStateSubject.onNext(screenState) 79 | } 80 | 81 | override fun onCleared() { 82 | super.onCleared() 83 | disposableBag.dispose() 84 | } 85 | 86 | @Suppress("UNCHECKED_CAST") 87 | class PickerViewModelFactory(private val repo: PickerRepo) : 88 | ViewModelProvider.NewInstanceFactory() { 89 | override fun create(modelClass: Class): T { 90 | return PickerViewModel(repo) as T 91 | } 92 | } 93 | } 94 | 95 | //TODO: style arrangement 96 | -------------------------------------------------------------------------------- /fixerio/src/main/resources/usd_based_currencies_fixerio.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "timestamp": 1583288406, 4 | "base": "EUR", 5 | "date": "2020-03-04", 6 | "rates": { 7 | "AED": 4.101566, 8 | "AFN": 84.028139, 9 | "ALL": 122.857268, 10 | "AMD": 532.613407, 11 | "ANG": 1.989356, 12 | "AOA": 549.252648, 13 | "ARS": 69.471865, 14 | "AUD": 1.692605, 15 | "AWG": 2.009916, 16 | "AZN": 1.90059, 17 | "BAM": 1.955769, 18 | "BBD": 2.24395, 19 | "BDT": 94.40276, 20 | "BGN": 1.953554, 21 | "BHD": 0.420538, 22 | "BIF": 2096.613471, 23 | "BMD": 1.11662, 24 | "BND": 1.547566, 25 | "BOB": 7.67376, 26 | "BRL": 5.041319, 27 | "BSD": 1.111375, 28 | "BTC": 0.000127, 29 | "BTN": 81.372356, 30 | "BWP": 12.389625, 31 | "BYN": 2.482845, 32 | "BYR": 21885.756324, 33 | "BZD": 2.24015, 34 | "CAD": 1.492307, 35 | "CDF": 1896.021647, 36 | "CHF": 1.067997, 37 | "CLF": 0.032981, 38 | "CLP": 910.152717, 39 | "CNY": 7.77458, 40 | "COP": 3865.739204, 41 | "CRC": 635.965888, 42 | "CUC": 1.11662, 43 | "CUP": 29.590436, 44 | "CVE": 110.261568, 45 | "CZK": 25.411263, 46 | "DJF": 198.445397, 47 | "DKK": 7.472813, 48 | "DOP": 59.390681, 49 | "DZD": 133.61137, 50 | "EGP": 17.474102, 51 | "ERN": 16.749029, 52 | "ETB": 36.251996, 53 | "EUR": 1, 54 | "FJD": 2.472141, 55 | "FKP": 0.870986, 56 | "GBP": 0.870981, 57 | "GEL": 3.115615, 58 | "GGP": 0.870986, 59 | "GHS": 6.034612, 60 | "GIP": 0.870986, 61 | "GMD": 56.947954, 62 | "GNF": 10589.464466, 63 | "GTQ": 8.535133, 64 | "GYD": 231.892776, 65 | "HKD": 8.673995, 66 | "HNL": 27.400392, 67 | "HRK": 7.484673, 68 | "HTG": 102.155732, 69 | "HUF": 336.16952, 70 | "IDR": 15793.253076, 71 | "ILS": 3.843239, 72 | "IMP": 0.870986, 73 | "INR": 81.781133, 74 | "IQD": 1326.728618, 75 | "IRR": 47015.294142, 76 | "ISK": 142.212856, 77 | "JEP": 0.870986, 78 | "JMD": 151.065649, 79 | "JOD": 0.791668, 80 | "JPY": 119.737438, 81 | "KES": 114.352905, 82 | "KGS": 77.996395, 83 | "KHR": 4523.799569, 84 | "KMF": 498.403361, 85 | "KPW": 1004.996568, 86 | "KRW": 1326.567419, 87 | "KWD": 0.341572, 88 | "KYD": 0.926171, 89 | "KZT": 422.896828, 90 | "LAK": 9875.392593, 91 | "LBP": 1680.56268, 92 | "LKR": 202.375099, 93 | "LRD": 220.546546, 94 | "LSL": 17.430825, 95 | "LTL": 3.297089, 96 | "LVL": 0.675433, 97 | "LYD": 1.560765, 98 | "MAD": 10.633569, 99 | "MDL": 19.420869, 100 | "MGA": 4118.668531, 101 | "MKD": 61.673079, 102 | "MMK": 1587.564073, 103 | "MNT": 3090.113538, 104 | "MOP": 8.905522, 105 | "MRO": 398.633458, 106 | "MUR": 41.815251, 107 | "MVR": 17.30648, 108 | "MWK": 816.053868, 109 | "MXN": 21.676669, 110 | "MYR": 4.675848, 111 | "MZN": 73.032501, 112 | "NAD": 17.430163, 113 | "NGN": 408.129425, 114 | "NIO": 37.491168, 115 | "NOK": 10.353864, 116 | "NPR": 130.197103, 117 | "NZD": 1.778452, 118 | "OMR": 0.42995, 119 | "PAB": 1.111365, 120 | "PEN": 3.819681, 121 | "PGK": 3.836215, 122 | "PHP": 56.601444, 123 | "PKR": 171.364694, 124 | "PLN": 4.307815, 125 | "PYG": 7246.939149, 126 | "QAR": 4.065571, 127 | "RON": 4.808386, 128 | "RSD": 117.546233, 129 | "RUB": 73.908316, 130 | "RWF": 1057.606507, 131 | "SAR": 4.188432, 132 | "SBD": 9.536153, 133 | "SCR": 15.303716, 134 | "SDG": 61.693561, 135 | "SEK": 10.559308, 136 | "SGD": 1.550494, 137 | "SHP": 0.870986, 138 | "SLL": 10831.215552, 139 | "SOS": 654.339137, 140 | "SRD": 8.327728, 141 | "STD": 24441.45232, 142 | "SVC": 9.724697, 143 | "SYP": 575.058096, 144 | "SZL": 17.294816, 145 | "THB": 35.060201, 146 | "TJS": 10.758865, 147 | "TMT": 3.919337, 148 | "TND": 3.157243, 149 | "TOP": 2.599937, 150 | "TRY": 6.830702, 151 | "TTD": 7.514566, 152 | "TWD": 33.466783, 153 | "TZS": 2574.473537, 154 | "UAH": 27.761884, 155 | "UGX": 4115.271773, 156 | "USD": 1.11662, 157 | "UYU": 43.525645, 158 | "UZS": 10558.841214, 159 | "VEF": 11.152248, 160 | "VND": 25804.427792, 161 | "VUV": 132.973628, 162 | "WST": 3.020235, 163 | "XAF": 655.942447, 164 | "XAG": 0.064782, 165 | "XAU": 0.000679, 166 | "XCD": 3.017722, 167 | "XDR": 0.805882, 168 | "XOF": 655.942446, 169 | "XPF": 119.25629, 170 | "YER": 279.491007, 171 | "ZAR": 17.211528, 172 | "ZMK": 10050.923204, 173 | "ZMW": 16.975924, 174 | "ZWL": 359.551712 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/MainRepo.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main 2 | 3 | import android.content.Context 4 | import androidx.work.* 5 | import com.squareup.moshi.Moshi 6 | import com.worker8.fixerio.network.SeedFixerIOLiveService 7 | import com.worker8.simplecurrency.common.sharedPreference.MainPreference 8 | import com.worker8.simplecurrency.db.SimpleCurrencyDatabase 9 | import com.worker8.simplecurrency.db.entity.RoomConversionRate 10 | import com.worker8.simplecurrency.db.entity.RoomUpdatedTimeStamp 11 | import com.worker8.simplecurrency.di.scope.ScopeConstant 12 | import com.worker8.simplecurrency.worker.UpdateCurrencyWorker 13 | import io.reactivex.Flowable 14 | import io.reactivex.Observable 15 | import io.reactivex.Scheduler 16 | import io.reactivex.functions.BiFunction 17 | import java.util.concurrent.TimeUnit 18 | import javax.inject.Inject 19 | import javax.inject.Named 20 | 21 | class MainRepo @Inject constructor( 22 | private val context: Context, 23 | val db: SimpleCurrencyDatabase, 24 | private val moshi: Moshi, 25 | private val workManager: WorkManager, 26 | @Named(ScopeConstant.MainThreadScheduler) 27 | override val mainThread: Scheduler, 28 | @Named(ScopeConstant.BackgroundThreadScheduler) 29 | override val backgroundThread: Scheduler 30 | ) : MainRepoInterface { 31 | 32 | override fun populateDbIfFirstTime(): Observable { 33 | return Observable.fromCallable { 34 | if (MainPreference.getFirstTime(context)) { 35 | // populate 36 | val (quotes, timestamp) = SeedFixerIOLiveService(moshi).getSeedCurrencies() 37 | val roomConversionRateList = quotes.conversionRates.map { 38 | RoomConversionRate.fromConversionRate(it) 39 | } 40 | db.roomConversionRateDao().insert(roomConversionRateList) 41 | db.roomUpdatedTimeStampDao().insert(RoomUpdatedTimeStamp("1", timestamp)) 42 | return@fromCallable MainPreference.setFirstTimeFalse(context) 43 | } else { 44 | return@fromCallable true 45 | } 46 | } 47 | } 48 | 49 | override fun getSelectedBaseCurrencyCode() = 50 | MainPreference.getSelectedBaseCurrencyCode(context) 51 | 52 | override fun getSelectedTargetCurrencyCode() = 53 | MainPreference.getSelectedTargetCurrencyCode(context) 54 | 55 | override fun setSelectedBaseCurrencyCode(currencyCode: String) = 56 | MainPreference.setSelectedBaseCurrencyCode(context, currencyCode) 57 | 58 | override fun setSelectedTargetCurrencyCode(currencyCode: String) = 59 | MainPreference.setSelectedTargetCurrencyCode(context, currencyCode) 60 | 61 | private fun getBaseRateFlowable(): Flowable> { 62 | val baseCurrency = getSelectedBaseCurrencyCode() // "JPY" 63 | return db.roomConversionRateDao().findConversionRateFlowable("$baseCurrency") 64 | } 65 | 66 | private fun getTargetRateFlowable(): Flowable> { 67 | val targetCurrency = getSelectedTargetCurrencyCode() // "JPY" 68 | return db.roomConversionRateDao().findConversionRateFlowable("$targetCurrency") 69 | } 70 | 71 | override fun getLatestSelectedRateFlowable(): Flowable { 72 | return Flowable.combineLatest( 73 | getBaseRateFlowable(), 74 | getTargetRateFlowable(), 75 | BiFunction, List, Double> { baseRateList, targetRateList -> 76 | return@BiFunction if (baseRateList.isNotEmpty() && targetRateList.isNotEmpty()) { 77 | targetRateList.first().rate / baseRateList.first().rate 78 | } else { 79 | -1.0 80 | } 81 | }) 82 | } 83 | 84 | override fun setupPeriodicUpdate() { 85 | val constraints = Constraints.Builder() 86 | .setRequiredNetworkType(NetworkType.CONNECTED).build() 87 | val updateCurrencyWorker = 88 | PeriodicWorkRequest.Builder(UpdateCurrencyWorker::class.java, 30, TimeUnit.MINUTES) 89 | .setConstraints(constraints) 90 | .build() 91 | workManager.enqueueUniquePeriodicWork( 92 | uniqueWorkerName, 93 | ExistingPeriodicWorkPolicy.KEEP, 94 | updateCurrencyWorker 95 | ) 96 | 97 | /* uncomment the following for testing purpose */ 98 | // val oneTimeCurrencyWorker = OneTimeWorkRequest.Builder(UpdateCurrencyWorker::class.java) 99 | // .setConstraints(constraints) 100 | // .build() 101 | // workManager.enqueue(oneTimeCurrencyWorker) 102 | } 103 | 104 | companion object { 105 | const val uniqueWorkerName = "get_latest_currency" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /privacy_policy.md: -------------------------------------------------------------------------------- 1 | ## Privacy Policy 2 | 3 | Tan Jun Rong built the Simple Currency app as an Open Source app. This service is provided by Tan Jun Rong at no cost and is intended for use as is. 4 | 5 | This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. 6 | 7 | If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at Simple Currency unless otherwise defined in this Privacy Policy. 10 | 11 | **Information Collection and Use** 12 | 13 | For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way. 14 | 15 | The app does use third party services that may collect information used to identify you. 16 | 17 | Link to privacy policy of third party service providers used by the app 18 | 19 | * [Google Play Services](https://www.google.com/policies/privacy/) 20 | * [Firebase Analytics](https://firebase.google.com/policies/analytics) 21 | * [Firebase Crashlytics](https://firebase.google.com/terms/crashlytics) 22 | 23 | **Log Data** 24 | 25 | I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and information (through third party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing my Service, the time and date of your use of the Service, and other statistics. 26 | 27 | **Cookies** 28 | 29 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 30 | 31 | This Service does not use these “cookies” explicitly. However, the app may use third party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 32 | 33 | **Service Providers** 34 | 35 | I may employ third-party companies and individuals due to the following reasons: 36 | 37 | * To facilitate our Service; 38 | * To provide the Service on our behalf; 39 | * To perform Service-related services; or 40 | * To assist us in analyzing how our Service is used. 41 | 42 | I want to inform users of this Service that these third parties have access to your Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 43 | 44 | **Security** 45 | 46 | I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. 47 | 48 | **Links to Other Sites** 49 | 50 | This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 51 | 52 | **Children’s Privacy** 53 | 54 | These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13\. In the case I discover that a child under 13 has provided me with personal information, I immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact me so that I will be able to do necessary actions. 55 | 56 | **Changes to This Privacy Policy** 57 | 58 | I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately after they are posted on this page. 59 | 60 | **Contact Us** 61 | 62 | If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at BeepBeep Software (beepbeepsoftware@gmail.com). 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.lifecycle.ViewModelProviders 7 | import com.google.android.material.snackbar.Snackbar 8 | import com.jakewharton.rxbinding3.view.clicks 9 | import com.jakewharton.rxbinding3.view.longClicks 10 | import com.worker8.simplecurrency.R 11 | import com.worker8.simplecurrency.common.addTo 12 | import com.worker8.simplecurrency.ui.picker.PickerActivity 13 | import dagger.android.support.DaggerAppCompatActivity 14 | import io.reactivex.disposables.CompositeDisposable 15 | import io.reactivex.subjects.PublishSubject 16 | import kotlinx.android.synthetic.main.activity_main.* 17 | import kotlinx.android.synthetic.main.numpad.* 18 | import javax.inject.Inject 19 | 20 | class MainActivity : DaggerAppCompatActivity() { 21 | private val disposableBag = CompositeDisposable() 22 | 23 | private val onBaseCurrencyChangedSubject: PublishSubject = PublishSubject.create() 24 | private val onTargetCurrencyChangedSubject: PublishSubject = PublishSubject.create() 25 | 26 | @Inject 27 | lateinit var repo: MainRepoInterface 28 | 29 | private val PICKER_BASE_REQUEST_CODE = 3832 30 | val PICKER_TARGET_REQUEST_CODE = 3833 31 | 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | setContentView(R.layout.activity_main) 35 | 36 | val mainInput = object : MainContract.Input { 37 | override val swapButtonClick = mainFab.clicks() 38 | override val onBaseCurrencyChanged = onBaseCurrencyChangedSubject.hide() 39 | override val onTargetCurrencyChanged = onTargetCurrencyChangedSubject.hide() 40 | override val onNumpad0Click by lazy { mainNum0.clicks().map { '0' } } 41 | override val onNumpad1Click by lazy { mainNum1.clicks().map { '1' } } 42 | override val onNumpad2Click by lazy { mainNum2.clicks().map { '2' } } 43 | override val onNumpad3Click by lazy { mainNum3.clicks().map { '3' } } 44 | override val onNumpad4Click by lazy { mainNum4.clicks().map { '4' } } 45 | override val onNumpad5Click by lazy { mainNum5.clicks().map { '5' } } 46 | override val onNumpad6Click by lazy { mainNum6.clicks().map { '6' } } 47 | override val onNumpad7Click by lazy { mainNum7.clicks().map { '7' } } 48 | override val onNumpad8Click by lazy { mainNum8.clicks().map { '8' } } 49 | override val onNumpad9Click by lazy { mainNum9.clicks().map { '9' } } 50 | override val backSpaceClick by lazy { mainNumBackspace.clicks() } 51 | override val dotClick by lazy { mainNumDot.clicks().map { '.' } } 52 | override val onTargetCurrencyClicked by lazy { mainTargetCurrencyPicker.clicks() } 53 | override val backSpaceLongClick = mainNumBackspace.longClicks() 54 | } 55 | val viewActionLocal = object : MainContract.ViewAction { 56 | override fun navigateToSelectTargetCurrency(inputAmount: Double) { 57 | val intent = Intent(this@MainActivity, PickerActivity::class.java) 58 | .apply { 59 | putExtra(PickerActivity.BASE_OR_TARGET_KEY, false) 60 | putExtra(PickerActivity.INPUT_AMOUNT, inputAmount) 61 | } 62 | startActivityForResult(intent, PICKER_TARGET_REQUEST_CODE) 63 | } 64 | 65 | override fun showTerminalError() { 66 | Snackbar.make(mainContainer, R.string.error_message, Snackbar.LENGTH_INDEFINITE) 67 | .show() 68 | } 69 | } 70 | val viewModel = 71 | ViewModelProviders.of(this, MainViewModel.MainViewModelFactory(repo)) 72 | .get(MainViewModel::class.java).apply { 73 | input = mainInput 74 | viewAction = viewActionLocal 75 | } 76 | lifecycle.addObserver(viewModel) 77 | mainBaseCurrencyPicker.setOnClickListener { 78 | val intent = Intent(this@MainActivity, PickerActivity::class.java) 79 | .apply { putExtra(PickerActivity.BASE_OR_TARGET_KEY, true) } 80 | startActivityForResult(intent, PICKER_BASE_REQUEST_CODE) 81 | } 82 | viewModel.screenState 83 | .distinctUntilChanged() 84 | .observeOn(repo.mainThread) 85 | .subscribe({ 86 | it.apply { 87 | mainInputCurrency.text = baseCurrencyCode 88 | mainOutputCurrency.text = targetCurrencyCode 89 | mainInputNumber.text = inputNumberString 90 | mainOutputNumber.text = outputNumberString 91 | mainNumDot.isEnabled = isEnableDot 92 | } 93 | }, { 94 | viewActionLocal.showTerminalError() 95 | }) 96 | .addTo(disposableBag) 97 | 98 | } 99 | 100 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 101 | super.onActivityResult(requestCode, resultCode, data) 102 | if (resultCode == Activity.RESULT_OK) { 103 | when (requestCode) { 104 | PICKER_BASE_REQUEST_CODE -> { 105 | data?.getStringExtra(PickerActivity.RESULT_KEY)?.let { 106 | onBaseCurrencyChangedSubject.onNext(it) 107 | } 108 | } 109 | PICKER_TARGET_REQUEST_CODE -> { 110 | data?.getStringExtra(PickerActivity.RESULT_KEY)?.let { 111 | onTargetCurrencyChangedSubject.onNext(it) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | override fun onDestroy() { 119 | super.onDestroy() 120 | disposableBag.dispose() 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/src/main/java/com/worker8/simplecurrency/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency.ui.main 2 | 3 | import androidx.lifecycle.* 4 | import com.worker8.simplecurrency.common.addTo 5 | import com.worker8.simplecurrency.common.extension.toTwoDecimalWithComma 6 | import com.worker8.simplecurrency.common.realValue 7 | import com.worker8.simplecurrency.common.util.NumberFormatterUtil 8 | import com.worker8.simplecurrency.ui.main.event.BackSpaceInputEvent 9 | import com.worker8.simplecurrency.ui.main.event.CalculateConversionRateEvent 10 | import com.worker8.simplecurrency.ui.main.event.NewNumberInputEvent 11 | import io.reactivex.Observable 12 | import io.reactivex.disposables.CompositeDisposable 13 | import io.reactivex.functions.BiFunction 14 | import io.reactivex.subjects.BehaviorSubject 15 | import io.reactivex.subjects.PublishSubject 16 | 17 | class MainViewModel(private val repo: MainRepoInterface) : 18 | ViewModel(), LifecycleObserver { 19 | private val screenStateSubject = BehaviorSubject.createDefault(MainContract.ScreenState()) 20 | private val disposableBag = CompositeDisposable() 21 | private val currentScreenState: MainContract.ScreenState get() = screenStateSubject.realValue 22 | var screenState: Observable = screenStateSubject.hide() 23 | 24 | lateinit var input: MainContract.Input 25 | lateinit var viewAction: MainContract.ViewAction 26 | private lateinit var seedDatabaseSharedObservable: Observable 27 | private lateinit var backSpaceLongClickSharedObservable: Observable 28 | private lateinit var newNumberInputSharedObservable: Observable 29 | private lateinit var backSpaceInputEventSharedObservable: Observable 30 | private lateinit var calculateConversionRateSharedObservable: Observable>> 31 | private lateinit var onTargetCurrencyClickedShared: Observable 32 | private val triggerCalculateSubject: PublishSubject = PublishSubject.create() 33 | 34 | @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) 35 | fun onCreate() { 36 | setupInputEvents() 37 | processOutputEvents() 38 | triggerCalculateSubject.onNext(currentInputString()) 39 | } 40 | 41 | private fun processOutputEvents() { 42 | Observable.merge( 43 | newNumberInputSharedObservable, 44 | backSpaceInputEventSharedObservable, 45 | backSpaceLongClickSharedObservable 46 | ) 47 | .subscribe({ newInputString -> 48 | dispatch( 49 | currentScreenState.copy( 50 | inputNumberString = NumberFormatterUtil.addComma(newInputString), 51 | inputNumberStringState = newInputString, 52 | isEnableDot = !newInputString.contains(".") 53 | ) 54 | ) 55 | }, { 56 | viewAction.showTerminalError() 57 | }) 58 | .addTo(disposableBag) 59 | 60 | input.onBaseCurrencyChanged 61 | .subscribe({ 62 | repo.setSelectedBaseCurrencyCode(it) 63 | onCreate() 64 | }, { 65 | viewAction.showTerminalError() 66 | }) 67 | .addTo(disposableBag) 68 | 69 | input.onTargetCurrencyChanged 70 | .subscribe({ 71 | repo.setSelectedTargetCurrencyCode(it) 72 | onCreate() 73 | }, { 74 | viewAction.showTerminalError() 75 | }) 76 | .addTo(disposableBag) 77 | 78 | calculateConversionRateSharedObservable 79 | .map { result -> 80 | val (input, rate) = result.getOrDefault(Pair(0.0, 0.0)) 81 | input to input * rate 82 | } 83 | .subscribe({ (input, outputCurrency) -> 84 | dispatch( 85 | currentScreenState.copy( 86 | baseCurrencyCode = repo.getSelectedBaseCurrencyCode(), 87 | targetCurrencyCode = repo.getSelectedTargetCurrencyCode(), 88 | outputNumberString = outputCurrency.toTwoDecimalWithComma() 89 | ) 90 | ) 91 | }, { 92 | viewAction.showTerminalError() 93 | }) 94 | .addTo(disposableBag) 95 | 96 | onTargetCurrencyClickedShared 97 | .subscribeOn(repo.mainThread) 98 | .withLatestFrom(calculateConversionRateSharedObservable, 99 | BiFunction>, Double> { _, result -> 100 | val (input, _) = result.getOrDefault(Pair(0.0, 0.0)) 101 | input 102 | }) 103 | .observeOn(repo.mainThread) 104 | .subscribe({ viewAction.navigateToSelectTargetCurrency(it) }, { 105 | viewAction.showTerminalError() 106 | }) 107 | .addTo(disposableBag) 108 | 109 | input.swapButtonClick 110 | .subscribe({ 111 | val tempBaseCode = repo.getSelectedBaseCurrencyCode() 112 | val tempTargetCode = repo.getSelectedTargetCurrencyCode() 113 | repo.setSelectedBaseCurrencyCode(tempTargetCode) 114 | repo.setSelectedTargetCurrencyCode(tempBaseCode) 115 | onCreate() 116 | }, { 117 | viewAction.showTerminalError() 118 | }) 119 | .addTo(disposableBag) 120 | 121 | repo.setupPeriodicUpdate() 122 | } 123 | 124 | private fun setupInputEvents() { 125 | disposableBag.clear() 126 | 127 | onTargetCurrencyClickedShared = input.onTargetCurrencyClicked.share() 128 | backSpaceLongClickSharedObservable = input.backSpaceLongClick.map { "0" }.share() 129 | newNumberInputSharedObservable = 130 | NewNumberInputEvent(input, screenStateSubject).process() 131 | backSpaceInputEventSharedObservable = 132 | BackSpaceInputEvent(input, screenStateSubject).process() 133 | seedDatabaseSharedObservable = repo.populateDbIfFirstTime().share() 134 | calculateConversionRateSharedObservable = CalculateConversionRateEvent( 135 | newNumberInputSharedObservable = newNumberInputSharedObservable, 136 | backSpaceInputEventSharedObservable = backSpaceInputEventSharedObservable, 137 | backSpaceLongClickSharedObservable = backSpaceLongClickSharedObservable, 138 | triggerCalculateSubject = triggerCalculateSubject, 139 | seedDatabaseSharedObservable = seedDatabaseSharedObservable, 140 | repo = repo 141 | ).process() 142 | } 143 | 144 | private fun currentInputString(): String { 145 | return currentScreenState.inputNumberStringState 146 | } 147 | 148 | override fun onCleared() { 149 | super.onCleared() 150 | disposableBag.dispose() 151 | } 152 | 153 | private fun dispatch(screenState: MainContract.ScreenState) { 154 | screenStateSubject.onNext(screenState) 155 | } 156 | 157 | @Suppress("UNCHECKED_CAST") 158 | class MainViewModelFactory(private val repo: MainRepoInterface) : 159 | ViewModelProvider.NewInstanceFactory() { 160 | override fun create(modelClass: Class): T { 161 | return MainViewModel(repo) as T 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 29 | 30 | 42 | 43 | 52 | 53 | 63 | 64 | 80 | 81 | 93 | 94 | 106 | 107 | 117 | 118 | 127 | 128 | 144 | 145 | 153 | 154 | 160 | 161 | 167 | 168 | 174 | 175 | 181 | 182 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleCurrency 2 | 3 | 4 | 5 | Simple Currency is an application that handles currency conversion from 168 countries. 6 | 7 | ## Playstore 8 | Get it in the Playstore: 9 | 10 | Get it on Google Play 11 | 12 | ## Demo Gif 13 | Here's a quick demo of how it works: 14 | 15 |
16 | 17 | click to show demo gif 18 | 19 | 20 | 21 |
22 | 23 | ## Demo Screenshots 24 | Here are some screenshots: 25 | 26 |
27 | 28 | click to show screenshots 29 | 30 | 31 | | HomeScreen | CurrencyScreen 1 | CurrencyScreen 2 | Landscape | 32 | | - | - | - | - | 33 | | | | | | 34 |
35 | 36 | ## Features 37 | - convert currency from one to another 38 | - support decimal point 39 | - long press on `'x'` will clear all input 40 | - swap button to switch between the base and target currency quickly 41 | - automatic currency rate updates every 30 minutes 42 | - pick from 168 currencies 43 | - filter currency by currency name or code in currency picker 44 | - comma seperation for big numbers 45 | - landscape mode supported 46 | 47 | ## How to Setup 48 | The API used for obtaining the latest currency data is [Fixer.io](https://fixer.io/). A free account can be made easily and it will provide an API key. You need to fill up the API key in `api_keys.properties` file at the root of this project. Instructions: 49 | 50 | 1. make a file named `api_keys.properties` in the root of the project 51 | 2. add this line in the file: `FIXER_IO_ACCESS_TOKEN=` 52 | 53 | This is how the file looks like: 54 | 55 | ```shell 56 | ༼つ◕_◕༽つ RootOfSimpleCurrency (master)$ cat api_keys.properties 57 | FIXER_IO_ACCESS_TOKEN=d0bbf06c7xxxxxxxxxxxx47d2e56ed6f 58 | 59 | ``` 60 | 61 | ## Tech Explanation 62 | The following dependencies are used in this project: 63 | - RxJava 64 | - RxBinding 65 | - Dagger2 66 | - Retrofit 67 | - Moshi 68 | - Room 69 | - Android Arch Lifecycle 70 | - Android Arch ViewModel 71 | - WorkManager 72 | - Material Design Library 73 | - Mockk 74 | - JUnit 75 | - etc.. 76 | 77 | ### Programming Language 78 | This project is written in Kotlin. 79 | 80 | ### Architecture 81 | MVVM is used in this app. The view layer is made reactive and passed into the `ViewModel` as the input. External sources that are needed (such as database access, network calls, shared preference access) will be passed into `ViewModel` as `Repo`. This way `ViewModel` doesn't have access to Android-related code so that it can be unit tested. 82 | 83 | Room Persistence library is used to access SQLite easily. This is used to store the currency data. `USD` is used as the base currency, so all the currency stored in the database is referenced against `USD`. Let's say we wanted to find out `Japanese Yen (JPY)` vs. `Pound Sterling (GBP)`, simple math calculation will be done. 84 | 85 | In every periodic interval (currently set at every 30 minutes), the `WorkManager` will fire up a Retrofit call to get the latest currency rate. The obtained json will be deserialized by Moshi and write into the database. 86 | 87 | The fixer io network library is extracted into a separate **module** so that it can be decoupled from the main app. It can be launched as a standalone separate network library or being swapped out and replaced by another currency API. 88 | 89 | ### Unidirectional data flow & Immutable data 90 | The project follows the unidirectional data flow rule to better structure the code. 91 | 92 | Here's a brief diagram of the main activity architecture. 93 | 94 | 95 | 1. RxView - The flow begins from the views. Every user view interactions are reactive and fed into the `ViewModel`. 96 | 2. Repo - Any external data access that is not from the user interactions, such as network calls, shared preference, database access, etc... will all go through the `Repo` class that is passed into `ViewModel`. 97 | 3. ScreenState - The reactive signals from `Views` will be processed inside `ViewModel` by some business logic. After that, it will produce the next `ScreenState` by using `.copy()` from Kotlin to ensure immutability. 98 | 4. View Update - Finally, all the views will listen to the `ScreenState` and update itself accordingly. 99 | 100 | This architecture is quite similar to [MVI](https://www.raywenderlich.com/817602-mvi-architecture-for-android-tutorial-getting-started). The difference is that it doesn't use reducer or model the input as 'intent'. 101 | 102 | 103 | ### Unit Test 104 | `ViewModel` doesn't have access to Android related code. Therefore, it can be tested by JUnit `test`, without using `androidTest`. Example Unit Test can be found [MainViewModelTest.kt](([MainViewModelTest.kt](https://github.com/worker8/SimpleCurrency/blob/master/app/src/test/java/com/worker8/simplecurrency/MainViewModelTest.kt))). 105 | 106 | The general idea of testing an Activity can be described by this diagram: 107 | 108 | 109 | 110 | The `RxViews` signals are replaced by fake inputs from the test. Reactive RxBinding signals are be replaced by RxJava's `Subjects` , reactive database access `Flowable` are replaced by RxJava's `Processors` and normal method calls are mocked by `mockk`. This way, user interactions can be controlled by us. After that, we can make `Assertion` on the `ScreenState` to check if it behaves correctly. 111 | 112 | Here's an example of testing a simple conversion ([MainViewModelTest.kt#L112](https://github.com/worker8/SimpleCurrency/blob/4f320e6d9cec77d78849f70d1f1ad1fa9b2dbdd8/app/src/test/java/com/worker8/simplecurrency/MainViewModelTest.kt#L112)): 113 | 114 | ```kotlin 115 | fun testSimpleConversion() { 116 | // 1. arrange 117 | val fakeRate = 2.0 118 | viewModel.onCreate() 119 | val screenStateTestObserver = viewModel.screenState.test() 120 | 121 | // 2. act 122 | populateDbIfFirstTime.onNext(true) 123 | getLatestSelectedRateFlowable.offer(fakeRate) 124 | 125 | onNumpad1Click.onNext('1') 126 | onNumpad0Click.onNext('0') 127 | onNumpad0Click.onNext('0') 128 | 129 | // 3. assert 130 | verify(exactly = 1) { repo.setupPeriodicUpdate() } 131 | screenStateTestObserver.assertNoErrors() 132 | 133 | screenStateTestObserver.lastValue.apply { 134 | Assert.assertEquals("200", outputNumberString) 135 | } 136 | } 137 | ``` 138 | 139 | #### Description 140 | 1. Arrange - we first setup the necessary objects 141 | 142 | 2. Act - next, we make some actions. 143 | 144 | While we run the following: 145 | 146 | ```kotlin 147 | // 2. act 148 | populateDbIfFirstTime.onNext(true) 149 | getLatestSelectedRateFlowable.offer(fakeRate) 150 | 151 | onNumpad1Click.onNext('1') 152 | onNumpad0Click.onNext('0') 153 | onNumpad0Click.onNext('0') 154 | ``` 155 | 156 | It is actually doing these: 157 | 158 | 1. `populateDbIfFirstTime.onNext(true)` - seeded the db 159 | 2. `getLatestSelectedRateFlowable.offer(fakeRate)` - taken the latest conversion rate from db 160 | 3. `onNumpad1Click.onNext('1')`, `onNumpad1Click.onNext('0')`, `onNumpad1Click.onNext('0')` - click on `1`, `0`, `0` (100) 161 | 162 | 3. Assert - finally, we check on the output: 163 | ```kotlin 164 | screenStateTestObserver.lastValue.apply { 165 | Assert.assertEquals("200", outputNumberString) 166 | } 167 | ``` 168 | Since the fake conversaion rate is set to `2.0`, the output should be `200` when input is `100`. 169 | 170 | ## Debugging 171 | [Stetho](http://facebook.github.io/stetho/) library is used for debugging purpose. Network calls through OkHttp3 and SQLite Database can be viewed by accessing `chrome://inspect/#devices` on a chrome browser. This is an example of how the database look like in DevTools: [devtool db debug screenshot](https://user-images.githubusercontent.com/1988156/64595502-09678580-d3ed-11e9-9a08-659d21389d51.png). 172 | 173 | 174 | ## Adaptive Icon 175 | An adaptive icon is created using Sketch. The sketch file can be found in [`logo.sketch`](https://github.com/worker8/SimpleCurrency/blob/master/logo.sketch) file at the root of this project. 176 | 177 | ## Coding Style 178 | `.editorconfig` is used in this project to make sure that the spacing and indentations are standardized, the `editorconfig` is obtained from [ktlint project](https://github.com/shyiko/ktlint/blob/master/.editorconfig). 179 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 29 | 30 | 42 | 43 | 52 | 53 | 63 | 64 | 80 | 81 | 94 | 95 | 107 | 108 | 118 | 119 | 128 | 129 | 145 | 146 | 154 | 155 | 163 | 164 | 172 | 173 | 181 | 182 | 190 | 191 | 199 | 200 | 206 | 207 | 208 | -------------------------------------------------------------------------------- /app/src/main/res/layout/numpad.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 32 | 33 | 50 | 51 | 66 | 67 | 68 | 73 | 74 | 91 | 92 | 108 | 109 | 125 | 126 | 127 | 132 | 133 | 150 | 151 | 167 | 168 | 184 | 185 | 186 | 191 | 192 | 209 | 210 | 226 | 227 | 243 | 244 | 245 | 246 | -------------------------------------------------------------------------------- /app/src/test/java/com/worker8/simplecurrency/MainViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.simplecurrency 2 | 3 | import com.worker8.simplecurrency.ui.main.MainContract 4 | import com.worker8.simplecurrency.ui.main.MainRepoInterface 5 | import com.worker8.simplecurrency.ui.main.MainViewModel 6 | import io.mockk.* 7 | import io.reactivex.observers.TestObserver 8 | import io.reactivex.processors.PublishProcessor 9 | import io.reactivex.schedulers.Schedulers 10 | import io.reactivex.subjects.PublishSubject 11 | import org.junit.Assert 12 | import org.junit.Before 13 | import org.junit.Test 14 | 15 | class MainViewModelTest { 16 | private lateinit var input: MainContract.Input 17 | private lateinit var repo: MainRepoInterface 18 | private lateinit var viewModel: MainViewModel 19 | private lateinit var viewAction: MainContract.ViewAction 20 | 21 | /* input */ 22 | private lateinit var onNumpad0Click: PublishSubject 23 | private lateinit var onNumpad1Click: PublishSubject 24 | private lateinit var onNumpad2Click: PublishSubject 25 | private lateinit var onNumpad3Click: PublishSubject 26 | private lateinit var onNumpad4Click: PublishSubject 27 | private lateinit var onNumpad5Click: PublishSubject 28 | private lateinit var onNumpad6Click: PublishSubject 29 | private lateinit var onNumpad7Click: PublishSubject 30 | private lateinit var onNumpad8Click: PublishSubject 31 | private lateinit var onNumpad9Click: PublishSubject 32 | private lateinit var backSpaceClick: PublishSubject 33 | private lateinit var backSpaceLongClick: PublishSubject 34 | private lateinit var swapButtonClick: PublishSubject 35 | private lateinit var dotClick: PublishSubject 36 | private lateinit var onBaseCurrencyChanged: PublishSubject 37 | private lateinit var onTargetCurrencyChanged: PublishSubject 38 | private lateinit var onTargetCurrencyClicked: PublishSubject 39 | 40 | /* repo */ 41 | private lateinit var getLatestSelectedRateFlowable: PublishProcessor 42 | private lateinit var populateDbIfFirstTime: PublishSubject 43 | 44 | @Before 45 | fun setup() { 46 | input = mockk() 47 | 48 | onNumpad0Click = PublishSubject.create() 49 | onNumpad1Click = PublishSubject.create() 50 | onNumpad2Click = PublishSubject.create() 51 | onNumpad3Click = PublishSubject.create() 52 | onNumpad4Click = PublishSubject.create() 53 | onNumpad5Click = PublishSubject.create() 54 | onNumpad6Click = PublishSubject.create() 55 | onNumpad7Click = PublishSubject.create() 56 | onNumpad8Click = PublishSubject.create() 57 | onNumpad9Click = PublishSubject.create() 58 | backSpaceClick = PublishSubject.create() 59 | backSpaceLongClick = PublishSubject.create() 60 | swapButtonClick = PublishSubject.create() 61 | dotClick = PublishSubject.create() 62 | onBaseCurrencyChanged = PublishSubject.create() 63 | onTargetCurrencyChanged = PublishSubject.create() 64 | onTargetCurrencyClicked = PublishSubject.create() 65 | 66 | getLatestSelectedRateFlowable = PublishProcessor.create() 67 | populateDbIfFirstTime = PublishSubject.create() 68 | 69 | every { input.onNumpad0Click } returns onNumpad0Click 70 | every { input.onNumpad1Click } returns onNumpad1Click 71 | every { input.onNumpad2Click } returns onNumpad2Click 72 | every { input.onNumpad3Click } returns onNumpad3Click 73 | every { input.onNumpad4Click } returns onNumpad4Click 74 | every { input.onNumpad5Click } returns onNumpad5Click 75 | every { input.onNumpad6Click } returns onNumpad6Click 76 | every { input.onNumpad7Click } returns onNumpad7Click 77 | every { input.onNumpad8Click } returns onNumpad8Click 78 | every { input.onNumpad9Click } returns onNumpad9Click 79 | 80 | every { input.backSpaceClick } returns backSpaceClick 81 | every { input.backSpaceLongClick } returns backSpaceLongClick 82 | every { input.swapButtonClick } returns swapButtonClick 83 | every { input.dotClick } returns dotClick 84 | every { input.onBaseCurrencyChanged } returns onBaseCurrencyChanged 85 | every { input.onTargetCurrencyChanged } returns onTargetCurrencyChanged 86 | every { input.onTargetCurrencyClicked } returns onTargetCurrencyClicked 87 | 88 | repo = mockk(relaxed = true) 89 | 90 | every { repo.mainThread } returns Schedulers.trampoline() 91 | every { repo.backgroundThread } returns Schedulers.trampoline() 92 | every { repo.populateDbIfFirstTime() } returns populateDbIfFirstTime 93 | every { repo.getSelectedBaseCurrencyCode() } returns "JPY" 94 | every { repo.getSelectedTargetCurrencyCode() } returns "USD" 95 | 96 | every { repo.setSelectedBaseCurrencyCode(any()) } returns true 97 | every { repo.setSelectedTargetCurrencyCode(any()) } returns true 98 | 99 | every { repo.getLatestSelectedRateFlowable() } returns getLatestSelectedRateFlowable 100 | every { repo.setupPeriodicUpdate() } just Runs 101 | 102 | viewAction = mockk(relaxed = true) 103 | 104 | viewModel = MainViewModel(repo) 105 | .apply { 106 | input = this@MainViewModelTest.input 107 | viewAction = this@MainViewModelTest.viewAction 108 | } 109 | } 110 | 111 | @Test 112 | fun testSimpleConversion() { 113 | // 1. arrange 114 | val fakeRate = 2.0 115 | viewModel.onCreate() 116 | val screenStateTestObserver = viewModel.screenState.test() 117 | 118 | // 2. act 119 | populateDbIfFirstTime.onNext(true) 120 | getLatestSelectedRateFlowable.offer(fakeRate) 121 | 122 | onNumpad1Click.onNext('1') 123 | onNumpad0Click.onNext('0') 124 | onNumpad0Click.onNext('0') 125 | 126 | // 3. assert 127 | verify(exactly = 1) { repo.setupPeriodicUpdate() } 128 | screenStateTestObserver.assertNoErrors() 129 | 130 | screenStateTestObserver.lastValue.apply { 131 | Assert.assertEquals("200", outputNumberString) 132 | } 133 | } 134 | 135 | @Test 136 | fun testDecimalConversion() { 137 | // 1. arrange 138 | val fakeRate = 2.0 139 | viewModel.onCreate() 140 | val screenStateTestObserver = viewModel.screenState.test() 141 | 142 | // 2. act 143 | populateDbIfFirstTime.onNext(true) 144 | getLatestSelectedRateFlowable.offer(fakeRate) 145 | 146 | // key in: 99.2 147 | onNumpad1Click.onNext('9') 148 | onNumpad0Click.onNext('9') 149 | onNumpad0Click.onNext('.') 150 | onNumpad0Click.onNext('2') 151 | 152 | // 3. assert 153 | verify(exactly = 1) { repo.setupPeriodicUpdate() } 154 | screenStateTestObserver.assertNoErrors() 155 | 156 | screenStateTestObserver.lastValue.apply { 157 | Assert.assertEquals("198.4", outputNumberString) // 99.2 * 2 = 198.4 158 | } 159 | } 160 | 161 | @Test 162 | fun testBigNumberWithCommaConversion() { 163 | // 1. arrange 164 | val fakeRate = 2.0 165 | viewModel.onCreate() 166 | val screenStateTestObserver = viewModel.screenState.test() 167 | 168 | // 2. act 169 | populateDbIfFirstTime.onNext(true) 170 | getLatestSelectedRateFlowable.offer(fakeRate) 171 | 172 | // key in: 1,000,123.2 173 | onNumpad1Click.onNext('1') 174 | onNumpad0Click.onNext('0') 175 | onNumpad0Click.onNext('0') 176 | onNumpad0Click.onNext('0') 177 | onNumpad0Click.onNext('1') 178 | onNumpad0Click.onNext('2') 179 | onNumpad0Click.onNext('3') 180 | onNumpad0Click.onNext('.') 181 | onNumpad0Click.onNext('2') 182 | 183 | // 3. assert 184 | verify(exactly = 1) { repo.setupPeriodicUpdate() } 185 | screenStateTestObserver.assertNoErrors() 186 | 187 | screenStateTestObserver.lastValue.apply { 188 | Assert.assertEquals("1,000,123.2", inputNumberString) 189 | Assert.assertEquals("2,000,246.4", outputNumberString) // 1,000,123.2 * 2 190 | } 191 | } 192 | 193 | @Test 194 | fun testBackSpaceLongPress() { 195 | // 1. arrange 196 | val fakeRate = 2.0 197 | viewModel.onCreate() 198 | val screenStateTestObserver = viewModel.screenState.test() 199 | 200 | // 2. act 201 | populateDbIfFirstTime.onNext(true) 202 | getLatestSelectedRateFlowable.offer(fakeRate) 203 | 204 | // key in: 1,000,123.2 205 | onNumpad1Click.onNext('1') 206 | onNumpad0Click.onNext('0') 207 | onNumpad0Click.onNext('0') 208 | onNumpad0Click.onNext('0') 209 | onNumpad0Click.onNext('0') 210 | onNumpad0Click.onNext('0') 211 | 212 | backSpaceClick.onNext(Unit) 213 | 214 | screenStateTestObserver.lastValue.apply { 215 | Assert.assertEquals("10,000", inputNumberString) 216 | Assert.assertEquals("20,000", outputNumberString) 217 | } 218 | 219 | backSpaceLongClick.onNext(Unit) 220 | 221 | // 3. assert 222 | screenStateTestObserver.assertNoErrors() 223 | screenStateTestObserver.lastValue.apply { 224 | Assert.assertEquals("0", inputNumberString) 225 | Assert.assertEquals("0", outputNumberString) 226 | } 227 | } 228 | 229 | @Test 230 | fun testSwapCurrency() { 231 | // 1. arrange 232 | val fakeRate = 2.0 233 | viewModel.onCreate() 234 | val screenStateTestObserver = viewModel.screenState.test() 235 | 236 | // 2. act 237 | populateDbIfFirstTime.onNext(true) 238 | getLatestSelectedRateFlowable.offer(fakeRate) 239 | onNumpad1Click.onNext('1') 240 | onNumpad0Click.onNext('0') 241 | onNumpad0Click.onNext('0') 242 | 243 | // 3. assert - check initial state 244 | screenStateTestObserver.lastValue.apply { 245 | Assert.assertEquals("JPY", baseCurrencyCode) 246 | Assert.assertEquals("USD", targetCurrencyCode) // 1,000,123.2 * 2 247 | } 248 | 249 | // 2. act - hit swap button 250 | verify(exactly = 4) { 251 | repo.getSelectedBaseCurrencyCode() 252 | } 253 | 254 | swapButtonClick.onNext(Unit) 255 | populateDbIfFirstTime.onNext(true) 256 | getLatestSelectedRateFlowable.offer(4.0) // not using real db here, update manually 257 | 258 | // 3. assert - check if output is updated once currency is updated 259 | screenStateTestObserver.lastValue.apply { 260 | Assert.assertEquals("100", inputNumberString) 261 | Assert.assertEquals("400", outputNumberString) // 1,000,123.2 * 2 262 | } 263 | } 264 | 265 | private val TestObserver.lastValue 266 | get() = this.values().last() 267 | } 268 | -------------------------------------------------------------------------------- /fixerio/src/main/java/com/worker8/fixerio/model/Currency.kt: -------------------------------------------------------------------------------- 1 | package com.worker8.fixerio.model 2 | 3 | data class Currency(val code: String, val name: String) { 4 | companion object { 5 | val ALL_STRING: String 6 | get() { 7 | return ALL.keys.joinToString(",") 8 | } 9 | // TODO: remove TEMP and friends 10 | val TEMP = listOf( 11 | "AED", 12 | "AFN", 13 | "ALL", 14 | "AMD", 15 | "ANG", 16 | "AOA", 17 | "ARS", 18 | "AUD", 19 | "AWG", 20 | "AZN", 21 | "BAM", 22 | "BBD", 23 | "BDT", 24 | "BGN", 25 | "BHD", 26 | "BIF", 27 | "BMD", 28 | "BND", 29 | "BOB", 30 | "BRL", 31 | "BSD", 32 | "BTC", 33 | "BTN", 34 | "BWP", 35 | "BYN", 36 | "BYR", 37 | "BZD", 38 | "CAD", 39 | "CDF", 40 | "CHF", 41 | "CLF", 42 | "CLP", 43 | "CNY", 44 | "COP", 45 | "CRC", 46 | "CUC", 47 | "CUP", 48 | "CVE", 49 | "CZK", 50 | "DJF", 51 | "DKK", 52 | "DOP", 53 | "DZD", 54 | "EGP", 55 | "ERN", 56 | "ETB", 57 | "EUR", 58 | "FJD", 59 | "FKP", 60 | "GBP", 61 | "GEL", 62 | "GGP", 63 | "GHS", 64 | "GIP", 65 | "GMD", 66 | "GNF", 67 | "GTQ", 68 | "GYD", 69 | "HKD", 70 | "HNL", 71 | "HRK", 72 | "HTG", 73 | "HUF", 74 | "IDR", 75 | "ILS", 76 | "IMP", 77 | "INR", 78 | "IQD", 79 | "IRR", 80 | "ISK", 81 | "JEP", 82 | "JMD", 83 | "JOD", 84 | "JPY", 85 | "KES", 86 | "KGS", 87 | "KHR", 88 | "KMF", 89 | "KPW", 90 | "KRW", 91 | "KWD", 92 | "KYD", 93 | "KZT", 94 | "LAK", 95 | "LBP", 96 | "LKR", 97 | "LRD", 98 | "LSL", 99 | "LTL", 100 | "LVL", 101 | "LYD", 102 | "MAD", 103 | "MDL", 104 | "MGA", 105 | "MKD", 106 | "MMK", 107 | "MNT", 108 | "MOP", 109 | "MRO", 110 | "MUR", 111 | "MVR", 112 | "MWK", 113 | "MXN", 114 | "MYR", 115 | "MZN", 116 | "NAD", 117 | "NGN", 118 | "NIO", 119 | "NOK", 120 | "NPR", 121 | "NZD", 122 | "OMR", 123 | "PAB", 124 | "PEN", 125 | "PGK", 126 | "PHP", 127 | "PKR", 128 | "PLN", 129 | "PYG", 130 | "QAR", 131 | "RON", 132 | "RSD", 133 | "RUB", 134 | "RWF", 135 | "SAR", 136 | "SBD", 137 | "SCR", 138 | "SDG", 139 | "SEK", 140 | "SGD", 141 | "SHP", 142 | "SLL", 143 | "SOS", 144 | "SRD", 145 | "STD", 146 | "SVC", 147 | "SYP", 148 | "SZL", 149 | "THB", 150 | "TJS", 151 | "TMT", 152 | "TND", 153 | "TOP", 154 | "TRY", 155 | "TTD", 156 | "TWD", 157 | "TZS", 158 | "UAH", 159 | "UGX", 160 | "USD", 161 | "UYU", 162 | "UZS", 163 | "VEF", 164 | "VND", 165 | "VUV", 166 | "WST", 167 | "XAF", 168 | "XAG", 169 | "XAU", 170 | "XCD", 171 | "XDR", 172 | "XOF", 173 | "XPF", 174 | "YER", 175 | "ZAR", 176 | "ZMK", 177 | "ZMW", 178 | "ZWL" 179 | ) 180 | val ALL = hashMapOf( 181 | "AED" to "United Arab Emirates Dirham", 182 | "AFN" to "Afghan Afghani", 183 | "ALL" to "Albanian Lek", 184 | "AMD" to "Armenian Dram", 185 | "ANG" to "Netherlands Antillean Guilder", 186 | "AOA" to "Angolan Kwanza", 187 | "ARS" to "Argentine Peso", 188 | "AUD" to "Australian Dollar", 189 | "AWG" to "Aruban Florin", 190 | "AZN" to "Azerbaijani Manat", 191 | "BAM" to "Bosnia-Herzegovina Convertible Mark", 192 | "BBD" to "Barbadian Dollar", 193 | "BDT" to "Bangladeshi Taka", 194 | "BGN" to "Bulgarian Lev", 195 | "BHD" to "Bahraini Dinar", 196 | "BIF" to "Burundian Franc", 197 | "BMD" to "Bermudan Dollar", 198 | "BND" to "Brunei Dollar", 199 | "BOB" to "Bolivian Boliviano", 200 | "BRL" to "Brazilian Real", 201 | "BSD" to "Bahamian Dollar", 202 | "BTC" to "Bitcoin", 203 | "BTN" to "Bhutanese Ngultrum", 204 | "BWP" to "Botswanan Pula", 205 | "BYN" to "New Belarusian Ruble", 206 | "BYR" to "Belarusian Ruble", 207 | "BZD" to "Belize Dollar", 208 | "CAD" to "Canadian Dollar", 209 | "CDF" to "Congolese Franc", 210 | "CHF" to "Swiss Franc", 211 | "CLF" to "Chilean Unit of Account (UF", 212 | "CLP" to "Chilean Peso", 213 | "CNY" to "Chinese Yuan", 214 | "COP" to "Colombian Peso", 215 | "CRC" to "Costa Rican Colón", 216 | "CUC" to "Cuban Convertible Peso", 217 | "CUP" to "Cuban Peso", 218 | "CVE" to "Cape Verdean Escudo", 219 | "CZK" to "Czech Republic Koruna", 220 | "DJF" to "Djiboutian Franc", 221 | "DKK" to "Danish Krone", 222 | "DOP" to "Dominican Peso", 223 | "DZD" to "Algerian Dinar", 224 | "EGP" to "Egyptian Pound", 225 | "ERN" to "Eritrean Nakfa", 226 | "ETB" to "Ethiopian Birr", 227 | "EUR" to "Euro", 228 | "FJD" to "Fijian Dollar", 229 | "FKP" to "Falkland Islands Pound", 230 | "GBP" to "British Pound Sterling", 231 | "GEL" to "Georgian Lari", 232 | "GGP" to "Guernsey Pound", 233 | "GHS" to "Ghanaian Cedi", 234 | "GIP" to "Gibraltar Pound", 235 | "GMD" to "Gambian Dalasi", 236 | "GNF" to "Guinean Franc", 237 | "GTQ" to "Guatemalan Quetzal", 238 | "GYD" to "Guyanaese Dollar", 239 | "HKD" to "Hong Kong Dollar", 240 | "HNL" to "Honduran Lempira", 241 | "HRK" to "Croatian Kuna", 242 | "HTG" to "Haitian Gourde", 243 | "HUF" to "Hungarian Forint", 244 | "IDR" to "Indonesian Rupiah", 245 | "ILS" to "Israeli New Sheqel", 246 | "IMP" to "Manx pound", 247 | "INR" to "Indian Rupee", 248 | "IQD" to "Iraqi Dinar", 249 | "IRR" to "Iranian Rial", 250 | "ISK" to "Icelandic Króna", 251 | "JEP" to "Jersey Pound", 252 | "JMD" to "Jamaican Dollar", 253 | "JOD" to "Jordanian Dinar", 254 | "JPY" to "Japanese Yen", 255 | "KES" to "Kenyan Shilling", 256 | "KGS" to "Kyrgystani Som", 257 | "KHR" to "Cambodian Riel", 258 | "KMF" to "Comorian Franc", 259 | "KPW" to "North Korean Won", 260 | "KRW" to "South Korean Won", 261 | "KWD" to "Kuwaiti Dinar", 262 | "KYD" to "Cayman Islands Dollar", 263 | "KZT" to "Kazakhstani Tenge", 264 | "LAK" to "Laotian Kip", 265 | "LBP" to "Lebanese Pound", 266 | "LKR" to "Sri Lankan Rupee", 267 | "LRD" to "Liberian Dollar", 268 | "LSL" to "Lesotho Loti", 269 | "LTL" to "Lithuanian Litas", 270 | "LVL" to "Latvian Lats", 271 | "LYD" to "Libyan Dinar", 272 | "MAD" to "Moroccan Dirham", 273 | "MDL" to "Moldovan Leu", 274 | "MGA" to "Malagasy Ariary", 275 | "MKD" to "Macedonian Denar", 276 | "MMK" to "Myanma Kyat", 277 | "MNT" to "Mongolian Tugrik", 278 | "MOP" to "Macanese Pataca", 279 | "MRO" to "Mauritanian Ouguiya", 280 | "MUR" to "Mauritian Rupee", 281 | "MVR" to "Maldivian Rufiyaa", 282 | "MWK" to "Malawian Kwacha", 283 | "MXN" to "Mexican Peso", 284 | "MYR" to "Malaysian Ringgit", 285 | "MZN" to "Mozambican Metical", 286 | "NAD" to "Namibian Dollar", 287 | "NGN" to "Nigerian Naira", 288 | "NIO" to "Nicaraguan Córdoba", 289 | "NOK" to "Norwegian Krone", 290 | "NPR" to "Nepalese Rupee", 291 | "NZD" to "New Zealand Dollar", 292 | "OMR" to "Omani Rial", 293 | "PAB" to "Panamanian Balboa", 294 | "PEN" to "Peruvian Nuevo Sol", 295 | "PGK" to "Papua New Guinean Kina", 296 | "PHP" to "Philippine Peso", 297 | "PKR" to "Pakistani Rupee", 298 | "PLN" to "Polish Zloty", 299 | "PYG" to "Paraguayan Guarani", 300 | "QAR" to "Qatari Rial", 301 | "RON" to "Romanian Leu", 302 | "RSD" to "Serbian Dinar", 303 | "RUB" to "Russian Ruble", 304 | "RWF" to "Rwandan Franc", 305 | "SAR" to "Saudi Riyal", 306 | "SBD" to "Solomon Islands Dollar", 307 | "SCR" to "Seychellois Rupee", 308 | "SDG" to "Sudanese Pound", 309 | "SEK" to "Swedish Krona", 310 | "SGD" to "Singapore Dollar", 311 | "SHP" to "Saint Helena Pound", 312 | "SLL" to "Sierra Leonean Leone", 313 | "SOS" to "Somali Shilling", 314 | "SRD" to "Surinamese Dollar", 315 | "STD" to "São Tomé and Príncipe Dobra", 316 | "SVC" to "Salvadoran Colón", 317 | "SYP" to "Syrian Pound", 318 | "SZL" to "Swazi Lilangeni", 319 | "THB" to "Thai Baht", 320 | "TJS" to "Tajikistani Somoni", 321 | "TMT" to "Turkmenistani Manat", 322 | "TND" to "Tunisian Dinar", 323 | "TOP" to "Tongan Paʻanga", 324 | "TRY" to "Turkish Lira", 325 | "TTD" to "Trinidad and Tobago Dollar", 326 | "TWD" to "New Taiwan Dollar", 327 | "TZS" to "Tanzanian Shilling", 328 | "UAH" to "Ukrainian Hryvnia", 329 | "UGX" to "Ugandan Shilling", 330 | "USD" to "United States Dollar", 331 | "UYU" to "Uruguayan Peso", 332 | "UZS" to "Uzbekistan Som", 333 | "VEF" to "Venezuelan Bolívar Fuerte", 334 | "VND" to "Vietnamese Dong", 335 | "VUV" to "Vanuatu Vatu", 336 | "WST" to "Samoan Tala", 337 | "XAF" to "CFA Franc BEAC", 338 | "XAG" to "Silver (troy ounce", 339 | "XAU" to "Gold (troy ounce", 340 | "XCD" to "East Caribbean Dollar", 341 | "XDR" to "Special Drawing Rights", 342 | "XOF" to "CFA Franc BCEAO", 343 | "XPF" to "CFP Franc", 344 | "YER" to "Yemeni Rial", 345 | "ZAR" to "South African Rand", 346 | "ZMK" to "Zambian Kwacha (pre-2013", 347 | "ZMW" to "Zambian Kwacha", 348 | "ZWL" to "Zimbabwean Dollar" 349 | ) 350 | } 351 | } 352 | --------------------------------------------------------------------------------