Files

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; } = [];
}
}