Add initial project structure with basic view models, views, and resources
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
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; } = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user