376 lines
11 KiB
C#
376 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Avalonia.Controls.Notifications;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Media.Imaging;
|
|
using Avalonia.Platform;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CryptoPortfolio_Tracker.Context;
|
|
using CryptoPortfolio_Tracker.Core;
|
|
using CryptoPortfolio_Tracker.Models;
|
|
using CryptoPortfolio_Tracker.Views;
|
|
using LiveChartsCore.Defaults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Ursa.Controls;
|
|
|
|
namespace CryptoPortfolio_Tracker.ViewModels;
|
|
|
|
public partial class HomeViewModel : ViewModelBase
|
|
{
|
|
private readonly Dictionary<string, Bitmap> _imageCache = [];
|
|
private readonly Bitmap _placeholderImage;
|
|
|
|
public ObservableCollection<Cryptocurrency> Cryptocurrencies { get; set; } = [];
|
|
|
|
[ObservableProperty] public partial decimal DollarBalance { get; set; }
|
|
[ObservableProperty] public partial decimal BitcoinBalance { get; set; }
|
|
[ObservableProperty] public partial decimal ProfitLoss { get; set; }
|
|
[ObservableProperty] public partial string CoinNames { get; set; } = null!;
|
|
|
|
[ObservableProperty]
|
|
public partial string UserName { get; set; } = AppState.CurrentUser?.Email ?? "example@mail.com";
|
|
|
|
[ObservableProperty] public partial bool IsCryptoLoading { get; set; } = false;
|
|
|
|
public HomeViewModel()
|
|
{
|
|
_placeholderImage =
|
|
new Bitmap(AssetLoader.Open(new Uri("avares://CryptoPortfolio Tracker/Assets/coin-placeholder.png")));
|
|
|
|
_ = LoadCryptocurrenciesCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task LoadCryptocurrenciesAsync()
|
|
{
|
|
IsCryptoLoading = true;
|
|
|
|
try
|
|
{
|
|
await using CryptoContext context = new();
|
|
|
|
var cryptocurrencies = await context.Cryptocurrencies
|
|
.Include(x => x.Network)
|
|
.ToListAsync();
|
|
|
|
var coinIds = cryptocurrencies
|
|
.Select(x => x.CoingeckoId)
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.ToList();
|
|
|
|
var ids = string.Join(",", coinIds);
|
|
|
|
var url =
|
|
"https://api.coingecko.com/api/v3/coins/markets" +
|
|
"?vs_currency=usd" +
|
|
$"&ids={Uri.EscapeDataString(ids)}" +
|
|
"&order=market_cap_desc" +
|
|
"&per_page=250" +
|
|
"&page=1" +
|
|
"&sparkline=false";
|
|
|
|
using var response = await AppState.CryptoHttpClient.GetAsync(url);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new HttpRequestException(
|
|
$"API error: {(int)response.StatusCode} " +
|
|
$"{response.ReasonPhrase}\n{content}");
|
|
}
|
|
|
|
var geckoMarkets =
|
|
JsonSerializer.Deserialize<List<CoinGeckoMarket>>(
|
|
content,
|
|
new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
}) ?? [];
|
|
|
|
Cryptocurrencies.Clear();
|
|
|
|
foreach (var r in geckoMarkets)
|
|
{
|
|
var crypto = cryptocurrencies.First(c => c.CoingeckoId == r.Id);
|
|
var currentPrice = r.CurrentPrice ?? 0m;
|
|
var changePercentage = r.PriceChangePercentage24H ?? 0m;
|
|
|
|
crypto.PriceChange24H = currentPrice * changePercentage / 100m;
|
|
crypto.PriceChangePercentage24H = changePercentage;
|
|
crypto.CurrentPriceUsd = currentPrice;
|
|
crypto.MarketCapUsd = r.MarketCap;
|
|
crypto.Volume24HUsd = r.TotalVolume;
|
|
crypto.PriceUpdatedAt = r.LastUpdated?.UtcDateTime;
|
|
crypto.Image = _placeholderImage;
|
|
crypto.PriceHistory24H.Clear();
|
|
crypto.PriceHistory7D.Clear();
|
|
|
|
Cryptocurrencies.Add(crypto);
|
|
|
|
_ = LoadImageAsync(crypto, r.Image);
|
|
}
|
|
|
|
await LoadPriceHistoriesAsync(Cryptocurrencies);
|
|
|
|
var wallets = await context.UserWallets
|
|
.Include(x => x.Type)
|
|
.Include(x => x.Cryptocurrency)
|
|
.Where(x => x.UserId == AppState.CurrentUser!.Id)
|
|
.ToListAsync();
|
|
|
|
DollarBalance = 0;
|
|
BitcoinBalance = 0;
|
|
|
|
List<string> names = [];
|
|
|
|
foreach (var wallet in wallets)
|
|
{
|
|
var geckoCurrency = geckoMarkets.First(x => x.Id == wallet.Cryptocurrency!.CoingeckoId);
|
|
var geckoBitcoin = geckoMarkets.First(x => x.Id == "bitcoin");
|
|
|
|
var dollarBalance = wallet.Amount * (geckoCurrency.CurrentPrice ?? 0m);
|
|
var bitcoinBalance = dollarBalance / (geckoBitcoin.CurrentPrice ?? 1m);
|
|
|
|
DollarBalance += dollarBalance;
|
|
BitcoinBalance += bitcoinBalance;
|
|
|
|
if (!names.Contains(wallet.Cryptocurrency!.Name))
|
|
{
|
|
names.Add(wallet.Cryptocurrency.Name);
|
|
}
|
|
}
|
|
|
|
CoinNames = string.Join(", ", names);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MainView.Current.ShowToast(
|
|
$"Ошибка при загрузке криптовалют: {ex.Message}",
|
|
NotificationType.Error);
|
|
}
|
|
finally
|
|
{
|
|
IsCryptoLoading = false;
|
|
}
|
|
}
|
|
|
|
private async Task LoadPriceHistoriesAsync(
|
|
IEnumerable<Cryptocurrency> cryptocurrencies)
|
|
{
|
|
using SemaphoreSlim semaphore = new(4);
|
|
List<Task> tasks = [];
|
|
|
|
foreach (var cryptocurrency in cryptocurrencies)
|
|
{
|
|
tasks.Add(LoadPriceHistoryAsync(cryptocurrency, semaphore));
|
|
}
|
|
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
|
|
private async Task LoadPriceHistoryAsync(
|
|
Cryptocurrency cryptocurrency,
|
|
SemaphoreSlim semaphore)
|
|
{
|
|
await semaphore.WaitAsync();
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(cryptocurrency.CoingeckoId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<DateTimePoint> prices24H =
|
|
await LoadChartAsync(cryptocurrency.CoingeckoId, 1);
|
|
|
|
foreach (var point in ReducePricePoints(prices24H, 120))
|
|
{
|
|
cryptocurrency.PriceHistory24H.Add(point);
|
|
}
|
|
|
|
List<DateTimePoint> prices7D =
|
|
await LoadChartAsync(cryptocurrency.CoingeckoId, 7);
|
|
|
|
foreach (var point in prices7D)
|
|
{
|
|
cryptocurrency.PriceHistory7D.Add(point);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ошибка одного графика не должна ломать загрузку всей таблицы.
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<List<DateTimePoint>> LoadChartAsync(
|
|
string coingeckoId,
|
|
int days)
|
|
{
|
|
var url =
|
|
$"https://api.coingecko.com/api/v3/coins/" +
|
|
$"{Uri.EscapeDataString(coingeckoId)}" +
|
|
"/market_chart" +
|
|
"?vs_currency=usd" +
|
|
$"&days={days}";
|
|
|
|
using var response =
|
|
await AppState.CryptoHttpClient.GetAsync(url);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var content =
|
|
await response.Content.ReadAsStringAsync();
|
|
|
|
var chart =
|
|
JsonSerializer.Deserialize<CoinGeckoMarketChart>(
|
|
content,
|
|
new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (chart?.Prices is null || chart.Prices.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
List<DateTimePoint> prices = [];
|
|
|
|
foreach (var point in chart.Prices)
|
|
{
|
|
if (point.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
long timestamp = Convert.ToInt64(point[0]);
|
|
|
|
DateTime date =
|
|
DateTimeOffset
|
|
.FromUnixTimeMilliseconds(timestamp)
|
|
.LocalDateTime;
|
|
|
|
prices.Add(
|
|
new DateTimePoint(
|
|
date,
|
|
point[1]));
|
|
}
|
|
|
|
return prices;
|
|
}
|
|
|
|
private static List<DateTimePoint> ReducePricePoints(
|
|
List<DateTimePoint> points,
|
|
int maxPoints)
|
|
{
|
|
if (points.Count <= maxPoints)
|
|
{
|
|
return points;
|
|
}
|
|
|
|
List<DateTimePoint> result = [];
|
|
|
|
double step =
|
|
(double)(points.Count - 1) /
|
|
(maxPoints - 1);
|
|
|
|
for (int i = 0; i < maxPoints; i++)
|
|
{
|
|
int index =
|
|
(int)Math.Round(i * step);
|
|
|
|
if (index >= points.Count)
|
|
{
|
|
index = points.Count - 1;
|
|
}
|
|
|
|
result.Add(points[index]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task LogoutAsync()
|
|
{
|
|
await MainView.Current.ReplaceAsync(
|
|
new LoginView
|
|
{
|
|
DataContext = new LoginViewModel()
|
|
});
|
|
}
|
|
|
|
private async Task LoadImageAsync(
|
|
Cryptocurrency crypto,
|
|
string? imageUrl)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_imageCache.TryGetValue(imageUrl, out var cachedImage))
|
|
{
|
|
crypto.Image = cachedImage;
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var imageBytes =
|
|
await AppState.CryptoHttpClient.GetByteArrayAsync(imageUrl);
|
|
|
|
await using var stream =
|
|
new MemoryStream(imageBytes);
|
|
|
|
var bitmap = new Bitmap(stream);
|
|
|
|
_imageCache[imageUrl] = bitmap;
|
|
crypto.Image = bitmap;
|
|
}
|
|
catch
|
|
{
|
|
crypto.Image = _placeholderImage;
|
|
}
|
|
}
|
|
|
|
private class CoinGeckoMarket
|
|
{
|
|
[JsonPropertyName("id")] public string Id { get; set; } = null!;
|
|
|
|
[JsonPropertyName("current_price")] public decimal? CurrentPrice { get; set; }
|
|
|
|
[JsonPropertyName("market_cap")] public decimal? MarketCap { get; set; }
|
|
|
|
[JsonPropertyName("total_volume")] public decimal? TotalVolume { get; set; }
|
|
|
|
[JsonPropertyName("last_updated")] public DateTimeOffset? LastUpdated { get; set; }
|
|
|
|
[JsonPropertyName("price_change_percentage_24h")]
|
|
public decimal? PriceChangePercentage24H { get; set; }
|
|
|
|
[JsonPropertyName("image")] public string? Image { get; set; }
|
|
}
|
|
|
|
private class CoinGeckoMarketChart
|
|
{
|
|
[JsonPropertyName("prices")] public List<List<double>> Prices { get; set; } = [];
|
|
}
|
|
} |