REF: Rewrite widget to kotlin

This commit is contained in:
Marcos Rodriguez Velez 2024-07-10 22:38:25 -04:00
parent ff2136e804
commit e6e3a76edc
No known key found for this signature in database
GPG key ID: 6030B2F48CCE86D7
13 changed files with 421 additions and 205 deletions

View file

@ -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

View file

@ -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
// <activity> in AndroidManifest.xml
@Rule

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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\"}}"))
}
}

View file

@ -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
}
}
}
}

View file

@ -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<WidgetUpdateWorker>()
.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<WidgetUpdateWorker>()
.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())
}
}
}

View file

@ -1,70 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:background="@drawable/widget_background"
android:layout_margin="16dp"
android:orientation="vertical">
<TextView
android:id="@+id/last_updated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Last Updated"
android:textSize="12sp"
android:textStyle="bold"
android:layout_marginEnd="8dp"
android:layout_alignParentEnd="true"/>
<TextView
android:id="@+id/last_updated_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:layout_below="@id/last_updated"
android:layout_marginTop="2dp"
android:layout_alignParentEnd="true"/>
<TextView
android:id="@+id/price_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="24sp"
android:textStyle="bold"
android:layout_below="@id/last_updated_time"
android:layout_marginTop="8dp"
android:layout_alignParentEnd="true"/>
android:text="@string/loading"
android:textSize="18sp"
android:textColor="@color/text_primary"
android:layout_centerHorizontal="true"/>
<LinearLayout
android:id="@+id/price_arrow_container"
<TextView
android:id="@+id/last_updated"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:text="@string/last_updated"
android:textSize="12sp"
android:textColor="@color/text_secondary"
android:layout_below="@id/price_value"
android:layout_alignParentEnd="true"
android:visibility="gone">
<ImageView
android:id="@+id/price_arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"/>
<TextView
android:id="@+id/previous_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:layout_marginBottom="8dp"/>
</LinearLayout>
android:layout_centerHorizontal="true"
android:visibility="gone"
android:layout_marginTop="8dp"/>
<ProgressBar
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:visibility="gone"/>
<ImageView
android:id="@+id/price_arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/price_value"
android:layout_marginTop="4dp"
android:layout_centerHorizontal="true"
android:visibility="gone"/>
<TextView
android:id="@+id/previous_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/text_secondary"
android:layout_below="@id/price_arrow"
android:layout_centerHorizontal="true"
android:visibility="gone"/>
</RelativeLayout>

View file

@ -1,3 +1,6 @@
<resources>
<string name="app_name">BlueWallet</string>
<string name="loading">Loading...</string>
<string name="last_updated">Last Updated</string>
<string name="from">From</string>
</resources>

View file

@ -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 = "<group>"; };
B4D0B2672C1DED67006B6B1B /* ReceiveMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveMethod.swift; sourceTree = "<group>"; };
B4D3235A177F4580BA52F2F9 /* libRNCSlider.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCSlider.a; sourceTree = "<group>"; };
B4EFF73A2C3F6C5E0095D655 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = "<group>"; };
B4EFF73E2C3F6C870095D655 /* PriceViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceViewTests.swift; sourceTree = "<group>"; };
B642AFB13483418CAB6FF25E /* libRCTQRCodeLocalImage.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTQRCodeLocalImage.a; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>";
@ -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;

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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))
}
}
}
}
}
}
}
}