From d39399101e22c3da3b58e224af87f1aa77695494 Mon Sep 17 00:00:00 2001 From: Sajal bansal <77548932+sajalbnl@users.noreply.github.com> Date: Sat, 2 Nov 2024 08:50:11 +0530 Subject: [PATCH] Token added functionality,balance and price of all tokens (#118) * Token added functionality,balance and price of all tokens * files added * Image Of tokens and Changes as requested * tweaks --------- Co-authored-by: Sajal Bansal Co-authored-by: Thomas Butler <58192340+trbutler4@users.noreply.github.com> Co-authored-by: Thomas --- wallet_app/app/build.gradle.kts | 9 + wallet_app/app/src/main/AndroidManifest.xml | 1 + .../example/walletapp/WalletAppApplication.kt | 11 ++ .../walletapp/data/coins/CoinGeckoApi.kt | 15 ++ .../walletapp/data/coins/CoinRepository.kt | 21 +- .../java/com/example/walletapp/db/TokenDao.kt | 19 ++ .../com/example/walletapp/db/TokenDatabase.kt | 59 ++++++ .../com/example/walletapp/model/CoinData.kt | 19 ++ .../java/com/example/walletapp/model/Token.kt | 14 +- .../walletapp/model/TokenIdsResponse.kt | 3 + .../walletapp/model/TokenIdsResponseItem.kt | 7 + .../com/example/walletapp/ui/MainActivity.kt | 12 +- .../com/example/walletapp/ui/WalletApp.kt | 7 +- .../walletapp/ui/account/AddTokenScreen.kt | 57 +++++- .../walletapp/ui/account/CoinViewModel.kt | 86 +++++++- .../walletapp/ui/account/TokenRepository.kt | 20 ++ .../walletapp/ui/account/TokenViewModel.kt | 39 ++++ .../walletapp/ui/account/WalletScreen.kt | 185 +++++++++++++----- .../com/example/walletapp/utils/Converters.kt | 17 ++ .../example/walletapp/utils/StarknetClient.kt | 8 +- .../example/walletapp/utils/WalletAppUtils.kt | 8 +- 21 files changed, 541 insertions(+), 76 deletions(-) create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/WalletAppApplication.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/db/TokenDao.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/db/TokenDatabase.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/model/CoinData.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponse.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponseItem.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenRepository.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenViewModel.kt create mode 100644 wallet_app/app/src/main/java/com/example/walletapp/utils/Converters.kt diff --git a/wallet_app/app/build.gradle.kts b/wallet_app/app/build.gradle.kts index 41e6c2b0..ed4a57db 100644 --- a/wallet_app/app/build.gradle.kts +++ b/wallet_app/app/build.gradle.kts @@ -117,6 +117,14 @@ dependencies { implementation(libs.androidx.hilt.navigation.fragment) implementation (libs.androidx.hilt.navigation.compose.v100alpha03) + implementation("androidx.room:room-runtime:2.6.1") + kapt("androidx.room:room-compiler:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + + // image loader + implementation("io.coil-kt:coil-compose:2.6.0") + implementation("com.github.skydoves:landscapist-glide:1.3.7") + implementation("io.coil-kt:coil-gif:2.6.0") implementation(libs.androidx.ui.tooling.preview) debugImplementation(libs.androidx.ui.tooling) @@ -131,6 +139,7 @@ dependencies { implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) diff --git a/wallet_app/app/src/main/AndroidManifest.xml b/wallet_app/app/src/main/AndroidManifest.xml index f3e65a07..7b3ef23a 100644 --- a/wallet_app/app/src/main/AndroidManifest.xml +++ b/wallet_app/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ >>; @@ -17,4 +20,16 @@ interface CoinGeckoApi { @Query("ids") ids: String, // Comma-separated token IDs @Query("vs_currencies") vsCurrencies: String // Comma-separated currency codes ): GetTokenPriceResponse + + + @Headers( + "accept: application/json", + "x-cg-demo-api-key: CG-mRdWfNFoZnKVan4GNdTrhZjL" + ) + @GET("coins/list") + suspend fun getTokenIds(@Query("include_platform") includePlatform:Boolean):TokenIdsResponse + + @Headers("accept: application/json", "x-cg-demo-api-key: CG-mRdWfNFoZnKVan4GNdTrhZjL") + @GET("coins/{id}") + suspend fun getTokenData(@Path("id") id: String,): Response } \ No newline at end of file diff --git a/wallet_app/app/src/main/java/com/example/walletapp/data/coins/CoinRepository.kt b/wallet_app/app/src/main/java/com/example/walletapp/data/coins/CoinRepository.kt index bc648bd6..77e685b3 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/data/coins/CoinRepository.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/data/coins/CoinRepository.kt @@ -1,10 +1,16 @@ + + package com.example.walletapp.data.coins import com.example.walletapp.di.RetrofitClient +import com.example.walletapp.model.CoinData +import com.example.walletapp.model.TokenIdsResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import retrofit2.Response class CoinRepository { + suspend fun getTokenPrices( ids: String, vsCurrencies: String @@ -13,4 +19,17 @@ class CoinRepository { RetrofitClient.apiService.getTokenPrices(ids, vsCurrencies) } } -} \ No newline at end of file + + suspend fun getTokenIds( + ): TokenIdsResponse { + return withContext(Dispatchers.IO) { + RetrofitClient.apiService.getTokenIds(false) + } + } + + suspend fun getTokenData(id:String):Response{ + return withContext(Dispatchers.IO) { + RetrofitClient.apiService.getTokenData(id) + } + } +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDao.kt b/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDao.kt new file mode 100644 index 00000000..a7084227 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDao.kt @@ -0,0 +1,19 @@ +package com.example.walletapp.db + + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import com.example.walletapp.model.Token +import kotlinx.coroutines.flow.Flow +import androidx.room.Query + +@Dao +interface TokenDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(token: Token) + + @Query("SELECT * FROM token_table") + fun getAllTokens(): Flow> +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDatabase.kt b/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDatabase.kt new file mode 100644 index 00000000..ec84a265 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/db/TokenDatabase.kt @@ -0,0 +1,59 @@ +package com.example.walletapp.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import com.example.walletapp.model.Token +import com.example.walletapp.utils.Converters +import com.swmansion.starknet.data.types.Felt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Database(entities = [Token::class], version = 2, exportSchema = false) +@TypeConverters(Converters::class) +abstract class TokenDatabase : RoomDatabase() { + + abstract fun tokenDao(): TokenDao + + companion object { + @Volatile + private var INSTANCE: TokenDatabase? = null + + fun getDatabase(context: Context): TokenDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + TokenDatabase::class.java, + "token_database" + ) + .addCallback(DatabaseCallback()) // Add callback here + .build() + INSTANCE = instance + instance + } + } + } + + private class DatabaseCallback : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + INSTANCE?.let { database -> + CoroutineScope(Dispatchers.IO).launch { + populateDatabase(database.tokenDao()) + } + } + } + + suspend fun populateDatabase(tokenDao: TokenDao) { + // Add default tokens + val token1 = Token(contactAddress = Felt.fromHex("0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"), name = "ethereum", symbol = "eth", decimals = 18,tokenId="ethereum") + val token2 = Token(contactAddress = Felt.fromHex("0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"), name = "starknet", symbol = "strk", decimals = 18,tokenId="starknet") + tokenDao.insert(token1) + tokenDao.insert(token2) + } + } +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/model/CoinData.kt b/wallet_app/app/src/main/java/com/example/walletapp/model/CoinData.kt new file mode 100644 index 00000000..a3265cde --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/model/CoinData.kt @@ -0,0 +1,19 @@ +package com.example.walletapp.model + +import com.google.gson.annotations.SerializedName + +data class CoinData( + @SerializedName("id") val id: String, + @SerializedName("symbol") val symbol: String, + @SerializedName("name") val name: String, + @SerializedName("web_slug") val webSlug: String, + @SerializedName("asset_platform_id") val assetPlatformId: String, + @SerializedName("image") val image: ImageData, + @SerializedName("contract_address") val contractAddress: String +) + +data class ImageData( + @SerializedName("thumb") val thumb: String, + @SerializedName("small") val small: String, + @SerializedName("large") val large: String +) diff --git a/wallet_app/app/src/main/java/com/example/walletapp/model/Token.kt b/wallet_app/app/src/main/java/com/example/walletapp/model/Token.kt index 77ae9120..4d43a1e1 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/model/Token.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/model/Token.kt @@ -1,7 +1,15 @@ package com.example.walletapp.model -class Token ( - val address: String, +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.swmansion.starknet.data.types.Felt + +@Entity(tableName = "token_table") +data class Token( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val contactAddress: Felt, + val name: String, val symbol: String, - val name: String + val decimals: Int, + val tokenId:String ) \ No newline at end of file diff --git a/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponse.kt b/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponse.kt new file mode 100644 index 00000000..edd05af5 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponse.kt @@ -0,0 +1,3 @@ +package com.example.walletapp.model + +class TokenIdsResponse : ArrayList() \ No newline at end of file diff --git a/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponseItem.kt b/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponseItem.kt new file mode 100644 index 00000000..7910d124 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/model/TokenIdsResponseItem.kt @@ -0,0 +1,7 @@ +package com.example.walletapp.model + +data class TokenIdsResponseItem( + val id: String, + val name: String, + val symbol: String +) \ No newline at end of file diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/MainActivity.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/MainActivity.kt index d4871733..1adf13c0 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/MainActivity.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/MainActivity.kt @@ -6,7 +6,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.view.WindowCompat +import androidx.lifecycle.ViewModelProvider import com.example.walletapp.BuildConfig +import com.example.walletapp.WalletAppApplication +import com.example.walletapp.ui.account.TokenRepository +import com.example.walletapp.ui.account.TokenViewModel import com.example.walletapp.utils.StarknetClient class MainActivity : ComponentActivity() { @@ -23,10 +27,16 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Access the database instance + val database = (application as WalletAppApplication).database + val repository = TokenRepository(database.tokenDao()) + val tokenViewModel: TokenViewModel = ViewModelProvider( + this, TokenViewModel.Factory(application,repository) + ).get(TokenViewModel::class.java) enableEdgeToEdge() WindowCompat.setDecorFitsSystemWindows(window, true) setContent { - WalletApp() + WalletApp(tokenViewModel) } } } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/WalletApp.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/WalletApp.kt index 02fb127f..b6426f6a 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/WalletApp.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/WalletApp.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.walletapp.ui.account.AddTokenScreen +import com.example.walletapp.ui.account.TokenViewModel import com.example.walletapp.ui.account.WalletScreen import com.example.walletapp.ui.activity.FinalizeAccountCreationScreen import com.example.walletapp.ui.onboarding.CreateAccountScreen @@ -42,7 +43,7 @@ object Send object Receive @Composable -fun WalletApp() { +fun WalletApp(tokenViewModel: TokenViewModel) { WalletappTheme { // TODO(#109): get this information from a data store @@ -90,11 +91,13 @@ fun WalletApp() { WalletScreen( onNewTokenPress = { navController.navigate( route = AddToken ) }, onReceivePress = { navController.navigate( route = Receive ) }, - onSendPress = { navController.navigate( route = Send ) } + onSendPress = { navController.navigate( route = Send ) }, + tokenViewModel = tokenViewModel ) } composable { AddTokenScreen( + tokenViewModel=tokenViewModel, onConfirm = { navController.navigateUp() } ) } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/AddTokenScreen.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/AddTokenScreen.kt index 67942fa1..53f76b2d 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/AddTokenScreen.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/AddTokenScreen.kt @@ -1,5 +1,7 @@ package com.example.walletapp.ui.account +import android.app.Activity +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,26 +18,42 @@ import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextStyle 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.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.walletapp.R +import com.example.walletapp.model.Token +import com.example.walletapp.model.TokenIdsResponse +import com.swmansion.starknet.data.types.Felt @Composable -fun AddTokenScreen(onConfirm: () -> Unit) { +fun AddTokenScreen(tokenViewModel:TokenViewModel,onConfirm: () -> Unit) { Surface(modifier = Modifier.fillMaxSize()) { val contactAddress = rememberSaveable { mutableStateOf("") } val name = rememberSaveable { mutableStateOf("") } val symbol = rememberSaveable { mutableStateOf("") } val decimals = rememberSaveable { mutableStateOf("") } + val context = (LocalContext.current as Activity) + val coinViewModel: CoinViewModel = viewModel() + var ids by rememberSaveable { mutableStateOf(TokenIdsResponse()) } + ids=coinViewModel.tokenIds.value + + LaunchedEffect(Unit) { + coinViewModel.getTokenIds() + } Column( modifier = Modifier @@ -90,9 +108,39 @@ fun AddTokenScreen(onConfirm: () -> Unit) { Spacer(modifier = Modifier.weight(1f)) - // TODO: handle saving the token data Button( - onClick = onConfirm, + onClick = { + val decimalValue = decimals.value.toIntOrNull() + val addressPattern = Regex("^0x[a-fA-F0-9]{64}$") + if (!addressPattern.matches(contactAddress.value)) { + Toast.makeText(context, "Please enter a valid address", Toast.LENGTH_LONG).show() + } + else if (name.value.isEmpty()) { + Toast.makeText(context, "Please enter name", Toast.LENGTH_LONG) + .show() + } + else if (symbol.value.isEmpty()) { + Toast.makeText(context, "Please enter symbol", Toast.LENGTH_LONG) + .show() + } else if (decimalValue == null || decimalValue !in 0..18) { + Toast.makeText(context, "Please enter valid decimal", Toast.LENGTH_LONG) + .show() + }else{ + onConfirm() + // Save data to local storage here + val address=Felt.fromHex(contactAddress.value) + val tokenId= ids.find { it.name.equals(name.value, ignoreCase = true) }?.id?:"" + val newToken = Token( + contactAddress = address, + name = name.value, + symbol = symbol.value, + decimals = decimalValue, + tokenId =tokenId + ) + tokenViewModel.insertToken(newToken) // Insert the new token + Toast.makeText(context, "Token added!", Toast.LENGTH_LONG).show() + } + }, colors = ButtonDefaults.buttonColors(backgroundColor = Color("#1B1B76".toColorInt())), modifier = Modifier .fillMaxWidth() @@ -135,10 +183,11 @@ fun SimpleTextField( shape = RoundedCornerShape(15.dp), modifier = Modifier.fillMaxWidth(), textStyle = TextStyle( - color = Color.Black, + color = Color.White, fontSize = 16.sp ) ) } } + diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/CoinViewModel.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/CoinViewModel.kt index d88ba66f..150fcf92 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/CoinViewModel.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/CoinViewModel.kt @@ -7,6 +7,10 @@ import kotlinx.coroutines.launch import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.State import com.example.walletapp.data.coins.CoinRepository +import com.example.walletapp.model.CoinData +import com.example.walletapp.model.TokenIdsResponse +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll class CoinViewModel : ViewModel() { private val repository = CoinRepository() @@ -17,8 +21,17 @@ class CoinViewModel : ViewModel() { private val _errorMessage = mutableStateOf("") val errorMessage: State = _errorMessage - fun getTokenPrices(ids: String, - vsCurrencies: String) { + private val _tokenIds = mutableStateOf(TokenIdsResponse()) // Token ID list + val tokenIds: State = _tokenIds + + private val _coinData = mutableStateOf(null) + val coinData: State = _coinData + + // State to hold the images map + private val _tokenImages = mutableStateOf>(hashMapOf()) + val tokenImages: State> = _tokenImages + + fun getTokenPrices(ids: String, vsCurrencies: String) { viewModelScope.launch { try { val response = repository.getTokenPrices(ids,vsCurrencies) @@ -40,4 +53,73 @@ class CoinViewModel : ViewModel() { } } } + + // Function to fetch token IDs + fun getTokenIds() { + viewModelScope.launch { + try { + val response = repository.getTokenIds() + _tokenIds.value = response + } catch (e: Exception) { + _errorMessage.value = "Exception: ${e.localizedMessage}" + } + } + } + + //function to fetch token data + fun getTokenData(id: String) { + viewModelScope.launch { + try { + val response = repository.getTokenData(id) + if (response.isSuccessful) { + response.body()?.let { data -> + _coinData.value = data + } ?: run { + _errorMessage.value = "No data available." + } + } else { + _errorMessage.value = "Error: ${response.code()} ${response.message()}" + } + } catch (e: Exception) { + _errorMessage.value = "Exception: ${e.localizedMessage}" + } + } + } + // Function to fetch token images in parallel + fun fetchTokenImages(tokenIds: List) { + viewModelScope.launch { + try { + // Create async requests for each tokenId + val requests = tokenIds.map { tokenId -> + async { + val response = repository.getTokenData(tokenId) + if (response.isSuccessful) { + response.body()?.let { coinData -> + // Return tokenId and its image URL if available + tokenId to coinData.image?.large + } + } else { + null // If not successful, return null + } + } + } + + // Wait for all requests to complete and filter out null results + val results = requests.awaitAll().filterNotNull() + + // Populate the HashMap with tokenId and image URL + val imageMap = hashMapOf() + results.forEach { (tokenId, imageUrl) -> + if (imageUrl != null) { + imageMap[tokenId] = imageUrl + } + } + + // Update the state with the new HashMap + _tokenImages.value = imageMap + } catch (e: Exception) { + _errorMessage.value = "Exception: ${e.localizedMessage}" + } + } + } } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenRepository.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenRepository.kt new file mode 100644 index 00000000..13d0851d --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenRepository.kt @@ -0,0 +1,20 @@ +package com.example.walletapp.ui.account + +import com.example.walletapp.db.TokenDao +import com.example.walletapp.model.Token +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class TokenRepository(private val tokenDao: TokenDao) { + + // Get all tokens from the database + val allTokens: Flow> = tokenDao.getAllTokens() + + // Insert a token into the database + suspend fun insertToken(token: Token) { + withContext(Dispatchers.IO) { + tokenDao.insert(token) + } + } +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenViewModel.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenViewModel.kt new file mode 100644 index 00000000..afddc8f5 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/TokenViewModel.kt @@ -0,0 +1,39 @@ +package com.example.walletapp.ui.account + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.example.walletapp.model.Token +import kotlinx.coroutines.launch + +class TokenViewModel(application: Application, private val repository: TokenRepository) : AndroidViewModel(application) { + + + // Expose all tokens as LiveData + val tokens: LiveData> = repository.allTokens.asLiveData() + + // Function to insert a new token + fun insertToken(token: Token) { + viewModelScope.launch { + repository.insertToken(token) + } + } + + // Define a factory to pass the repository + class Factory(private val application: Application, private val repository: TokenRepository) : + ViewModelProvider.AndroidViewModelFactory(application) { + + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(TokenViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return TokenViewModel(application, repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} + diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt index 34dc73d2..fb55db3e 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt @@ -1,9 +1,10 @@ package com.example.walletapp.ui.account +import android.annotation.SuppressLint import android.app.Activity +import android.util.Log import android.widget.Toast import androidx.compose.foundation.Image -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -18,6 +19,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -40,77 +42,111 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.* import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.toLowerCase +import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest import com.example.walletapp.BuildConfig import com.example.walletapp.R +import com.example.walletapp.model.CoinData import com.example.walletapp.utils.StarknetClient import com.example.walletapp.utils.toDoubleWithTwoDecimal import com.example.walletapp.utils.weiToEther +import com.swmansion.starknet.crypto.starknetKeccak import com.swmansion.starknet.data.types.Felt import com.swmansion.starknet.provider.exceptions.RpcRequestFailedException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import java.text.NumberFormat +import java.util.Locale @Composable fun WalletScreen( onNewTokenPress: () -> Unit, onSendPress: () -> Unit, - onReceivePress: () -> Unit + onReceivePress: () -> Unit, + tokenViewModel: TokenViewModel ) { Surface(modifier = Modifier.fillMaxSize()) { Wallet( modifier = Modifier.padding(10.dp), onNewTokenPress = onNewTokenPress, onSendPress = onSendPress, - onReceivePress = onReceivePress + onReceivePress = onReceivePress, + tokenViewModel = tokenViewModel ) } } +@SuppressLint("MutableCollectionMutableState") @Composable -fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () -> Unit, onSendPress: () -> Unit) { +fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () -> Unit, onSendPress: () -> Unit,tokenViewModel: TokenViewModel) { val networkList = listOf("Starknet Mainnet", "Test Networks") var selectedNetworkIndex by remember { mutableStateOf(0) } + val coinViewModel: CoinViewModel = viewModel() + val tokens by tokenViewModel.tokens.observeAsState(initial = emptyList()) val context = (LocalContext.current as Activity) val address= BuildConfig.ACCOUNT_ADDRESS val accountAddress = Felt.fromHex(address) val starknetClient = StarknetClient(BuildConfig.RPC_URL) - var balance by remember { mutableStateOf("") } - val coinViewModel: CoinViewModel = viewModel() + var tokenImages by rememberSaveable { mutableStateOf>(hashMapOf()) } + var balances by remember { mutableStateOf>>(emptyList()) } + val tokenIds = remember { mutableStateListOf() } + val coinsPrices by rememberSaveable { mutableStateOf>(hashMapOf()) } + var prices by rememberSaveable { mutableStateOf(mapOf()) } - val coinsPrices : HashMap = rememberSaveable { - hashMapOf() - } - val prices by coinViewModel.prices + prices = coinViewModel.prices.value + tokenImages=coinViewModel.tokenImages.value val errorMessage by coinViewModel.errorMessage - // TODO(#106): use the accounts stored tokens instead of hardcoding - LaunchedEffect(Unit) { - coinViewModel.getTokenPrices(ids = "starknet,ethereum", vsCurrencies = "usd") - } - - LaunchedEffect (Unit){ - // TODO(#107): fetch all token balances - try { - val getBalance = starknetClient.getEthBalance(accountAddress) - withContext(Dispatchers.Main) { - balance = weiToEther(getBalance).toDoubleWithTwoDecimal() + LaunchedEffect(tokens) { + if(tokens.isNotEmpty()){ + tokenIds.addAll(tokens.map { it.tokenId }) + coinViewModel.fetchTokenImages(tokenIds) + try { + coinViewModel.getTokenPrices(ids = tokenIds.joinToString(",") { it }, vsCurrencies = "usd") + val balanceDeferred: List>> = tokens.map { token -> + async(Dispatchers.IO) { + try { + val balanceInWei = starknetClient.getBalance(accountAddress, token.contactAddress) + val balanceInEther = weiToEther(balanceInWei).toDoubleWithTwoDecimal() + hashMapOf(token.name to balanceInEther) + } catch (e: RpcRequestFailedException) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "${e.code}: ${e.message}", Toast.LENGTH_LONG).show() + } + hashMapOf(token.name to 0.0) + } + } + } + // Wait for all balance fetching to complete + balances = balanceDeferred.awaitAll() + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, e.message, Toast.LENGTH_LONG).show() + } } - } catch (e: RpcRequestFailedException) { - withContext(Dispatchers.Main) { Toast.makeText(context, "${e.code}: ${e.message}", Toast.LENGTH_LONG).show() } - } catch (e: Exception) { - withContext(Dispatchers.Main) { Toast.makeText(context, e.message, Toast.LENGTH_LONG).show() } } + } if (errorMessage.isNotEmpty()) { Text( @@ -149,8 +185,16 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - } + val totalBalance = balances.flatMap { balanceMap -> + balanceMap.entries.map { (token, balance) -> + val price = coinsPrices[token] ?: 0.0 + balance * price + } + + }.sum() + val formatter = NumberFormat.getCurrencyInstance(Locale.US) Text( - text ="$11,625.48", + text = formatter.format(totalBalance), fontFamily = FontFamily(Font(R.font.publicsans_bold)), color = Color.White, fontSize = 24.sp, @@ -165,38 +209,47 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - fontSize = 14.sp, modifier = Modifier.align(Alignment.CenterHorizontally) ) - Spacer(modifier = Modifier.height(32.dp)) + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp/2 + if(tokens.isNotEmpty() && tokenImages.isNotEmpty()){ + LazyColumn(modifier=Modifier.height(screenHeight)) { + items(tokens.size) { index-> + val tokenBalance=balances.firstOrNull { balanceMap -> + balanceMap.containsKey(tokens[index].name) + }?.get(tokens[index].name)?: 0.0 + val price = coinsPrices[tokens[index].tokenId]?.let { it * tokenBalance } + ?.toDoubleWithTwoDecimal() + ?: 0.0 + WalletCard( + imageUrl = tokenImages[tokens[index].tokenId]?:"", + amount = "$$price", + name=tokens[index].name, + balance = tokenBalance.toString(), + type = tokens[index].symbol.uppercase() + ) + } + } - WalletCard( - icon = painterResource(id = R.drawable.ic_ethereum), - amount = coinsPrices["ethereum"]?.let { "$ $it" } ?: "", - exchange = balance, - type = "ETH" - ) + }else{ + CircularProgressIndicator(modifier = Modifier.height(screenHeight).align(Alignment.CenterHorizontally)) - // TOOD(#82): load actual balance - WalletCard( - icon = painterResource(id = R.drawable.token2), - amount = coinsPrices["starknet"]?.let { "$ $it" } ?: "", - exchange ="4.44", - type = "STRK" - ) + } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(32.dp)) Text( text = "+ New Token", fontFamily = FontFamily(Font(R.font.publicsans_bold)), color = Color.White, fontSize = 14.sp, - modifier = Modifier + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() .clickable { onNewTokenPress() } .background(Color.Transparent) - .padding(10.dp) .align(Alignment.CenterHorizontally) ) @@ -235,10 +288,8 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - } - - @Composable -fun WalletCard(icon: Painter, amount: String, exchange: String, type: String) { +fun WalletCard(imageUrl: String,name:String, amount: String, balance: String, type: String) { Card( backgroundColor = Color(0xFF1E1E96), modifier = Modifier @@ -251,10 +302,39 @@ fun WalletCard(icon: Painter, amount: String, exchange: String, type: String) { .padding(16.dp) .fillMaxWidth() ) { - Image( - painter = icon, // replace with your Ethereum icon - contentDescription = null, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .data(data = imageUrl) // Set default image here + .apply { crossfade(true) } + .build() + ) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + Color(0xFF1E1E96) + ) + ){Image( + painter = painter,// replace with your Ethereum icon + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .size(40.dp) + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = name.replaceFirstChar { it.uppercase() }, + fontFamily = FontFamily(Font(R.font.publicsans_semibold)), + color = Color.White, + fontSize = 18.sp + ) + + } + Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier, horizontalAlignment = Alignment.End) { Text( @@ -267,7 +347,7 @@ fun WalletCard(icon: Painter, amount: String, exchange: String, type: String) { Row { Text( - text = exchange, + text = balance, fontFamily = FontFamily(Font(R.font.publicsans_bold)), color = Color.White, fontSize = 14.sp @@ -442,8 +522,5 @@ fun SwitchNetwork( } } } - - - } } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/utils/Converters.kt b/wallet_app/app/src/main/java/com/example/walletapp/utils/Converters.kt new file mode 100644 index 00000000..bb9ea1f6 --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/utils/Converters.kt @@ -0,0 +1,17 @@ +package com.example.walletapp.utils + +import androidx.room.TypeConverter +import com.swmansion.starknet.data.types.Felt + +class Converters { + + @TypeConverter + fun fromFelt(felt: Felt): String { + return felt.hexString() // Convert Felt to String + } + + @TypeConverter + fun toFelt(feltString: String): Felt { + return Felt.fromHex(feltString) // Convert String back to Felt + } +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt b/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt index 13a93447..94dac787 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt @@ -25,6 +25,7 @@ import java.io.IOException import java.math.BigInteger import java.security.GeneralSecurityException + const val ETH_ERC20_ADDRESS = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" const val ACCOUNT_CLASS_HASH = "0x04c6d6cf894f8bc96bb9c525e6853e5483177841f7388f74a46cfda6f028c755" @@ -80,14 +81,11 @@ class StarknetClient(private val rpcUrl: String) { provider.deployAccount(payload).send() } - // TODO(#107): change name to getBalance, support getting balance for any token - // follow example: https://github.com/software-mansion/starknet-jvm/blob/main/androiddemo/src/main/java/com/example/androiddemo/MainActivity.kt - suspend fun getEthBalance(accountAddress: Felt): Uint256 { - val erc20ContractAddress = Felt.fromHex(ETH_ERC20_ADDRESS) + suspend fun getBalance(accountAddress: Felt,contractAddress:Felt): Uint256 { // Create a call to Starknet ERC-20 ETH contract val call = Call( - contractAddress = erc20ContractAddress, + contractAddress = contractAddress, entrypoint = "balanceOf", // entrypoint can be passed both as a string name and Felt value calldata = listOf(accountAddress), // calldata is List, so we wrap accountAddress in listOf() ) diff --git a/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt b/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt index b1fe16df..637b304c 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt @@ -5,14 +5,14 @@ import java.math.BigDecimal import java.text.DecimalFormat // Function to format BigDecimal to Double with 2 decimal places -fun BigDecimal.toDoubleWithTwoDecimal(): String { +fun BigDecimal.toDoubleWithTwoDecimal(): Double { val decimalFormat = DecimalFormat("#.00") - return decimalFormat.format(this.toDouble()) + return decimalFormat.format(this.toDouble()).toDouble() } -fun Double.toDoubleWithTwoDecimal(): String { +fun Double.toDoubleWithTwoDecimal(): Double { val decimalFormat = DecimalFormat("#.00") val formattedValue = decimalFormat.format(this) - return if (this < 1) "0$formattedValue" else formattedValue + return if (this < 1) "0$formattedValue".toDouble() else formattedValue.toDouble() } fun weiToEther(wei: Uint256): BigDecimal {