diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..b3cdec5 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Dolarcito \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..527d2b7 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..e5149fb --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,32 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..f8467b4 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..53962f4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6979d72 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,88 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + kotlin("kapt") + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "io.iakanoe.github.dolarcito" + compileSdk = 34 + + defaultConfig { + applicationId = "io.iakanoe.github.dolarcito" + minSdk = 27 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + buildConfigField("String", "API_KEY", gradleLocalProperties(rootDir).getProperty("API_KEY")) + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.3" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kapt { + correctErrorTypes = true +} + +dependencies { + implementation("com.google.dagger:hilt-android:2.48") + kapt("com.google.dagger:hilt-android-compiler:2.48") + + implementation("androidx.hilt:hilt-navigation-compose:1.1.0-rc01") + + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.activity:activity-compose:1.8.0") + implementation("androidx.navigation:navigation-compose:2.7.4") + implementation("androidx.datastore:datastore-preferences:1.0.0") + + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7ee2ec8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/DolarcitoApplication.kt b/app/src/main/java/io/iakanoe/github/dolarcito/DolarcitoApplication.kt new file mode 100644 index 0000000..aaa83b1 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/DolarcitoApplication.kt @@ -0,0 +1,7 @@ +package io.iakanoe.github.dolarcito + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class DolarcitoApplication : Application() \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/MainActivity.kt b/app/src/main/java/io/iakanoe/github/dolarcito/MainActivity.kt new file mode 100644 index 0000000..6bee92f --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/MainActivity.kt @@ -0,0 +1,21 @@ +package io.iakanoe.github.dolarcito + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint +import io.iakanoe.github.dolarcito.ui.DolarcitoNavigation +import io.iakanoe.github.dolarcito.ui.common.theme.DolarcitoTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DolarcitoTheme { + DolarcitoNavigation() + } + } + } +} + diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/data/ExchangeRateRepository.kt b/app/src/main/java/io/iakanoe/github/dolarcito/data/ExchangeRateRepository.kt new file mode 100644 index 0000000..ae2bfa4 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/data/ExchangeRateRepository.kt @@ -0,0 +1,10 @@ +package io.iakanoe.github.dolarcito.data + +import io.iakanoe.github.dolarcito.gateway.DolarcitoApiGateway + +class ExchangeRateRepository( + private val apiGateway: DolarcitoApiGateway +) { + suspend fun getExchangeRates() = + apiGateway.getRates().values.filterNotNull() +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/data/SettingsRepository.kt b/app/src/main/java/io/iakanoe/github/dolarcito/data/SettingsRepository.kt new file mode 100644 index 0000000..396130a --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/data/SettingsRepository.kt @@ -0,0 +1,21 @@ +package io.iakanoe.github.dolarcito.data + +import io.iakanoe.github.dolarcito.gateway.SettingsGateway +import io.iakanoe.github.dolarcito.model.Settings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class SettingsRepository( + private val settingsGateway: SettingsGateway +) { + fun getSettings(): Flow { + val showingRatesNames = settingsGateway.retrieveShowingRatesNames() + val hiddenRatesNames = settingsGateway.retrieveHiddenRatesNames() + return combine(showingRatesNames, hiddenRatesNames) { showing, hidden -> + Settings(showing, hidden) + } + } + + suspend fun setSettings(settings: Settings) = + settingsGateway.saveBothRatesNames(settings.showingRatesNames, settings.hiddenRatesNames) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/di/GatewayModule.kt b/app/src/main/java/io/iakanoe/github/dolarcito/di/GatewayModule.kt new file mode 100644 index 0000000..fdec816 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/di/GatewayModule.kt @@ -0,0 +1,33 @@ +package io.iakanoe.github.dolarcito.di + +import android.content.Context +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.iakanoe.github.dolarcito.gateway.DolarcitoApiGateway +import io.iakanoe.github.dolarcito.gateway.SettingsGateway +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object GatewayModule { + + @Provides + @Singleton + fun providesDolarcitoApiGateway( + retrofit: Retrofit + ) = retrofit.create() + + @Provides + @Singleton + fun providesSettingsGateway( + @ApplicationContext context: Context, + gson: Gson + ) = SettingsGateway(context, gson) + +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/di/GsonModule.kt b/app/src/main/java/io/iakanoe/github/dolarcito/di/GsonModule.kt new file mode 100644 index 0000000..c0adcd6 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/di/GsonModule.kt @@ -0,0 +1,17 @@ +package io.iakanoe.github.dolarcito.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object GsonModule { + + @Provides + @Singleton + fun provideGson() = Gson() +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/di/RepositoryModule.kt b/app/src/main/java/io/iakanoe/github/dolarcito/di/RepositoryModule.kt new file mode 100644 index 0000000..3106e23 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/di/RepositoryModule.kt @@ -0,0 +1,28 @@ +package io.iakanoe.github.dolarcito.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import io.iakanoe.github.dolarcito.data.ExchangeRateRepository +import io.iakanoe.github.dolarcito.data.SettingsRepository +import io.iakanoe.github.dolarcito.gateway.DolarcitoApiGateway +import io.iakanoe.github.dolarcito.gateway.SettingsGateway + +@Module +@InstallIn(ActivityRetainedComponent::class) +object RepositoryModule { + + @Provides + @ActivityRetainedScoped + fun providesExchangeRateRepository( + dolarcitoApiGateway: DolarcitoApiGateway + ) = ExchangeRateRepository(dolarcitoApiGateway) + + @Provides + @ActivityRetainedScoped + fun providesSettingsRepository( + settingsGateway: SettingsGateway + ) = SettingsRepository(settingsGateway) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/di/RetrofitModule.kt b/app/src/main/java/io/iakanoe/github/dolarcito/di/RetrofitModule.kt new file mode 100644 index 0000000..f1e4a9b --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/di/RetrofitModule.kt @@ -0,0 +1,43 @@ +package io.iakanoe.github.dolarcito.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.iakanoe.github.dolarcito.BuildConfig +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RetrofitModule { + + private class AuthInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request().newBuilder() + .addHeader("Auth-Client", BuildConfig.API_KEY) + .build() + + return chain.proceed(newRequest) + } + } + + private fun createClient() = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor()) + .build() + + @Provides + @Singleton + fun providesRetrofit( + gson: Gson + ): Retrofit = Retrofit.Builder() + .baseUrl("https://dolarito.ar/") + .client(createClient()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/di/UseCaseModule.kt b/app/src/main/java/io/iakanoe/github/dolarcito/di/UseCaseModule.kt new file mode 100644 index 0000000..10b8adb --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/di/UseCaseModule.kt @@ -0,0 +1,39 @@ +package io.iakanoe.github.dolarcito.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import io.iakanoe.github.dolarcito.data.ExchangeRateRepository +import io.iakanoe.github.dolarcito.data.SettingsRepository +import io.iakanoe.github.dolarcito.domain.GetOrderedExchangeRatesUseCase +import io.iakanoe.github.dolarcito.domain.GetSettingsUseCase +import io.iakanoe.github.dolarcito.domain.SaveNewSettingsUseCase + +@Module +@InstallIn(ActivityRetainedComponent::class) +object UseCaseModule { + + @Provides + @ActivityRetainedScoped + fun provideGetOrderedExchangeRatesUseCase( + settingsRepository: SettingsRepository, + exchangeRateRepository: ExchangeRateRepository + ) = GetOrderedExchangeRatesUseCase( + settingsRepository, + exchangeRateRepository + ) + + @Provides + @ActivityRetainedScoped + fun provideGetSettingsUseCase( + settingsRepository: SettingsRepository + ) = GetSettingsUseCase(settingsRepository) + + @Provides + @ActivityRetainedScoped + fun provideSaveNewSettingsUseCase( + settingsRepository: SettingsRepository + ) = SaveNewSettingsUseCase(settingsRepository) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetOrderedExchangeRatesUseCase.kt b/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetOrderedExchangeRatesUseCase.kt new file mode 100644 index 0000000..355443d --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetOrderedExchangeRatesUseCase.kt @@ -0,0 +1,63 @@ +package io.iakanoe.github.dolarcito.domain + +import android.util.Log +import io.iakanoe.github.dolarcito.data.ExchangeRateRepository +import io.iakanoe.github.dolarcito.data.SettingsRepository +import io.iakanoe.github.dolarcito.model.ExchangeRate +import io.iakanoe.github.dolarcito.model.ExchangeRateOrder +import io.iakanoe.github.dolarcito.model.Settings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class GetOrderedExchangeRatesUseCase( + private val settingsRepository: SettingsRepository, + private val exchangeRateRepository: ExchangeRateRepository +) { + suspend fun execute(): Flow { + val existingRates = exchangeRateRepository.getExchangeRates() + + val actualSettings = settingsRepository.getSettings() + .map { settings -> + val updated = settings.update(existingRates.map { it.name }) + + Log.d("UseCase", "map\nsettings=$settings\nupdated=$updated") + if (settings != updated) settingsRepository.setSettings(updated) + updated + } + .onEach { Log.d("onEach", "ONEACH 1 $it") } + .map { settings -> + ExchangeRateOrder( + showing = settings.showingRatesNames + .map { exchangeRateByName(existingRates, it) }, + hidden = settings.hiddenRatesNames + .map { exchangeRateByName(existingRates, it) } + ) + } + .onEach { Log.d("onEach", "ONEACH $it") } + + return actualSettings + } + + private fun exchangeRateByName(rates: List, name: String) = + rates.first { it.name == name } + + private fun Settings.update(existingRatesNames: List): Settings { + var showing = showingRatesNames + var hidden = hiddenRatesNames + + // delete no longer existing rates + showing = showing.filter { it in existingRatesNames } + hidden = hidden.filter { it in existingRatesNames } + + // add new rates to showing by default + val newRates = existingRatesNames + .filterNot { it in showing } + .filterNot { it in hidden } + + showing = showing + newRates + + // return updated settings + return Settings(showing, hidden) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetSettingsUseCase.kt b/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetSettingsUseCase.kt new file mode 100644 index 0000000..bd6d756 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/domain/GetSettingsUseCase.kt @@ -0,0 +1,10 @@ +package io.iakanoe.github.dolarcito.domain + +import io.iakanoe.github.dolarcito.data.SettingsRepository + +class GetSettingsUseCase( + private val settingsRepository: SettingsRepository, +) { + fun execute() = + settingsRepository.getSettings() +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/domain/SaveNewSettingsUseCase.kt b/app/src/main/java/io/iakanoe/github/dolarcito/domain/SaveNewSettingsUseCase.kt new file mode 100644 index 0000000..b333a4d --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/domain/SaveNewSettingsUseCase.kt @@ -0,0 +1,11 @@ +package io.iakanoe.github.dolarcito.domain + +import io.iakanoe.github.dolarcito.data.SettingsRepository +import io.iakanoe.github.dolarcito.model.Settings + +class SaveNewSettingsUseCase( + private val settingsRepository: SettingsRepository +) { + suspend fun execute(newSettings: Settings) = + settingsRepository.setSettings(newSettings) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/gateway/DolarcitoApiGateway.kt b/app/src/main/java/io/iakanoe/github/dolarcito/gateway/DolarcitoApiGateway.kt new file mode 100644 index 0000000..dd6558d --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/gateway/DolarcitoApiGateway.kt @@ -0,0 +1,9 @@ +package io.iakanoe.github.dolarcito.gateway + +import io.iakanoe.github.dolarcito.model.ExchangeRate +import retrofit2.http.GET + +interface DolarcitoApiGateway { + @GET("/api/frontend/quotations/dolar") + suspend fun getRates(): Map +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/gateway/SettingsGateway.kt b/app/src/main/java/io/iakanoe/github/dolarcito/gateway/SettingsGateway.kt new file mode 100644 index 0000000..6e556aa --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/gateway/SettingsGateway.kt @@ -0,0 +1,39 @@ +package io.iakanoe.github.dolarcito.gateway + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.flow.map + +class SettingsGateway( + private val context: Context, + private val gson: Gson +) { + private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "settings") + + private val showingRatesNamesKey = stringPreferencesKey("showingRates") + private val hiddenRatesNamesKey = stringPreferencesKey("hiddenRates") + + private val stringListTypeToken get() = object : TypeToken>() {}.type + + fun retrieveShowingRatesNames() = retrieveRatesNames(showingRatesNamesKey) + fun retrieveHiddenRatesNames() = retrieveRatesNames(hiddenRatesNamesKey) + + suspend fun saveBothRatesNames(showingRatesNames: List, hiddenRatesNames: List) { + context.settingsDataStore.edit { + it[showingRatesNamesKey] = gson.toJson(showingRatesNames) + it[hiddenRatesNamesKey] = gson.toJson(hiddenRatesNames) + } + } + + private fun retrieveRatesNames(key: Preferences.Key) = + context.settingsDataStore.data + .map { it[key] ?: "[]" } + .map { runCatching { gson.fromJson>(it, stringListTypeToken) } } + .map { it.getOrDefault(emptyList()) } +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRate.kt b/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRate.kt new file mode 100644 index 0000000..29bd0a5 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRate.kt @@ -0,0 +1,15 @@ +package io.iakanoe.github.dolarcito.model + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class ExchangeRate( + val name: String, + val buy: Float?, + val sell: Float?, + val timestamp: Long, + val variation: Float?, + val spread: Float?, + @SerializedName("volumen") val volume: Long? +) diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRateOrder.kt b/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRateOrder.kt new file mode 100644 index 0000000..4dd37d0 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/model/ExchangeRateOrder.kt @@ -0,0 +1,6 @@ +package io.iakanoe.github.dolarcito.model + +data class ExchangeRateOrder( + val showing: List, + val hidden: List +) diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/model/Settings.kt b/app/src/main/java/io/iakanoe/github/dolarcito/model/Settings.kt new file mode 100644 index 0000000..18b08d2 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/model/Settings.kt @@ -0,0 +1,6 @@ +package io.iakanoe.github.dolarcito.model + +data class Settings( + val showingRatesNames: List, + val hiddenRatesNames: List +) \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/DolarcitoNavigation.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/DolarcitoNavigation.kt new file mode 100644 index 0000000..28e7aa4 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/DolarcitoNavigation.kt @@ -0,0 +1,140 @@ +package io.iakanoe.github.dolarcito.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.rememberNavController +import io.iakanoe.github.dolarcito.ui.common.navigation.Destination +import io.iakanoe.github.dolarcito.ui.common.navigation.NavHost +import io.iakanoe.github.dolarcito.ui.common.navigation.composable +import io.iakanoe.github.dolarcito.ui.common.navigation.navigate +import io.iakanoe.github.dolarcito.ui.rates.ExchangeRatesScreen +import io.iakanoe.github.dolarcito.ui.settings.SettingsScreen + +enum class Screen : Destination { + RATES, + SETTINGS; +} + +class TopAppBarState { + var title by mutableStateOf("") + private set + + var subtitle by mutableStateOf(null) + private set + + var actions by mutableStateOf(emptyList()) + private set + + var navigationButton by mutableStateOf(null) + private set + + fun update( + title: String, + subtitle: String? = null, + actions: List = emptyList(), + navigationButton: IconButton? = null + ) { + this.title = title + this.subtitle = subtitle + this.actions = actions + this.navigationButton = navigationButton + } + + data class IconButton( + val imageVector: ImageVector, + val contentDescription: String?, + val onClick: () -> Unit, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DolarcitoNavigation() { + val navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } + val topAppBarState = remember { TopAppBarState() } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Column { + Text(text = topAppBarState.title) + + topAppBarState.subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall + ) + } + } + }, + actions = { + topAppBarState.actions.forEach { + IconButton(onClick = { it.onClick() }) { + Icon( + imageVector = it.imageVector, + contentDescription = it.contentDescription + ) + } + } + }, + navigationIcon = { + topAppBarState.navigationButton?.let { + IconButton(onClick = { it.onClick() }) { + Icon( + imageVector = it.imageVector, + contentDescription = it.contentDescription + ) + } + } + }, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.RATES, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.RATES) { + ExchangeRatesScreen( + onNavigateToSettings = { navController.navigate(Screen.SETTINGS) }, + topAppBarState = topAppBarState, + viewModel = hiltViewModel() + ) + } + + composable(Screen.SETTINGS) { + SettingsScreen( + onNavigateBack = { navController.navigateUp() }, + topAppBarState = topAppBarState, + snackbarHostState = snackbarHostState, + viewModel = hiltViewModel() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/navigation/ComposeNavigation.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/navigation/ComposeNavigation.kt new file mode 100644 index 0000000..745fa89 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/navigation/ComposeNavigation.kt @@ -0,0 +1,86 @@ +package io.iakanoe.github.dolarcito.ui.common.navigation + +import androidx.annotation.MainThread +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +interface Destination { + val name: String +} + +@Composable +fun NavHost( + navController: NavHostController, + startDestination: Destination, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + route: String? = null, + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + { fadeIn(animationSpec = tween(700)) }, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + { fadeOut(animationSpec = tween(700)) }, + popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + enterTransition, + popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + exitTransition, + builder: NavGraphBuilder.() -> Unit +) = androidx.navigation.compose.NavHost( + navController = navController, + startDestination = startDestination.name, + modifier = modifier, + contentAlignment = contentAlignment, + route = route, + enterTransition = enterTransition, + exitTransition = exitTransition, + popEnterTransition = popEnterTransition, + popExitTransition = popExitTransition, + builder = builder +) + +fun NavGraphBuilder.composable( + destination: Destination, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition?)? = null, + exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition?)? = null, + popEnterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition?)? = enterTransition, + popExitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition?)? = exitTransition, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit +) = composable( + route = destination.name, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = enterTransition, + exitTransition = exitTransition, + popEnterTransition = popEnterTransition, + popExitTransition = popExitTransition, + content = content +) + +@MainThread +@JvmOverloads +fun NavHostController.navigate( + destination: Destination, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null +) = navigate( + route = destination.name, + navOptions = navOptions, + navigatorExtras = navigatorExtras +) \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Color.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Color.kt new file mode 100644 index 0000000..d12d6a2 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Color.kt @@ -0,0 +1,11 @@ +package io.iakanoe.github.dolarcito.ui.common.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Theme.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Theme.kt new file mode 100644 index 0000000..0681228 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Theme.kt @@ -0,0 +1,60 @@ +package io.iakanoe.github.dolarcito.ui.common.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val dolarcitoDarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val dolarcitoLightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun DolarcitoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> dolarcitoDarkColorScheme + else -> dolarcitoLightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Type.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Type.kt new file mode 100644 index 0000000..f001a89 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/common/theme/Type.kt @@ -0,0 +1,30 @@ +package io.iakanoe.github.dolarcito.ui.common.theme + +import androidx.compose.material3.Typography + +// Set of Material typography styles to start with +val Typography = Typography( + /*bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + )*/ + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesScreen.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesScreen.kt new file mode 100644 index 0000000..4eafc4e --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesScreen.kt @@ -0,0 +1,311 @@ +package io.iakanoe.github.dolarcito.ui.rates + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.iakanoe.github.dolarcito.model.ExchangeRate +import io.iakanoe.github.dolarcito.ui.TopAppBarState +import java.text.DecimalFormat +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Calendar +import kotlin.math.floor +import kotlin.math.sign + +val Int.minutesText + get() = when (this) { + 0 -> "hace menos de un minuto" + 1 -> "hace un minuto" + else -> "hace $this minutos" + } + +val Float.priceText: String + get() = DecimalFormat("$#.##") + .format(this) + +val Long.largeNumberText: String + get() = DecimalFormat("#,###") + .format(this) + +@Composable +fun ExchangeRatesScreen( + onNavigateToSettings: () -> Unit, + topAppBarState: TopAppBarState, + viewModel: ExchangeRatesViewModel = viewModel() +) { + val viewState by viewModel.viewState.collectAsState() + + val lastUpdated by remember { + derivedStateOf { + viewState.let { + if (it is ExchangeRatesViewState.Loaded) { + val millis = Calendar.getInstance().timeInMillis - it.updatedTime + val minutes = floor(millis / 60000f) + minutes.toInt() + } else null + } + } + } + + LaunchedEffect(Unit) { + viewModel.update() + } + + LaunchedEffect(Unit, lastUpdated) { + topAppBarState.update( + title = "Dolarcito", + subtitle = lastUpdated?.let { "Actualizado ${it.minutesText}" }, + actions = listOf( + TopAppBarState.IconButton( + Icons.Filled.List, + contentDescription = "settings button", + onClick = onNavigateToSettings + ), + TopAppBarState.IconButton( + Icons.Filled.Refresh, + contentDescription = "refresh button", + onClick = { viewModel.update() } + ) + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (val state = viewState) { + is ExchangeRatesViewState.Loading -> CircularProgressIndicator() + + is ExchangeRatesViewState.Error -> ErrorContent( + onRetryClick = { viewModel.update() } + ) + + is ExchangeRatesViewState.Loaded -> ExchangeRateList( + exchangeRates = state.exchangeRates, + hiddenExchangeRates = state.hiddenExchangeRates + ) + } + } +} + +@Composable +fun ErrorContent(onRetryClick: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Hubo un error.", + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button(onClick = onRetryClick) { + Text(text = "Volver a intentar") + } + } +} + +@Composable +fun ExchangeRateList( + exchangeRates: List, + hiddenExchangeRates: List +) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + items( + count = exchangeRates.size, + key = { exchangeRates[it].name } + ) { + if (it > 0) Spacer(modifier = Modifier.height(16.dp)) + + ExchangeRateCard(exchangeRate = exchangeRates[it]) + } + + if (hiddenExchangeRates.isNotEmpty()) { + item { + TextButton(onClick = { isExpanded = !isExpanded }) { + Text( + text = if (isExpanded) "Colapsar ocultos" else "Expandir ocultos", + textAlign = TextAlign.Center + ) + } + } + + if (isExpanded) { + items( + count = hiddenExchangeRates.size, + key = { hiddenExchangeRates[it].name } + ) { + if (it > 0) Spacer(modifier = Modifier.height(16.dp)) + + ExchangeRateCard(exchangeRate = hiddenExchangeRates[it]) + } + } + } + } +} + +@Composable +fun ExchangeRateCard(exchangeRate: ExchangeRate) { + val borderColor = when (exchangeRate.variation?.sign) { + -1f -> Color.Red + 1f -> Color.Green + else -> MaterialTheme.colorScheme.outline + } + + val lastUpdated by remember { + derivedStateOf { + val millis = Calendar.getInstance().timeInMillis - exchangeRate.timestamp + val minutes = floor(millis / 60000f) + minutes.toInt() + } + } + + val lastUpdatedText = + if (lastUpdated < 10) lastUpdated.minutesText + else LocalDateTime.ofInstant(Instant.ofEpochMilli(exchangeRate.timestamp), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")) + + val secondaryText = when { + exchangeRate.volume != null -> "- vol: ${exchangeRate.volume.largeNumberText}" + exchangeRate.spread != null -> "- spread: ${exchangeRate.spread.priceText}" + else -> null + } + + OutlinedCard( + shape = RoundedCornerShape(16.dp), + border = BorderStroke(width = 2.dp, color = borderColor) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = exchangeRate.name.uppercase(), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Row { + Text( + text = lastUpdatedText, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.width(8.dp)) + + secondaryText?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.outline + ) + } + } + + Spacer( + modifier = Modifier + .padding(4.dp) + .height(1.dp) + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.onPrimaryContainer) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + exchangeRate.buy?.let { + Price(name = "COMPRA", value = it) + } + + exchangeRate.sell?.let { + Price(name = "VENTA", value = it) + } + } + } + } +} + +@Composable +fun Price(name: String, value: Float) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = name, + style = MaterialTheme.typography.labelLarge + ) + + Text( + text = value.priceText, + style = MaterialTheme.typography.titleLarge + ) + } +} + +@Preview +@Composable +fun ExchangeRateCardPreview() { + val t = Calendar.getInstance() + .apply { roll(Calendar.MINUTE, -4) } + .timeInMillis + + ExchangeRateCard( + exchangeRate = ExchangeRate( + name = "dolar blue", + buy = 930.1f, + sell = 980f, + timestamp = t, + variation = null, + spread = null, + volume = null + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesViewModel.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesViewModel.kt new file mode 100644 index 0000000..4d7af57 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/rates/ExchangeRatesViewModel.kt @@ -0,0 +1,74 @@ +package io.iakanoe.github.dolarcito.ui.rates + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.iakanoe.github.dolarcito.domain.GetOrderedExchangeRatesUseCase +import io.iakanoe.github.dolarcito.model.ExchangeRate +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject + +@HiltViewModel +class ExchangeRatesViewModel @Inject constructor( + private val getOrderedExchangeRatesUseCase: GetOrderedExchangeRatesUseCase +) : ViewModel() { + + private val _viewState = MutableStateFlow(ExchangeRatesViewState.Loading) + val viewState get() = _viewState.asStateFlow() + + private var waitingJob: Job? = null + + init { + update() + } + + fun update() { + viewModelScope.launch { + waitingJob?.cancel() + _viewState.value = ExchangeRatesViewState.Loading + + val order = runCatching { getOrderedExchangeRatesUseCase.execute() } + .onFailure { + it.printStackTrace() + _viewState.value = ExchangeRatesViewState.Error + } + .getOrDefault(emptyFlow()) + .firstOrNull() + + order?.let { + Log.d("ExchangeRates", "loaded: \nshowing=${it.showing}\nhidden=${it.hidden}") + + _viewState.value = ExchangeRatesViewState.Loaded( + exchangeRates = it.showing, + hiddenExchangeRates = it.hidden, + updatedTime = Calendar.getInstance().timeInMillis + ) + } + + waitingJob = launch { + delay(5 * 60000L) + update() + } + } + } +} + +sealed class ExchangeRatesViewState { + data object Loading : ExchangeRatesViewState() + + data object Error : ExchangeRatesViewState() + + data class Loaded( + val exchangeRates: List, + val hiddenExchangeRates: List, + val updatedTime: Long + ) : ExchangeRatesViewState() +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsScreen.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..062e0a9 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsScreen.kt @@ -0,0 +1,240 @@ +package io.iakanoe.github.dolarcito.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.iakanoe.github.dolarcito.ui.TopAppBarState + +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + topAppBarState: TopAppBarState, + snackbarHostState: SnackbarHostState, + viewModel: SettingsViewModel = viewModel(), +) { + val viewState by viewModel.viewState.collectAsState() + + LaunchedEffect(viewState) { + when (viewState) { + is SettingsViewState.SavingError -> { + snackbarHostState.showSnackbar("Error al guardar configuraciĆ³n.") + onNavigateBack() + } + + is SettingsViewState.RetrievingError -> { + snackbarHostState.showSnackbar("Error al leer configuraciĆ³n guardada.") + onNavigateBack() + } + + is SettingsViewState.Saved -> onNavigateBack() + + else -> Unit + } + } + + LaunchedEffect(Unit) { + topAppBarState.update( + title = "Ordenar", + actions = listOf( + TopAppBarState.IconButton( + Icons.Filled.Done, + contentDescription = "save button", + onClick = { viewModel.saveNewOrder() } + ) + ), + navigationButton = TopAppBarState.IconButton( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "back button", + onClick = onNavigateBack + ) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (viewState) { + is SettingsViewState.Loading -> CircularProgressIndicator() + is SettingsViewState.Loaded -> OrderableList() + else -> Unit + } + } +} + +@Composable +fun OrderableList(viewModel: SettingsViewModel = viewModel()) { + val viewState by viewModel.viewState.collectAsState() + + val state = viewState as? SettingsViewState.Loaded ?: return + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top + ) { + item { Header(text = "Visibles") } + items( + count = state.showingRates.size, + key = { state.showingRates[it] } + ) { + val name = state.showingRates[it] + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline) + ) + + Item( + text = name, + canMoveUp = it > 0, + canMoveDown = true, + isHidden = false, + onMoveUp = { viewModel.moveItem(name, true) }, + onMoveDown = { viewModel.moveItem(name, false) }, + onHide = { viewModel.hideItem(name) }, + onShow = { viewModel.showItem(name) } + ) + } + + item { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline) + ) + + Header(text = "Ocultos") + } + + items( + count = state.hiddenRates.size, + key = { state.hiddenRates[it] } + ) { + val name = state.hiddenRates[it] + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outline) + ) + + Item( + text = name, + canMoveUp = true, + canMoveDown = it < state.hiddenRates.size - 1, + isHidden = true, + onMoveUp = { viewModel.moveItem(name, true) }, + onMoveDown = { viewModel.moveItem(name, false) }, + onHide = { viewModel.hideItem(name) }, + onShow = { viewModel.showItem(name) } + ) + } + } +} + +@Composable +fun Header(text: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text(text = text) + } +} + +@Composable +fun Item( + text: String, + canMoveUp: Boolean, + canMoveDown: Boolean, + isHidden: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onHide: () -> Unit, + onShow: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + modifier = Modifier.weight(1f) + ) + + if (isHidden) { + IconButton(onClick = onShow) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = "show button" + ) + } + } else { + IconButton(onClick = onHide) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "hide button" + ) + } + } + + IconButton( + onClick = onMoveUp, + enabled = canMoveUp + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + contentDescription = "move up button" + ) + } + + IconButton( + onClick = onMoveDown, + enabled = canMoveDown + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = "move down button" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsViewModel.kt b/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..d7cf5a3 --- /dev/null +++ b/app/src/main/java/io/iakanoe/github/dolarcito/ui/settings/SettingsViewModel.kt @@ -0,0 +1,133 @@ +package io.iakanoe.github.dolarcito.ui.settings + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.iakanoe.github.dolarcito.domain.GetSettingsUseCase +import io.iakanoe.github.dolarcito.domain.SaveNewSettingsUseCase +import io.iakanoe.github.dolarcito.model.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +fun MutableList.swap(index1: Int, index2: Int) { + val tmp = this[index1] + this[index1] = this[index2] + this[index2] = tmp +} + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val getSettingsUseCase: GetSettingsUseCase, + private val saveNewSettingsUseCase: SaveNewSettingsUseCase +) : ViewModel() { + + private val _viewState = MutableStateFlow(SettingsViewState.Loading) + val viewState get() = _viewState.asStateFlow() + + init { + getData() + } + + private fun getData() { + viewModelScope.launch { + try { + val actualSettings = getSettingsUseCase.execute().first() + Log.d("getdata", "GET DATA $actualSettings") + _viewState.value = SettingsViewState.Loaded( + showingRates = actualSettings.showingRatesNames, + hiddenRates = actualSettings.hiddenRatesNames + ) + } catch (exception: Exception) { + exception.printStackTrace() + _viewState.value = SettingsViewState.RetrievingError + } + } + } + + fun moveItem(name: String, up: Boolean) { + val order = _viewState.value as? SettingsViewState.Loaded ?: throw RuntimeException() + val showing = order.showingRates.toMutableList() + val hidden = order.hiddenRates.toMutableList() + + if (name in showing) { + val index = showing.indexOf(name) + + if (index == 0 && up) { + return + } else if (index == (showing.size - 1) && !up) { + hidden.add(0, showing.removeAt(index)) + } else { + showing.swap(index, index + (if (up) -1 else 1)) + } + } else if (name in hidden) { + val index = hidden.indexOf(name) + + if (index == 0 && up) { + showing.add(hidden.removeAt(index)) + } else if (index == (hidden.size - 1) && !up) { + return + } else { + hidden.swap(index, index + (if (up) -1 else 1)) + } + } else throw RuntimeException() + + _viewState.value = SettingsViewState.Loaded(showing, hidden) + } + + fun hideItem(name: String) { + val order = _viewState.value as? SettingsViewState.Loaded ?: throw RuntimeException() + val showing = order.showingRates.toMutableList() + val hidden = order.hiddenRates.toMutableList() + + if (name in showing) hidden.add(showing.removeAt(showing.indexOf(name))) + else throw RuntimeException() + + _viewState.value = SettingsViewState.Loaded(showing, hidden) + } + + fun showItem(name: String) { + val order = _viewState.value as? SettingsViewState.Loaded ?: throw RuntimeException() + val showing = order.showingRates.toMutableList() + val hidden = order.hiddenRates.toMutableList() + + if (name in hidden) showing.add(hidden.removeAt(hidden.indexOf(name))) + else throw RuntimeException() + + _viewState.value = SettingsViewState.Loaded(showing, hidden) + } + + fun saveNewOrder() { + viewModelScope.launch { + try { + val newOrder = _viewState.value as? SettingsViewState.Loaded ?: throw RuntimeException() + saveNewSettingsUseCase.execute( + Settings(newOrder.showingRates, newOrder.hiddenRates) + ) + + _viewState.value = SettingsViewState.Saved + } catch (exception: Exception) { + exception.printStackTrace() + _viewState.value = SettingsViewState.SavingError + } + } + } +} + +sealed class SettingsViewState { + data object Loading : SettingsViewState() + + data object SavingError : SettingsViewState() + + data object RetrievingError : SettingsViewState() + + data object Saved : SettingsViewState() + + data class Loaded( + val showingRates: List, + val hiddenRates: List, + ) : SettingsViewState() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..73ddbd4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Dolarcito + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..16afe96 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +