commit 45401645c6a569c96212e0effbad006d4a0f48e2 Author: zxcdenjir Date: Mon Sep 14 19:46:46 2026 +0300 Add initial project structure with basic view models, views, and resources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Android/Application.cs b/CryptoPortfolio Tracker.Android/Application.cs new file mode 100644 index 0000000..38446db --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Application.cs @@ -0,0 +1,21 @@ +using Android.App; +using Android.Runtime; +using Avalonia; +using Avalonia.Android; + +namespace CryptoPortfolio_Tracker.Android +{ + [Application] + public class Application : AvaloniaAndroidApplication + { + protected Application(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) + { + } + + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder) + .WithInterFont(); + } + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Android/CryptoPortfolio Tracker.Android.csproj b/CryptoPortfolio Tracker.Android/CryptoPortfolio Tracker.Android.csproj new file mode 100644 index 0000000..c5f1b08 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/CryptoPortfolio Tracker.Android.csproj @@ -0,0 +1,28 @@ + + + Exe + net10.0-android + 23 + enable + com.CompanyName.CryptoPortfolio_Tracker + 1 + 1.0 + apk + false + + + + + Resources\drawable\Icon.png + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Android/Icon.png b/CryptoPortfolio Tracker.Android/Icon.png new file mode 100644 index 0000000..3c39845 Binary files /dev/null and b/CryptoPortfolio Tracker.Android/Icon.png differ diff --git a/CryptoPortfolio Tracker.Android/MainActivity.cs b/CryptoPortfolio Tracker.Android/MainActivity.cs new file mode 100644 index 0000000..a8e1844 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/MainActivity.cs @@ -0,0 +1,16 @@ +using Android.App; +using Android.Content.PM; +using Avalonia; +using Avalonia.Android; + +namespace CryptoPortfolio_Tracker.Android; + +[Activity( + Label = "CryptoPortfolio_Tracker.Android", + Theme = "@style/MyTheme.NoActionBar", + Icon = "@drawable/icon", + MainLauncher = true, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] +public class MainActivity : AvaloniaMainActivity +{ +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Android/Properties/AndroidManifest.xml b/CryptoPortfolio Tracker.Android/Properties/AndroidManifest.xml new file mode 100644 index 0000000..9db9b3e --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Properties/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/CryptoPortfolio Tracker.Android/Resources/AboutResources.txt b/CryptoPortfolio Tracker.Android/Resources/AboutResources.txt new file mode 100644 index 0000000..c2bca97 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/AboutResources.txt @@ -0,0 +1,44 @@ +Images, layout descriptions, binary blobs and string dictionaries can be included +in your application as resource files. Various Android APIs are designed to +operate on the resource IDs instead of dealing with images, strings or binary blobs +directly. + +For example, a sample Android app that contains a user interface layout (main.axml), +an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png) +would keep its resources in the "Resources" directory of the application: + +Resources/ + drawable/ + icon.png + + layout/ + main.axml + + values/ + strings.xml + +In order to get the build system to recognize Android resources, set the build action to +"AndroidResource". The native Android APIs do not operate directly with filenames, but +instead operate on resource IDs. When you compile an Android application that uses resources, +the build system will package the resources for distribution and generate a class called "R" +(this is an Android convention) that contains the tokens for each one of the resources +included. For example, for the above Resources layout, this is what the R class would expose: + +public class R { + public class drawable { + public const int icon = 0x123; + } + + public class layout { + public const int main = 0x456; + } + + public class strings { + public const int first_string = 0xabc; + public const int second_string = 0xbcd; + } +} + +You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main +to reference the layout/main.axml file, or R.strings.first_string to reference the first +string in the dictionary file values/strings.xml. \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Android/Resources/drawable-night-v31/avalonia_anim.xml b/CryptoPortfolio Tracker.Android/Resources/drawable-night-v31/avalonia_anim.xml new file mode 100644 index 0000000..dde4b5a --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/drawable-night-v31/avalonia_anim.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Android/Resources/drawable-v31/avalonia_anim.xml b/CryptoPortfolio Tracker.Android/Resources/drawable-v31/avalonia_anim.xml new file mode 100644 index 0000000..94f27d9 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/drawable-v31/avalonia_anim.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Android/Resources/drawable/splash_screen.xml b/CryptoPortfolio Tracker.Android/Resources/drawable/splash_screen.xml new file mode 100644 index 0000000..2e920b4 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/drawable/splash_screen.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Android/Resources/values-night/colors.xml b/CryptoPortfolio Tracker.Android/Resources/values-night/colors.xml new file mode 100644 index 0000000..3d47b6f --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #212121 + diff --git a/CryptoPortfolio Tracker.Android/Resources/values-v31/styles.xml b/CryptoPortfolio Tracker.Android/Resources/values-v31/styles.xml new file mode 100644 index 0000000..d5ecec4 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/values-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/CryptoPortfolio Tracker.Android/Resources/values/colors.xml b/CryptoPortfolio Tracker.Android/Resources/values/colors.xml new file mode 100644 index 0000000..59279d5 --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/values/colors.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/CryptoPortfolio Tracker.Android/Resources/values/styles.xml b/CryptoPortfolio Tracker.Android/Resources/values/styles.xml new file mode 100644 index 0000000..6e534de --- /dev/null +++ b/CryptoPortfolio Tracker.Android/Resources/values/styles.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/CryptoPortfolio Tracker.Browser/CryptoPortfolio Tracker.Browser.csproj b/CryptoPortfolio Tracker.Browser/CryptoPortfolio Tracker.Browser.csproj new file mode 100644 index 0000000..c2fe53c --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/CryptoPortfolio Tracker.Browser.csproj @@ -0,0 +1,17 @@ + + + net10.0-browser + Exe + true + enable + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Browser/Program.cs b/CryptoPortfolio Tracker.Browser/Program.cs new file mode 100644 index 0000000..b072262 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/Program.cs @@ -0,0 +1,15 @@ +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser; +using CryptoPortfolio_Tracker; + +internal sealed partial class Program +{ + private static Task Main(string[] args) => BuildAvaloniaApp() + .WithInterFont() + .StartBrowserAppAsync("out"); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure(); +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Browser/Properties/AssemblyInfo.cs b/CryptoPortfolio Tracker.Browser/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f31aed8 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.Versioning.SupportedOSPlatform("browser")] \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Browser/Properties/launchSettings.json b/CryptoPortfolio Tracker.Browser/Properties/launchSettings.json new file mode 100644 index 0000000..bcd0746 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "CryptoPortfolio_Tracker.Browser": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7169;http://localhost:5235", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/CryptoPortfolio Tracker.Browser/runtimeconfig.template.json b/CryptoPortfolio Tracker.Browser/runtimeconfig.template.json new file mode 100644 index 0000000..b96a943 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/runtimeconfig.template.json @@ -0,0 +1,10 @@ +{ + "wasmHostProperties": { + "perHostConfig": [ + { + "name": "browser", + "host": "browser" + } + ] + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Browser/wwwroot/app.css b/CryptoPortfolio Tracker.Browser/wwwroot/app.css new file mode 100644 index 0000000..1d6f754 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/wwwroot/app.css @@ -0,0 +1,58 @@ +/* HTML styles for the splash screen */ +.avalonia-splash { + position: absolute; + height: 100%; + width: 100%; + background: white; + font-family: 'Outfit', sans-serif; + justify-content: center; + align-items: center; + display: flex; + pointer-events: none; +} + +/* Light theme styles */ +@media (prefers-color-scheme: light) { + .avalonia-splash { + background: white; + } + + .avalonia-splash h2 { + color: #1b2a4e; + } + + .avalonia-splash a { + color: #0D6EFD; + } +} + +@media (prefers-color-scheme: dark) { + .avalonia-splash { + background: #1b2a4e; + } + + .avalonia-splash h2 { + color: white; + } + + .avalonia-splash a { + color: white; + } +} + +.avalonia-splash h2 { + font-weight: 400; + font-size: 1.5rem; +} + +.avalonia-splash a { + text-decoration: none; + font-size: 2.5rem; + display: block; +} + +.avalonia-splash.splash-close { + transition: opacity 200ms, display 200ms; + display: none; + opacity: 0; +} diff --git a/CryptoPortfolio Tracker.Browser/wwwroot/favicon.ico b/CryptoPortfolio Tracker.Browser/wwwroot/favicon.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/CryptoPortfolio Tracker.Browser/wwwroot/favicon.ico differ diff --git a/CryptoPortfolio Tracker.Browser/wwwroot/index.html b/CryptoPortfolio Tracker.Browser/wwwroot/index.html new file mode 100644 index 0000000..0b75686 --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/wwwroot/index.html @@ -0,0 +1,36 @@ + + + + + CryptoPortfolio_Tracker.Browser + + + + + + +
+
+

+ Powered by + + + + + + + + + + + + + + +

+
+
+ + + + diff --git a/CryptoPortfolio Tracker.Browser/wwwroot/main.js b/CryptoPortfolio Tracker.Browser/wwwroot/main.js new file mode 100644 index 0000000..bf1555e --- /dev/null +++ b/CryptoPortfolio Tracker.Browser/wwwroot/main.js @@ -0,0 +1,13 @@ +import { dotnet } from './_framework/dotnet.js' + +const is_browser = typeof window != "undefined"; +if (!is_browser) throw new Error(`Expected to be running in a browser`); + +const dotnetRuntime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .create(); + +const config = dotnetRuntime.getConfig(); + +await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]); diff --git a/CryptoPortfolio Tracker.Desktop/CryptoPortfolio Tracker.Desktop.csproj b/CryptoPortfolio Tracker.Desktop/CryptoPortfolio Tracker.Desktop.csproj new file mode 100644 index 0000000..7e627b9 --- /dev/null +++ b/CryptoPortfolio Tracker.Desktop/CryptoPortfolio Tracker.Desktop.csproj @@ -0,0 +1,21 @@ + + + WinExe + + net10.0 + enable + + + + app.manifest + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.Desktop/Program.cs b/CryptoPortfolio Tracker.Desktop/Program.cs new file mode 100644 index 0000000..10918ac --- /dev/null +++ b/CryptoPortfolio Tracker.Desktop/Program.cs @@ -0,0 +1,21 @@ +using System; +using Avalonia; + +namespace CryptoPortfolio_Tracker.Desktop; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.Desktop/app.manifest b/CryptoPortfolio Tracker.Desktop/app.manifest new file mode 100644 index 0000000..f3821a4 --- /dev/null +++ b/CryptoPortfolio Tracker.Desktop/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.iOS/AppDelegate.cs b/CryptoPortfolio Tracker.iOS/AppDelegate.cs new file mode 100644 index 0000000..7ccc373 --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/AppDelegate.cs @@ -0,0 +1,23 @@ +using Foundation; +using UIKit; +using Avalonia; +using Avalonia.Controls; +using Avalonia.iOS; +using Avalonia.Media; + +namespace CryptoPortfolio_Tracker.iOS; + +// The UIApplicationDelegate for the application. This class is responsible for launching the +// User Interface of the application, as well as listening (and optionally responding) to +// application events from iOS. +[Register("AppDelegate")] +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public partial class AppDelegate : AvaloniaAppDelegate +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder) + .WithInterFont(); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.iOS/CryptoPortfolio Tracker.iOS.csproj b/CryptoPortfolio Tracker.iOS/CryptoPortfolio Tracker.iOS.csproj new file mode 100644 index 0000000..5b4dc4f --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/CryptoPortfolio Tracker.iOS.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0-ios + 13.0 + enable + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.iOS/Entitlements.plist b/CryptoPortfolio Tracker.iOS/Entitlements.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/Entitlements.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/CryptoPortfolio Tracker.iOS/Info.plist b/CryptoPortfolio Tracker.iOS/Info.plist new file mode 100644 index 0000000..28c4082 --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDisplayName + CryptoPortfolio_Tracker + CFBundleIdentifier + companyName.CryptoPortfolio_Tracker + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + MinimumOSVersion + 13.0 + UIDeviceFamily + + 1 + 2 + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/CryptoPortfolio Tracker.iOS/Main.cs b/CryptoPortfolio Tracker.iOS/Main.cs new file mode 100644 index 0000000..554ab35 --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/Main.cs @@ -0,0 +1,14 @@ +using UIKit; + +namespace CryptoPortfolio_Tracker.iOS; + +public class Application +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker.iOS/Resources/LaunchScreen.xib b/CryptoPortfolio Tracker.iOS/Resources/LaunchScreen.xib new file mode 100644 index 0000000..ae4c49a --- /dev/null +++ b/CryptoPortfolio Tracker.iOS/Resources/LaunchScreen.xib @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker.slnx b/CryptoPortfolio Tracker.slnx new file mode 100644 index 0000000..646c63d --- /dev/null +++ b/CryptoPortfolio Tracker.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/CryptoPortfolio Tracker/App.axaml b/CryptoPortfolio Tracker/App.axaml new file mode 100644 index 0000000..8fe368e --- /dev/null +++ b/CryptoPortfolio Tracker/App.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CryptoPortfolio Tracker/App.axaml.cs b/CryptoPortfolio Tracker/App.axaml.cs new file mode 100644 index 0000000..3cb625a --- /dev/null +++ b/CryptoPortfolio Tracker/App.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using CryptoPortfolio_Tracker.ViewModels; +using CryptoPortfolio_Tracker.Views; + +namespace CryptoPortfolio_Tracker; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); +#if DEBUG + this.AttachDeveloperTools(); +#endif + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) + { + singleViewFactoryApplicationLifetime.MainViewFactory = () => new PageNavigationHost() + { + Page = new MainView { DataContext = new MainViewModel() } + }; + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + singleViewPlatform.MainView = new PageNavigationHost() + { + Page = new MainView { DataContext = new MainViewModel() } + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Assets/Icon.png b/CryptoPortfolio Tracker/Assets/Icon.png new file mode 100644 index 0000000..3b3ba79 Binary files /dev/null and b/CryptoPortfolio Tracker/Assets/Icon.png differ diff --git a/CryptoPortfolio Tracker/Assets/avalonia-logo.ico b/CryptoPortfolio Tracker/Assets/avalonia-logo.ico new file mode 100644 index 0000000..c0488b9 Binary files /dev/null and b/CryptoPortfolio Tracker/Assets/avalonia-logo.ico differ diff --git a/CryptoPortfolio Tracker/Assets/coin-placeholder.png b/CryptoPortfolio Tracker/Assets/coin-placeholder.png new file mode 100644 index 0000000..d15df46 Binary files /dev/null and b/CryptoPortfolio Tracker/Assets/coin-placeholder.png differ diff --git a/CryptoPortfolio Tracker/Behaviors/WindowsDragBehavior.cs b/CryptoPortfolio Tracker/Behaviors/WindowsDragBehavior.cs new file mode 100644 index 0000000..168bc4e --- /dev/null +++ b/CryptoPortfolio Tracker/Behaviors/WindowsDragBehavior.cs @@ -0,0 +1,124 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace CryptoPortfolio_Tracker.Behaviors; + +public static class WindowDragBehavior +{ + public static readonly AttachedProperty EnableWindowDragProperty = + AvaloniaProperty.RegisterAttached( + "EnableWindowDrag", + typeof(WindowDragBehavior)); + + public static bool GetEnableWindowDrag(Control element) => + element.GetValue(EnableWindowDragProperty); + + public static void SetEnableWindowDrag(Control element, bool value) => + element.SetValue(EnableWindowDragProperty, value); + + static WindowDragBehavior() + { + EnableWindowDragProperty.Changed.AddClassHandler(OnEnableWindowDragChanged); + } + + private static void OnEnableWindowDragChanged(Control control, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is true) + { + // Контрол ещё может не быть в визуальном дереве + if (control.IsLoaded) + Attach(control); + else + control.Loaded += OnControlLoaded; + } + else + { + control.Loaded -= OnControlLoaded; + Detach(control); + } + } + + private static void OnControlLoaded(object? sender, RoutedEventArgs e) + { + if (sender is Control control) + { + control.Loaded -= OnControlLoaded; + Attach(control); + } + } + + private static void Attach(Control control) + { + var topBar = FindInVisualTree(control, "PART_NavigationBar"); + if (topBar is null) + return; + + topBar.AddHandler( + InputElement.PointerPressedEvent, + OnTopBarPointerPressed, + RoutingStrategies.Tunnel); + } + + private static void Detach(Control control) + { + var topBar = FindInVisualTree(control, "Border#PART_NavigationBar"); + if (topBar is null) + return; + + topBar.RemoveHandler( + InputElement.PointerPressedEvent, + OnTopBarPointerPressed); + } + + private static void OnTopBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(null).Properties.IsLeftButtonPressed) + return; + + // Проверяем — нажатие было на кнопке или интерактивном элементе? + if (e.Source is Visual source && IsInteractiveElement(source)) + return; + + if (sender is Visual visual && + TopLevel.GetTopLevel(visual) is Window window) + { + window.BeginMoveDrag(e); + } + } + + private static bool IsInteractiveElement(Visual visual) + { + Visual? current = visual; + while (current is not null) + { + if (current is Button or ToggleButton or CheckBox or + RadioButton or Slider or TextBox or ComboBox) + return true; + + if (current is Border { Name: "Border#PART_NavigationBar" }) + break; + + current = current.GetVisualParent(); + } + return false; + } + + private static T? FindInVisualTree(Visual root, string name) where T : Visual + { + if (root is T typed && root.Name == name) + return typed; + + foreach (var child in root.GetVisualChildren()) + { + var result = FindInVisualTree(child, name); + if (result is not null) + return result; + } + + return null; + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Context/CryptoContext.cs b/CryptoPortfolio Tracker/Context/CryptoContext.cs new file mode 100644 index 0000000..af46949 --- /dev/null +++ b/CryptoPortfolio Tracker/Context/CryptoContext.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using CryptoPortfolio_Tracker.Models; +using Microsoft.EntityFrameworkCore; + +namespace CryptoPortfolio_Tracker.Context; + +public partial class CryptoContext : DbContext +{ + public CryptoContext() + { + } + + public CryptoContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet CryptoTypes { get; set; } + + public virtual DbSet Cryptocurrencies { get; set; } + + public virtual DbSet Networks { get; set; } + + public virtual DbSet Transactions { get; set; } + + public virtual DbSet TransactionTypes { get; set; } + + public virtual DbSet TransactionsStatuses { get; set; } + + public virtual DbSet Users { get; set; } + + public virtual DbSet UserWallets { get; set; } + + public virtual DbSet WalletTypes { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. + => optionsBuilder.UseNpgsql("Host=denjir-host.ru;Database=crypto;Username=crypto;Password=crypto;"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("crypto_types_pkey"); + + entity.ToTable("crypto_types"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("cryptocurrencies_pkey"); + + entity.ToTable("cryptocurrencies"); + + entity.HasIndex(e => e.CoingeckoId, "cryptocurrencies_coingecko_id_key").IsUnique(); + + entity.HasIndex(e => e.Symbol, "cryptocurrencies_symbol_key").IsUnique(); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CoingeckoId).HasColumnName("coingecko_id"); + entity.Property(e => e.CryptoTypeId).HasColumnName("crypto_type_id"); + entity.Property(e => e.Name).HasColumnName("name"); + entity.Property(e => e.NetworkId).HasColumnName("network_id"); + entity.Property(e => e.Symbol).HasColumnName("symbol"); + + entity.HasOne(d => d.CryptoType).WithMany(p => p.Cryptocurrencies) + .HasForeignKey(d => d.CryptoTypeId) + .HasConstraintName("cryptocurrencies_crypto_type_id_fkey"); + + entity.HasOne(d => d.Network).WithMany(p => p.Cryptocurrencies) + .HasForeignKey(d => d.NetworkId) + .HasConstraintName("cryptocurrencies_networks_fk"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("networks_pk"); + + entity.ToTable("networks"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("transactions_pkey"); + + entity.ToTable("transactions"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Amount) + .HasPrecision(30, 18) + .HasColumnName("amount"); + entity.Property(e => e.CryptocurrencyId).HasColumnName("cryptocurrency_id"); + entity.Property(e => e.FeeUsd) + .HasPrecision(30, 2) + .HasColumnName("fee_usd"); + entity.Property(e => e.PriceUsd) + .HasPrecision(24, 10) + .HasColumnName("price_usd"); + entity.Property(e => e.RecipientAddress).HasColumnName("recipient_address"); + entity.Property(e => e.SenderAddress).HasColumnName("sender_address"); + entity.Property(e => e.StatusId).HasColumnName("status_id"); + entity.Property(e => e.TotalUsd) + .HasPrecision(30, 2) + .HasColumnName("total_usd"); + entity.Property(e => e.TransactionDate) + .HasDefaultValueSql("now()") + .HasColumnName("transaction_date"); + entity.Property(e => e.TransactionHash).HasColumnName("transaction_hash"); + entity.Property(e => e.TransactionTypeId).HasColumnName("transaction_type_id"); + entity.Property(e => e.WalletId).HasColumnName("wallet_id"); + + entity.HasOne(d => d.Cryptocurrency).WithMany(p => p.Transactions) + .HasForeignKey(d => d.CryptocurrencyId) + .HasConstraintName("transactions_cryptocurrency_id_fkey"); + + entity.HasOne(d => d.Status).WithMany(p => p.Transactions) + .HasForeignKey(d => d.StatusId) + .HasConstraintName("transactions_status_id_fkey"); + + entity.HasOne(d => d.TransactionType).WithMany(p => p.Transactions) + .HasForeignKey(d => d.TransactionTypeId) + .HasConstraintName("transactions_transaction_type_id_fkey"); + + entity.HasOne(d => d.Wallet).WithMany(p => p.Transactions) + .HasForeignKey(d => d.WalletId) + .HasConstraintName("transactions_wallet_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("transaction_types_pkey"); + + entity.ToTable("transaction_types"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("transactions_statuses_pkey"); + + entity.ToTable("transactions_statuses"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("users_pkey"); + + entity.ToTable("users"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Email).HasColumnName("email"); + entity.Property(e => e.Login).HasColumnName("login"); + entity.Property(e => e.Password).HasColumnName("password"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_wallets_pkey"); + + entity.ToTable("user_wallets"); + + entity.HasIndex(e => e.Address, "user_wallets_address_key").IsUnique(); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Address).HasColumnName("address"); + entity.Property(e => e.Amount) + .HasPrecision(30, 12) + .HasColumnName("amount"); + entity.Property(e => e.CryptocurrencyId).HasColumnName("cryptocurrency_id"); + entity.Property(e => e.Name).HasColumnName("name"); + entity.Property(e => e.TypeId).HasColumnName("type_id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + + entity.HasOne(d => d.Cryptocurrency).WithMany(p => p.UserWallets) + .HasForeignKey(d => d.CryptocurrencyId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("user_wallets_cryptocurrencies_fk"); + + entity.HasOne(d => d.Type).WithMany(p => p.UserWallets) + .HasForeignKey(d => d.TypeId) + .HasConstraintName("user_wallets_type_id_fkey"); + + entity.HasOne(d => d.User).WithMany(p => p.UserWallets) + .HasForeignKey(d => d.UserId) + .HasConstraintName("user_wallets_user_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("wallet_types_pkey"); + + entity.ToTable("wallet_types"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Name).HasColumnName("name"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/CryptoPortfolio Tracker/Core/AppState.cs b/CryptoPortfolio Tracker/Core/AppState.cs new file mode 100644 index 0000000..73c4d80 --- /dev/null +++ b/CryptoPortfolio Tracker/Core/AppState.cs @@ -0,0 +1,20 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using CryptoPortfolio_Tracker.Models; + +namespace CryptoPortfolio_Tracker.Core; + +public static class AppState +{ + public static User? CurrentUser { get; set; } = null; + + public static HttpClient CryptoHttpClient { get; } + + static AppState() + { + CryptoHttpClient = new HttpClient(); + CryptoHttpClient.DefaultRequestHeaders.Add("x-cg-demo-api-key", "CG-tRuAG4pgWucXgGYSpZMi7ADN"); + CryptoHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + CryptoHttpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CryptoPortfolioApp", "1.0")); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/CryptoPortfolio Tracker.csproj b/CryptoPortfolio Tracker/CryptoPortfolio Tracker.csproj new file mode 100644 index 0000000..3773536 --- /dev/null +++ b/CryptoPortfolio Tracker/CryptoPortfolio Tracker.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + latest + + + + + + + + + + + + None + All + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/CryptoPortfolio Tracker/Models/CryptoType.cs b/CryptoPortfolio Tracker/Models/CryptoType.cs new file mode 100644 index 0000000..3c652ae --- /dev/null +++ b/CryptoPortfolio Tracker/Models/CryptoType.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class CryptoType +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection Cryptocurrencies { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/Models/Cryptocurrency.cs b/CryptoPortfolio Tracker/Models/Cryptocurrency.cs new file mode 100644 index 0000000..c48ebeb --- /dev/null +++ b/CryptoPortfolio Tracker/Models/Cryptocurrency.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.DataAnnotations.Schema; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using LiveChartsCore.Defaults; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class Cryptocurrency : ObservableObject +{ + public long Id { get; set; } + + [ObservableProperty] public partial string Symbol { get; set; } = null!; + + [ObservableProperty] public partial string Name { get; set; } = null!; + + [ObservableProperty] public partial int CryptoTypeId { get; set; } + + [ObservableProperty] public partial long NetworkId { get; set; } + + [ObservableProperty] public partial string CoingeckoId { get; set; } = null!; + + [ObservableProperty] public partial CryptoType CryptoType { get; set; } = null!; + + [ObservableProperty] public partial Network Network { get; set; } = null!; + + [ObservableProperty] public partial ICollection Transactions { get; set; } = new List(); + + [ObservableProperty] public partial ICollection UserWallets { get; set; } = new List(); + + [NotMapped] [ObservableProperty] public partial decimal? PriceChange24H { get; set; } + + [NotMapped] + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPositive))] + [NotifyPropertyChangedFor(nameof(IsNegative))] + public partial decimal? PriceChangePercentage24H { get; set; } + + [NotMapped] [ObservableProperty] public partial decimal CurrentPriceUsd { get; set; } + + [NotMapped] [ObservableProperty] public partial DateTime? PriceUpdatedAt { get; set; } + + [NotMapped] [ObservableProperty] public partial decimal? MarketCapUsd { get; set; } + + [NotMapped] [ObservableProperty] public partial decimal? Volume24HUsd { get; set; } + + [NotMapped] [ObservableProperty] public partial Bitmap? Image { get; set; } + + [NotMapped] public bool IsPositive => PriceChangePercentage24H > 0; + + [NotMapped] public bool IsNegative => PriceChangePercentage24H < 0; + + [NotMapped] public ObservableCollection PriceHistory24H { get; set; } = []; + + [NotMapped] public ObservableCollection PriceHistory7D { get; set; } = []; +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Models/Network.cs b/CryptoPortfolio Tracker/Models/Network.cs new file mode 100644 index 0000000..81e55e2 --- /dev/null +++ b/CryptoPortfolio Tracker/Models/Network.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class Network +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection Cryptocurrencies { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/Models/Transaction.cs b/CryptoPortfolio Tracker/Models/Transaction.cs new file mode 100644 index 0000000..7c2bf5c --- /dev/null +++ b/CryptoPortfolio Tracker/Models/Transaction.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class Transaction +{ + public long Id { get; set; } + + public long CryptocurrencyId { get; set; } + + public long WalletId { get; set; } + + public DateTime TransactionDate { get; set; } + + public int TransactionTypeId { get; set; } + + public decimal Amount { get; set; } + + public decimal PriceUsd { get; set; } + + public decimal TotalUsd { get; set; } + + public decimal FeeUsd { get; set; } + + public string? TransactionHash { get; set; } + + public int StatusId { get; set; } + + public string? SenderAddress { get; set; } + + public string? RecipientAddress { get; set; } + + public virtual Cryptocurrency Cryptocurrency { get; set; } = null!; + + public virtual TransactionsStatus Status { get; set; } = null!; + + public virtual TransactionType TransactionType { get; set; } = null!; + + public virtual UserWallet Wallet { get; set; } = null!; +} diff --git a/CryptoPortfolio Tracker/Models/TransactionType.cs b/CryptoPortfolio Tracker/Models/TransactionType.cs new file mode 100644 index 0000000..9f059ea --- /dev/null +++ b/CryptoPortfolio Tracker/Models/TransactionType.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class TransactionType +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection Transactions { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/Models/TransactionsStatus.cs b/CryptoPortfolio Tracker/Models/TransactionsStatus.cs new file mode 100644 index 0000000..86cc3b8 --- /dev/null +++ b/CryptoPortfolio Tracker/Models/TransactionsStatus.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class TransactionsStatus +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection Transactions { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/Models/User.cs b/CryptoPortfolio Tracker/Models/User.cs new file mode 100644 index 0000000..cf8ec9c --- /dev/null +++ b/CryptoPortfolio Tracker/Models/User.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class User +{ + public long Id { get; set; } + + public string Login { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string Password { get; set; } = null!; + + public virtual ICollection UserWallets { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/Models/UserWallet.cs b/CryptoPortfolio Tracker/Models/UserWallet.cs new file mode 100644 index 0000000..2745c01 --- /dev/null +++ b/CryptoPortfolio Tracker/Models/UserWallet.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class UserWallet +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public string Address { get; set; } = null!; + + public int TypeId { get; set; } + + public long UserId { get; set; } + + public long? CryptocurrencyId { get; set; } + + public decimal Amount { get; set; } + + public virtual Cryptocurrency? Cryptocurrency { get; set; } + + public virtual ICollection Transactions { get; set; } = new List(); + + public virtual WalletType Type { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/CryptoPortfolio Tracker/Models/WalletType.cs b/CryptoPortfolio Tracker/Models/WalletType.cs new file mode 100644 index 0000000..6663051 --- /dev/null +++ b/CryptoPortfolio Tracker/Models/WalletType.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace CryptoPortfolio_Tracker.Models; + +public partial class WalletType +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection UserWallets { get; set; } = new List(); +} diff --git a/CryptoPortfolio Tracker/ViewModels/CryptoChartViewModel.cs b/CryptoPortfolio Tracker/ViewModels/CryptoChartViewModel.cs new file mode 100644 index 0000000..239fd07 --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/CryptoChartViewModel.cs @@ -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 PriceHistory7D { get; } = []; + + public TimeSpan UnitWidth { get; } = TimeSpan.FromHours(1); + + public Func DateFormatter { get; } = + date => date.ToString("dd.MM HH:mm"); + + public Func 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 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 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) + }; + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/ViewModels/HomeViewModel.cs b/CryptoPortfolio Tracker/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..b17f4a2 --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/HomeViewModel.cs @@ -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 _imageCache = []; + private readonly Bitmap _placeholderImage; + + public ObservableCollection 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>( + 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 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 cryptocurrencies) + { + using SemaphoreSlim semaphore = new(4); + List 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 prices24H = + await LoadChartAsync(cryptocurrency.CoingeckoId, 1); + + foreach (var point in ReducePricePoints(prices24H, 120)) + { + cryptocurrency.PriceHistory24H.Add(point); + } + + List prices7D = + await LoadChartAsync(cryptocurrency.CoingeckoId, 7); + + foreach (var point in prices7D) + { + cryptocurrency.PriceHistory7D.Add(point); + } + } + catch + { + // Ошибка одного графика не должна ломать загрузку всей таблицы. + } + finally + { + semaphore.Release(); + } + } + + private async Task> 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( + content, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (chart?.Prices is null || chart.Prices.Count == 0) + { + return []; + } + + List 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 ReducePricePoints( + List points, + int maxPoints) + { + if (points.Count <= maxPoints) + { + return points; + } + + List 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> Prices { get; set; } = []; + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/ViewModels/LoginViewModel.cs b/CryptoPortfolio Tracker/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..a223b2c --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/LoginViewModel.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/ViewModels/MainViewModel.cs b/CryptoPortfolio Tracker/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..78ec633 --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/MainViewModel.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CryptoPortfolio_Tracker.ViewModels; + +public partial class MainViewModel : ViewModelBase +{ + +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/ViewModels/SettingsViewModel.cs b/CryptoPortfolio Tracker/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..751f4d7 --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/SettingsViewModel.cs @@ -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"; +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/ViewModels/ViewModelBase.cs b/CryptoPortfolio Tracker/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..85d7dbc --- /dev/null +++ b/CryptoPortfolio Tracker/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CryptoPortfolio_Tracker.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Views/CryptoChartView.axaml b/CryptoPortfolio Tracker/Views/CryptoChartView.axaml new file mode 100644 index 0000000..65c16f0 --- /dev/null +++ b/CryptoPortfolio Tracker/Views/CryptoChartView.axaml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Views/CryptoChartView.axaml.cs b/CryptoPortfolio Tracker/Views/CryptoChartView.axaml.cs new file mode 100644 index 0000000..2a456ec --- /dev/null +++ b/CryptoPortfolio Tracker/Views/CryptoChartView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CryptoPortfolio_Tracker.Views; + +public partial class CryptoChartView : UserControl +{ + public CryptoChartView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Views/HomeView.axaml b/CryptoPortfolio Tracker/Views/HomeView.axaml new file mode 100644 index 0000000..4b2b0a0 --- /dev/null +++ b/CryptoPortfolio Tracker/Views/HomeView.axaml @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Views/HomeView.axaml.cs b/CryptoPortfolio Tracker/Views/HomeView.axaml.cs new file mode 100644 index 0000000..9d24b78 --- /dev/null +++ b/CryptoPortfolio Tracker/Views/HomeView.axaml.cs @@ -0,0 +1,55 @@ +using Avalonia.Controls; +using CryptoPortfolio_Tracker.Models; +using CryptoPortfolio_Tracker.ViewModels; +using Ursa.Controls; + +namespace CryptoPortfolio_Tracker.Views; + +public partial class HomeView : ContentPage +{ + public HomeView() + { + InitializeComponent(); + } + + private void CryptoTable_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs e) + { + if (sender is not TableView tableView) + { + return; + } + + if (tableView.SelectedItem is not Cryptocurrency cryptocurrency) + { + return; + } + + CryptoChartViewModel viewModel = + new(cryptocurrency); + + tableView.SelectedIndex = -1; + + _ = OverlayDialog.ShowCustomAsync< + CryptoChartView, + CryptoChartViewModel, + object>( + viewModel, + null, + new OverlayDialogOptions + { + Title = cryptocurrency.Name, + CanDragMove = true, + CanResize = true, + CanLightDismiss = true, + IsCloseButtonVisible = true, + HorizontalScrollBarVisibility = + Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = + Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + Buttons = DialogButton.None, + StyleClass = "CryptoChartDialog" + }); + } +} \ No newline at end of file diff --git a/CryptoPortfolio Tracker/Views/LoginView.axaml b/CryptoPortfolio Tracker/Views/LoginView.axaml new file mode 100644 index 0000000..09663ab --- /dev/null +++ b/CryptoPortfolio Tracker/Views/LoginView.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + +