diff --git a/build.gradle.kts b/build.gradle.kts index 68c93594d..507df665f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.9.1" apply false + alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.compose.compiler) apply false @@ -8,6 +8,7 @@ plugins { id("com.google.gms.google-services") version "4.4.2" apply false id("com.google.firebase.crashlytics") version "3.0.1" apply false id("org.jlleitschuh.gradle.ktlint") version "12.1.1" + id("com.google.dagger.hilt.android") version "2.56.2" apply false } allprojects { diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/CryptoRateResponseMapper.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/CryptoRateResponseMapper.kt index cdd04b8a9..f1d23004a 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/CryptoRateResponseMapper.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/CryptoRateResponseMapper.kt @@ -5,9 +5,6 @@ import dev.arkbuilders.rate.core.domain.model.CurrencyRate import dev.arkbuilders.rate.core.domain.model.CurrencyType import java.math.BigDecimal import javax.inject.Inject -import javax.inject.Singleton - -@Singleton class CryptoRateResponseMapper @Inject constructor() { fun map(response: List) = response.map { diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/FiatRateResponseMapper.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/FiatRateResponseMapper.kt index 5831c30e3..1e4915f9c 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/FiatRateResponseMapper.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/mapper/FiatRateResponseMapper.kt @@ -6,9 +6,6 @@ import dev.arkbuilders.rate.core.domain.model.CurrencyRate import dev.arkbuilders.rate.core.domain.model.CurrencyType import java.math.BigDecimal import javax.inject.Inject -import javax.inject.Singleton - -@Singleton class FiatRateResponseMapper @Inject constructor() { fun map(response: FiatRateResponse): List = response.rates.map { (code, rate) -> diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/network/OkHttpClientBuilder.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/network/OkHttpClientBuilder.kt index 54e654e89..419ef1fad 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/network/OkHttpClientBuilder.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/network/OkHttpClientBuilder.kt @@ -4,9 +4,7 @@ import android.content.Context import android.webkit.WebSettings import okhttp3.OkHttpClient import javax.inject.Inject -import javax.inject.Singleton -@Singleton class OkHttpClientBuilder @Inject constructor(val context: Context) { fun build(): OkHttpClient { val agent = WebSettings.getDefaultUserAgent(context) diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/TimestampRepoImpl.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/TimestampRepoImpl.kt index 943137507..7eb7b15bc 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/TimestampRepoImpl.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/TimestampRepoImpl.kt @@ -8,9 +8,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.OffsetDateTime import javax.inject.Inject -import javax.inject.Singleton -@Singleton class TimestampRepoImpl @Inject constructor(private val dao: TimestampDao) : TimestampRepo { override suspend fun rememberTimestamp(type: TimestampType) = dao.insert( diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CryptoCurrencyDataSource.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CryptoCurrencyDataSource.kt index ceb9856a9..ba0179db5 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CryptoCurrencyDataSource.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CryptoCurrencyDataSource.kt @@ -8,9 +8,7 @@ import dev.arkbuilders.rate.core.data.network.api.CryptoAPI import dev.arkbuilders.rate.core.domain.model.CurrencyRate import dev.arkbuilders.rate.core.domain.model.CurrencyType import javax.inject.Inject -import javax.inject.Singleton -@Singleton class CryptoCurrencyDataSource @Inject constructor( private val cryptoAPI: CryptoAPI, private val cryptoRateResponseMapper: CryptoRateResponseMapper, diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyInfoDataSource.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyInfoDataSource.kt index ca8ef3948..9c995d064 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyInfoDataSource.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyInfoDataSource.kt @@ -11,9 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext import javax.inject.Inject -import javax.inject.Singleton -@Singleton class CurrencyInfoDataSource @Inject constructor( private val ctx: Context, ) { diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyRepoImpl.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyRepoImpl.kt index 2f79c7e89..f25fafdee 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyRepoImpl.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/CurrencyRepoImpl.kt @@ -20,9 +20,7 @@ import kotlinx.coroutines.withContext import java.time.Duration import java.time.OffsetDateTime import javax.inject.Inject -import javax.inject.Singleton -@Singleton class CurrencyRepoImpl @Inject constructor( private val fiatDataSource: FiatCurrencyDataSource, private val cryptoDataSource: CryptoCurrencyDataSource, diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FallbackRatesProvider.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FallbackRatesProvider.kt index d3638c0fc..5e3a84fdc 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FallbackRatesProvider.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FallbackRatesProvider.kt @@ -12,9 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.time.OffsetDateTime import javax.inject.Inject -import javax.inject.Singleton -@Singleton class FallbackRatesProvider @Inject constructor( private val ctx: Context, private val fiatRateResponseMapper: FiatRateResponseMapper, diff --git a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FiatCurrencyDataSource.kt b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FiatCurrencyDataSource.kt index 742e1f464..132874c71 100644 --- a/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FiatCurrencyDataSource.kt +++ b/core/data/src/main/java/dev/arkbuilders/rate/core/data/repo/currency/FiatCurrencyDataSource.kt @@ -8,9 +8,7 @@ import dev.arkbuilders.rate.core.data.network.api.FiatAPI import dev.arkbuilders.rate.core.domain.model.CurrencyRate import dev.arkbuilders.rate.core.domain.model.CurrencyType import javax.inject.Inject -import javax.inject.Singleton -@Singleton class FiatCurrencyDataSource @Inject constructor( private val fiatAPI: FiatAPI, private val fiatRateResponseMapper: FiatRateResponseMapper, diff --git a/core/db/build.gradle.kts b/core/db/build.gradle.kts index c54cf41b4..30ef0a54e 100644 --- a/core/db/build.gradle.kts +++ b/core/db/build.gradle.kts @@ -52,7 +52,7 @@ dependencies { ksp(libs.androidx.room.compiler) testImplementation(libs.junit) - androidTestImplementation(libs.androidx.room.testing) +// androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/ApiModule.kt b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/ApiModule.kt index 9339fe4e3..b4bbd404a 100644 --- a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/ApiModule.kt +++ b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/ApiModule.kt @@ -1,5 +1,6 @@ package dev.arkbuilders.rate.core.di.modules +import android.content.Context import com.google.gson.GsonBuilder import dagger.Module import dagger.Provides @@ -12,6 +13,12 @@ import javax.inject.Singleton @Module class ApiModule { + @Singleton + @Provides + fun okHttpClientBuilder(context: Context): OkHttpClientBuilder { + return OkHttpClientBuilder(context) + } + @Singleton @Provides fun cryptoAPI(clientBuilder: OkHttpClientBuilder): CryptoAPI { diff --git a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt index c14a324db..385140376 100644 --- a/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt +++ b/core/di/src/main/java/dev/arkbuilders/rate/core/di/modules/RepoModule.kt @@ -3,7 +3,11 @@ package dev.arkbuilders.rate.core.di.modules import android.content.Context import dagger.Module import dagger.Provides +import dev.arkbuilders.rate.core.data.mapper.CryptoRateResponseMapper +import dev.arkbuilders.rate.core.data.mapper.FiatRateResponseMapper import dev.arkbuilders.rate.core.data.network.NetworkStatusImpl +import dev.arkbuilders.rate.core.data.network.api.CryptoAPI +import dev.arkbuilders.rate.core.data.network.api.FiatAPI import dev.arkbuilders.rate.core.data.preferences.PrefsImpl import dev.arkbuilders.rate.core.data.repo.AnalyticsManagerImpl import dev.arkbuilders.rate.core.data.repo.BuildConfigFieldsProviderImpl @@ -18,6 +22,7 @@ import dev.arkbuilders.rate.core.data.repo.currency.FallbackRatesProvider import dev.arkbuilders.rate.core.data.repo.currency.FiatCurrencyDataSource import dev.arkbuilders.rate.core.data.repo.currency.LocalCurrencyDataSource import dev.arkbuilders.rate.core.db.dao.CodeUseStatDao +import dev.arkbuilders.rate.core.db.dao.CurrencyRateDao import dev.arkbuilders.rate.core.db.dao.GroupDao import dev.arkbuilders.rate.core.db.dao.TimestampDao import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider @@ -35,6 +40,22 @@ import javax.inject.Singleton @Module class RepoModule { + @Singleton + @Provides + fun provideFallbackRatesProvider( + context: Context, + fiatRateResponseMapper: FiatRateResponseMapper, + cryptoRateResponseMapper: CryptoRateResponseMapper, + buildConfigFieldsProvider: BuildConfigFieldsProvider, + ): FallbackRatesProvider { + return FallbackRatesProvider( + context, + fiatRateResponseMapper, + cryptoRateResponseMapper, + buildConfigFieldsProvider, + ) + } + @Singleton @Provides fun currencyRepo( @@ -90,6 +111,30 @@ class RepoModule { fun defaultGroupNameProvider(context: Context): DefaultGroupNameProvider = DefaultGroupNameProviderImpl(context) + @Singleton + @Provides + fun fiatCurrencyDataSource( + fiatAPI: FiatAPI, + fiatRateResponseMapper: FiatRateResponseMapper, + ): FiatCurrencyDataSource = FiatCurrencyDataSource(fiatAPI, fiatRateResponseMapper) + + @Singleton + @Provides + fun cryptoCurrencyDataSource( + cryptoAPI: CryptoAPI, + cryptoRateResponseMapper: CryptoRateResponseMapper, + ): CryptoCurrencyDataSource = CryptoCurrencyDataSource(cryptoAPI, cryptoRateResponseMapper) + + @Singleton + @Provides + fun localCurrencyDataSource(dao: CurrencyRateDao): LocalCurrencyDataSource = + LocalCurrencyDataSource(dao) + + @Singleton + @Provides + fun currencyInfoDataSource(context: Context): CurrencyInfoDataSource = + CurrencyInfoDataSource(context) + @Singleton @Provides fun inAppReviewManager( diff --git a/core/presentation/src/main/res/drawable/ic_update.xml b/core/presentation/src/main/res/drawable/ic_update.xml new file mode 100644 index 000000000..b8df7f914 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_update.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 09c7e6833..5c3f57606 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -144,6 +144,8 @@ Delete Re-use Edit + Update + Options Oops, request time out! Please check your connection and refresh the page to try again. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cae15d523..84f6570fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,16 @@ [versions] +composeNavigation = "1.3.0" +hilt = "2.56.2" kotlin = "2.1.20" agp = "8.1.4" +playServicesWearable = "18.2.0" coreKtx = "1.15.0" minSdk = "26" compileSdk = "36" ksp = "2.1.20-1.0.32" arkAbout = "0.2.1" +androidApp = "8.9.1" activityCompose = "1.10.1" composeDestinationsVersion = "2.1.0" arrowFxCoroutines = "1.2.1" @@ -37,8 +41,14 @@ material = "1.12.0" gson = "2.11.0" reorderable = "2.4.3" playReview = "2.0.2" +composeBom = "2023.08.00" +composeMaterial = "1.2.1" +composeFoundation = "1.2.1" +coreSplashscreen = "1.0.1" +watchface = "1.2.1" [libraries] +androidx-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "composeNavigation" } ark-about = { module = "dev.arkbuilders.components:about", version.ref = "arkAbout" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } @@ -69,6 +79,8 @@ dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } play-review = { group = "com.google.android.play", name = "review", version.ref = "playReview" } play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" } @@ -90,8 +102,23 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } + +play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "composeMaterial" } +androidx-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "composeFoundation" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +androidx-wear-watchface = { module = "androidx.wear.watchface:watchface", version.ref = "watchface" } +androidx-wear-watchface-complications-data = { module = "androidx.wear.watchface:watchface-complications-data", version.ref = "watchface" } +androidx-wear-watchface-complications-data-source = { module = "androidx.wear.watchface:watchface-complications-data-source", version.ref = "watchface" } +androidx-wear-watchface-complications-data-source-ktx = { module = "androidx.wear.watchface:watchface-complications-data-source-ktx", version.ref = "watchface" } +androidx-wear-watchface-complications-rendering = { module = "androidx.wear.watchface:watchface-complications-rendering", version.ref = "watchface" } +androidx-wear-watchface-editor = { module = "androidx.wear.watchface:watchface-editor", version.ref = "watchface" } +androidx-wear-watchface-style = { module = "androidx.wear.watchface:watchface-style", version.ref = "watchface" } + [plugins] android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} +android-application = { id = "com.android.application", version.ref = "androidApp"} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5090e0e26..975c7bc98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,9 +2,9 @@ import java.util.Properties pluginManagement { repositories { - gradlePluginPortal() google() mavenCentral() + gradlePluginPortal() maven { setUrl("https://jitpack.io") } @@ -54,6 +54,8 @@ include(":feature:quickwidget") include(":feature:search") include(":feature:settings") include(":feature:onboarding") +include(":watchapp") + fun getLocalProps(): Properties { val props = Properties() diff --git a/watchapp/.gitignore b/watchapp/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/watchapp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/watchapp/build.gradle.kts b/watchapp/build.gradle.kts new file mode 100644 index 000000000..03f4a6a46 --- /dev/null +++ b/watchapp/build.gradle.kts @@ -0,0 +1,147 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) + id("com.google.dagger.hilt.android") +} +android { + namespace = "dev.arkbuilders.rate.watchapp" + compileSdk = libs.versions.compileSdk.get().toInt() + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + applicationId = "dev.arkbuilders.rate.watchapp" + minSdk = libs.versions.minSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + vectorDrawables { + useSupportLibrary = true + } + + val cryptoRatesLastModified = + rootProject.file("core/data/src/main/assets/crypto-rates.json").lastModified() + val fiatRatesLastModified = + rootProject.file("core/data/src/main/assets/fiat-rates.json").lastModified() + + buildConfigField( + "long", + "CRYPTO_LAST_MODIFIED", + cryptoRatesLastModified.toString(), + ) + buildConfigField( + "long", + "FIAT_LAST_MODIFIED", + fiatRatesLastModified.toString(), + ) + + val cryptoIcons = collectCurrencyIcons(project.rootDir.resolve("cryptoicons")) + val fiatIcons = collectCurrencyIcons(project.rootDir.resolve("fiaticons")) + val allIcons = (cryptoIcons + fiatIcons).distinct() + + buildConfigField( + "String[]", + "ICON_CODES", + allIcons.joinToString( + prefix = "new String[] {", + postfix = "}", + separator = ", ", + ) { + "\"$it\"" + }, + ) + } + + buildTypes { + debug { + addManifestPlaceholders( + mapOf( + "appLabel" to "@string/app_name_debug", + ), + ) + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + addManifestPlaceholders( + mapOf( + "appLabel" to "@string/app_name", + ), + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = "21" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +fun collectCurrencyIcons(moduleDir: File): List { + val drawableDir = moduleDir.resolve("src/main/res/drawable") + return drawableDir.listFiles()?.map { it.nameWithoutExtension.uppercase() } + ?.map { if (it == "curr_try") "try" else it } ?: emptyList() +} + +dependencies { + implementation(project(":core:db")) + implementation(project(":core:data")) + + implementation(project(":cryptoicons")) + implementation(project(":fiaticons")) + implementation(project(":feature:quick")) + implementation(project(":core:domain")) + implementation(project(":core:presentation")) + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + + + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.logging.interceptor) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + implementation(libs.play.services.wearable) +// implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation (libs.androidx.compose.navigation )// Or the latest version + implementation("androidx.wear.compose:compose-material3:1.6.1") // Or current version + + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.splashscreen) + implementation(libs.material3) + implementation(libs.navigation.compose) + + implementation(libs.androidx.wear.watchface) + implementation(libs.androidx.wear.watchface.complications.data) + implementation(libs.androidx.wear.watchface.complications.data.source) + implementation(libs.androidx.wear.watchface.complications.data.source.ktx) + implementation(libs.androidx.wear.watchface.complications.rendering) + implementation(libs.androidx.wear.watchface.editor) + implementation(libs.androidx.wear.watchface.style) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/watchapp/lint.xml b/watchapp/lint.xml new file mode 100644 index 000000000..44fac75b8 --- /dev/null +++ b/watchapp/lint.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/watchapp/proguard-rules.pro b/watchapp/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/watchapp/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/watchapp/src/main/AndroidManifest.xml b/watchapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..487fa177f --- /dev/null +++ b/watchapp/src/main/AndroidManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/ApiModule.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/ApiModule.kt new file mode 100644 index 000000000..dba841a91 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/ApiModule.kt @@ -0,0 +1,67 @@ +package dev.arkbuilders.rate.watchapp.di + +import android.content.Context +import android.webkit.WebSettings +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.arkbuilders.rate.core.data.network.OkHttpClientBuilder +import dev.arkbuilders.rate.core.data.network.api.CryptoAPI +import dev.arkbuilders.rate.core.data.network.api.FiatAPI +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class ApiModule { + + @Singleton + @Provides + fun clientBuilder(@ApplicationContext context: Context): OkHttpClient { + val client = + OkHttpClient.Builder() + .addNetworkInterceptor { chain -> + chain.proceed( + chain.request() + .newBuilder() + .build(), + ) + } + .build() + + return client + } + + @Singleton + @Provides + fun cryptoAPI(clientBuilder: OkHttpClient): CryptoAPI { + val httpClient = clientBuilder + val gson = GsonBuilder().create() + + return Retrofit.Builder() + .baseUrl("https://raw.githubusercontent.com") + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(httpClient) + .build() + .create(CryptoAPI::class.java) + } + + @Singleton + @Provides + fun fiatAPI(clientBuilder: OkHttpClient): FiatAPI { + val httpClient = clientBuilder + val gson = GsonBuilder().create() + + return Retrofit.Builder() + .baseUrl("https://raw.githubusercontent.com") + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(httpClient) + .build() + .create(FiatAPI::class.java) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/DBModule.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/DBModule.kt new file mode 100644 index 000000000..57d1743aa --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/DBModule.kt @@ -0,0 +1,40 @@ +package dev.arkbuilders.rate.watchapp.di + +import android.app.Application +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.arkbuilders.rate.core.db.Database +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class DBModule { + @Singleton + @Provides + fun database(app: Application): Database { + return Database.build(app) + } + + @Provides + fun assetsDao(db: Database) = db.assetsDao() + + @Provides + fun quickDao(db: Database) = db.quickDao() + + @Provides + fun rateDao(db: Database) = db.rateDao() + + @Provides + fun pairAlertDao(db: Database) = db.pairAlertDao() + + @Provides + fun fetchTimestampDao(db: Database) = db.fetchTimestampDao() + + @Provides + fun codeUseStatDao(db: Database) = db.codeUseStatDao() + + @Provides + fun groupDao(db: Database) = db.groupDao() +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/RepoModule.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/RepoModule.kt new file mode 100644 index 000000000..10bafaa17 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/RepoModule.kt @@ -0,0 +1,175 @@ +package dev.arkbuilders.rate.watchapp.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.arkbuilders.rate.core.data.mapper.CryptoRateResponseMapper +import dev.arkbuilders.rate.core.data.mapper.FiatRateResponseMapper +import dev.arkbuilders.rate.core.data.network.NetworkStatusImpl +import dev.arkbuilders.rate.core.data.network.OkHttpClientBuilder +import dev.arkbuilders.rate.core.data.network.api.CryptoAPI +import dev.arkbuilders.rate.core.data.preferences.PrefsImpl +import dev.arkbuilders.rate.core.data.repo.AnalyticsManagerImpl +import dev.arkbuilders.rate.core.data.repo.BuildConfigFieldsProviderImpl +import dev.arkbuilders.rate.core.data.repo.CodeUseStatRepoImpl +import dev.arkbuilders.rate.core.data.repo.GooglePlayInAppReviewManagerImpl +import dev.arkbuilders.rate.core.data.repo.GroupRepoImpl +import dev.arkbuilders.rate.core.data.repo.TimestampRepoImpl +import dev.arkbuilders.rate.core.data.repo.currency.CryptoCurrencyDataSource +import dev.arkbuilders.rate.core.data.repo.currency.CurrencyInfoDataSource +import dev.arkbuilders.rate.core.data.repo.currency.CurrencyRepoImpl +import dev.arkbuilders.rate.core.data.repo.currency.FallbackRatesProvider +import dev.arkbuilders.rate.core.data.repo.currency.FiatCurrencyDataSource +import dev.arkbuilders.rate.core.data.repo.currency.LocalCurrencyDataSource +import dev.arkbuilders.rate.core.db.dao.CodeUseStatDao +import dev.arkbuilders.rate.core.db.dao.CurrencyRateDao +import dev.arkbuilders.rate.core.db.dao.GroupDao +import dev.arkbuilders.rate.core.db.dao.TimestampDao +import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider +import dev.arkbuilders.rate.core.domain.repo.AnalyticsManager +import dev.arkbuilders.rate.core.domain.repo.CodeUseStatRepo +import dev.arkbuilders.rate.core.domain.repo.CurrencyRepo +import dev.arkbuilders.rate.core.domain.repo.GroupRepo +import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager +import dev.arkbuilders.rate.core.domain.repo.NetworkStatus +import dev.arkbuilders.rate.core.domain.repo.Prefs +import dev.arkbuilders.rate.core.domain.repo.TimestampRepo +import dev.arkbuilders.rate.core.domain.usecase.DefaultGroupNameProvider +import dev.arkbuilders.rate.core.presentation.utils.DefaultGroupNameProviderImpl +import dev.arkbuilders.rate.feature.quick.data.QuickRepoImpl +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import dev.arkbuilders.rate.core.db.dao.QuickCalculationDao +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class RepoModule { + + @Singleton + @Provides + fun buildConfigFieldsProvider(): BuildConfigFieldsProvider = BuildConfigFieldsProviderImpl() + + @Singleton + @Provides + fun provideFCryptoRateResponseMapper(): CryptoRateResponseMapper{ + return CryptoRateResponseMapper() + } + + @Singleton + @Provides + fun provideFiatRateResponseMapper(): FiatRateResponseMapper{ + return FiatRateResponseMapper() + } + + @Singleton + @Provides + fun provideFallbackRatesProvider( + @ApplicationContext context: Context, + fiatRateResponseMapper: FiatRateResponseMapper, + cryptoRateResponseMapper: CryptoRateResponseMapper, + buildConfigFieldsProvider: BuildConfigFieldsProvider, + ): FallbackRatesProvider { + return FallbackRatesProvider( + context, + fiatRateResponseMapper, + cryptoRateResponseMapper, + buildConfigFieldsProvider, + ) + } + + @Singleton + @Provides + fun provideCurrencyInfoDataSource( + @ApplicationContext context: Context, + ): CurrencyInfoDataSource { + return CurrencyInfoDataSource(context) + } + + @Singleton + @Provides + fun provideCryptoCurrencyDataSource( + cryptoAPI: CryptoAPI, + cryptoRateResponseMapper: CryptoRateResponseMapper, + ): CryptoCurrencyDataSource { + return CryptoCurrencyDataSource(cryptoAPI, cryptoRateResponseMapper) + } + + @Singleton + @Provides + fun provideLocalCurrencyDataSource(dao: CurrencyRateDao):LocalCurrencyDataSource { + return LocalCurrencyDataSource(dao) + } + + @Singleton + @Provides + fun currencyRepo( + fiatCurrencyDataSource: FiatCurrencyDataSource, + cryptoCurrencyDataSource: CryptoCurrencyDataSource, + localCurrencyDataSource: LocalCurrencyDataSource, + currencyInfoDataSource: CurrencyInfoDataSource, + timestampRepo: TimestampRepo, + networkStatus: NetworkStatus, + fallbackRatesProvider: FallbackRatesProvider, + ): CurrencyRepo = + CurrencyRepoImpl( + fiatCurrencyDataSource, + cryptoCurrencyDataSource, + localCurrencyDataSource, + fallbackRatesProvider, + currencyInfoDataSource, + timestampRepo, + networkStatus, + ) + + @Singleton + @Provides + fun groupRepo(groupDao: GroupDao): GroupRepo = GroupRepoImpl(groupDao) + + @Singleton + @Provides + fun prefs(@ApplicationContext context: Context): Prefs = PrefsImpl(context) + + @Singleton + @Provides + fun codeUseStatRepo(codeUseStatDao: CodeUseStatDao): CodeUseStatRepo = + CodeUseStatRepoImpl(codeUseStatDao) + + @Singleton + @Provides + fun analyticsManager(prefs: Prefs): AnalyticsManager = AnalyticsManagerImpl(prefs) + + @Singleton + @Provides + fun timestampRepo(timestampDao: TimestampDao): TimestampRepo = TimestampRepoImpl(timestampDao) + + @Singleton + @Provides + fun networkStatus(@ApplicationContext context: Context): NetworkStatus = + NetworkStatusImpl(context) + + @Singleton + @Provides + fun defaultGroupNameProvider(@ApplicationContext context: Context): DefaultGroupNameProvider = + DefaultGroupNameProviderImpl(context) + + @Singleton + @Provides + fun inAppReviewManager( + analyticsManager: AnalyticsManager, + buildConfigFieldsProvider: BuildConfigFieldsProvider, + ): InAppReviewManager = + GooglePlayInAppReviewManagerImpl( + analyticsManager, + buildConfigFieldsProvider.provide(), + ) + + @Singleton + @Provides + fun provideQuickRepo( + quickCalculationDao: QuickCalculationDao, + groupRepo: GroupRepo + ): QuickRepo = QuickRepoImpl(quickCalculationDao, groupRepo) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/UseCaseModule.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/UseCaseModule.kt new file mode 100644 index 000000000..cec60593c --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/di/UseCaseModule.kt @@ -0,0 +1,66 @@ +package dev.arkbuilders.rate.watchapp.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider +import dev.arkbuilders.rate.core.domain.repo.CodeUseStatRepo +import dev.arkbuilders.rate.core.domain.repo.CurrencyRepo +import dev.arkbuilders.rate.core.domain.repo.GroupRepo +import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager +import dev.arkbuilders.rate.core.domain.repo.Prefs +import dev.arkbuilders.rate.core.domain.usecase.CalcFrequentCurrUseCase +import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase +import dev.arkbuilders.rate.core.domain.usecase.DefaultGroupNameProvider +import dev.arkbuilders.rate.core.domain.usecase.GetGroupByIdOrCreateDefaultUseCase +import dev.arkbuilders.rate.core.domain.usecase.GroupReorderSwapUseCase +import dev.arkbuilders.rate.core.domain.usecase.SearchUseCase +import dev.arkbuilders.rate.core.domain.usecase.ValidateGroupNameUseCase +import dev.arkbuilders.rate.feature.quick.domain.usecase.LaunchInAppReviewUseCase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class UseCaseModule { + @Singleton + @Provides + fun calcFrequentCurrUseCase(codeUseStatRepo: CodeUseStatRepo) = + CalcFrequentCurrUseCase(codeUseStatRepo) + + @Singleton + @Provides + fun convertWithRateUseCase(currencyRepo: CurrencyRepo) = ConvertWithRateUseCase(currencyRepo) + + @Singleton + @Provides + fun prepopulateDefaultGroupUseCase( + groupRepo: GroupRepo, + defaultGroupNameProvider: DefaultGroupNameProvider, + ) = GetGroupByIdOrCreateDefaultUseCase(groupRepo, defaultGroupNameProvider) + + @Singleton + @Provides + fun groupReorderSwapUseCase(groupRepo: GroupRepo) = GroupReorderSwapUseCase(groupRepo) + + @Singleton + @Provides + fun validateGroupNameUseCase() = ValidateGroupNameUseCase() + + @Singleton + @Provides + fun searchUseCase(buildConfigFieldsProvider: BuildConfigFieldsProvider) = + SearchUseCase(buildConfigFieldsProvider.provide()) + + + @Singleton + @Provides + fun provideLaunchInAppReviewUseCase( + + inAppReviewManager: InAppReviewManager, + prefs: Prefs, + buildConfigFieldsProvider: BuildConfigFieldsProvider + + ) = + LaunchInAppReviewUseCase(inAppReviewManager, prefs, buildConfigFieldsProvider) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/MainActivity.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/MainActivity.kt new file mode 100644 index 000000000..c4d5476b7 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/MainActivity.kt @@ -0,0 +1,135 @@ +package dev.arkbuilders.rate.watchapp.presentation + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import dagger.hilt.android.AndroidEntryPoint +import dev.arkbuilders.rate.watchapp.presentation.addquickpairs.AddQuickCalculationsScreen +import dev.arkbuilders.rate.watchapp.presentation.options.OptionsScreen +import dev.arkbuilders.rate.watchapp.presentation.options.SuccessScreen +import dev.arkbuilders.rate.watchapp.presentation.quickpairs.QuickCalculationsScreen +import dev.arkbuilders.rate.watchapp.presentation.search.SearchScreen +import dev.arkbuilders.rate.watchapp.presentation.theme.ArkrateTheme +import androidx.navigation.NavType +import androidx.navigation.navArgument + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + + super.onCreate(savedInstanceState) + + setTheme(android.R.style.Theme_DeviceDefault) + + setContent { + ArkrateTheme { + val navController = rememberSwipeDismissableNavController() + Scaffold( + vignette = { + Vignette(vignettePosition = VignettePosition.TopAndBottom) + } + ) { + SwipeDismissableNavHost( + navController = navController, + startDestination = "list" + ) { + composable("list") { + QuickCalculationsScreen( + onNavigateToAdd = { + navController.navigate("addquickpairs") + }, + onNavigateToOptions = { id -> + navController.navigate("options/$id") + } + ) + } + composable( + route = "addquickpairs?id={id}", + arguments = listOf(navArgument("id") { + type = NavType.StringType + nullable = true + defaultValue = null + }) + ) { + AddQuickCalculationsScreen( + navController = navController, + onNavigateToSearch = { field -> + navController.navigate("search/$field") + }, + onNavigateBack = { + navController.popBackStack("list", inclusive = false) + }, + onNavigateToSuccess = { message -> + navController.navigate("success?message=$message") + } + ) + } + composable( + route = "options/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }) + ) { + OptionsScreen( + onDeleteSuccess = { message -> + navController.navigate("success?message=$message") + }, + onUpdateClick = { id -> + navController.navigate("addquickpairs?id=$id") + }, + onPinClick = { message -> + navController.navigate("success?message=$message") + }, + onDismiss = { navController.popBackStack() } + ) + } + composable( + route = "search/{field}", + arguments = listOf(navArgument("field") { type = NavType.StringType }) + ) { backStackEntry -> + val field = backStackEntry.arguments?.getString("field") + SearchScreen( + onCurrencyClick = { code -> + navController.previousBackStackEntry?.savedStateHandle?.set("selected_currency", code) + navController.previousBackStackEntry?.savedStateHandle?.set("target_field", field) + navController.popBackStack() + } + ) + } + composable("search") { + SearchScreen( + onCurrencyClick = { code -> + navController.popBackStack() + } + ) + } + composable( + route = "success?message={message}", + arguments = listOf(navArgument("message") { + type = NavType.StringType + nullable = true + defaultValue = "Success" + }) + ) { backStackEntry -> + val message = backStackEntry.arguments?.getString("message") ?: "Success" + SuccessScreen( + message = message, + onTimeout = { + navController.popBackStack("list", inclusive = false) + } + ) + } + } + } + } + + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/RateWatchApplication.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/RateWatchApplication.kt new file mode 100644 index 000000000..cfcbf018e --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/RateWatchApplication.kt @@ -0,0 +1,41 @@ +package dev.arkbuilders.rate.watchapp.presentation + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +import dev.arkbuilders.rate.core.domain.BuildConfigFields +import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider +import dev.arkbuilders.rate.watchapp.BuildConfig +import java.time.Instant +import java.time.ZoneOffset +import javax.inject.Inject + +@HiltAndroidApp +class RateWatchApplication: Application() { + @Inject + lateinit var buildConfigFieldsProvider: BuildConfigFieldsProvider + + override fun onCreate() { + super.onCreate() + initBuildConfigFields() + } + + private fun initBuildConfigFields() { + val fallbackCryptoRatesFetchDate = + Instant.ofEpochMilli(BuildConfig.CRYPTO_LAST_MODIFIED).atOffset(ZoneOffset.UTC) + val fallbackFiatRatesFetchDate = + Instant.ofEpochMilli(BuildConfig.FIAT_LAST_MODIFIED).atOffset(ZoneOffset.UTC) + + buildConfigFieldsProvider.init( + BuildConfigFields( + buildType = BuildConfig.BUILD_TYPE, + versionCode = BuildConfig.VERSION_CODE, + versionName = BuildConfig.VERSION_NAME, + isGooglePlayBuild = false, // Default to false for watch app for now + fallbackCryptoRatesFetchDate = fallbackCryptoRatesFetchDate, + fallbackFiatRatesFetchDate = fallbackFiatRatesFetchDate, + availableIconCodes = BuildConfig.ICON_CODES.toSet(), + ), + ) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsScreen.kt new file mode 100644 index 000000000..a0ab7da68 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsScreen.kt @@ -0,0 +1,285 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables.CurrIcon + +@Composable +fun AddQuickCalculationsScreen( + modifier: Modifier = Modifier, + viewModel: AddQuickCalculationsViewModel = hiltViewModel(), + navController: NavHostController, + onNavigateToSearch: (String) -> Unit, + onNavigateBack: () -> Unit, + onNavigateToSuccess: (String) -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + val selectedCurrency = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_currency", null) + ?.collectAsStateWithLifecycle() + + val targetField = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("target_field", null) + ?.collectAsStateWithLifecycle() + + LaunchedEffect(selectedCurrency?.value) { + val code = selectedCurrency?.value ?: return@LaunchedEffect + val field = targetField?.value ?: return@LaunchedEffect + + if (field == "from") { + viewModel.onBaseCurrencyChanged(code) + } else if (field == "to") { + viewModel.onTargetCurrencyChanged(code) + } + + // Clear the result after processing + navController.currentBackStackEntry?.savedStateHandle?.remove("selected_currency") + navController.currentBackStackEntry?.savedStateHandle?.remove("target_field") + } + + if (state.isSaved) { + val message = if (state.editId != null) "Updated Successfully" else "Added Successfully" + LaunchedEffect(Unit) { + onNavigateToSuccess(message) + } + } + + ScalingLazyColumn( + modifier = modifier + .fillMaxSize() + .background(Color.White), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + text = if (state.editId != null) "Update" else "Add", + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = ArkColor.TextPrimary, + fontSize = 16.sp + ) + } + + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp), + text = "From", + textAlign = TextAlign.Start, + color = ArkColor.TextSecondary, + fontSize = 12.sp + ) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth(0.95f) + .clip(RoundedCornerShape(8.dp)) + .background(ArkColor.UtilitySuccess50) // Light greenish background + .border(1.dp, ArkColor.UtilitySuccess200, RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Currency Selector + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onNavigateToSearch("from") } + ) { + CurrIcon(modifier = Modifier.size(20.dp), code = state.baseCurrency) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = state.baseCurrency, + color = ArkColor.TextPrimary, + fontWeight = FontWeight.Medium, + fontSize = 14.sp + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Select Currency", + tint = ArkColor.Primary, + modifier = Modifier.size(16.dp) + ) + } + + // Amount + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd + ) { + if (state.baseAmount.isEmpty()) { + Text( + text = "0", + color = ArkColor.TextPlaceHolder, + fontSize = 14.sp, + textAlign = TextAlign.End + ) + } + BasicTextField( + value = state.baseAmount, + onValueChange = { viewModel.onAmountInput(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + textStyle = TextStyle( + color = ArkColor.TextPrimary, + fontSize = 14.sp, + textAlign = TextAlign.End + ), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + item { + Box( + modifier = Modifier + .padding(vertical = 4.dp) + .size(24.dp) + .clip(CircleShape) + .background(Color.White) + .border(1.dp, ArkColor.BorderSecondary, CircleShape) + .clickable { viewModel.onSwap() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Swap", + tint = ArkColor.Primary, + modifier = Modifier.size(14.dp) + ) + } + } + + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp), + text = "To", + textAlign = TextAlign.Start, + color = ArkColor.TextSecondary, + fontSize = 12.sp + ) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth(0.95f) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .border(1.dp, ArkColor.BorderSecondary, RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onNavigateToSearch("to") } + ) { + CurrIcon(modifier = Modifier.size(20.dp), code = state.targetCurrency) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = state.targetCurrency, + color = ArkColor.TextPrimary, + fontWeight = FontWeight.Medium, + fontSize = 14.sp + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Select Currency", + tint = ArkColor.Primary, + modifier = Modifier.size(16.dp) + ) + } + + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd + ) { + if (state.targetAmount.isEmpty()) { + Text( + text = "0", + color = ArkColor.TextPlaceHolder, + fontSize = 14.sp, + textAlign = TextAlign.End + ) + } + BasicTextField( + value = state.targetAmount, + onValueChange = { viewModel.onTargetAmountInput(it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + textStyle = TextStyle( + color = ArkColor.TextPrimary, + fontSize = 14.sp, + textAlign = TextAlign.End + ), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + item { + Button( + onClick = { viewModel.savePair() }, + modifier = Modifier + .padding(top = 16.dp, bottom = 12.dp) + .fillMaxWidth(0.8f) + .height(36.dp), + colors = ButtonDefaults.primaryButtonColors(backgroundColor = ArkColor.Primary) + ) { + Text(if (state.editId != null) "Update Pair" else "Save Pair", fontSize = 14.sp, fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsViewModel.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsViewModel.kt new file mode 100644 index 000000000..fcd052808 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/AddQuickCalculationsViewModel.kt @@ -0,0 +1,150 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.rate.core.domain.CurrUtils +import dev.arkbuilders.rate.core.domain.model.Amount +import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase +import dev.arkbuilders.rate.core.domain.usecase.GetGroupByIdOrCreateDefaultUseCase +import java.time.OffsetDateTime +import androidx.lifecycle.SavedStateHandle +import dev.arkbuilders.rate.core.domain.model.GroupFeatureType +import dev.arkbuilders.rate.feature.quick.domain.model.QuickCalculation +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import dev.arkbuilders.rate.core.domain.toBigDecimalArk +import dev.arkbuilders.rate.core.domain.toDoubleArk +import dev.arkbuilders.rate.watchapp.watchface.WatchRefreshManager +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class AddQuickState( + val baseCurrency: String = "USD", + val baseAmount: String = "", + val targetCurrency: String = "EUR", + val targetAmount: String = "", + val isSaved: Boolean = false, + val editId: Long? = null, + val pinnedDate: OffsetDateTime? = null +) + +@HiltViewModel +class AddQuickCalculationsViewModel @Inject constructor( + private val application: Application, + private val quickRepo: QuickRepo, + private val convertUseCase: ConvertWithRateUseCase, + private val getGroupByIdOrCreateDefaultUseCase: GetGroupByIdOrCreateDefaultUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _state = MutableStateFlow(AddQuickState()) + val state: StateFlow = _state.asStateFlow() + + init { + val idStr: String? = savedStateHandle["id"] + val id = idStr?.toLongOrNull() + if (id != null) { + viewModelScope.launch { + quickRepo.allFlow().collect { pairs -> + pairs.find { it.id == id }?.let { pair -> + _state.value = _state.value.copy( + baseCurrency = pair.from, + baseAmount = CurrUtils.roundOff(pair.amount), + targetCurrency = pair.to.firstOrNull()?.code ?: "EUR", + targetAmount = CurrUtils.roundOff(pair.to.firstOrNull()?.value ?: java.math.BigDecimal.ZERO), + editId = id, + pinnedDate = pair.pinnedDate + ) + } + } + } + } else { + calculate(fromBase = true) + } + } + + fun onBaseCurrencyChanged(code: String) { + _state.value = _state.value.copy(baseCurrency = code) + calculate(fromBase = true) + } + + fun onTargetCurrencyChanged(code: String) { + _state.value = _state.value.copy(targetCurrency = code) + calculate(fromBase = true) + } + + fun onAmountInput(input: String) { + val newAmount = CurrUtils.validateInput(_state.value.baseAmount, input) + _state.value = _state.value.copy(baseAmount = newAmount) + calculate(fromBase = true) + } + + fun onTargetAmountInput(input: String) { + val newAmount = CurrUtils.validateInput(_state.value.targetAmount, input) + _state.value = _state.value.copy(targetAmount = newAmount) + calculate(fromBase = false) + } + + fun onSwap() { + val currentState = _state.value + _state.value = currentState.copy( + baseCurrency = currentState.targetCurrency, + targetCurrency = currentState.baseCurrency, + baseAmount = currentState.targetAmount, + targetAmount = currentState.baseAmount + ) + calculate(fromBase = true) + } + + private fun calculate(fromBase: Boolean) { + viewModelScope.launch { + val s = _state.value + if (fromBase) { + if (s.baseAmount.isEmpty() || s.baseAmount.toDoubleArk() == 0.0) { + _state.value = s.copy(targetAmount = "") + return@launch + } + val (amount, _) = convertUseCase.invoke( + Amount(s.baseCurrency, s.baseAmount.toBigDecimalArk()), + s.targetCurrency + ) + _state.value = _state.value.copy(targetAmount = CurrUtils.roundOff(amount.value)) + } else { + if (s.targetAmount.isEmpty() || s.targetAmount.toDoubleArk() == 0.0) { + _state.value = s.copy(baseAmount = "") + return@launch + } + val (amount, _) = convertUseCase.invoke( + Amount(s.targetCurrency, s.targetAmount.toBigDecimalArk()), + s.baseCurrency + ) + _state.value = _state.value.copy(baseAmount = CurrUtils.roundOff(amount.value)) + } + } + } + + fun savePair() { + viewModelScope.launch { + val s = _state.value + val group = getGroupByIdOrCreateDefaultUseCase(null, GroupFeatureType.Quick) + val quick = QuickCalculation( + id = s.editId ?: 0, + from = s.baseCurrency, + amount = s.baseAmount.toBigDecimalArk(), + to = listOf(Amount(s.targetCurrency, s.targetAmount.toBigDecimalArk())), + calculatedDate = OffsetDateTime.now(), + pinnedDate = s.pinnedDate, + group = group + ) + quickRepo.insert(quick) + if (quick.isPinned()) { + WatchRefreshManager.refreshComplications(application) + } + _state.value = s.copy(isSaved = true) + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/CurrencyInputField.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/CurrencyInputField.kt new file mode 100644 index 000000000..7dc6eb924 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/CurrencyInputField.kt @@ -0,0 +1,200 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs.composables + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.KeyboardArrowDown +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun CurrencyInputField( + label: String, + currencyCode: String, + value: String, + onValueChange: (String) -> Unit, + onCurrencyClick: () -> Unit, + modifier: Modifier = Modifier, + showDeleteButton: Boolean = false, + onDeleteClick: () -> Unit = {}, + showLabel: Boolean = true, + hintText: String = "This is a hint text to help user." +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom + ) { + // Main input field + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (showLabel) { + Text( + text = label, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = ArkColor.TextSecondary + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = Color.White, + shape = RoundedCornerShape(8.dp) + ) + .border( + width = 1.dp, + color = ArkColor.BorderSecondary, + shape = RoundedCornerShape(8.dp) + ) + ) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + // Currency dropdown + Box( + modifier = Modifier + .width(80.dp) + .clickable { onCurrencyClick() } + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = currencyCode, + fontSize = 14.sp, + color = ArkColor.TextSecondary + ) + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = "Select currency", + modifier = Modifier.size(16.dp), + tint = ArkColor.FGQuinary + ) + } + } + + // Value input + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 10.dp), + textStyle = TextStyle( + fontSize = 14.sp, + color = ArkColor.TextPrimary, + textAlign = TextAlign.Start + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true + ) + } + } + + if (showLabel) { + Text( + text = hintText, + fontSize = 14.sp, + color = ArkColor.TextTertiary + ) + } + } + + // Delete button + if (showDeleteButton) { + Button( + onClick = onDeleteClick, + modifier = Modifier.size(44.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = ArkColor.FGErrorPrimary + ), + border = ButtonDefaults.buttonBorder( + borderStroke = BorderStroke(1.dp, ArkColor.BorderError) + ), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Delete", + modifier = Modifier.size(20.dp), + tint = ArkColor.FGErrorPrimary + ) + } + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun CurrencyInputFieldPreview() { + var value by remember { mutableStateOf("1") } + + CurrencyInputField( + label = "From", + currencyCode = "USD", + value = value, + onValueChange = { value = it }, + onCurrencyClick = {}, + showDeleteButton = true, + onDeleteClick = {} + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun CurrencyInputFieldNoLabelPreview() { + var value by remember { mutableStateOf("0.92") } + + CurrencyInputField( + label = "To", + currencyCode = "EUR", + value = value, + onValueChange = { value = it }, + onCurrencyClick = {}, + showLabel = false, + showDeleteButton = true, + onDeleteClick = {} + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionItem.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionItem.kt new file mode 100644 index 000000000..93997adac --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionItem.kt @@ -0,0 +1,82 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs.composables + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.OutlinedButton +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.CoreRDrawable +import dev.arkbuilders.rate.core.presentation.CoreRString +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun OptionItem( + modifier: Modifier = Modifier, + icon: Painter, + text: String, + onClick: () -> Unit, + isDeleteButton: Boolean = false, +) { + OutlinedButton( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + onClick = onClick, + shape = RoundedCornerShape(20), + border = ButtonDefaults.buttonBorder( + borderStroke = BorderStroke( + width = 1.dp, + color = if (isDeleteButton) Color.Red else ArkColor.Border + ), + ), + colors = ButtonDefaults.outlinedButtonColors(contentColor = if (isDeleteButton) Color.Red else Color.White), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = icon, + contentDescription = "", + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun OptionItemPreview(modifier: Modifier = Modifier) { + OptionItem( + modifier = modifier.fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_download), + text = stringResource(id = CoreRString.edit), + onClick = {}, + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionsMenu.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionsMenu.kt new file mode 100644 index 000000000..69c94daa5 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/OptionsMenu.kt @@ -0,0 +1,88 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.CoreRDrawable +import dev.arkbuilders.rate.core.presentation.CoreRString + +@Composable +fun OptionsMenu(modifier: Modifier = Modifier) { + ScalingLazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = modifier.fillMaxWidth(), + text = "Options", + textAlign = TextAlign.Center + ) + } + item { + OptionItem( + modifier = Modifier + .fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_update), + text = stringResource(id = CoreRString.update), + onClick = {}, + ) + } + item { + OptionItem( + modifier = Modifier + .fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_pin), + text = stringResource(id = CoreRString.pin), + onClick = {}, + ) + } + + item { + OptionItem( + modifier = Modifier + .fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_edit), + text = stringResource(id = CoreRString.edit), + onClick = {}, + ) + } + + item { + OptionItem( + modifier = Modifier + .fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_reuse), + text = stringResource(id = CoreRString.re_use), + onClick = {}, + ) + } + item { + OptionItem( + modifier = Modifier + .fillMaxWidth(), + icon = painterResource(id = CoreRDrawable.ic_delete), + text = stringResource(id = CoreRString.delete), + onClick = {}, + isDeleteButton = true + ) + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun AddQuickCalculationsPreview() { + OptionsMenu() +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/SwapButton.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/SwapButton.kt new file mode 100644 index 000000000..e74406a7f --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/SwapButton.kt @@ -0,0 +1,84 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs.composables + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun SwapButton( + onSwapClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Left divider + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(ArkColor.BorderSecondary) + ) + + // Swap button + Button( + onClick = onSwapClick, + modifier = Modifier.size(40.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = ArkColor.TextPrimary + ), + border = ButtonDefaults.buttonBorder( + borderStroke = BorderStroke(1.dp, ArkColor.BorderSecondary) + ), + shape = CircleShape + ) { + Icon( + imageVector = Icons.Outlined.AccountBox, + contentDescription = "Swap currencies", + modifier = Modifier.size(20.dp), + tint = ArkColor.TextPrimary + ) + } + + // Right divider + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(ArkColor.BorderSecondary) + ) + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun SwapButtonPreview() { + SwapButton( + onSwapClick = {} + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/WearNumpad.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/WearNumpad.kt new file mode 100644 index 000000000..9b56ab812 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/addquickpairs/composables/WearNumpad.kt @@ -0,0 +1,128 @@ +package dev.arkbuilders.rate.watchapp.presentation.addquickpairs.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun WearNumpad( + modifier: Modifier = Modifier, + amount: String, + onNumberClick: (Int) -> Unit, + onDotClick: () -> Unit, + onDeleteClick: () -> Unit, + onConfirmClick: () -> Unit +) { + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Display + Text( + text = amount.ifEmpty { "0" }, + fontWeight = FontWeight.Bold, + color = ArkColor.TextPrimary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Numpad 1-3 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + NumpadButton("1") { onNumberClick(1) } + NumpadButton("2") { onNumberClick(2) } + NumpadButton("3") { onNumberClick(3) } + } + + // Numpad 4-6 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + NumpadButton("4") { onNumberClick(4) } + NumpadButton("5") { onNumberClick(5) } + NumpadButton("6") { onNumberClick(6) } + } + + // Numpad 7-9 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + NumpadButton("7") { onNumberClick(7) } + NumpadButton("8") { onNumberClick(8) } + NumpadButton("9") { onNumberClick(9) } + } + + // Bottom row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + NumpadButton(".") { onDotClick() } + NumpadButton("0") { onNumberClick(0) } + + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(ArkColor.BGTertiary) + .clickable { onDeleteClick() }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.ArrowBack, contentDescription = "Delete", tint = ArkColor.TextPrimary) + } + } + + // Confirm Button + Box( + modifier = Modifier + .padding(top = 8.dp) + .size(40.dp) + .clip(CircleShape) + .background(ArkColor.Primary) + .clickable { onConfirmClick() }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.Check, contentDescription = "Confirm", tint = Color.White) + } + } +} + +@Composable +fun NumpadButton(text: String, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(40.dp) + .padding(2.dp) + .clip(CircleShape) + .background(ArkColor.BGSecondaryAlt) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Text(text = text, fontWeight = FontWeight.SemiBold, color = ArkColor.TextPrimary) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/main/MainViewModel.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/main/MainViewModel.kt new file mode 100644 index 000000000..744915d44 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/main/MainViewModel.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.rate.watchapp.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.rate.core.domain.repo.CurrencyRepo +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val currencyRepo: CurrencyRepo, + ): ViewModel() { + + init { + viewModelScope.launch { + currencyRepo.initialize() + launch { + currencyRepo.getCurrencyRates() + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsScreen.kt new file mode 100644 index 000000000..d986f4be3 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsScreen.kt @@ -0,0 +1,158 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.watchapp.presentation.theme.WearConfirmationDialog +import dev.arkbuilders.rate.watchapp.presentation.theme.WearInfoDialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +import dev.arkbuilders.rate.watchapp.presentation.theme.WearSnackbar + +@Composable +fun OptionsScreen( + modifier: Modifier = Modifier, + viewModel: OptionsViewModel = hiltViewModel(), + onUpdateClick: (Long) -> Unit = {}, + onPinClick: (String) -> Unit = {}, + onDeleteSuccess: (String) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val quickPair by viewModel.quickPair.collectAsStateWithLifecycle() + val showPinLimitDialog by viewModel.showPinLimitDialog.collectAsStateWithLifecycle() + val listState = rememberScalingLazyListState() + var showDeleteDialog by remember { mutableStateOf(false) } + var snackbarMessage by remember { mutableStateOf(null) } + + if (showDeleteDialog) { + WearConfirmationDialog( + title = "Delete Pair", + message = "Are you sure you want to delete this pair?", + onConfirm = { + showDeleteDialog = false + viewModel.deletePair(onDeleted = { + onDeleteSuccess("Deleted Successfully") + }) + }, + onDismiss = { + showDeleteDialog = false + onDismiss() + }, + isDestructive = true + ) + } + + if (showPinLimitDialog) { + WearInfoDialog( + title = "Pin Limit Reached", + message = "You can only pin up to 4 pairs. Please unpin another pair first.", + onDismiss = { viewModel.dismissPinLimitDialog() }, + dismissText = "Ok got it" + ) + } + + snackbarMessage?.let { msg -> + WearSnackbar( + message = msg, + onDismiss = { + snackbarMessage = null + if (msg == "Unpinned") { + onDismiss() + } + } + ) + } + + Scaffold( + positionIndicator = { + PositionIndicator(scalingLazyListState = listState) + } + ) { + ScalingLazyColumn( + modifier = modifier.fillMaxSize() + .background(ArkColor.BGSecondaryAlt), + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + text = "Options", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextPrimary + ) + } + + item { + WearOptionButton( + text = "Update", + icon = WearOptionButtonIcon.Refresh, + onClick = { onUpdateClick(viewModel.pairId) } + ) + } + + item { + val isPinned = quickPair?.isPinned() == true + WearOptionButton( + text = if (isPinned) "Unpin" else "Pin", + icon = WearOptionButtonIcon.Pin, + onClick = { + viewModel.togglePin(onSuccess = { pinned -> + if (pinned) { + onPinClick("Pinned Successfully") + } else { + snackbarMessage = "Unpinned" + } + }) + } + ) + } + + item { + WearOptionButton( + text = "Delete", + icon = WearOptionButtonIcon.Delete, + buttonType = WearOptionButtonType.Destructive, + onClick = { + showDeleteDialog = true + } + ) + } + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun OptionsScreenPreview() { + OptionsScreen() +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsViewModel.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsViewModel.kt new file mode 100644 index 000000000..fb1bbf83b --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/OptionsViewModel.kt @@ -0,0 +1,78 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.rate.feature.quick.domain.model.QuickCalculation +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import dev.arkbuilders.rate.watchapp.watchface.WatchRefreshManager +import javax.inject.Inject + +import android.app.Application + +@HiltViewModel +class OptionsViewModel @Inject constructor( + private val application: Application, + private val quickRepo: QuickRepo, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val pairId: Long = checkNotNull(savedStateHandle["id"]) + + private val _quickPair = MutableStateFlow(null) + val quickPair: StateFlow = _quickPair.asStateFlow() + + private val _showPinLimitDialog = MutableStateFlow(false) + val showPinLimitDialog: StateFlow = _showPinLimitDialog.asStateFlow() + + init { + viewModelScope.launch { + quickRepo.allFlow().collect { pairs -> + _quickPair.value = pairs.find { it.id == pairId } + } + } + } + + fun togglePin(onSuccess: (Boolean) -> Unit) { + viewModelScope.launch { + val pair = _quickPair.value ?: return@launch + if (pair.isPinned()) { + // Unpin + val updated = pair.copy(pinnedDate = null) + quickRepo.insert(updated) + WatchRefreshManager.refreshComplications(application) + onSuccess(false) + } else { + // Check limit + val pinnedCount = quickRepo.getAll().count { it.isPinned() } + if (pinnedCount >= 4) { + _showPinLimitDialog.value = true + } else { + // Pin + val updated = pair.copy(pinnedDate = java.time.OffsetDateTime.now()) + quickRepo.insert(updated) + WatchRefreshManager.refreshComplications(application) + onSuccess(true) + } + } + } + } + + fun dismissPinLimitDialog() { + _showPinLimitDialog.value = false + } + + fun deletePair(onDeleted: () -> Unit) { + viewModelScope.launch { + _quickPair.value?.let { p -> + quickRepo.delete(p.id) + onDeleted() + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/SuccessScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/SuccessScreen.kt new file mode 100644 index 000000000..80d2828a8 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/SuccessScreen.kt @@ -0,0 +1,59 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import kotlinx.coroutines.delay + +@Composable +fun SuccessScreen( + modifier: Modifier = Modifier, + message: String = "Success", + onTimeout: () -> Unit +) { + LaunchedEffect(Unit) { + delay(1500L) + onTimeout() + } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + tint = ArkColor.BrandSecondary, + modifier = Modifier + .size(48.dp) + .padding(bottom = 8.dp) + ) + Text( + text = message, + color = ArkColor.TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionButton.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionButton.kt new file mode 100644 index 000000000..3651e076b --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionButton.kt @@ -0,0 +1,140 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.Search +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +enum class WearOptionButtonType { + Default, + Success, + Destructive +} + +enum class WearOptionButtonIcon(val imageVector: ImageVector) { + Refresh(Icons.Outlined.Refresh), + Pin(Icons.Outlined.Star), + Edit(Icons.Outlined.Edit), + Reuse(Icons.Outlined.Share), + Search(Icons.Outlined.Search), + Delete(Icons.Outlined.Delete) +} + +@Composable +fun WearOptionButton( + text: String, + icon: WearOptionButtonIcon, + onClick: () -> Unit, + modifier: Modifier = Modifier, + buttonType: WearOptionButtonType = WearOptionButtonType.Default +) { + val colors = when (buttonType) { + WearOptionButtonType.Default -> ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = ArkColor.TextSecondary + ) + WearOptionButtonType.Success -> ButtonDefaults.buttonColors( + backgroundColor = ArkColor.UtilitySuccess50, + contentColor = ArkColor.Primary + ) + WearOptionButtonType.Destructive -> ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = ArkColor.FGErrorPrimary + ) + } + + val borderStroke = when (buttonType) { + WearOptionButtonType.Default -> ButtonDefaults.buttonBorder( + borderStroke = androidx.compose.foundation.BorderStroke(1.dp, ArkColor.BorderSecondary) + ) + WearOptionButtonType.Success -> ButtonDefaults.buttonBorder( + borderStroke = androidx.compose.foundation.BorderStroke(1.dp, ArkColor.UtilitySuccess200) + ) + WearOptionButtonType.Destructive -> ButtonDefaults.buttonBorder( + borderStroke = androidx.compose.foundation.BorderStroke(1.dp, ArkColor.BorderError) + ) + } + + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = colors, + border = borderStroke + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon.imageVector, + contentDescription = text, + modifier = Modifier.size(20.dp), + tint = when (buttonType) { + WearOptionButtonType.Default -> ArkColor.TextSecondary + WearOptionButtonType.Success -> ArkColor.Primary + WearOptionButtonType.Destructive -> ArkColor.FGErrorPrimary + } + ) + Text( + text = text, + modifier = Modifier.padding(start = 6.dp), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = TextAlign.Start, + color = when (buttonType) { + WearOptionButtonType.Default -> ArkColor.TextSecondary + WearOptionButtonType.Success -> ArkColor.TextSecondary + WearOptionButtonType.Destructive -> ArkColor.FGErrorPrimary + } + ) + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearOptionButtonPreview() { + WearOptionButton( + text = "Update", + icon = WearOptionButtonIcon.Refresh, + onClick = {} + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearOptionButtonDestructivePreview() { + WearOptionButton( + text = "Delete", + icon = WearOptionButtonIcon.Delete, + buttonType = WearOptionButtonType.Destructive, + onClick = {} + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionsHomeScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionsHomeScreen.kt new file mode 100644 index 000000000..b433a0f02 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearOptionsHomeScreen.kt @@ -0,0 +1,124 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun WearOptionsHomeScreen( + modifier: Modifier = Modifier, + currentPage: Int = 1, + totalPages: Int = 3, + onUpdateClick: () -> Unit = {}, + onPinClick: () -> Unit = {}, + onEditClick: () -> Unit = {}, + onReuseClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {} +) { + val listState = rememberScalingLazyListState() + + Scaffold( + modifier = modifier + .fillMaxSize() + .clip(CircleShape), + positionIndicator = { + PositionIndicator(scalingLazyListState = listState) + } + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title section + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + text = "Options", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextPrimary + ) + } + + // Page indicator + item { + WearPageIndicator( + totalPages = totalPages, + currentPage = currentPage, + modifier = Modifier.padding(vertical = 6.dp) + ) + } + + // Main slot - Option buttons + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + WearOptionButton( + text = "Update", + icon = WearOptionButtonIcon.Refresh, + onClick = onUpdateClick + ) + + WearOptionButton( + text = "Pin", + icon = WearOptionButtonIcon.Pin, + onClick = onPinClick + ) + + WearOptionButton( + text = "Edit", + icon = WearOptionButtonIcon.Edit, + onClick = onEditClick + ) + + WearOptionButton( + text = "Re-Use", + icon = WearOptionButtonIcon.Reuse, + onClick = onReuseClick + ) + + WearOptionButton( + text = "Delete", + icon = WearOptionButtonIcon.Delete, + buttonType = WearOptionButtonType.Destructive, + onClick = onDeleteClick + ) + } + } + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearOptionsHomeScreenPreview() { + WearOptionsHomeScreen() +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearPageIndicator.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearPageIndicator.kt new file mode 100644 index 000000000..e257c4446 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/options/WearPageIndicator.kt @@ -0,0 +1,64 @@ +package dev.arkbuilders.rate.watchapp.presentation.options + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun WearPageIndicator( + totalPages: Int, + currentPage: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(totalPages) { index -> + PageIndicatorDot( + isSelected = index == currentPage, + modifier = Modifier.size(6.dp) + ) + } + } +} + +@Composable +private fun PageIndicatorDot( + isSelected: Boolean, + modifier: Modifier = Modifier +) { + val backgroundColor = if (isSelected) { + ArkColor.TextSecondary + } else { + ArkColor.TextSecondary.copy(alpha = 0.3f) + } + + Box( + modifier = modifier + .clip(CircleShape) + .background(backgroundColor) + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearPageIndicatorPreview() { + WearPageIndicator( + totalPages = 3, + currentPage = 1 + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsScreen.kt new file mode 100644 index 000000000..8ebf8def2 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsScreen.kt @@ -0,0 +1,88 @@ +package dev.arkbuilders.rate.watchapp.presentation.quickpairs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables.QuickCalculationItem +import dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables.QuickCalculationsEmpty +import dev.arkbuilders.rate.watchapp.presentation.theme.WearFab + +@Composable +fun QuickCalculationsScreen( + modifier: Modifier = Modifier, + viewModel: QuickCalculationsViewModel = hiltViewModel(), + onNavigateToAdd: () -> Unit, + onNavigateToOptions: (Long) -> Unit +) { + val quickCalculationsList = viewModel.quickCalculations.collectAsStateWithLifecycle().value + val listState = rememberScalingLazyListState() + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + WearFab( + onClick = onNavigateToAdd, + icon = Icons.Outlined.Add, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + ) { padding -> + Box(modifier = Modifier.padding(padding).fillMaxSize()) { + if (quickCalculationsList.isEmpty()) { + QuickCalculationsEmpty( + modifier = Modifier.fillMaxSize(), + onAddClick = onNavigateToAdd + ) + } else { + ScalingLazyColumn( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + state = listState, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth(), + text = "Quick", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextPrimary + ) + } + items(quickCalculationsList.size, key = { quickCalculationsList[it].id }) { idx -> + QuickCalculationItem( + quick = quickCalculationsList[idx], + onClick = { onNavigateToOptions(quickCalculationsList[idx].id) } + ) + } + } + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsViewModel.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsViewModel.kt new file mode 100644 index 000000000..ad371f2b4 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/QuickCalculationsViewModel.kt @@ -0,0 +1,54 @@ +package dev.arkbuilders.rate.watchapp.presentation.quickpairs + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase +import dev.arkbuilders.rate.feature.quick.domain.model.QuickCalculation +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import dev.arkbuilders.rate.watchapp.watchface.WatchRefreshManager +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class QuickCalculationsViewModel @Inject constructor( + private val application: Application, + private val quickRepo: QuickRepo, + private val convertUseCase: ConvertWithRateUseCase, +) : ViewModel() { + + val quickCalculations: StateFlow> = quickRepo.allFlow() + .map { pairs -> + pairs.map { pair -> + val actualTo = pair.to.map { toAmount -> + val (convertedAmt, _) = convertUseCase.invoke(pair.from, pair.amount, toAmount.code) + convertedAmt + } + pair.copy(to = actualTo) + }.sortedWith( + compareByDescending { it.isPinned() } + .thenByDescending { it.calculatedDate } + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + fun deletePair(pair: QuickCalculation) { + viewModelScope.launch { + if (pair.isPinned()) { + quickRepo.delete(pair.id) + WatchRefreshManager.refreshComplications(application) + } else { + quickRepo.delete(pair.id) + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationItem.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationItem.kt new file mode 100644 index 000000000..3edc14ad2 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationItem.kt @@ -0,0 +1,150 @@ +package dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Star +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.Text +import dev.arkbuilders.rate.core.domain.CurrUtils +import dev.arkbuilders.rate.core.domain.model.Amount +import dev.arkbuilders.rate.core.domain.model.CurrencyCode +import dev.arkbuilders.rate.core.domain.model.Group +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.core.presentation.utils.IconUtils +import dev.arkbuilders.rate.feature.quick.domain.model.QuickCalculation +import java.math.BigDecimal +import java.time.OffsetDateTime + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.CircleShape + +@Composable +fun QuickCalculationItem( + modifier: Modifier = Modifier, + quick: QuickCalculation, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 8.dp) + .background(Color.White) + ) { + // Top row: Flags and "2 mins ago" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CurrIcon(modifier = Modifier.size(24.dp), code = quick.from) + val target = quick.to.firstOrNull() + if (target != null) { + CurrIcon( + modifier = Modifier + .size(24.dp) + .offset(x = (-8).dp) + .border(1.dp, Color.White, CircleShape), + code = target.code + ) + } + } + Text( + text = "2 mins ago", + color = ArkColor.TextTertiary, + fontSize = 10.sp + ) + } + + // Title Row: "EUR to USD" + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val targetCode = quick.to.firstOrNull()?.code ?: "" + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${quick.from} to $targetCode", + color = ArkColor.TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + if (quick.isPinned()) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.Star, + contentDescription = "Pinned", + tint = ArkColor.Primary, + modifier = Modifier.size(12.dp) + ) + } + } + } + + // Subtitle: Conversion Rate + val target = quick.to.firstOrNull() + if (target != null) { + val baseAmountStr = CurrUtils.prepareToDisplay(quick.amount) + val targetAmountStr = CurrUtils.prepareToDisplay(target.value) + Text( + text = "$baseAmountStr ${quick.from} = $targetAmountStr ${target.code}", + color = ArkColor.TextTertiary, + fontSize = 12.sp, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} + +@Composable +fun CurrIcon( + modifier: Modifier = Modifier, + code: CurrencyCode, +) { + val ctx = LocalContext.current + Icon( + modifier = modifier, + painter = painterResource(id = IconUtils.iconForCurrCode(ctx, code)), + contentDescription = code, + tint = Color.Unspecified, + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun QuickCalculationItemPreview() { + QuickCalculationItem( + quick = QuickCalculation( + id = 1, + from = "BTC", + amount = BigDecimal.valueOf(1.2), + to = listOf(Amount("USD", BigDecimal.valueOf(12.0))), + calculatedDate = OffsetDateTime.now(), + pinnedDate = null, + group = Group.empty() + ), + onClick = {} + ) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationsEmpty.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationsEmpty.kt new file mode 100644 index 000000000..3d911ba88 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/quickpairs/composables/QuickCalculationsEmpty.kt @@ -0,0 +1,72 @@ +package dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.Text +import dev.arkbuilders.rate.core.presentation.CoreRDrawable +import dev.arkbuilders.rate.core.presentation.CoreRString +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +@Composable +fun QuickCalculationsEmpty( + modifier: Modifier = Modifier, + onAddClick: () -> Unit +) { + ScalingLazyColumn( + modifier = modifier.fillMaxSize().background(Color.White), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 24.dp) + ) { + item { + Icon( + painter = painterResource(id = CoreRDrawable.ic_empty_quick), + contentDescription = null, + tint = Color.Unspecified, + ) + } + item { + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(CoreRString.quick_empty_title), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = ArkColor.TextPrimary, + textAlign = TextAlign.Center, + ) + } + item { + Text( + modifier = Modifier.padding(top = 4.dp), + text = stringResource(CoreRString.quick_empty_desc), + fontSize = 12.sp, + color = ArkColor.TextTertiary, + textAlign = TextAlign.Center, + ) + } + item { + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun QuickCalculationEmptyPreview() { + QuickCalculationsEmpty(onAddClick = {}) +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchScreen.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchScreen.kt new file mode 100644 index 000000000..33a4fbfbb --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchScreen.kt @@ -0,0 +1,79 @@ +package dev.arkbuilders.rate.watchapp.presentation.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor +import dev.arkbuilders.rate.core.presentation.ui.SearchTextField +import dev.arkbuilders.rate.watchapp.presentation.quickpairs.composables.CurrIcon + +@Composable +fun SearchScreen( + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), + onCurrencyClick: (String) -> Unit = {} +) { + val state by viewModel.state.collectAsState() + + ScalingLazyColumn( + modifier = modifier.fillMaxSize().background(Color.White), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + SearchTextField( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + text = state.query, + onValueChange = { viewModel.onQueryChange(it) } + ) + } + + items(state.filteredCurrencies.size) { index -> + val currency = state.filteredCurrencies[index] + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCurrencyClick(currency.code) } + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + CurrIcon(modifier = Modifier.size(24.dp), code = currency.code) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = currency.code, + color = ArkColor.TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = currency.name, + color = ArkColor.TextTertiary, + fontSize = 10.sp + ) + } + } + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchViewModel.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchViewModel.kt new file mode 100644 index 000000000..ba2de89b9 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/search/SearchViewModel.kt @@ -0,0 +1,57 @@ +package dev.arkbuilders.rate.watchapp.presentation.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.rate.core.domain.model.CurrencyInfo +import dev.arkbuilders.rate.core.domain.repo.CurrencyRepo +import dev.arkbuilders.rate.core.domain.usecase.SearchUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SearchState( + val query: String = "", + val allCurrencies: List = emptyList(), + val filteredCurrencies: List = emptyList(), + val isLoading: Boolean = true +) + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val currencyRepo: CurrencyRepo, + private val searchUseCase: SearchUseCase +): ViewModel() { + + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + val all = currencyRepo.getCurrencyInfo() + _state.update { it.copy( + allCurrencies = all, + filteredCurrencies = all, + isLoading = false + ) } + } + } + + fun onQueryChange(query: String) { + _state.update { it.copy(query = query) } + filter() + } + + private fun filter() { + val currentState = _state.value + val filtered = searchUseCase( + currentState.allCurrencies, + emptyList(), + currentState.query + ) + _state.update { it.copy(filteredCurrencies = filtered) } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/Theme.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/Theme.kt new file mode 100644 index 000000000..d8672db26 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/Theme.kt @@ -0,0 +1,34 @@ +package dev.arkbuilders.rate.watchapp.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material.Colors +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +val LightWearColors = Colors( + primary = ArkColor.Primary, + primaryVariant = ArkColor.BrandSecondary, + secondary = ArkColor.Secondary, + secondaryVariant = ArkColor.Teal700, + background = Color.White, + surface = Color.White, + error = ArkColor.UtilityError500, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = ArkColor.TextPrimary, + onSurface = ArkColor.TextPrimary, + onSurfaceVariant = ArkColor.TextSecondary, + onError = Color.White +) + +@Composable +fun ArkrateTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colors = LightWearColors, + content = content + ) +} \ No newline at end of file diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponentExamples.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponentExamples.kt new file mode 100644 index 000000000..4bd0ebd71 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponentExamples.kt @@ -0,0 +1,165 @@ +package dev.arkbuilders.rate.watchapp.presentation.theme + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Refresh +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import dev.arkbuilders.rate.watchapp.presentation.options.WearOptionsHomeScreen +import dev.arkbuilders.rate.watchapp.presentation.options.WearPageIndicator + +/** + * Examples of how to use the WearOS components created for the ARK Rate app. + * These components follow the Figma design system and WearOS best practices. + */ + +@Composable +fun WearButtonExamples(modifier: Modifier = Modifier) { + val listState = rememberScalingLazyListState() + + Scaffold( + positionIndicator = { + PositionIndicator(scalingLazyListState = listState) + } + ) { + ScalingLazyColumn( + modifier = modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + WearButton( + text = "Primary Button", + onClick = {}, + style = WearButtonStyle.Primary, + leadingIcon = Icons.Outlined.Add + ) + } + + item { + WearButton( + text = "Secondary Button", + onClick = {}, + style = WearButtonStyle.Secondary, + leadingIcon = Icons.Outlined.Edit + ) + } + + item { + WearButton( + text = "Outlined Button", + onClick = {}, + style = WearButtonStyle.Outlined, + leadingIcon = Icons.Outlined.Refresh + ) + } + + item { + WearButton( + text = "Destructive Button", + onClick = {}, + style = WearButtonStyle.Destructive + ) + } + + item { + WearPageIndicator( + totalPages = 5, + currentPage = 2 + ) + } + } + } +} + +@Composable +fun WearDialogExamples(modifier: Modifier = Modifier) { + var showConfirmDialog by remember { mutableStateOf(false) } + var showInfoDialog by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + WearButton( + text = "Show Confirmation Dialog", + onClick = { showConfirmDialog = true } + ) + + WearButton( + text = "Show Info Dialog", + onClick = { showInfoDialog = true } + ) + + if (showConfirmDialog) { + WearConfirmationDialog( + title = "Delete Item", + message = "Are you sure you want to delete this item?", + onConfirm = { + showConfirmDialog = false + // Handle confirmation + }, + onDismiss = { showConfirmDialog = false }, + isDestructive = true + ) + } + + if (showInfoDialog) { + WearInfoDialog( + title = "Success", + message = "Operation completed successfully!", + onDismiss = { showInfoDialog = false } + ) + } + } +} + +// Preview showing the main Options screen from Figma +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearOptionsHomeScreenExample() { + WearOptionsHomeScreen( + currentPage = 1, + totalPages = 3, + onUpdateClick = { /* Handle update */ }, + onPinClick = { /* Handle pin */ }, + onEditClick = { /* Handle edit */ }, + onReuseClick = { /* Handle reuse */ }, + onDeleteClick = { /* Handle delete */ } + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearButtonExamplesPreview() { + WearButtonExamples() +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND, showSystemUi = true) +@Composable +fun WearDialogExamplesPreview() { + WearDialogExamples() +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponents.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponents.kt new file mode 100644 index 000000000..e2f7fb49e --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearComponents.kt @@ -0,0 +1,204 @@ +package dev.arkbuilders.rate.watchapp.presentation.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.OutlinedButton +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +enum class WearButtonStyle { + Primary, + Secondary, + Outlined, + Destructive +} + +@Composable +fun WearButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: WearButtonStyle = WearButtonStyle.Primary, + leadingIcon: ImageVector? = null, + enabled: Boolean = true +) { + when (style) { + WearButtonStyle.Primary -> { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = ArkColor.Primary, + contentColor = Color.White + ), + enabled = enabled + ) { + ButtonContent(text = text, leadingIcon = leadingIcon) + } + } + + WearButtonStyle.Secondary -> { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = ArkColor.BGSecondaryAlt, + contentColor = ArkColor.TextSecondary + ), + enabled = enabled + ) { + ButtonContent(text = text, leadingIcon = leadingIcon) + } + } + + WearButtonStyle.Outlined -> { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = ArkColor.TextSecondary + ), + border = ButtonDefaults.buttonBorder( + borderStroke = BorderStroke(1.dp, ArkColor.BorderSecondary) + ), + enabled = enabled + ) { + ButtonContent(text = text, leadingIcon = leadingIcon) + } + } + + WearButtonStyle.Destructive -> { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = ArkColor.UtilityError500, + contentColor = Color.White + ), + enabled = enabled + ) { + ButtonContent(text = text, leadingIcon = leadingIcon) + } + } + } +} + +@Composable +private fun ButtonContent( + text: String, + leadingIcon: ImageVector?, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (leadingIcon != null) Arrangement.Start else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + leadingIcon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier.size(18.dp) + ) + } + Text( + text = text, + modifier = Modifier.padding( + start = if (leadingIcon != null) 8.dp else 0.dp + ), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = if (leadingIcon != null) TextAlign.Start else TextAlign.Center + ) + } +} + +@Composable +fun WearCompactButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: WearButtonStyle = WearButtonStyle.Outlined +) { + val colors = when (style) { + WearButtonStyle.Primary -> ButtonDefaults.buttonColors( + backgroundColor = ArkColor.Primary, + contentColor = Color.White + ) + + WearButtonStyle.Destructive -> ButtonDefaults.buttonColors( + backgroundColor = ArkColor.UtilityError500, + contentColor = Color.White + ) + + else -> ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = ArkColor.TextSecondary + ) + } + + val border = if (style == WearButtonStyle.Outlined) { + ButtonDefaults.buttonBorder( + borderStroke = BorderStroke(1.dp, ArkColor.BorderSecondary) + ) + } else { + ButtonDefaults.buttonBorder() + } + + Button( + onClick = onClick, + modifier = modifier, + colors = colors, + border = border, + shape = RoundedCornerShape(20.dp) + ) { + Text( + text = text, + fontWeight = FontWeight.Medium, + fontSize = 14.sp + ) + } +} + +@Composable +fun WearFab( + onClick: () -> Unit, + icon: ImageVector, + modifier: Modifier = Modifier, + backgroundColor: Color = ArkColor.Primary, + contentColor: Color = Color.White +) { + Button( + onClick = onClick, + modifier = modifier.size(44.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = backgroundColor, + contentColor = contentColor + ), + shape = CircleShape + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearDialog.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearDialog.kt new file mode 100644 index 000000000..a1685d387 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/presentation/theme/WearDialog.kt @@ -0,0 +1,174 @@ +package dev.arkbuilders.rate.watchapp.presentation.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.delay +import androidx.wear.compose.material.Text +import dev.arkbuilders.rate.core.presentation.theme.ArkColor + +@Composable +fun WearConfirmationDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + confirmText: String = "Yes", + dismissText: String = "No", + confirmIcon: ImageVector = Icons.Outlined.Check, + dismissIcon: ImageVector = Icons.Outlined.Close, + isDestructive: Boolean = false +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = Color.White) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + fontSize = 14.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextSecondary + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + WearCompactButton( + text = dismissText, + onClick = onDismiss, + modifier = Modifier.weight(1f), + style = WearButtonStyle.Outlined + ) + + WearCompactButton( + text = confirmText, + onClick = onConfirm, + modifier = Modifier.weight(1f), + style = if (isDestructive) WearButtonStyle.Destructive else WearButtonStyle.Primary + ) + } + } + } +} + +@Composable +fun WearInfoDialog( + title: String, + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + dismissText: String = "OK" +) { + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = Color.White) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = message, + fontSize = 14.sp, + textAlign = TextAlign.Center, + color = ArkColor.TextSecondary + ) + + Spacer(modifier = Modifier.height(20.dp)) + + WearCompactButton( + text = dismissText, + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(0.8f), + style = WearButtonStyle.Primary + ) + } + } +} + +@Composable +fun WearSnackbar( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + LaunchedEffect(message) { + delay(2000L) + onDismiss() + } + + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = 20.dp), + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = message, + modifier = Modifier + .background( + color = Color.DarkGray.copy(alpha = 0.9f), + shape = RoundedCornerShape(20.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + color = Color.White, + fontSize = 12.sp, + textAlign = TextAlign.Center + ) + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationComplicationService.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationComplicationService.kt new file mode 100644 index 000000000..fd57725a5 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationComplicationService.kt @@ -0,0 +1,76 @@ +package dev.arkbuilders.rate.watchapp.watchface + +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.data.LongTextComplicationData +import androidx.wear.watchface.complications.data.PlainComplicationText +import androidx.wear.watchface.complications.data.ShortTextComplicationData +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService +import dagger.hilt.android.AndroidEntryPoint +import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@AndroidEntryPoint +class QuickCalculationComplicationService : SuspendingComplicationDataSourceService() { + + @Inject + lateinit var quickRepo: QuickRepo + + @Inject + lateinit var convertUseCase: ConvertWithRateUseCase + + override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? { + val pairs = quickRepo.allFlow().first() + val pair = pairs.firstOrNull() ?: return null + + val toAmount = pair.to.firstOrNull() ?: return null + val (convertedAmt, _) = convertUseCase.invoke(pair.from, pair.amount, toAmount.code) + + val resultStr = String.format("%.2f", convertedAmt) + val text = "$resultStr ${toAmount.code}" + val title = "${pair.amount} ${pair.from}" + + return when (request.complicationType) { + ComplicationType.SHORT_TEXT -> { + ShortTextComplicationData.Builder( + text = PlainComplicationText.Builder(text).build(), + contentDescription = PlainComplicationText.Builder("Currency Rate").build() + ).setTitle(PlainComplicationText.Builder(title).build()) + .build() + } + ComplicationType.LONG_TEXT -> { + LongTextComplicationData.Builder( + text = PlainComplicationText.Builder("$title = $text").build(), + contentDescription = PlainComplicationText.Builder("Currency Rate").build() + ).setTitle(PlainComplicationText.Builder("Quick Pair Rate").build()) + .build() + } + else -> null + } + } + + override fun getPreviewData(type: ComplicationType): ComplicationData? { + val text = "1.00 EUR" + val title = "1 USD" + return when (type) { + ComplicationType.SHORT_TEXT -> { + ShortTextComplicationData.Builder( + text = PlainComplicationText.Builder(text).build(), + contentDescription = PlainComplicationText.Builder("Currency Rate Preview").build() + ).setTitle(PlainComplicationText.Builder(title).build()) + .build() + } + ComplicationType.LONG_TEXT -> { + LongTextComplicationData.Builder( + text = PlainComplicationText.Builder("$title = $text").build(), + contentDescription = PlainComplicationText.Builder("Currency Rate Preview").build() + ).setTitle(PlainComplicationText.Builder("Quick Pair Rate").build()) + .build() + } + else -> null + } + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsRenderer.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsRenderer.kt new file mode 100644 index 000000000..074e68aa1 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsRenderer.kt @@ -0,0 +1,133 @@ +package dev.arkbuilders.rate.watchapp.watchface + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import android.view.SurfaceHolder +import androidx.wear.watchface.CanvasType +import androidx.wear.watchface.DrawMode +import androidx.wear.watchface.Renderer +import androidx.wear.watchface.WatchState +import androidx.wear.watchface.style.CurrentUserStyleRepository +import dev.arkbuilders.rate.core.domain.CurrUtils +import dev.arkbuilders.rate.core.presentation.utils.IconUtils +import dev.arkbuilders.rate.feature.quick.domain.model.QuickCalculation +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class QuickCalculationsRenderer( + private val context: Context, + surfaceHolder: SurfaceHolder, + private val watchState: WatchState, + currentUserStyleRepository: CurrentUserStyleRepository, +) : Renderer.CanvasRenderer( + surfaceHolder, + currentUserStyleRepository, + watchState, + CanvasType.SOFTWARE, + 1000L // 1 second refresh is enough for HH:mm +) { + private var quickPairs: List = emptyList() + private val backgroundPaint = Paint().apply { + color = Color.WHITE + isAntiAlias = true + } + + private val timePaint = Paint().apply { + color = Color.parseColor("#7F56D9") // ArkColor.Primary + textSize = 48f + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + isAntiAlias = true + } + + + private val itemTitlePaint = Paint().apply { + color = Color.parseColor("#101828") // ArkColor.TextPrimary + textSize = 18f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + isAntiAlias = true + } + + private val itemSubtitlePaint = Paint().apply { + color = Color.parseColor("#475467") // ArkColor.TextTertiary + textSize = 14f + isAntiAlias = true + } + + private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") + + + fun updateQuickCalculations(pairs: List) { + quickPairs = pairs + invalidate() + } + + override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) { + val isAmbient = renderParameters.drawMode == DrawMode.AMBIENT + + val centerX = bounds.centerX().toFloat() + val centerY = bounds.centerY().toFloat() + + if (isAmbient) { + canvas.drawColor(Color.BLACK) + timePaint.color = Color.WHITE + val timeText = zonedDateTime.format(timeFormatter) + canvas.drawText(timeText, centerX, centerY, timePaint) + return + } + + // Draw Background (White like the app) + canvas.drawColor(Color.WHITE) + + // Draw Time at the top + val timeText = zonedDateTime.format(timeFormatter) + canvas.drawText(timeText, centerX, 60f, timePaint) + + // Draw Quick Pairs + var currentY = 110f + val pairsToShow = quickPairs + + if (pairsToShow.isNotEmpty()) { + for (pair in pairsToShow) { + val toAmount = pair.to.firstOrNull() ?: continue + + // Draw Icons + val fromIconId = IconUtils.iconForCurrCode(context, pair.from) + val fromIcon = context.getDrawable(fromIconId) + fromIcon?.let { + it.setBounds(30, currentY.toInt() - 20, 70, currentY.toInt() + 20) + it.draw(canvas) + } + + val toIconId = IconUtils.iconForCurrCode(context, toAmount.code) + val toIcon = context.getDrawable(toIconId) + toIcon?.let { + it.setBounds(50, currentY.toInt() - 20, 90, currentY.toInt() + 20) + it.draw(canvas) + } + + // Draw Title: "EUR to USD" + val titleText = "${pair.from} to ${toAmount.code}" + canvas.drawText(titleText, 100f, currentY, itemTitlePaint) + + // Draw Subtitle: "1 USD = 0.92 EUR" + currentY += 25f + val baseAmountStr = CurrUtils.prepareToDisplay(pair.amount) + val targetAmountStr = CurrUtils.prepareToDisplay(toAmount.value) + val subtitleText = "$baseAmountStr ${pair.from} = $targetAmountStr ${toAmount.code}" + canvas.drawText(subtitleText, 100f, currentY, itemSubtitlePaint) + + currentY += 45f + } + } else { + canvas.drawText("No pinned pairs", centerX, centerY + 20f, itemSubtitlePaint) + } + } + + override fun renderHighlightLayer(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) { + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsWatchFaceService.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsWatchFaceService.kt new file mode 100644 index 000000000..51c39e3cb --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/QuickCalculationsWatchFaceService.kt @@ -0,0 +1,88 @@ +package dev.arkbuilders.rate.watchapp.watchface + +import android.util.Log +import android.view.SurfaceHolder +import androidx.wear.watchface.ComplicationSlotsManager +import androidx.wear.watchface.WatchFace +import androidx.wear.watchface.WatchFaceService +import androidx.wear.watchface.WatchFaceType +import androidx.wear.watchface.WatchState +import androidx.wear.watchface.style.CurrentUserStyleRepository +import dagger.hilt.android.AndroidEntryPoint +import dev.arkbuilders.rate.core.domain.model.Amount +import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase +import dev.arkbuilders.rate.feature.quick.domain.repo.QuickRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@AndroidEntryPoint +class QuickCalculationsWatchFaceService : WatchFaceService() { + + @Inject + lateinit var quickRepo: QuickRepo + + @Inject + lateinit var convertUseCase: ConvertWithRateUseCase + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override suspend fun createWatchFace( + surfaceHolder: SurfaceHolder, + watchState: WatchState, + complicationSlotsManager: ComplicationSlotsManager, + currentUserStyleRepository: CurrentUserStyleRepository + ): WatchFace { + Log.d("QuickCalculationsWatchFace", "Creating watch face") + val renderer = QuickCalculationsRenderer( + context = this, + surfaceHolder = surfaceHolder, + watchState = watchState, + currentUserStyleRepository = currentUserStyleRepository + ) + + scope.launch { + Log.d("QuickCalculationsWatchFace", "Starting data collection") + quickRepo.allFlow() + .map { pairs -> pairs.filter { it.isPinned() } } + .distinctUntilChanged { old, new -> + old.size == new.size && old.all { oldPair -> + new.any { it.id == oldPair.id && it.amount == oldPair.amount && it.from == oldPair.from && it.to == oldPair.to } + } + } + .flowOn(Dispatchers.IO) + .collectLatest { pinnedPairs -> + Log.d("QuickCalculationsWatchFace", "Received ${pinnedPairs.size} pinned pairs") + val processedPairs = withContext(Dispatchers.IO) { + pinnedPairs.map { pair -> + val actualTo = pair.to.map { toAmount -> + val (convertedAmt, _) = convertUseCase.invoke(pair.from, pair.amount, toAmount.code) + Amount(convertedAmt.code, convertedAmt.value) + } + pair.copy(to = actualTo) + }.sortedByDescending { it.pinnedDate } + } + Log.d("QuickCalculationsWatchFace", "Updating renderer with ${processedPairs.size} processed pairs") + renderer.updateQuickCalculations(processedPairs) + } + } + + return WatchFace( + watchFaceType = WatchFaceType.DIGITAL, + renderer = renderer + ) + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/WatchRefreshManager.kt b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/WatchRefreshManager.kt new file mode 100644 index 000000000..266b9c361 --- /dev/null +++ b/watchapp/src/main/java/dev/arkbuilders/rate/watchapp/watchface/WatchRefreshManager.kt @@ -0,0 +1,19 @@ +package dev.arkbuilders.rate.watchapp.watchface + +import android.content.ComponentName +import android.content.Context +import androidx.wear.watchface.complications.datasource.ComplicationDataSourceUpdateRequester + +object WatchRefreshManager { + fun refreshComplications(context: Context) { + try { + val requester = ComplicationDataSourceUpdateRequester.create( + context, + ComponentName(context, QuickCalculationComplicationService::class.java) + ) + requester.requestUpdateAll() + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/watchapp/src/main/res/drawable/ic_launcher_foreground.xml b/watchapp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..b6b151901 --- /dev/null +++ b/watchapp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/watchapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/watchapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/watchapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/watchapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/watchapp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..377149d72 Binary files /dev/null and b/watchapp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/watchapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/watchapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..dc7f6ea4d Binary files /dev/null and b/watchapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/watchapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/watchapp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..271fe6b87 Binary files /dev/null and b/watchapp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/watchapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/watchapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..cf339ab45 Binary files /dev/null and b/watchapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/watchapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/watchapp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..6e9a07967 Binary files /dev/null and b/watchapp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/watchapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/watchapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..28714d50a Binary files /dev/null and b/watchapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..f2b876c2c Binary files /dev/null and b/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..accf51462 Binary files /dev/null and b/watchapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..895eaaefd Binary files /dev/null and b/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..e1a6e68db Binary files /dev/null and b/watchapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/watchapp/src/main/res/values-round/strings.xml b/watchapp/src/main/res/values-round/strings.xml new file mode 100644 index 000000000..42f12297f --- /dev/null +++ b/watchapp/src/main/res/values-round/strings.xml @@ -0,0 +1,3 @@ + + From the Round world,\nHello, %1$s! + \ No newline at end of file diff --git a/watchapp/src/main/res/values/ic_launcher_background.xml b/watchapp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/watchapp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/watchapp/src/main/res/values/strings.xml b/watchapp/src/main/res/values/strings.xml new file mode 100644 index 000000000..2c2a70373 --- /dev/null +++ b/watchapp/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + ARK Rates + + From the Square world,\nHello, %1$s! + diff --git a/watchapp/src/main/res/values/styles.xml b/watchapp/src/main/res/values/styles.xml new file mode 100644 index 000000000..3355fa349 --- /dev/null +++ b/watchapp/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/watchapp/src/main/res/xml/watch_face.xml b/watchapp/src/main/res/xml/watch_face.xml new file mode 100644 index 000000000..060be98bb --- /dev/null +++ b/watchapp/src/main/res/xml/watch_face.xml @@ -0,0 +1,2 @@ + +