diff --git a/android/app/build.gradle b/android/app/build.gradle index ebd383d3b..52646e92f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -94,12 +94,27 @@ android { release { // Caution! In production, you need to generate your own keystore file. // see https://reactnative.dev/docs/signed-apk-android. + buildConfigField "boolean", "USE_MOCK_SERVER", "false" minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro" } + debug { + buildConfigField "boolean", "USE_MOCK_SERVER", "false" + } + } + + productFlavors { + normal { + dimension "mode" + } + mock { + dimension "mode" + buildConfigField "boolean", "USE_MOCK_SERVER", "true" + } } + flavorDimensions "mode" } task copyFiatUnits(type: Copy) { @@ -114,6 +129,7 @@ dependencies { implementation("com.facebook.react:react-android") implementation files("../../node_modules/rn-ldk/android/libs/LDK-release.aar") implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.core:core-remoteviews:1.1.0' if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") @@ -121,11 +137,23 @@ dependencies { implementation jscFlavor } androidTestImplementation('com.wix:detox:+') + androidTestImplementation "androidx.work:work-testing:2.7.1" + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + testImplementation 'com.google.guava:guava:30.1.1-android' + androidTestImplementation 'com.google.guava:guava:30.1.1-android' + androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0' + androidTestImplementation 'androidx.test:core:1.4.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation fileTree(dir: "libs", include: ["*.jar"]) implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.4.0' implementation "androidx.work:work-runtime-ktx:2.7.1" + testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.3' + implementation 'com.squareup.okhttp3:mockwebserver:4.9.3' + + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" } apply plugin: 'com.google.gms.google-services' // Google Services plugin diff --git a/android/app/src/androidTest/java/io/bluewallet/bluewallet/DetoxTest.java b/android/app/src/androidTest/java/io/bluewallet/bluewallet/DetoxTest.java index d44f620a7..8316d5fad 100644 --- a/android/app/src/androidTest/java/io/bluewallet/bluewallet/DetoxTest.java +++ b/android/app/src/androidTest/java/io/bluewallet/bluewallet/DetoxTest.java @@ -13,7 +13,7 @@ package io.bluewallet.bluewallet; @RunWith(AndroidJUnit4.class) @LargeTest -public class DetoxTest { +public class DetoxTest { // Replace 'MainActivity' with the value of android:name entry in // in AndroidManifest.xml @Rule diff --git a/android/app/src/androidTest/java/io/bluewallet/bluewallet/MockServerTest.kt b/android/app/src/androidTest/java/io/bluewallet/bluewallet/MockServerTest.kt new file mode 100644 index 000000000..f03606834 --- /dev/null +++ b/android/app/src/androidTest/java/io/bluewallet/bluewallet/MockServerTest.kt @@ -0,0 +1,87 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.widget.RemoteViews +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.WorkManager +import com.google.common.util.concurrent.ListenableFuture +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class MockServerTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + mockWebServer = MockWebServer() + mockWebServer.start() + + val baseUrl = mockWebServer.url("/").toString() + MarketAPI.baseUrl = baseUrl + + WorkManagerTestInitHelper.initializeTestWorkManager(context) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun testWidgetUpdate() { + // Mock API response + val mockResponse = MockResponse() + .setBody(createMockJsonResponse("USD", "61500")) + .setResponseCode(200) + mockWebServer.enqueue(mockResponse) + + // Trigger the widget update + WidgetUpdateWorker.scheduleWork(context) + + // Wait for the worker to run + val workInfos = WorkManager.getInstance(context).getWorkInfosByTag(WidgetUpdateWorker.WORK_NAME).get() + val testDriver = WorkManagerTestInitHelper.getTestDriver() + for (workInfo in workInfos) { + testDriver?.setAllConstraintsMet(workInfo.id) + } + Thread.sleep(10000) // Wait for 10 seconds to simulate the update interval + + // Validate the widget updates + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, BitcoinPriceWidget::class.java)) + + for (widgetId in widgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_layout) + // Add your assertions here to validate the widget update + } + } + + private fun createMockJsonResponse(currency: String, price: String): String { + return """ + { + "$currency": { + "endPointKey": "$currency", + "locale": "en-US", + "source": "Kraken", + "symbol": "$", + "country": "United States (US Dollar)", + "price": "$price" + } + } + """.trimIndent() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt index 0b8c5b10d..0db946572 100644 --- a/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt +++ b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt @@ -2,37 +2,27 @@ package io.bluewallet.bluewallet import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider -import android.content.ComponentName import android.content.Context import android.content.Intent -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequest +import android.util.Log import androidx.work.WorkManager -import java.util.concurrent.TimeUnit class BitcoinPriceWidget : AppWidgetProvider() { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { super.onUpdate(context, appWidgetManager, appWidgetIds) - scheduleWork(context) + WidgetUpdateWorker.scheduleWork(context) } - private fun scheduleWork(context: Context) { - val workRequest = PeriodicWorkRequest.Builder(WidgetUpdateWorker::class.java, 15, TimeUnit.MINUTES) - .setInitialDelay(15L, TimeUnit.MINUTES) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - "UpdateWidgetWork", - ExistingPeriodicWorkPolicy.REPLACE, - workRequest - ) + override fun onEnabled(context: Context) { + super.onEnabled(context) + Log.d("BitcoinPriceWidget", "Widget enabled") + WidgetUpdateWorker.scheduleWork(context) } - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - scheduleWork(context) - } + override fun onDisabled(context: Context) { + super.onDisabled(context) + Log.d("BitcoinPriceWidget", "Widget disabled") + WorkManager.getInstance(context).cancelAllWorkByTag("widget_update_work") } } \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt index 1b8260a50..c3038d31a 100644 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt @@ -2,7 +2,6 @@ package io.bluewallet.bluewallet import android.app.Application import android.content.Context -import android.content.SharedPreferences import com.bugsnag.android.Bugsnag import com.facebook.react.PackageList import com.facebook.react.ReactApplication @@ -13,6 +12,8 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.soloader.SoLoader import com.facebook.react.modules.i18nmanager.I18nUtil +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.MockResponse class MainApplication : Application(), ReactApplication { @@ -43,6 +44,10 @@ class MainApplication : Application(), ReactApplication { // If you opted-in for the New Architecture, we load the native entry point for this app. DefaultNewArchitectureEntryPoint.load() } + if (BuildConfig.USE_MOCK_SERVER) { + startMockServer() + } + val sharedPref = applicationContext.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) // Retrieve the "donottrack" value. Default to "0" if not found. @@ -54,4 +59,12 @@ class MainApplication : Application(), ReactApplication { Bugsnag.start(this) } } + + private fun startMockServer() { + val mockWebServer = MockWebServer() + mockWebServer.start(8080) + MarketAPI.baseUrl = mockWebServer.url("/").toString() + + mockWebServer.enqueue(MockResponse().setBody("{\"USD\": {\"price\": \"60000.00\"}}")) + } } \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt index ee8813f3c..819767458 100644 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt @@ -2,26 +2,25 @@ package io.bluewallet.bluewallet import android.content.Context import android.util.Log -import org.json.JSONArray import org.json.JSONObject import java.io.InputStreamReader import java.net.HttpURLConnection -import java.net.URI import java.net.URL object MarketAPI { - private const val HARD_CODED_JSON = """ - { - "USD": { - "endPointKey": "USD", - "locale": "en-US", - "source": "Kraken", - "symbol": "$", - "country": "United States (US Dollar)" - } - } - """ + private const val TAG = "MarketAPI" + private const val HARD_CODED_JSON = "{\n" + + " \"USD\": {\n" + + " \"endPointKey\": \"USD\",\n" + + " \"locale\": \"en-US\",\n" + + " \"source\": \"Kraken\",\n" + + " \"symbol\": \"$\",\n" + + " \"country\": \"United States (US Dollar)\"\n" + + " }\n" + + "}" + + var baseUrl: String? = null fun fetchPrice(context: Context, currency: String): String? { return try { @@ -31,43 +30,50 @@ object MarketAPI { val endPointKey = currencyInfo.getString("endPointKey") val urlString = buildURLString(source, endPointKey) - Log.d("MarketAPI", "Fetching price from: $urlString") - URI(urlString).toURL().run { - (openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connect() - }.run { - if (responseCode != HttpURLConnection.HTTP_OK) return null + Log.d(TAG, "Fetching price from URL: $urlString") - InputStreamReader(inputStream).use { reader -> - val jsonResponse = StringBuilder() - val buffer = CharArray(1024) - var read: Int - while (reader.read(buffer).also { read = it } != -1) { - jsonResponse.append(buffer, 0, read) - } - parseJSONBasedOnSource(jsonResponse.toString(), source, endPointKey) - } - } + val url = URL(urlString) + val urlConnection = url.openConnection() as HttpURLConnection + urlConnection.requestMethod = "GET" + urlConnection.connect() + + val responseCode = urlConnection.responseCode + if (responseCode != 200) { + Log.e(TAG, "Failed to fetch price. Response code: $responseCode") + return null } + + val reader = InputStreamReader(urlConnection.inputStream) + val jsonResponse = StringBuilder() + val buffer = CharArray(1024) + var read: Int + while (reader.read(buffer).also { read = it } != -1) { + jsonResponse.append(buffer, 0, read) + } + + parseJSONBasedOnSource(jsonResponse.toString(), source, endPointKey) } catch (e: Exception) { - e.printStackTrace() + Log.e(TAG, "Error fetching price", e) null } } private fun buildURLString(source: String, endPointKey: String): String { - return when (source) { - "Yadio" -> "https://api.yadio.io/json/$endPointKey" - "YadioConvert" -> "https://api.yadio.io/convert/1/BTC/$endPointKey" - "Exir" -> "https://api.exir.io/v1/ticker?symbol=btc-irt" - "wazirx" -> "https://api.wazirx.com/api/v2/tickers/btcinr" - "Bitstamp" -> "https://www.bitstamp.net/api/v2/ticker/btc${endPointKey.toLowerCase()}" - "Coinbase" -> "https://api.coinbase.com/v2/prices/BTC-${endPointKey.toUpperCase()}/buy" - "CoinGecko" -> "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${endPointKey.toLowerCase()}" - "BNR" -> "https://www.bnr.ro/nbrfxrates.xml" - "Kraken" -> "https://api.kraken.com/0/public/Ticker?pair=XXBTZ${endPointKey.toUpperCase()}" - else -> "https://api.coindesk.com/v1/bpi/currentprice/$endPointKey.json" + return if (baseUrl != null) { + baseUrl + endPointKey + } else { + when (source) { + "Yadio" -> "https://api.yadio.io/json/$endPointKey" + "YadioConvert" -> "https://api.yadio.io/convert/1/BTC/$endPointKey" + "Exir" -> "https://api.exir.io/v1/ticker?symbol=btc-irt" + "wazirx" -> "https://api.wazirx.com/api/v2/tickers/btcinr" + "Bitstamp" -> "https://www.bitstamp.net/api/v2/ticker/btc${endPointKey.lowercase()}" + "Coinbase" -> "https://api.coinbase.com/v2/prices/BTC-${endPointKey.uppercase()}/buy" + "CoinGecko" -> "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${endPointKey.lowercase()}" + "BNR" -> "https://www.bnr.ro/nbrfxrates.xml" + "Kraken" -> "https://api.kraken.com/0/public/Ticker?pair=XXBTZ${endPointKey.uppercase()}" + else -> "https://api.coindesk.com/v1/bpi/currentprice/$endPointKey.json" + } } } @@ -77,16 +83,17 @@ object MarketAPI { when (source) { "Yadio" -> json.getJSONObject(endPointKey).getString("price") "YadioConvert" -> json.getString("rate") - "CoinGecko" -> json.getJSONObject("bitcoin").getString(endPointKey.toLowerCase()) - "Exir", "Bitstamp" -> json.getString("last") + "CoinGecko" -> json.getJSONObject("bitcoin").getString(endPointKey.lowercase()) + "Exir" -> json.getString("last") + "Bitstamp" -> json.getString("last") "wazirx" -> json.getJSONObject("ticker").getString("buy") "Coinbase" -> json.getJSONObject("data").getString("amount") - "Kraken" -> json.getJSONObject("result").getJSONObject("XXBTZ${endPointKey.toUpperCase()}").getJSONArray("c").getString(0) + "Kraken" -> json.getJSONObject("result").getJSONObject("XXBTZ${endPointKey.uppercase()}").getJSONArray("c").getString(0) else -> null } } catch (e: Exception) { - e.printStackTrace() + Log.e(TAG, "Error parsing price", e) null } } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt index 656258dc2..3f4080e5b 100644 --- a/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt +++ b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt @@ -1,95 +1,72 @@ package io.bluewallet.bluewallet +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.util.Log +import android.view.View import android.widget.RemoteViews -import androidx.work.Worker -import androidx.work.WorkerParameters +import androidx.work.* import java.text.NumberFormat -import java.util.* +import java.util.Locale +import java.util.concurrent.TimeUnit -class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { +class WidgetUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) { override fun doWork(): Result { - val context = applicationContext - val appWidgetManager = android.appwidget.AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, BitcoinPriceWidget::class.java) - val allWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + val widgetComponent = ComponentName(applicationContext, BitcoinPriceWidget::class.java) + val allWidgetIds = appWidgetManager.getAppWidgetIds(widgetComponent) + + val price = MarketAPI.fetchPrice(applicationContext, "USD") + val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout) + + if (price != null) { + val formattedPrice = NumberFormat.getCurrencyInstance(Locale.getDefault()).format(price.toDouble()) + + views.setTextViewText(R.id.price_value, formattedPrice) + views.setViewVisibility(R.id.loading_indicator, View.GONE) + views.setViewVisibility(R.id.price_value, View.VISIBLE) + views.setViewVisibility(R.id.last_updated, View.VISIBLE) + + val currentTime = System.currentTimeMillis() + val formattedTime = java.text.DateFormat.getTimeInstance().format(currentTime) + views.setTextViewText(R.id.last_updated, "Last Updated: $formattedTime") + } else { + Log.d("WidgetUpdateWorker", "Failed to fetch price") + } for (widgetId in allWidgetIds) { - val views = RemoteViews(context.packageName, R.layout.widget_layout) - - // Show loading indicator - views.setViewVisibility(R.id.loading_indicator, android.view.View.VISIBLE) - views.setViewVisibility(R.id.price_value, android.view.View.GONE) - views.setViewVisibility(R.id.last_updated, android.view.View.GONE) - views.setViewVisibility(R.id.last_updated_time, android.view.View.GONE) - views.setViewVisibility(R.id.price_arrow_container, android.view.View.GONE) - appWidgetManager.updateAppWidget(widgetId, views) - - val price = MarketAPI.fetchPrice(context, "USD") - - if (price != null) { - updateWidgetWithPrice(context, appWidgetManager, widgetId, views, price) - } else { - handleError(context, appWidgetManager, widgetId, views) - } } + + scheduleNextUpdate() return Result.success() } - private fun updateWidgetWithPrice(context: Context, appWidgetManager: android.appwidget.AppWidgetManager, widgetId: Int, views: RemoteViews, price: String) { - val sharedPref = context.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) - val prevPrice = sharedPref.getString("prev_price", null) - val editor = sharedPref.edit() + private fun scheduleNextUpdate() { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(10, TimeUnit.MINUTES) + .build() - Log.d("WidgetUpdateWorker", "Fetch completed with price: $price at ${getCurrentTime()}. Previous price: $prevPrice at ${sharedPref.getString("prev_time", "N/A")}") + WorkManager.getInstance(applicationContext).enqueueUniqueWork( + "widget_update_work", + ExistingWorkPolicy.REPLACE, + request + ) - val currencyFormat = NumberFormat.getCurrencyInstance(Locale.getDefault()) - views.setTextViewText(R.id.price_value, currencyFormat.format(price.toDouble())) + Log.d("WidgetUpdateWorker", "Scheduled next update for widget") + } - if (prevPrice != null) { - val previousPrice = prevPrice.toDouble() - val currentPrice = price.toDouble() - - if (currentPrice > previousPrice) { - views.setImageViewResource(R.id.price_arrow, android.R.drawable.arrow_up_float) - } else if (currentPrice < previousPrice) { - views.setImageViewResource(R.id.price_arrow, android.R.drawable.arrow_down_float) - } else { - views.setImageViewResource(R.id.price_arrow, 0) - } - - views.setTextViewText(R.id.previous_price, "from ${currencyFormat.format(previousPrice)}") - views.setViewVisibility(R.id.price_arrow_container, android.view.View.VISIBLE) + companion object { + fun createWorkRequest(): OneTimeWorkRequest { + return OneTimeWorkRequestBuilder() + .setInitialDelay(0, TimeUnit.SECONDS) + .build() } - editor.putString("prev_price", price) - editor.putString("prev_time", getCurrentTime()) - editor.apply() - - views.setTextViewText(R.id.last_updated, "Last Updated") - views.setTextViewText(R.id.last_updated_time, getCurrentTime()) - - // Hide loading indicator - views.setViewVisibility(R.id.loading_indicator, android.view.View.GONE) - views.setViewVisibility(R.id.price_value, android.view.View.VISIBLE) - views.setViewVisibility(R.id.last_updated, android.view.View.VISIBLE) - views.setViewVisibility(R.id.last_updated_time, android.view.View.VISIBLE) - - appWidgetManager.updateAppWidget(widgetId, views) - } - - private fun handleError(context: Context, appWidgetManager: android.appwidget.AppWidgetManager, widgetId: Int, views: RemoteViews) { - Log.e("WidgetUpdateWorker", "Failed to fetch Bitcoin price") - views.setViewVisibility(R.id.loading_indicator, android.view.View.GONE) - views.setViewVisibility(R.id.price_value, android.view.View.VISIBLE) - appWidgetManager.updateAppWidget(widgetId, views) - } - - private fun getCurrentTime(): String { - val dateFormatter = java.text.SimpleDateFormat.getTimeInstance(java.text.SimpleDateFormat.SHORT, Locale.getDefault()) - return dateFormatter.format(Date()) + fun scheduleWork(context: Context) { + WorkManager.getInstance(context).enqueue(createWorkRequest()) + } } } \ No newline at end of file diff --git a/android/app/src/main/res/layout/widget_layout.xml b/android/app/src/main/res/layout/widget_layout.xml index 140ab5fd3..c27a71b87 100644 --- a/android/app/src/main/res/layout/widget_layout.xml +++ b/android/app/src/main/res/layout/widget_layout.xml @@ -1,70 +1,57 @@ + - - - - + android:text="@string/loading" + android:textSize="18sp" + android:textColor="@color/text_primary" + android:layout_centerHorizontal="true"/> - - - - - - + android:layout_centerHorizontal="true" + android:visibility="gone" + android:layout_marginTop="8dp"/> + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f0a8fbef3..4b2a094b9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ BlueWallet + Loading... + Last Updated + From diff --git a/ios/BlueWallet.xcodeproj/project.pbxproj b/ios/BlueWallet.xcodeproj/project.pbxproj index 2f33593d4..5675964fd 100644 --- a/ios/BlueWallet.xcodeproj/project.pbxproj +++ b/ios/BlueWallet.xcodeproj/project.pbxproj @@ -169,8 +169,21 @@ B4D0B2662C1DEB7F006B6B1B /* ReceiveInterfaceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D0B2652C1DEB7F006B6B1B /* ReceiveInterfaceMode.swift */; }; B4D0B2682C1DED67006B6B1B /* ReceiveMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D0B2672C1DED67006B6B1B /* ReceiveMethod.swift */; }; B4EE583C226703320003363C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; }; + B4EFF73B2C3F6C5E0095D655 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73A2C3F6C5E0095D655 /* MockData.swift */; }; + B4EFF73C2C3F6C5E0095D655 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73A2C3F6C5E0095D655 /* MockData.swift */; }; + B4EFF73D2C3F6C6C0095D655 /* MarketData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E82BCC371A00162242 /* MarketData.swift */; }; + B4EFF73F2C3F6C870095D655 /* PriceViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73E2C3F6C870095D655 /* PriceViewTests.swift */; }; + B4EFF7402C3F6C870095D655 /* PriceViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73E2C3F6C870095D655 /* PriceViewTests.swift */; }; + B4EFF7412C3F6C960095D655 /* PriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */; }; + B4EFF7422C3F6C990095D655 /* PriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5272558EC52009312A5 /* PriceView.swift */; }; + B4EFF7432C3F6F650095D655 /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033C92BCC350A00162242 /* Currency.swift */; }; + B4EFF7442C3F6F6A0095D655 /* FiatUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2AA8072568B8F40090B089 /* FiatUnit.swift */; }; + B4EFF7452C3F6FF30095D655 /* Placeholders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4BFA254FBA0E00E9F9AA /* Placeholders.swift */; }; + B4EFF7462C3F6FF90095D655 /* WalletData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E32BCC36FF00162242 /* WalletData.swift */; }; + B4EFF7472C3F70010095D655 /* LatestTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033DC2BCC36C300162242 /* LatestTransaction.swift */; }; + B4EFF7482C3F70090095D655 /* BitcoinUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033BE2BCC32F800162242 /* BitcoinUnit.swift */; }; C59F90CE0D04D3E4BB39BC5D /* libPods-BlueWalletUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F02C2F7CA3591E4E0B06EBA /* libPods-BlueWalletUITests.a */; }; - C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + C978A716948AB7DEC5B6F677 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -461,6 +474,8 @@ B4D0B2652C1DEB7F006B6B1B /* ReceiveInterfaceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveInterfaceMode.swift; sourceTree = ""; }; B4D0B2672C1DED67006B6B1B /* ReceiveMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveMethod.swift; sourceTree = ""; }; B4D3235A177F4580BA52F2F9 /* libRNCSlider.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCSlider.a; sourceTree = ""; }; + B4EFF73A2C3F6C5E0095D655 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = ""; }; + B4EFF73E2C3F6C870095D655 /* PriceViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceViewTests.swift; sourceTree = ""; }; B642AFB13483418CAB6FF25E /* libRCTQRCodeLocalImage.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTQRCodeLocalImage.a; sourceTree = ""; }; B68F8552DD4428F64B11DCFB /* Pods-BlueWallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlueWallet.debug.xcconfig"; path = "Target Support Files/Pods-BlueWallet/Pods-BlueWallet.debug.xcconfig"; sourceTree = ""; }; B9D9B3A7B2CB4255876B67AF /* libz.tbd */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; @@ -485,7 +500,7 @@ files = ( 782F075B5DD048449E2DECE9 /* libz.tbd in Frameworks */, 764B49B1420D4AEB8109BF62 /* libsqlite3.0.tbd in Frameworks */, - C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */, + C978A716948AB7DEC5B6F677 /* (null) in Frameworks */, 773E382FE62E836172AAB98B /* libPods-BlueWallet.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -550,6 +565,8 @@ children = ( 00E356F01AD99517003FC87E /* Supporting Files */, B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */, + B4EFF73A2C3F6C5E0095D655 /* MockData.swift */, + B4EFF73E2C3F6C870095D655 /* PriceViewTests.swift */, ); path = BlueWalletTests; sourceTree = ""; @@ -1627,10 +1644,12 @@ B44033DB2BCC369B00162242 /* Colors.swift in Sources */, B40D4E632258425500428FCC /* ReceiveInterfaceController.swift in Sources */, B43D0378225847C500FBAA95 /* WalletGradient.swift in Sources */, + B4EFF73B2C3F6C5E0095D655 /* MockData.swift in Sources */, B44033C02BCC32F800162242 /* BitcoinUnit.swift in Sources */, B44033E52BCC36FF00162242 /* WalletData.swift in Sources */, B44033EF2BCC374500162242 /* Numeric+abbreviated.swift in Sources */, B4D0B2622C1DEA11006B6B1B /* ReceivePageInterfaceController.swift in Sources */, + B4EFF73F2C3F6C870095D655 /* PriceViewTests.swift in Sources */, B44033D02BCC352F00162242 /* UserDefaultsGroup.swift in Sources */, B44033C52BCC332400162242 /* Balance.swift in Sources */, 6D4AF18425D215D1009DD853 /* UserDefaultsExtension.swift in Sources */, @@ -1645,6 +1664,17 @@ buildActionMask = 2147483647; files = ( B49038D92B8FBAD300A8164A /* BlueWalletUITest.swift in Sources */, + B4EFF7472C3F70010095D655 /* LatestTransaction.swift in Sources */, + B4EFF7422C3F6C990095D655 /* PriceView.swift in Sources */, + B4EFF7482C3F70090095D655 /* BitcoinUnit.swift in Sources */, + B4EFF73D2C3F6C6C0095D655 /* MarketData.swift in Sources */, + B4EFF73C2C3F6C5E0095D655 /* MockData.swift in Sources */, + B4EFF7432C3F6F650095D655 /* Currency.swift in Sources */, + B4EFF7412C3F6C960095D655 /* PriceWidget.swift in Sources */, + B4EFF7462C3F6FF90095D655 /* WalletData.swift in Sources */, + B4EFF7402C3F6C870095D655 /* PriceViewTests.swift in Sources */, + B4EFF7452C3F6FF30095D655 /* Placeholders.swift in Sources */, + B4EFF7442C3F6F6A0095D655 /* FiatUnit.swift in Sources */, B47B21EC2B2128B8001F6690 /* BlueWalletUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/BlueWalletTests/MockData.swift b/ios/BlueWalletTests/MockData.swift new file mode 100644 index 000000000..8460b865a --- /dev/null +++ b/ios/BlueWalletTests/MockData.swift @@ -0,0 +1,15 @@ +// +// MockData.swift +// BlueWallet +// +// Created by Marcos Rodriguez on 7/10/24. +// Copyright © 2024 BlueWallet. All rights reserved. +// + +import Foundation + +struct MockData { + static let currentMarketData = MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2023-01-01T00:00:00+00:00") + static let previousMarketData = MarketData(nextBlock: "", sats: "", price: "$9,000", rate: 9000, dateString: "2022-12-31T00:00:00+00:00") + static let noChangeMarketData = MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2023-01-01T00:00:00+00:00") +} diff --git a/ios/BlueWalletTests/PriceViewTests.swift b/ios/BlueWalletTests/PriceViewTests.swift new file mode 100644 index 000000000..716163b0c --- /dev/null +++ b/ios/BlueWalletTests/PriceViewTests.swift @@ -0,0 +1,75 @@ +// +// PriceViewTests.swift +// BlueWallet +// +// Created by Marcos Rodriguez on 7/10/24. +// Copyright © 2024 BlueWallet. All rights reserved. +// + +import XCTest +import SwiftUI +import WidgetKit + +@testable import BlueWallet + +class PriceViewTests: XCTestCase { + + func testAccessoryCircularView() { + guard #available(iOS 16.0, *) else { return } + let entry = PriceWidgetEntry(date: Date(), family: .accessoryCircular, currentMarketData: MockData.currentMarketData, previousMarketData: MockData.previousMarketData) + let view = PriceView(entry: entry) + let exp = expectation(description: "Test Circular View") + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + XCTAssertNotNil(view.body) + exp.fulfill() + } + waitForExpectations(timeout: 10.0, handler: nil) + } + + func testAccessoryInlineView() { + guard #available(iOS 16.0, *) else { return } + let entry = PriceWidgetEntry(date: Date(), family: .accessoryInline, currentMarketData: MockData.currentMarketData, previousMarketData: MockData.previousMarketData) + let view = PriceView(entry: entry) + let exp = expectation(description: "Test Inline View") + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + XCTAssertNotNil(view.body) + exp.fulfill() + } + waitForExpectations(timeout: 10.0, handler: nil) + } + + func testAccessoryRectangularView() { + guard #available(iOS 16.0, *) else { return } + let entry = PriceWidgetEntry(date: Date(), family: .accessoryRectangular, currentMarketData: MockData.currentMarketData, previousMarketData: MockData.previousMarketData) + let view = PriceView(entry: entry) + let exp = expectation(description: "Test Rectangular View") + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + XCTAssertNotNil(view.body) + exp.fulfill() + } + waitForExpectations(timeout: 10.0, handler: nil) + } + + func testDefaultView() { + let entry = PriceWidgetEntry(date: Date(), family: .systemSmall, currentMarketData: MockData.currentMarketData, previousMarketData: MockData.previousMarketData) + let view = PriceView(entry: entry) + let exp = expectation(description: "Test Default View") + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + XCTAssertNotNil(view.body) + exp.fulfill() + } + waitForExpectations(timeout: 10.0, handler: nil) + } + + func testNoChangeCircularView() { + guard #available(iOS 16.0, *) else { return } + let entry = PriceWidgetEntry(date: Date(), family: .accessoryCircular, currentMarketData: MockData.noChangeMarketData, previousMarketData: MockData.noChangeMarketData) + let view = PriceView(entry: entry) + let exp = expectation(description: "Test No Change Circular View") + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + XCTAssertNotNil(view.body) + exp.fulfill() + } + waitForExpectations(timeout: 10.0, handler: nil) + } +} diff --git a/ios/Widgets/Shared/Views/PriceView.swift b/ios/Widgets/Shared/Views/PriceView.swift index 6f24b1a8b..04502f5e2 100644 --- a/ios/Widgets/Shared/Views/PriceView.swift +++ b/ios/Widgets/Shared/Views/PriceView.swift @@ -15,9 +15,13 @@ struct PriceView: View { var body: some View { switch entry.family { case .accessoryInline, .accessoryCircular, .accessoryRectangular: - wrappedView(for: getView(for: entry.family), family: entry.family) + if #available(iOSApplicationExtension 16.0, *) { + wrappedView(for: getView(for: entry.family), family: entry.family) + } else { + getView(for: entry.family) + } default: - defaultView.background(Color.widgetBackground) + defaultView.background(Color(UIColor.systemBackground)) } } @@ -40,7 +44,7 @@ struct PriceView: View { ZStack { if family == .accessoryRectangular { AccessoryWidgetBackground() - .background(Color(.systemBackground)) + .background(Color(UIColor.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) } else { AccessoryWidgetBackground() @@ -118,26 +122,26 @@ struct PriceView: View { } .padding(.all, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemBackground)) + .background(Color(UIColor.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) } private var defaultView: some View { VStack(alignment: .trailing, spacing: nil, content: { - Text("Last Updated").font(Font.system(size: 11, weight: .regular, design: .default)).foregroundColor(.textColorLightGray) + Text("Last Updated").font(Font.system(size: 11, weight: .regular)).foregroundColor(Color(UIColor.lightGray)) HStack(alignment: .lastTextBaseline, spacing: nil, content: { - Text(entry.currentMarketData?.formattedDate ?? "").lineLimit(1).foregroundColor(.textColor).font(Font.system(size:13, weight: .regular, design: .default)).minimumScaleFactor(0.01).transition(.opacity) + Text(entry.currentMarketData?.formattedDate ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01).transition(.opacity) }) Spacer() VStack(alignment: .trailing, spacing: 16, content: { HStack(alignment: .lastTextBaseline, spacing: nil, content: { - Text(entry.currentMarketData?.price ?? "").lineLimit(1).foregroundColor(.textColor).font(Font.system(size:28, weight: .bold, design: .default)).minimumScaleFactor(0.01).transition(.opacity) + Text(entry.currentMarketData?.price ?? "").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 28, weight: .bold)).minimumScaleFactor(0.01).transition(.opacity) }) if let previousMarketDataPrice = entry.previousMarketData?.price, let currentMarketDataRate = entry.currentMarketData?.rate, let previousMarketDataRate = entry.previousMarketData?.rate, previousMarketDataRate > 0, currentMarketDataRate != previousMarketDataRate { HStack(alignment: .lastTextBaseline, spacing: nil, content: { - Image(systemName: currentMarketDataRate > previousMarketDataRate ? "arrow.up" : "arrow.down") - Text("from").lineLimit(1).foregroundColor(.textColor).font(Font.system(size:13, weight: .regular, design: .default)).minimumScaleFactor(0.01) - Text(previousMarketDataPrice).lineLimit(1).foregroundColor(.textColor).font(Font.system(size:13, weight: .regular, design: .default)).minimumScaleFactor(0.01) + Image(systemName: currentMarketDataRate > previousMarketDataRate ? "arrow.up" : "arrow.down") + Text("from").lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01) + Text(previousMarketDataPrice).lineLimit(1).foregroundColor(.primary).font(Font.system(size: 13, weight: .regular)).minimumScaleFactor(0.01) }).transition(.slide) } }) @@ -178,7 +182,7 @@ struct PriceView_Previews: PreviewProvider { .previewContext(WidgetPreviewContext(family: .accessoryInline)) PriceView(entry: PriceWidgetEntry(date: Date(), family: .accessoryRectangular, currentMarketData: MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2019-09-18T17:27:00+00:00"), previousMarketData: emptyMarketData)) .previewContext(WidgetPreviewContext(family: .accessoryRectangular)) - } - } - } - } + } + } + } +}