Add initial project structure with basic view models, views, and resources
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CryptoPortfolio_Tracker.Models;
|
||||
using LiveChartsCore;
|
||||
using LiveChartsCore.Defaults;
|
||||
using LiveChartsCore.SkiaSharpView;
|
||||
using LiveChartsCore.SkiaSharpView.Painting;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace CryptoPortfolio_Tracker.ViewModels;
|
||||
|
||||
public partial class CryptoChartViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] public partial string CryptocurrencyName { get; set; } = null!;
|
||||
[ObservableProperty] public partial decimal CurrentPrice { get; set; }
|
||||
[ObservableProperty] public partial decimal PriceChangePercentage24H { get; set; }
|
||||
[ObservableProperty] public partial decimal PriceChange24H { get; set; }
|
||||
[ObservableProperty] public partial decimal? MarketCap { get; set; }
|
||||
[ObservableProperty] public partial decimal? Volume24H { get; set; }
|
||||
[ObservableProperty] public partial bool IsPositive { get; set; }
|
||||
[ObservableProperty] public partial bool IsNegative { get; set; }
|
||||
[ObservableProperty] public partial DateTime StartDate { get; set; }
|
||||
[ObservableProperty] public partial DateTime EndDate { get; set; }
|
||||
|
||||
public ObservableCollection<DateTimePoint> PriceHistory7D { get; } = [];
|
||||
|
||||
public TimeSpan UnitWidth { get; } = TimeSpan.FromHours(1);
|
||||
|
||||
public Func<DateTime, string> DateFormatter { get; } =
|
||||
date => date.ToString("dd.MM HH:mm");
|
||||
|
||||
public Func<double, string> PriceFormatter { get; } =
|
||||
FormatPrice;
|
||||
|
||||
public SolidColorPaint GridPaint { get; } =
|
||||
new(new SKColor(255, 255, 255, 25));
|
||||
|
||||
public SolidColorPaint ChartStroke { get; } =
|
||||
new(new SKColor(0x3B, 0xB3, 0x46));
|
||||
|
||||
public ISeries[] Series { get; private set; } = [];
|
||||
|
||||
public CryptoChartViewModel(Cryptocurrency cryptocurrency)
|
||||
{
|
||||
CryptocurrencyName = cryptocurrency.Name;
|
||||
CurrentPrice = cryptocurrency.CurrentPriceUsd;
|
||||
PriceChangePercentage24H = cryptocurrency.PriceChangePercentage24H ?? 0;
|
||||
PriceChange24H = cryptocurrency.PriceChange24H ?? 0;
|
||||
MarketCap = cryptocurrency.MarketCapUsd;
|
||||
Volume24H = cryptocurrency.Volume24HUsd;
|
||||
|
||||
IsPositive = cryptocurrency.PriceChangePercentage24H > 0;
|
||||
IsNegative = cryptocurrency.PriceChangePercentage24H < 0;
|
||||
|
||||
foreach (var point in cryptocurrency.PriceHistory7D)
|
||||
{
|
||||
PriceHistory7D.Add(point);
|
||||
}
|
||||
|
||||
if (PriceHistory7D.Count > 0)
|
||||
{
|
||||
StartDate = PriceHistory7D[0].DateTime;
|
||||
EndDate = PriceHistory7D[^1].DateTime;
|
||||
}
|
||||
|
||||
UpdateSeries();
|
||||
}
|
||||
|
||||
partial void OnStartDateChanged(DateTime value)
|
||||
{
|
||||
UpdateSeries();
|
||||
}
|
||||
|
||||
partial void OnEndDateChanged(DateTime value)
|
||||
{
|
||||
UpdateSeries();
|
||||
}
|
||||
|
||||
private void UpdateSeries()
|
||||
{
|
||||
ObservableCollection<DateTimePoint> visiblePoints = [];
|
||||
|
||||
foreach (var point in PriceHistory7D)
|
||||
{
|
||||
if (point.DateTime >= StartDate &&
|
||||
point.DateTime <= EndDate)
|
||||
{
|
||||
visiblePoints.Add(point);
|
||||
}
|
||||
}
|
||||
|
||||
if (visiblePoints.Count == 0)
|
||||
{
|
||||
Series = [];
|
||||
OnPropertyChanged(nameof(Series));
|
||||
return;
|
||||
}
|
||||
|
||||
LineSeries<DateTimePoint> lineSeries =
|
||||
new()
|
||||
{
|
||||
Values = visiblePoints,
|
||||
GeometrySize = 0,
|
||||
Fill = null,
|
||||
GeometryFill = null,
|
||||
GeometryStroke = null,
|
||||
Stroke = ChartStroke,
|
||||
LineSmoothness = 0.2
|
||||
};
|
||||
|
||||
Series =
|
||||
[
|
||||
lineSeries
|
||||
];
|
||||
|
||||
OnPropertyChanged(nameof(Series));
|
||||
}
|
||||
|
||||
private static string FormatPrice(double price)
|
||||
{
|
||||
return price switch
|
||||
{
|
||||
>= 1000 => price.ToString("N0", CultureInfo.InvariantCulture),
|
||||
>= 1 => price.ToString("N2", CultureInfo.InvariantCulture),
|
||||
>= 0.01 => price.ToString("N4", CultureInfo.InvariantCulture),
|
||||
_ => price.ToString("0.########", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CryptoPortfolio_Tracker.Views;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls.Notifications;
|
||||
using CryptoPortfolio_Tracker.Context;
|
||||
using CryptoPortfolio_Tracker.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CryptoPortfolio_Tracker.ViewModels
|
||||
{
|
||||
public partial class LoginViewModel : ObservableValidator
|
||||
{
|
||||
[Required] [ObservableProperty] public partial string Login { get; set; } = "admin";
|
||||
|
||||
[Required] [ObservableProperty] public partial string Password { get; set; } = "admin";
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
if (Validate())
|
||||
{
|
||||
await using CryptoContext context = new();
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u =>
|
||||
(u.Email == Login || u.Login == Login) && u.Password == Password);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
MainView.Current.ShowToast("Неверный логин или пароль", NotificationType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
AppState.CurrentUser = user;
|
||||
|
||||
await MainView.Current.ReplaceAsync(new HomeView
|
||||
{
|
||||
DataContext = new HomeViewModel()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bool Validate()
|
||||
{
|
||||
ValidateAllProperties();
|
||||
return !HasErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace CryptoPortfolio_Tracker.ViewModels;
|
||||
|
||||
public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace CryptoPortfolio_Tracker.ViewModels;
|
||||
|
||||
public partial class SettingsViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] public partial string Greeting { get; set; } = "This is Settings";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace CryptoPortfolio_Tracker.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user