Add initial project structure with basic view models, views, and resources

This commit is contained in:
2026-09-14 19:46:46 +03:00
commit 45401645c6
68 changed files with 2538 additions and 0 deletions
@@ -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
{
}