mirror of
https://github.com/GalaxyViewer/GalaxyViewer.git
synced 2026-08-14 00:57:53 +00:00
Feature/welcome view (#34)
* 📦️ Upgrades packages, renames LoggedInView to WelcomeView * 📦️ Upgrades packages, removes Shell theme * 🐛 📦 Fixes issues with trying to Login on Android, updates packages * 🐛 Grid Selection now works * 🚧 Moved some things to a Session service, fixed grids not populating on clean install * 🚧 L$ Balance is now visible * ✨ Adds a working address bar and teleporting, renames WelcomeView to DashboardView, fixes login location, general formatting tweaks * 📦️ Upgrades packages * 🌐 Localization update, tweaks for emoji, and adding RTL support * ✨ Working basic chat functionality * Update GalaxyViewer/ViewModels/MainViewModel.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Galaxy Littlepaws <34807062+GalaxyLittlepaws@users.noreply.github.com> * Update GalaxyViewer/Views/LoginView.axaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Galaxy Littlepaws <34807062+GalaxyLittlepaws@users.noreply.github.com> --------- Signed-off-by: Galaxy Littlepaws <34807062+GalaxyLittlepaws@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
2a97adc156
commit
1001a2649e
+18
@@ -452,3 +452,21 @@ $RECYCLE.BIN/
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
##
|
||||
## GalaxyViewer specific build things
|
||||
##
|
||||
|
||||
# AppImage files
|
||||
*.AppImage
|
||||
|
||||
# Build output directory (already covered by publish/ above)
|
||||
/publish/
|
||||
|
||||
# Desktop files generated during build
|
||||
*.desktop
|
||||
|
||||
# LinuxDeploy build artifacts
|
||||
linuxdeploy-output/
|
||||
linuxdeploy-*.AppImage
|
||||
appimagetool-*.AppImage
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.1.0</Version>
|
||||
<Product>GalaxyViewer</Product>
|
||||
<Description>GalaxyViewer is a cross-platform viewer for Second Life and NGC OpenSimulator.</Description>
|
||||
<Authors>Galaxy Littlepaws</Authors>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaVersion>11.1.0</AvaloniaVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -15,8 +15,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" Version="11.2.5" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.14" />
|
||||
<PackageReference Include="Avalonia.Android" Version="11.3.2" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
using Android;
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Android.Widget;
|
||||
using AndroidX.Core.App;
|
||||
using AndroidX.Core.Content;
|
||||
using Android.Views;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using Avalonia.ReactiveUI;
|
||||
using GalaxyViewer.Services;
|
||||
|
||||
namespace GalaxyViewer.Android
|
||||
{
|
||||
@@ -17,60 +12,11 @@ namespace GalaxyViewer.Android
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
Icon = "@drawable/icon",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
WindowSoftInputMode = SoftInput.AdjustResize,
|
||||
ConfigurationChanges =
|
||||
ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
public class MainActivity : AvaloniaMainActivity<App>
|
||||
{
|
||||
const int RequestStorageId = 0;
|
||||
|
||||
protected override void OnCreate(Bundle savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != Permission.Granted)
|
||||
{
|
||||
ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.WriteExternalStorage }, RequestStorageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadResources();
|
||||
// TODO: Fix this so that it doesn't instantly crash and asks for permission before loading resources
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
|
||||
{
|
||||
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if (requestCode == RequestStorageId)
|
||||
{
|
||||
if (grantResults.Length > 0 && grantResults[0] == Permission.Granted)
|
||||
{
|
||||
// Permission granted, proceed with loading resources
|
||||
LoadResources();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Permission denied, show a message to the user
|
||||
var message = "Permission to access storage denied. The application will not be able to load resources.";
|
||||
var toast = Toast.MakeText(this, message, ToastLength.Long);
|
||||
toast.Show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadResources()
|
||||
{
|
||||
// Load your resources here
|
||||
InitializeLiteDbService();
|
||||
}
|
||||
|
||||
private void InitializeLiteDbService()
|
||||
{
|
||||
// Initialize LiteDbService here
|
||||
var liteDbService = new LiteDbService();
|
||||
// Use liteDbService as needed
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"
|
||||
package="com.GalaxyViewer.GalaxyViewer">
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.galaxyviewer.android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34"/>
|
||||
<!-- For Android 11+ broad storage access (use only if really needed) -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_SURFACE_FLINGER"/>
|
||||
<uses-permission android:name="android.permission.ROTATE_SURFACE_FLINGER"/>
|
||||
<uses-permission android:name="android.permission.INTERNAL_SYSTEM_WINDOW"/>
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35"/>
|
||||
<application
|
||||
android:label="GalaxyViewer"
|
||||
android:icon="@drawable/icon"
|
||||
android:allowBackup="true"
|
||||
android:supportsRtl="true">
|
||||
<!-- Your activities and other components -->
|
||||
<activity
|
||||
android:name="GalaxyViewer"
|
||||
android:label="GalaxyViewer.Android"
|
||||
android:theme="@style/MyTheme.NoActionBar"
|
||||
android:icon="@drawable/icon"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="http" android:host="maps.secondlife.com"/>
|
||||
<data android:scheme="https" android:host="maps.secondlife.com"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -13,9 +13,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.ReactiveUI;
|
||||
|
||||
@@ -20,4 +20,4 @@ sealed class Program
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<StyleInclude Source="avares://GalaxyViewer/Styles/TextBlockOverride.axaml" />
|
||||
<semi:SemiTheme Locale="en-US" />
|
||||
<u-Semi:SemiTheme Locale="en-US" />
|
||||
<StyleInclude Source="avares://GalaxyViewer/Styles.axaml" />
|
||||
@@ -18,8 +19,15 @@
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://GalaxyViewer/Styles/Resources.axaml" />
|
||||
<ResourceInclude Source="avares://GalaxyViewer/Resources/Strings.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<converters:LocalizedStringConverter x:Key="LocalizedStringConverter" />
|
||||
<converters:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||
<converters:BoolToBackgroundConverter x:Key="BoolToBackgroundConverter" />
|
||||
<converters:BoolToFontWeightConverter x:Key="BoolToFontWeightConverter" />
|
||||
<converters:BoolToForegroundConverter x:Key="BoolToForegroundConverter" />
|
||||
<converters:BoolToStatusColorConverter x:Key="BoolToStatusColorConverter" />
|
||||
<converters:BoolToStatusTextConverter x:Key="BoolToStatusTextConverter" />
|
||||
</ResourceDictionary>
|
||||
<SolidColorBrush x:Key="SystemControlTransparentBrush" Color="Transparent" />
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
+289
-53
@@ -1,19 +1,21 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Markup.Xaml.Styling;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Media;
|
||||
using GalaxyViewer.Models;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.ViewModels;
|
||||
using GalaxyViewer.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenMetaverse;
|
||||
using Serilog;
|
||||
|
||||
namespace GalaxyViewer;
|
||||
@@ -22,8 +24,24 @@ public class App : Application, IDisposable
|
||||
{
|
||||
private static IServiceProvider? _serviceProvider;
|
||||
public static PreferencesManager? PreferencesManager { get; private set; }
|
||||
private static LiteDbService _liteDbService;
|
||||
private static SessionModel _session;
|
||||
private static LiteDbService? _liteDbService;
|
||||
private static GridClient? _gridClient;
|
||||
|
||||
private static bool _isLoggedIn;
|
||||
private IPlatformSettings? _platformSettings;
|
||||
|
||||
public static event PropertyChangedEventHandler? StaticPropertyChanged;
|
||||
|
||||
public static bool IsLoggedIn
|
||||
{
|
||||
get => _isLoggedIn;
|
||||
set
|
||||
{
|
||||
if (_isLoggedIn == value) return;
|
||||
_isLoggedIn = value;
|
||||
OnStaticPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public App()
|
||||
{
|
||||
@@ -37,15 +55,27 @@ public class App : Application, IDisposable
|
||||
"GalaxyViewer", "logs", "error.log");
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(logFilePath, rollingInterval: RollingInterval.Day)
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console(
|
||||
outputTemplate:
|
||||
"[{Timestamp:HH:mm:ss} {Level:u3}] GALAXYVIEWER: {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.File(logFilePath,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
outputTemplate:
|
||||
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<LiteDbService>();
|
||||
// Register other services here
|
||||
_gridClient = new GridClient();
|
||||
services.AddSingleton(_gridClient);
|
||||
services.AddSingleton<LiteDbService>(_ => new LiteDbService(_gridClient));
|
||||
services.AddSingleton<SessionService>(provider =>
|
||||
new SessionService(
|
||||
provider.GetRequiredService<LiteDbService>(),
|
||||
provider.GetRequiredService<GridClient>()
|
||||
));
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
@@ -54,74 +84,124 @@ public class App : Application, IDisposable
|
||||
ConfigureServices(serviceCollection);
|
||||
_serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
|
||||
_liteDbService = _serviceProvider.GetService<LiteDbService>();
|
||||
if (_liteDbService == null)
|
||||
{
|
||||
throw new InvalidOperationException("LiteDbService is not registered.");
|
||||
}
|
||||
_liteDbService = _serviceProvider.GetRequiredService<LiteDbService>();
|
||||
|
||||
PreferencesManager = new PreferencesManager(_liteDbService);
|
||||
|
||||
PreferencesManager.PreferencesChanged += OnPreferencesChanged;
|
||||
|
||||
_session = _liteDbService.GetSession();
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
base.Initialize();
|
||||
}
|
||||
|
||||
public static bool IsLoggedIn
|
||||
{
|
||||
get => _session.IsLoggedIn;
|
||||
set
|
||||
try
|
||||
{
|
||||
if (_session.IsLoggedIn != value)
|
||||
_platformSettings = PlatformSettings;
|
||||
if (_platformSettings != null)
|
||||
{
|
||||
_session.IsLoggedIn = value;
|
||||
_liteDbService.SaveSession(_session);
|
||||
OnStaticPropertyChanged();
|
||||
_platformSettings.ColorValuesChanged += OnSystemThemeChanged;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to subscribe to system theme changes");
|
||||
}
|
||||
|
||||
base.Initialize();
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_serviceProvider == null)
|
||||
{
|
||||
Log.Error("Service provider is null during framework initialization");
|
||||
throw new InvalidOperationException(
|
||||
"Service provider was not properly initialized");
|
||||
}
|
||||
|
||||
var liteDbService = _serviceProvider.GetRequiredService<LiteDbService>();
|
||||
var gridClient = _serviceProvider.GetRequiredService<GridClient>();
|
||||
var sessionService = _serviceProvider.GetRequiredService<SessionService>();
|
||||
|
||||
Log.Information("Services initialized successfully");
|
||||
|
||||
// Create UI first to avoid blocking
|
||||
switch (ApplicationLifetime)
|
||||
{
|
||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||
Log.Information("Initializing MainWindow for desktop application.");
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainViewModel(_liteDbService)
|
||||
DataContext = new MainViewModel(liteDbService, gridClient, sessionService)
|
||||
};
|
||||
desktop.MainWindow.Show();
|
||||
Log.Information("Desktop main window created and shown");
|
||||
break;
|
||||
case ISingleViewApplicationLifetime singleViewPlatform:
|
||||
Log.Information("Initializing MainView for single view application.");
|
||||
singleViewPlatform.MainView = new MainView
|
||||
{
|
||||
DataContext = new MainViewModel(_liteDbService)
|
||||
};
|
||||
Log.Information("Creating MainView for single view platform (Android)");
|
||||
break;
|
||||
default:
|
||||
Log.Warning("Unknown application lifetime type: {Type}",
|
||||
ApplicationLifetime?.GetType().Name);
|
||||
break;
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (PreferencesManager != null)
|
||||
{
|
||||
var preferences = await PreferencesManager.LoadPreferencesAsync();
|
||||
if (preferences != null)
|
||||
{
|
||||
ApplyPreferences(preferences);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load initial preferences");
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "An error occurred while initializing the main window.");
|
||||
throw; // Optionally rethrow the exception if you want to halt the application
|
||||
throw;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
public static event PropertyChangedEventHandler? StaticPropertyChanged;
|
||||
|
||||
private static void OnStaticPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
private static void OnStaticPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
StaticPropertyChanged?.Invoke(null, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private void OnSystemThemeChanged(object? sender, PlatformColorValues e)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (PreferencesManager != null)
|
||||
{
|
||||
var preferences = await PreferencesManager.LoadPreferencesAsync();
|
||||
if (preferences?.Theme == "System")
|
||||
{
|
||||
ApplyPreferences(preferences);
|
||||
RefreshThemeForAllWindows();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error handling system theme change");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnPreferencesChanged(object? sender, PreferencesModel preferences)
|
||||
{
|
||||
ApplyPreferences(preferences);
|
||||
@@ -132,36 +212,192 @@ public class App : Application, IDisposable
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
RequestedThemeVariant = preferences.Theme switch
|
||||
{
|
||||
"Light" => ThemeVariant.Light,
|
||||
"Dark" => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default
|
||||
};
|
||||
RequestedThemeVariant = GetThemeVariant(preferences.Theme);
|
||||
|
||||
// TODO: Apply other preferences
|
||||
var isDarkTheme = RequestedThemeVariant == ThemeVariant.Dark ||
|
||||
(RequestedThemeVariant == ThemeVariant.Default &&
|
||||
DetectSystemTheme() == ThemeVariant.Dark);
|
||||
|
||||
UpdateAccentColorResource(preferences.AccentColor);
|
||||
UpdateTextColorResource(isDarkTheme);
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateAccentColorResource(string accentColorPreference)
|
||||
{
|
||||
try
|
||||
{
|
||||
IBrush accentBrush;
|
||||
|
||||
if (accentColorPreference == "System Default")
|
||||
{
|
||||
accentBrush = GetSystemAccentBrush();
|
||||
}
|
||||
else
|
||||
{
|
||||
var isDarkTheme = RequestedThemeVariant == ThemeVariant.Dark ||
|
||||
(RequestedThemeVariant == ThemeVariant.Default &&
|
||||
DetectSystemTheme() == ThemeVariant.Dark);
|
||||
|
||||
var colorValue =
|
||||
PreferencesOptions.GetAccentColorForTheme(accentColorPreference, isDarkTheme);
|
||||
|
||||
try
|
||||
{
|
||||
accentBrush = new SolidColorBrush(Color.Parse(colorValue));
|
||||
}
|
||||
catch
|
||||
{
|
||||
accentBrush = GetSystemAccentBrush();
|
||||
}
|
||||
}
|
||||
|
||||
Resources["SystemAccentColorBrush"] = accentBrush;
|
||||
Resources["SystemAccentColor"] = ((SolidColorBrush)accentBrush).Color;
|
||||
// Ensure these resources are available for DynamicResource lookups
|
||||
if (Current == null) return;
|
||||
Current.Resources["SystemAccentColorBrush"] = accentBrush;
|
||||
Current.Resources["SystemAccentColor"] = ((SolidColorBrush)accentBrush).Color;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to update accent color resource");
|
||||
}
|
||||
}
|
||||
|
||||
internal static IBrush GetSystemAccentBrush()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Current?.TryGetResource("SystemAccentColorBrush",
|
||||
Current.ActualThemeVariant, out var resource) == true)
|
||||
{
|
||||
if (resource is IBrush brush)
|
||||
return brush;
|
||||
}
|
||||
|
||||
if (Current?.TryGetResource("SystemAccentColor",
|
||||
Current.ActualThemeVariant, out var colorResource) == true)
|
||||
{
|
||||
if (colorResource is Color color)
|
||||
return new SolidColorBrush(color);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to fallback
|
||||
}
|
||||
|
||||
// Final fallback to a reasonable blue
|
||||
return new SolidColorBrush(Color.Parse("#0078D4"));
|
||||
}
|
||||
|
||||
private void UpdateTextColorResource(bool isDarkTheme)
|
||||
{
|
||||
var color = isDarkTheme ? Color.Parse("#F0F0F0") : Color.Parse("#222222");
|
||||
var brush = new SolidColorBrush(color);
|
||||
|
||||
Resources["TextColor"] = color;
|
||||
Resources["TextColorBrush"] = brush;
|
||||
if (Current != null)
|
||||
{
|
||||
Current.Resources["TextColor"] = color;
|
||||
Current.Resources["TextColorBrush"] = brush;
|
||||
}
|
||||
}
|
||||
|
||||
private ThemeVariant GetThemeVariant(string themePreference)
|
||||
{
|
||||
return themePreference switch
|
||||
{
|
||||
"Light" => ThemeVariant.Light,
|
||||
"Dark" => ThemeVariant.Dark,
|
||||
"System" => DetectSystemTheme(),
|
||||
_ => ThemeVariant.Default
|
||||
};
|
||||
}
|
||||
|
||||
private ThemeVariant DetectSystemTheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_platformSettings != null)
|
||||
{
|
||||
var colorValues = _platformSettings.GetColorValues();
|
||||
return colorValues.ThemeVariant == PlatformThemeVariant.Dark
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to detect system theme, falling back to default");
|
||||
}
|
||||
|
||||
return ThemeVariant.Default;
|
||||
}
|
||||
|
||||
private async void RefreshThemeForAllWindows()
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||
return;
|
||||
|
||||
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
try
|
||||
{
|
||||
foreach (var window in desktopLifetime.Windows)
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||
return;
|
||||
|
||||
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
if (window is not BaseWindow baseWindow) continue;
|
||||
var resultTheme = (await PreferencesManager?.LoadPreferencesAsync())?.Theme;
|
||||
if (resultTheme != null)
|
||||
baseWindow.ApplyTheme(resultTheme);
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
if (PreferencesManager != null)
|
||||
{
|
||||
var preferences = await PreferencesManager.LoadPreferencesAsync();
|
||||
if (preferences?.Theme != null)
|
||||
{
|
||||
foreach (var window in desktopLifetime.Windows)
|
||||
{
|
||||
if (window is BaseWindow baseWindow)
|
||||
{
|
||||
baseWindow.ApplyTheme(preferences.Theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error refreshing themes for windows");
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Warning(e, "Failed to refresh theme for all windows");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement a method to set language resources based on user preferences
|
||||
// Currently we only have US English resources
|
||||
private static void SetLanguageResources()
|
||||
{
|
||||
var language = PreferencesManager?.CurrentPreferences?.Language ?? "en-US";
|
||||
var resources = Current?.Resources;
|
||||
if (resources == null) return;
|
||||
resources.MergedDictionaries.Clear();
|
||||
|
||||
if (language == "en-US")
|
||||
{
|
||||
resources.MergedDictionaries.Add(
|
||||
new ResourceInclude(new Uri("avares://GalaxyViewer/Resources/Strings.axaml")));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_platformSettings != null)
|
||||
{
|
||||
_platformSettings.ColorValuesChanged -= OnSystemThemeChanged;
|
||||
}
|
||||
|
||||
//PreferencesManager?.Dispose();
|
||||
(_serviceProvider as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,30 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Resources;
|
||||
|
||||
namespace GalaxyViewer.Assets.Localization;
|
||||
|
||||
public class LocalizationManager : INotifyPropertyChanged
|
||||
{
|
||||
private readonly ResourceManager _resourceManager = new(typeof(Strings));
|
||||
private CultureInfo _currentCulture = CultureInfo.CurrentCulture;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public string GetString(string name)
|
||||
{
|
||||
return _resourceManager.GetString(name, _currentCulture) ?? $"[{name}]";
|
||||
}
|
||||
|
||||
public void SetCulture(string cultureCode)
|
||||
{
|
||||
var newCulture = new CultureInfo(cultureCode);
|
||||
if (_currentCulture.Name != newCulture.Name)
|
||||
{
|
||||
_currentCulture = newCulture;
|
||||
CultureInfo.CurrentCulture = newCulture;
|
||||
CultureInfo.CurrentUICulture = newCulture;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace GalaxyViewer.Assets.Localization {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Strings {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Strings() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GalaxyViewer.Assets.Localization.Strings", typeof(Strings).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace GalaxyViewer.Assets.Localization {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Strings_en_US {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Strings_en_US() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GalaxyViewer.Assets.Localization.Strings.en-US", typeof(Strings_en_US).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Login.
|
||||
/// </summary>
|
||||
internal static string LoginScreenLoginButton {
|
||||
get {
|
||||
return ResourceManager.GetString("LoginScreenLoginButton", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Password.
|
||||
/// </summary>
|
||||
internal static string LoginScreenPassword {
|
||||
get {
|
||||
return ResourceManager.GetString("LoginScreenPassword", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Register.
|
||||
/// </summary>
|
||||
internal static string LoginScreenRegisterButton {
|
||||
get {
|
||||
return ResourceManager.GetString("LoginScreenRegisterButton", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Login.
|
||||
/// </summary>
|
||||
internal static string LoginScreenTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("LoginScreenTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Username.
|
||||
/// </summary>
|
||||
internal static string LoginScreenUsername {
|
||||
get {
|
||||
return ResourceManager.GetString("LoginScreenUsername", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<!-- Add your English strings here -->
|
||||
<!-- General Stuff -->
|
||||
<data name="WelcomeMessage" xml:space="preserve">
|
||||
<value>Welcome to GalaxyViewer 🌌</value>
|
||||
</data>
|
||||
<data name="Error" xml:space="preserve">
|
||||
<value>Error</value>
|
||||
</data>
|
||||
<data name="Ok" xml:space="preserve">
|
||||
<value>Ok</value>
|
||||
</data>
|
||||
<data name="Cancel" xml:space="preserve">
|
||||
<value>Cancel</value>
|
||||
</data>
|
||||
<!-- Login Screen -->
|
||||
<data name="LoginScreenTitle" xml:space="preserve">
|
||||
<value>Login</value>
|
||||
</data>
|
||||
<data name="LoginScreenUsername" xml:space="preserve">
|
||||
<value>Username</value>
|
||||
</data>
|
||||
<data name="LoginScreenPassword" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="LoginLocation" xml:space="preserve">
|
||||
<value>Login Location</value>
|
||||
</data>
|
||||
<data name="LoginScreenGrid" xml:space="preserve">
|
||||
<value>Grid</value>
|
||||
</data>
|
||||
<data name="LoginScreenLoginButton" xml:space="preserve">
|
||||
<value>Login</value>
|
||||
</data>
|
||||
<data name="LoginSuceess" xml:space="preserve">
|
||||
<value>Login successful</value>
|
||||
</data>
|
||||
<data name="LoginScreenWrongCredentials" xml:space="preserve">
|
||||
<value>Wrong username or password</value>
|
||||
</data>
|
||||
<!-- Preferences Screen -->
|
||||
<data name="PreferencesTitle" xml:space="preserve">
|
||||
<value>Preferences</value>
|
||||
</data>
|
||||
<data name="PreferencesLanguage" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="PreferencesLoginLocation" xml:space="preserve">
|
||||
<value>Login Location</value>
|
||||
</data>
|
||||
<data name="PreferencesTheme" xml:space="preserve">
|
||||
<value>Theme</value>
|
||||
</data>
|
||||
<data name="PreferencesFont" xml:space="preserve">
|
||||
<value>Font</value>
|
||||
</data>
|
||||
<data name="PreferencesSaveButton" xml:space="preserve">
|
||||
<value>Save Preferences</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,82 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!--
|
||||
This is in English (United States) language, as the default. It is in case there is no other language file available.
|
||||
-->
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<!-- General Stuff -->
|
||||
<data name="WelcomeMessage" xml:space="preserve">
|
||||
<value>Welcome to GalaxyViewer 🌌</value>
|
||||
</data>
|
||||
<data name="Error" xml:space="preserve">
|
||||
<value>Error</value>
|
||||
</data>
|
||||
<data name="Ok" xml:space="preserve">
|
||||
<value>Ok</value>
|
||||
</data>
|
||||
<data name="Cancel" xml:space="preserve">
|
||||
<value>Cancel</value>
|
||||
</data>
|
||||
<!-- Login Screen -->
|
||||
<data name="LoginScreenTitle" xml:space="preserve">
|
||||
<value>Login</value>
|
||||
</data>
|
||||
<data name="LoginScreenUsername" xml:space="preserve">
|
||||
<value>Username</value>
|
||||
</data>
|
||||
<data name="LoginScreenPassword" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="LoginLocation" xml:space="preserve">
|
||||
<value>Login Location</value>
|
||||
</data>
|
||||
<data name="LoginScreenGrid" xml:space="preserve">
|
||||
<value>Grid</value>
|
||||
</data>
|
||||
<data name="LoginScreenLoginButton" xml:space="preserve">
|
||||
<value>Login</value>
|
||||
</data>
|
||||
<data name="LoginSuceess" xml:space="preserve">
|
||||
<value>Login successful</value>
|
||||
</data>
|
||||
<data name="LoginScreenWrongCredentials" xml:space="preserve">
|
||||
<value>Wrong username or password</value>
|
||||
</data>
|
||||
<!-- Preferences Screen -->
|
||||
<data name="PreferencesTitle" xml:space="preserve">
|
||||
<value>Preferences</value>
|
||||
</data>
|
||||
<data name="PreferencesLanguage" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="PreferencesLoginLocation" xml:space="preserve">
|
||||
<value>Login Location</value>
|
||||
</data>
|
||||
<data name="PreferencesTheme" xml:space="preserve">
|
||||
<value>Theme</value>
|
||||
</data>
|
||||
<data name="PreferencesFont" xml:space="preserve">
|
||||
<value>Font</value>
|
||||
</data>
|
||||
<data name="PreferencesSaveButton" xml:space="preserve">
|
||||
<value>Save Preferences</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:viewModels="clr-namespace:GalaxyViewer.ViewModels"
|
||||
xmlns:converters="clr-namespace:GalaxyViewer.Converters"
|
||||
x:Class="GalaxyViewer.Controls.AddressBar"
|
||||
x:DataType="viewModels:AddressBarViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<converters:BoolToDoubleConverter x:Key="BoolToDoubleConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<!-- Custom Styles for AddressBar using accent color -->
|
||||
<UserControl.Styles>
|
||||
<!-- Address bar button styling -->
|
||||
<Style Selector="Button.address-bar-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="Button.address-bar-button:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.address-bar-button:pressed">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Address display styling -->
|
||||
<Style Selector="Border.address-display">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Margin" Value="8,0" />
|
||||
</Style>
|
||||
<Style Selector="Border.address-display:focus-within">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
</Style>
|
||||
|
||||
<!-- Address bar container styling -->
|
||||
<Style Selector="Border.address-bar-container">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
</Style>
|
||||
|
||||
<!-- Icon styling -->
|
||||
<Style Selector="PathIcon.address-bar-icon">
|
||||
<Setter Property="Width" Value="16" />
|
||||
<Setter Property="Height" Value="16" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.address-bar-button:pointerover PathIcon.address-bar-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.address-bar-button:pressed PathIcon.address-bar-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<UserControl.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding CommitEditCommand}" />
|
||||
</UserControl.KeyBindings>
|
||||
|
||||
<Border Classes="address-bar-container">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Home Button -->
|
||||
<Button Grid.Column="0"
|
||||
Classes="address-bar-button"
|
||||
Command="{Binding HomeCommand}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Home_Tooltip}"
|
||||
Margin="4"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Home_A11y}">
|
||||
<PathIcon Classes="address-bar-icon"
|
||||
Data="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" />
|
||||
</Button>
|
||||
|
||||
<!-- Navigation Button Group -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="0">
|
||||
<!-- Back Button -->
|
||||
<Button Classes="address-bar-button"
|
||||
Command="{Binding BackCommand}"
|
||||
IsEnabled="{Binding CanGoBack}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Back_Tooltip}"
|
||||
Margin="2,4"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Back_A11y}"
|
||||
Opacity="{Binding CanGoBack, Converter={StaticResource BoolToDoubleConverter}, ConverterParameter='1.0 0.4'}">
|
||||
<PathIcon Classes="address-bar-icon"
|
||||
Data="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z" />
|
||||
</Button>
|
||||
|
||||
<!-- Forward Button -->
|
||||
<Button Classes="address-bar-button"
|
||||
Command="{Binding ForwardCommand}"
|
||||
IsEnabled="{Binding CanGoForward}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Forward_Tooltip}"
|
||||
Margin="2,4"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Forward_A11y}"
|
||||
Opacity="{Binding CanGoForward, Converter={StaticResource BoolToDoubleConverter}, ConverterParameter='1.0 0.4'}">
|
||||
<PathIcon Classes="address-bar-icon"
|
||||
Data="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Address Display/Editor -->
|
||||
<Border Grid.Column="3" Classes="address-display">
|
||||
<Grid>
|
||||
<!-- Read-only current location display -->
|
||||
<Button Command="{Binding StartEditCommand}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
VerticalContentAlignment="Center"
|
||||
Padding="8,4"
|
||||
AutomationProperties.Name="Current location, click to edit"
|
||||
IsVisible="{Binding IsEditing, Converter={x:Static BoolConverters.Not}}">
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!-- Maturity Rating Badge -->
|
||||
<Border Width="18" Height="18"
|
||||
CornerRadius="9"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="{Binding MaturityRatingBrush}"
|
||||
IsVisible="{Binding ShowMaturityRating}"
|
||||
ToolTip.Tip="{Binding MaturityRatingTooltip}"
|
||||
AutomationProperties.Name="{Binding MaturityRatingTooltip}">
|
||||
<TextBlock Text="{Binding MaturityRating}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
FontWeight="Bold"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
|
||||
<!-- Current Location Text -->
|
||||
<TextBlock Text="{Binding CurrentLocationDisplay}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}"
|
||||
FontWeight="Medium" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Stretch">
|
||||
<TextBox x:Name="EditTextBox"
|
||||
Text="{Binding EditableLocation, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,2,0,2"
|
||||
FontSize="14"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Watermark="{StaticResource AddressBar_Edit_Watermark}"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"
|
||||
SelectionBrush="{DynamicResource SystemControlHighlightAccentBrush}"
|
||||
CaretBrush="{DynamicResource SystemControlForegroundBaseHighBrush}"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Edit_A11y}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding CommitEditCommand}" />
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelEditCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
<TextBox.Styles>
|
||||
<Style Selector="TextBox /template/ TextBlock#PART_Watermark">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ TextBlock#PART_Watermark">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
</TextBox.Styles>
|
||||
</TextBox>
|
||||
<Button Content="✕"
|
||||
Margin="16,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
Command="{Binding CancelEditCommand}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Cancel_Tooltip}"
|
||||
MinWidth="32"
|
||||
MinHeight="32" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Action Buttons Group -->
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="0">
|
||||
<!-- Search/Go Button -->
|
||||
<Button Classes="address-bar-button"
|
||||
Command="{Binding CommitEditCommand}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Go_Tooltip}"
|
||||
Margin="2,4"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Go_A11y}">
|
||||
<PathIcon Classes="address-bar-icon"
|
||||
Data="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" />
|
||||
</Button>
|
||||
|
||||
<!-- Settings Button -->
|
||||
<Button Classes="address-bar-button"
|
||||
Command="{Binding SettingsCommand}"
|
||||
ToolTip.Tip="{StaticResource AddressBar_Settings_Tooltip}"
|
||||
Margin="4"
|
||||
AutomationProperties.Name="{StaticResource AddressBar_Settings_A11y}">
|
||||
<PathIcon Classes="address-bar-icon"
|
||||
Data="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,43 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using System.Linq;
|
||||
using Avalonia.VisualTree;
|
||||
using GalaxyViewer.ViewModels;
|
||||
using System.Reactive;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace GalaxyViewer.Controls
|
||||
{
|
||||
public partial class AddressBar : UserControl
|
||||
{
|
||||
public AddressBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
AddHandler(PointerPressedEvent, OnRootPointerPressed, handledEventsToo: true);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
private void OnRootPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (DataContext is not AddressBarViewModel vm)
|
||||
return;
|
||||
if (!vm.IsEditing)
|
||||
return;
|
||||
|
||||
var textBox = this.GetVisualDescendants().OfType<TextBox>().FirstOrDefault(tb => tb.IsVisible);
|
||||
if (textBox == null)
|
||||
return;
|
||||
|
||||
var pointerPos = e.GetPosition(textBox);
|
||||
var bounds = new Avalonia.Rect(0, 0, textBox.Bounds.Width, textBox.Bounds.Height);
|
||||
if (!bounds.Contains(pointerPos))
|
||||
{
|
||||
vm.CancelEditCommand.Execute(Unit.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace GalaxyViewer.Controls;
|
||||
|
||||
public class EmojiAwareTextBlock : TextBlock
|
||||
{
|
||||
public static readonly StyledProperty<FontFamily> EmojiFontFamilyProperty =
|
||||
AvaloniaProperty.Register<EmojiAwareTextBlock, FontFamily>(
|
||||
nameof(EmojiFontFamily));
|
||||
|
||||
public FontFamily EmojiFontFamily
|
||||
{
|
||||
get => GetValue(EmojiFontFamilyProperty);
|
||||
set => SetValue(EmojiFontFamilyProperty, value);
|
||||
}
|
||||
|
||||
public EmojiAwareTextBlock()
|
||||
{
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == TextProperty || e.Property == EmojiFontFamilyProperty)
|
||||
{
|
||||
ApplyFonts();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyFonts()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text))
|
||||
return;
|
||||
|
||||
var inlines = new List<Inline>();
|
||||
for (var i = 0; i < Text.Length; i++)
|
||||
{
|
||||
string charOrEmoji;
|
||||
bool isEmoji;
|
||||
|
||||
if (char.IsSurrogatePair(Text, i))
|
||||
{
|
||||
charOrEmoji = Text.Substring(i, 2);
|
||||
isEmoji = true;
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
charOrEmoji = Text[i].ToString();
|
||||
// This really could use some improvement, but it's as good as I can get for now
|
||||
isEmoji = char.IsSurrogate(Text[i]) ||
|
||||
(Text[i] >= '\u2600' && Text[i] <= '\u27BF') ||
|
||||
(Text[i] >= '\u2B50' && Text[i] <= '\u2B55');
|
||||
}
|
||||
|
||||
var run = new Run(charOrEmoji)
|
||||
{
|
||||
FontFamily = isEmoji ? EmojiFontFamily : FontFamily
|
||||
};
|
||||
inlines.Add(run);
|
||||
}
|
||||
|
||||
if (Inlines == null)
|
||||
{
|
||||
Inlines = new InlineCollection();
|
||||
}
|
||||
|
||||
Inlines.Clear();
|
||||
Inlines.AddRange(inlines);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,26 @@
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
using Serilog;
|
||||
|
||||
namespace GalaxyViewer.Controls;
|
||||
|
||||
public abstract partial class EmojiTextBlock : TextBlock
|
||||
public partial class EmojiTextBlock : TextBlock
|
||||
{
|
||||
private static readonly Regex EmojiRegex = MyRegex();
|
||||
|
||||
public static readonly StyledProperty<FontFamily> EmojiFontFamilyProperty =
|
||||
AvaloniaProperty.Register<EmojiTextBlock, FontFamily>(nameof(EmojiFontFamily));
|
||||
|
||||
public static readonly StyledProperty<FontFamily> DefaultFontFamilyProperty =
|
||||
AvaloniaProperty.Register<EmojiTextBlock, FontFamily>(nameof(DefaultFontFamily));
|
||||
|
||||
public FontFamily EmojiFontFamily
|
||||
{
|
||||
get => GetValue(EmojiFontFamilyProperty);
|
||||
set => SetValue(EmojiFontFamilyProperty, value);
|
||||
}
|
||||
|
||||
public static readonly StyledProperty<FontFamily> DefaultFontFamilyProperty =
|
||||
AvaloniaProperty.Register<EmojiTextBlock, FontFamily>(nameof(DefaultFontFamily));
|
||||
|
||||
public FontFamily DefaultFontFamily
|
||||
{
|
||||
get => GetValue(DefaultFontFamilyProperty);
|
||||
@@ -31,9 +29,11 @@ public abstract partial class EmojiTextBlock : TextBlock
|
||||
|
||||
protected EmojiTextBlock()
|
||||
{
|
||||
this.PropertyChanged += (_, e) =>
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == TextProperty)
|
||||
if (e.Property == TextProperty ||
|
||||
e.Property == EmojiFontFamilyProperty ||
|
||||
e.Property == DefaultFontFamilyProperty)
|
||||
{
|
||||
ApplyFonts();
|
||||
}
|
||||
@@ -45,12 +45,43 @@ public abstract partial class EmojiTextBlock : TextBlock
|
||||
if (string.IsNullOrEmpty(Text))
|
||||
return;
|
||||
|
||||
var inlines = Text.Select(ch => new Run(ch.ToString()) { FontFamily = EmojiRegex.IsMatch(ch.ToString()) ? EmojiFontFamily : DefaultFontFamily }).Cast<Inline>().ToList();
|
||||
var inlines = new List<Inline>();
|
||||
for (int i = 0; i < Text.Length; i++)
|
||||
{
|
||||
string charOrEmoji;
|
||||
bool isEmoji;
|
||||
|
||||
Inlines?.Clear();
|
||||
Inlines?.AddRange(inlines);
|
||||
if (char.IsSurrogatePair(Text, i))
|
||||
{
|
||||
charOrEmoji = Text.Substring(i, 2);
|
||||
isEmoji = true;
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
charOrEmoji = Text[i].ToString();
|
||||
isEmoji = char.IsSurrogate(Text[i]) ||
|
||||
(Text[i] >= '\u2600' && Text[i] <= '\u27BF') ||
|
||||
(Text[i] >= '\u2B50' && Text[i] <= '\u2B55');
|
||||
}
|
||||
|
||||
var fontFamily = isEmoji ? EmojiFontFamily : DefaultFontFamily;
|
||||
|
||||
var run = new Run(charOrEmoji)
|
||||
{
|
||||
FontFamily = fontFamily
|
||||
};
|
||||
inlines.Add(run);
|
||||
}
|
||||
|
||||
if (Inlines == null)
|
||||
{
|
||||
Inlines = new InlineCollection();
|
||||
}
|
||||
|
||||
Inlines.Clear();
|
||||
Inlines.AddRange(inlines);
|
||||
|
||||
Log.Information("Font application complete. Total inlines: {Count}", inlines.Count);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[\u203C-\u3299\u1F000-\u1F644\u1F680-\u1F6FF\u1F700-\u1F77F\u1F780-\u1F7FF\u1F800-\u1F8FF\u1F900-\u1F9FF\u1FA00-\u1FA6F\u1FA70-\u1FAFF\u1FB00-\u1FBFF]", RegexOptions.Compiled)]
|
||||
private static partial Regex MyRegex();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using GalaxyViewer.Models;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class AccentColorConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string accentColorPreference || accentColorPreference == "System Default")
|
||||
return GetSystemAccentBrush();
|
||||
|
||||
var isDarkTheme = IsCurrentThemeDark();
|
||||
|
||||
var colorValue = PreferencesOptions.GetAccentColorForTheme(accentColorPreference, isDarkTheme);
|
||||
|
||||
try
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorValue));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return GetSystemAccentBrush();
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static bool IsCurrentThemeDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
var app = Avalonia.Application.Current;
|
||||
if (app?.ActualThemeVariant != null)
|
||||
{
|
||||
return app.ActualThemeVariant == ThemeVariant.Dark;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to fallback
|
||||
}
|
||||
|
||||
// Fallback: assume light theme
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IBrush GetSystemAccentBrush()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Avalonia.Application.Current?.TryGetResource("SystemAccentColorBrush",
|
||||
Avalonia.Application.Current.ActualThemeVariant, out var resource) == true)
|
||||
{
|
||||
if (resource is IBrush brush)
|
||||
return brush;
|
||||
}
|
||||
|
||||
if (Avalonia.Application.Current?.TryGetResource("SystemAccentColor",
|
||||
Avalonia.Application.Current.ActualThemeVariant, out var colorResource) == true)
|
||||
{
|
||||
if (colorResource is Color color)
|
||||
return new SolidColorBrush(color);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to fallback
|
||||
}
|
||||
|
||||
// Final fallback to a reasonable blue
|
||||
return new SolidColorBrush(Color.Parse("#0078D4"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class BoolToDoubleConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not bool boolValue || parameter is not string doubleParams)
|
||||
return 1.0;
|
||||
var values = doubleParams.Split(' ', ',');
|
||||
if (values.Length < 2) return 1.0;
|
||||
if (double.TryParse(values[0].Trim(), out var trueValue) &&
|
||||
double.TryParse(values[1].Trim(), out var falseValue))
|
||||
{
|
||||
return boolValue ? trueValue : falseValue;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class InverseBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is bool b ? !b : value;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using GalaxyViewer.Assets.Localization;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class LocalizedStringConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (parameter is string key) return new LocalizationManager().GetString(key);
|
||||
return "Key not found for " + value;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
// Change to en-US if the language is not found
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class BoolToBackgroundConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isActive && isActive)
|
||||
{
|
||||
return new SolidColorBrush(Colors.Transparent);
|
||||
}
|
||||
return new SolidColorBrush(Colors.LightGray);
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class BoolToFontWeightConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isActive && isActive)
|
||||
{
|
||||
return FontWeight.SemiBold;
|
||||
}
|
||||
return FontWeight.Normal;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class BoolToForegroundConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isActive && isActive)
|
||||
{
|
||||
return Brushes.Black;
|
||||
}
|
||||
return Brushes.Gray;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class BoolToStatusColorConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isLoggedIn && isLoggedIn)
|
||||
{
|
||||
return new SolidColorBrush(Colors.Green);
|
||||
}
|
||||
return new SolidColorBrush(Colors.Red);
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class BoolToStatusTextConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isLoggedIn && isLoggedIn)
|
||||
{
|
||||
return "Connected";
|
||||
}
|
||||
return "Disconnected";
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace GalaxyViewer.Converters;
|
||||
|
||||
public class TextDirectionConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string text || string.IsNullOrEmpty(text))
|
||||
return FlowDirection.LeftToRight;
|
||||
|
||||
// Check if the first character in the text is RTL
|
||||
var firstChar = text.FirstOrDefault(char.IsLetter);
|
||||
if (firstChar == default)
|
||||
return FlowDirection.LeftToRight;
|
||||
|
||||
var unicodeCategory = char.GetUnicodeCategory(firstChar);
|
||||
|
||||
// Check if it's an RTL character
|
||||
if (IsRtlUnicodeCategory(unicodeCategory))
|
||||
return FlowDirection.RightToLeft;
|
||||
|
||||
// Additional check for RTL scripts by Unicode ranges
|
||||
if (IsRtlCharacter(firstChar))
|
||||
return FlowDirection.RightToLeft;
|
||||
|
||||
return FlowDirection.LeftToRight;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static bool IsRtlUnicodeCategory(UnicodeCategory category)
|
||||
{
|
||||
return category == UnicodeCategory.OtherLetter &&
|
||||
char.GetUnicodeCategory('\u0627') == category; // Arabic letter Alef
|
||||
}
|
||||
|
||||
private static bool IsRtlCharacter(char character)
|
||||
{
|
||||
var code = (int)character;
|
||||
|
||||
// Hebrew: U+0590 to U+05FF
|
||||
if (code >= 0x0590 && code <= 0x05FF) return true;
|
||||
|
||||
// Arabic: U+0600 to U+06FF
|
||||
if (code >= 0x0600 && code <= 0x06FF) return true;
|
||||
|
||||
// Arabic Supplement: U+0750 to U+077F
|
||||
if (code >= 0x0750 && code <= 0x077F) return true;
|
||||
|
||||
// Arabic Extended-A: U+08A0 to U+08FF
|
||||
if (code >= 0x08A0 && code <= 0x08FF) return true;
|
||||
|
||||
// Persian/Farsi and Urdu use Arabic script
|
||||
// Thaana (Maldivian): U+0780 to U+07BF
|
||||
if (code >= 0x0780 && code <= 0x07BF) return true;
|
||||
|
||||
// Syriac: U+0700 to U+074F
|
||||
if (code >= 0x0700 && code <= 0x074F) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,27 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>2025.07.22-test</Version>
|
||||
<AssemblyInformationalVersion>$(Version)</AssemblyInformationalVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<ApplicationIcon>Assets\GalaxyViewerLogo.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- <PropertyGroup>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup> -->
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Remove="Assets\Fonts\*.ttf" />
|
||||
<AvaloniaResource Include="Assets\Fonts\*.ttf" />
|
||||
<None Update="Assets\GalaxyViewerLogo.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="Assets\Fonts\*.ttf">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<AvaloniaResource Include="Styles\**" />
|
||||
<AvaloniaResource Include="Styles\Atkinson Hyperlegible.axaml" />
|
||||
<AvaloniaResource Include="Styles\Inter.axaml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,55 +30,34 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
|
||||
<!-- Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration. -->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<!-- More things will go here -->
|
||||
<PackageReference Include="AvaloniaInside.Shell" Version="1.3.0" />
|
||||
<PackageReference Include="Irihi.Ursa" Version="1.9.0" />
|
||||
<PackageReference Include="Irihi.Ursa" Version="1.12.0" />
|
||||
<PackageReference Include="Irihi.Ursa.ReactiveUIExtension" Version="1.0.1" />
|
||||
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="1.9.0" />
|
||||
<PackageReference Include="LibreMetaverse" Version="2.2.4.917" />
|
||||
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="1.12.0" />
|
||||
<PackageReference Include="LibreMetaverse" Version="2.4.3.1065" />
|
||||
<PackageReference Include="LiteDB" Version="5.0.21" />
|
||||
<PackageReference Include="Live.Avalonia" Version="1.4.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Semi.Avalonia" Version="11.2.1.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
|
||||
<PackageReference Include="Semi.Avalonia" Version="11.2.1.9" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Assets\Localization\Strings.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Assets\Localization\Strings.en-US.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Strings.en.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Assets\Localization\Strings.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Strings.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Assets\Localization\Strings.en-US.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Strings.en-US.resx</DependentUpon>
|
||||
</Compile>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Libs\" />
|
||||
<Folder Include="Tests\" />
|
||||
<Folder Include="Wrappers\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.Models;
|
||||
|
||||
public class ChatConversation : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isTyping;
|
||||
|
||||
public UUID AvatarUuid { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ChatMessageType MessageType { get; init; }
|
||||
public UUID ParticipantId { get; init; }
|
||||
public UUID GroupId { get; set; }
|
||||
public string? GroupName { get; set; }
|
||||
public UUID? SessionId { get; init; }
|
||||
public string? AvatarImage { get; set; }
|
||||
public ObservableCollection<ChatMessage> Messages { get; set; } = [];
|
||||
public ObservableCollection<string> TypingUsers { get; set; } = [];
|
||||
|
||||
private DateTime _lastActivity = DateTime.Now;
|
||||
private bool _hasUnreadMessages;
|
||||
private int _unreadCount;
|
||||
private string _lastMessage = string.Empty;
|
||||
private bool _isActive;
|
||||
|
||||
public DateTime LastActivity
|
||||
{
|
||||
get => _lastActivity;
|
||||
set
|
||||
{
|
||||
if (_lastActivity == value) return;
|
||||
_lastActivity = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LastActivity)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasUnreadMessages
|
||||
{
|
||||
get => _hasUnreadMessages;
|
||||
set
|
||||
{
|
||||
if (_hasUnreadMessages == value) return;
|
||||
_hasUnreadMessages = value;
|
||||
PropertyChanged?.Invoke(this,
|
||||
new PropertyChangedEventArgs(nameof(HasUnreadMessages)));
|
||||
}
|
||||
}
|
||||
|
||||
public int UnreadCount
|
||||
{
|
||||
get => _unreadCount;
|
||||
set
|
||||
{
|
||||
if (_unreadCount == value) return;
|
||||
_unreadCount = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UnreadCount)));
|
||||
}
|
||||
}
|
||||
|
||||
public string LastMessage
|
||||
{
|
||||
get => _lastMessage;
|
||||
set
|
||||
{
|
||||
if (_lastMessage == value) return;
|
||||
_lastMessage = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LastMessage)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActive
|
||||
{
|
||||
get => _isActive;
|
||||
set
|
||||
{
|
||||
if (_isActive == value) return;
|
||||
_isActive = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsActive)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTyping
|
||||
{
|
||||
get => _isTyping;
|
||||
set
|
||||
{
|
||||
if (_isTyping == value) return;
|
||||
_isTyping = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsTyping)));
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.Models;
|
||||
|
||||
public class ChatMessage
|
||||
{
|
||||
public string SenderName { get; set; } = string.Empty;
|
||||
public UUID SenderUuid { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
public ChatMessageType MessageType { get; set; }
|
||||
public ChatType ChatType { get; set; } = ChatType.Normal;
|
||||
public ChatSourceType SourceType { get; set; }
|
||||
public ChatAudibleLevel AudibleLevel { get; set; }
|
||||
public UUID GroupId { get; set; }
|
||||
public string? GroupName { get; set; }
|
||||
public bool IsFromSelf { get; set; }
|
||||
public InstantMessageDialog? ImDialog { get; set; }
|
||||
public bool IsSystemMessage { get; set; }
|
||||
|
||||
public string MessageTag
|
||||
{
|
||||
get
|
||||
{
|
||||
if (MessageType == ChatMessageType.System)
|
||||
return "system-message";
|
||||
return IsFromSelf ? "from-self" : "from-other";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatMessageType
|
||||
{
|
||||
LocalChat,
|
||||
InstantMessage,
|
||||
GroupChat,
|
||||
System,
|
||||
Objects
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using ReactiveUI;
|
||||
|
||||
namespace GalaxyViewer.Models
|
||||
{
|
||||
public class DebugViewModel(IScreen screen) : ReactiveObject, IRoutableViewModel
|
||||
{
|
||||
public string UrlPathSegment => "debug";
|
||||
public IScreen HostScreen { get; } = screen;
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,15 @@ public class GridModel
|
||||
public string Register { get; set; }
|
||||
public string Password { get; set; }
|
||||
public string Version { get; set; }
|
||||
public object IsDefault { get; set; }
|
||||
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is GridModel other && GridName == other.GridName;
|
||||
|
||||
public override int GetHashCode() => GridName?.GetHashCode() ?? 0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return GridName;
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,7 @@ public class PreferencesModel
|
||||
public string Font { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string SelectedGridNick { get; set; }
|
||||
public string AccentColor { get; set; } = "System Default";
|
||||
public long LastSavedEpoch { get; set; }
|
||||
public string Version { get; set; }
|
||||
}
|
||||
@@ -4,8 +4,44 @@ namespace GalaxyViewer.Models;
|
||||
|
||||
public static class PreferencesOptions
|
||||
{
|
||||
public static readonly List<string> ThemeOptions = ["Light", "Dark", "Default"];
|
||||
public static readonly List<string> ThemeOptions = ["Light", "Dark", "System"];
|
||||
public static readonly List<string> LoginLocationOptions = ["Home", "Last Location"];
|
||||
public static readonly List<string> FontOptions = ["Inter", "Atkinson Hyperlegible"];
|
||||
public static readonly List<string> LanguageOptions = ["en-US"];
|
||||
|
||||
public static readonly List<string> AccentColorOptions = [
|
||||
"System Default", // Uses OS accent color
|
||||
"Blue", // Classic blue
|
||||
"Purple", // Purple accent
|
||||
"Teal", // Teal accent
|
||||
"Green", // Green accent
|
||||
"Orange", // Orange accent
|
||||
"Red", // Red accent
|
||||
"Pink", // Pink accent
|
||||
"Indigo" // Indigo accent
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, (string Light, string Dark)> AccentColors = new()
|
||||
{
|
||||
{ "System Default", ("SystemAccent", "SystemAccent") }, // Special key for system accent
|
||||
{ "Blue", ("#0078D4", "#60CDFF") }, // Light blue -> Bright blue for dark mode
|
||||
{ "Purple", ("#8B5A9F", "#B19CD9") }, // Purple -> Light purple for dark mode
|
||||
{ "Teal", ("#0F7173", "#4CC2C4") }, // Teal -> Light teal for dark mode
|
||||
{ "Green", ("#107C10", "#6BCF7F") }, // Green -> Light green for dark mode
|
||||
{ "Orange", ("#D83B01", "#FF8C00") }, // Orange -> Light orange for dark mode
|
||||
{ "Red", ("#D13438", "#FF6B6B") }, // Red -> Light red for dark mode
|
||||
{ "Pink", ("#E3008C", "#FF69B4") }, // Pink -> Light pink for dark mode
|
||||
{ "Indigo", ("#5C2D91", "#9A7DC4") } // Indigo -> Light indigo for dark mode
|
||||
};
|
||||
|
||||
public static string GetAccentColorForTheme(string accentColorName, bool isDarkTheme)
|
||||
{
|
||||
if (!AccentColors.TryGetValue(accentColorName, out var colorPair))
|
||||
{
|
||||
// Fallback to Blue if accent color not found
|
||||
colorPair = AccentColors["Blue"];
|
||||
}
|
||||
|
||||
return isDarkTheme ? colorPair.Dark : colorPair.Light;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.Models;
|
||||
|
||||
public class SessionModel
|
||||
public class SessionModel : INotifyPropertyChanged
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public bool IsLoggedIn { get; set; }
|
||||
public string AvatarName { get; set; }
|
||||
public UUID AvatarKey { get; set; }
|
||||
public int Balance { get; set; }
|
||||
public string CurrentLocation { get; set; }
|
||||
public string LoginWelcomeMessage { get; set; }
|
||||
private int _id;
|
||||
private string _avatarName;
|
||||
private UUID _avatarKey;
|
||||
private string _currentLocation;
|
||||
private string _loginWelcomeMessage;
|
||||
|
||||
public int Id
|
||||
{
|
||||
get => _id;
|
||||
set
|
||||
{
|
||||
if (_id == value) return;
|
||||
_id = value;
|
||||
OnPropertyChanged(nameof(Id));
|
||||
}
|
||||
}
|
||||
|
||||
public string AvatarName
|
||||
{
|
||||
get => _avatarName;
|
||||
set
|
||||
{
|
||||
if (_avatarName == value) return;
|
||||
_avatarName = value;
|
||||
OnPropertyChanged(nameof(AvatarName));
|
||||
}
|
||||
}
|
||||
|
||||
public UUID AvatarKey
|
||||
{
|
||||
get => _avatarKey;
|
||||
set
|
||||
{
|
||||
if (_avatarKey == value) return;
|
||||
_avatarKey = value;
|
||||
OnPropertyChanged(nameof(AvatarKey));
|
||||
}
|
||||
}
|
||||
|
||||
public string CurrentLocation
|
||||
{
|
||||
get => _currentLocation;
|
||||
set
|
||||
{
|
||||
if (_currentLocation == value) return;
|
||||
_currentLocation = value;
|
||||
OnPropertyChanged(nameof(CurrentLocation));
|
||||
}
|
||||
}
|
||||
|
||||
public string LoginWelcomeMessage
|
||||
{
|
||||
get => _loginWelcomeMessage;
|
||||
set
|
||||
{
|
||||
if (_loginWelcomeMessage == value) return;
|
||||
_loginWelcomeMessage = value;
|
||||
OnPropertyChanged(nameof(LoginWelcomeMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged(string propertyName) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- ReSharper disable InconsistentNaming -->
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<x:String x:Key="AddressBar_Back_A11y">Go back</x:String>
|
||||
<x:String x:Key="AddressBar_Back_Tooltip">Go Back</x:String>
|
||||
<x:String x:Key="AddressBar_Cancel_Tooltip">Cancel editing</x:String>
|
||||
<x:String x:Key="AddressBar_Edit_A11y">Location editor</x:String>
|
||||
<x:String x:Key="AddressBar_Edit_Watermark">Enter location: Region Name, Region/X/Y/Z, secondlife://URL, or maps.secondlife.com URL</x:String>
|
||||
<x:String x:Key="AddressBar_Forward_A11y">Go forward</x:String>
|
||||
<x:String x:Key="AddressBar_Forward_Tooltip">Go Forward</x:String>
|
||||
<x:String x:Key="AddressBar_Go_A11y">Search or go to location</x:String>
|
||||
<x:String x:Key="AddressBar_Go_Tooltip">Search/Go</x:String>
|
||||
<x:String x:Key="AddressBar_Home_A11y">Go to home location</x:String>
|
||||
<x:String x:Key="AddressBar_Home_Tooltip">Go Home</x:String>
|
||||
<x:String x:Key="AddressBar_Settings_A11y">Open address bar settings</x:String>
|
||||
<x:String x:Key="AddressBar_Settings_Tooltip">Settings</x:String>
|
||||
<x:String x:Key="App_Title">GalaxyViewer</x:String>
|
||||
<x:String x:Key="App_WelcomeMessage">Welcome to GalaxyViewer</x:String>
|
||||
<x:String x:Key="Chat_MessagesPlaceholder">Chat messages will appear here...</x:String>
|
||||
<x:String x:Key="Chat_ObjectImWarning">You can't send instant messages to objects.</x:String>
|
||||
<x:String x:Key="Chat_PopOut">Pop Out</x:String>
|
||||
<x:String x:Key="Chat_PopOutA11y">Pop out chat window</x:String>
|
||||
<x:String x:Key="Chat_PopOutTooltip">Open chat in separate window</x:String>
|
||||
<x:String x:Key="Chat_Send">Send</x:String>
|
||||
<x:String x:Key="Chat_Title">Chat</x:String>
|
||||
<x:String x:Key="Chat_TypeMessage">Type a message...</x:String>
|
||||
<x:String x:Key="ChatWindow_Title">GalaxyViewer - Chat</x:String>
|
||||
<x:String x:Key="Common_Cancel">Cancel</x:String>
|
||||
<x:String x:Key="Common_Error">Error</x:String>
|
||||
<x:String x:Key="Common_Ok">Ok</x:String>
|
||||
<x:String x:Key="ConversationDrawer_Title">Conversations</x:String>
|
||||
<x:String x:Key="Dashboard_RefreshBalance_Tooltip">Click to refresh balance</x:String>
|
||||
<x:String x:Key="DesktopMenu_AboutLand">About Land</x:String>
|
||||
<x:String x:Key="DesktopMenu_AboutRegion">About Region</x:String>
|
||||
<x:String x:Key="DesktopMenu_Chat">Chat</x:String>
|
||||
<x:String x:Key="DesktopMenu_Classifieds">Classifieds</x:String>
|
||||
<x:String x:Key="DesktopMenu_Communicate">Communicate</x:String>
|
||||
<x:String x:Key="DesktopMenu_Community">Community</x:String>
|
||||
<x:String x:Key="DesktopMenu_CreateLandmark">Create new Landmark Here</x:String>
|
||||
<x:String x:Key="DesktopMenu_Dev">Dev</x:String>
|
||||
<x:String x:Key="DesktopMenu_DevTools">Developer Tools</x:String>
|
||||
<x:String x:Key="DesktopMenu_Events">Events</x:String>
|
||||
<x:String x:Key="DesktopMenu_Exit">Exit</x:String>
|
||||
<x:String x:Key="DesktopMenu_Favorites">Favorites</x:String>
|
||||
<x:String x:Key="DesktopMenu_File">File</x:String>
|
||||
<x:String x:Key="DesktopMenu_Friends">Friends</x:String>
|
||||
<x:String x:Key="DesktopMenu_FriendsList">Friends List</x:String>
|
||||
<x:String x:Key="DesktopMenu_Groups">Groups</x:String>
|
||||
<x:String x:Key="DesktopMenu_ImportObject">Import Object</x:String>
|
||||
<x:String x:Key="DesktopMenu_Landmarks">Landmarks</x:String>
|
||||
<x:String x:Key="DesktopMenu_Login">Login</x:String>
|
||||
<x:String x:Key="DesktopMenu_Logout">Logout</x:String>
|
||||
<x:String x:Key="DesktopMenu_Marketplace">Marketplace</x:String>
|
||||
<x:String x:Key="DesktopMenu_MiniMap">Mini-Map</x:String>
|
||||
<x:String x:Key="DesktopMenu_NearbyMedia">Nearby Media</x:String>
|
||||
<x:String x:Key="DesktopMenu_NearbyObjects">Nearby Objects</x:String>
|
||||
<x:String x:Key="DesktopMenu_NearbyPeople">Nearby People</x:String>
|
||||
<x:String x:Key="DesktopMenu_NewWindow">New Window</x:String>
|
||||
<x:String x:Key="DesktopMenu_ObjectsNearby">Objects Nearby</x:String>
|
||||
<x:String x:Key="DesktopMenu_PeopleNearby">People Nearby</x:String>
|
||||
<x:String x:Key="DesktopMenu_Preferences">Preferences</x:String>
|
||||
<x:String x:Key="DesktopMenu_Relog">Relog</x:String>
|
||||
<x:String x:Key="DesktopMenu_ScriptEditor">Script Editor</x:String>
|
||||
<x:String x:Key="DesktopMenu_SetHome">Set Home to Here</x:String>
|
||||
<x:String x:Key="DesktopMenu_TeleportHistory">Teleport History</x:String>
|
||||
<x:String x:Key="DesktopMenu_TeleportHome">Teleport Home</x:String>
|
||||
<x:String x:Key="DesktopMenu_UploadBlinnPhong">Upload Blinn-Phong Texture</x:String>
|
||||
<x:String x:Key="DesktopMenu_UploadMesh">Upload Mesh</x:String>
|
||||
<x:String x:Key="DesktopMenu_UploadPBR">Upload PBR Material</x:String>
|
||||
<x:String x:Key="DesktopMenu_Voice">Voice</x:String>
|
||||
<x:String x:Key="DesktopMenu_World">World</x:String>
|
||||
<x:String x:Key="DesktopMenu_WorldMap">World Map</x:String>
|
||||
<x:String x:Key="Login_Button">Login</x:String>
|
||||
<x:String x:Key="Login_Error_WrongCredentials">Wrong username or password</x:String>
|
||||
<x:String x:Key="Login_Grid">Grid</x:String>
|
||||
<x:String x:Key="Login_Location">Login Location</x:String>
|
||||
<x:String x:Key="Login_Password">Password</x:String>
|
||||
<x:String x:Key="Login_Success">Login successful</x:String>
|
||||
<x:String x:Key="Login_Title">Login</x:String>
|
||||
<x:String x:Key="Login_Username">Username</x:String>
|
||||
<x:String x:Key="Login_Username_Tooltip">Enter your username</x:String>
|
||||
<x:String x:Key="Login_Username_A11y">Username input field</x:String>
|
||||
<x:String x:Key="Login_Username_Watermark">Username</x:String>
|
||||
<x:String x:Key="Login_Password_Tooltip">Enter your password</x:String>
|
||||
<x:String x:Key="Login_Password_A11y">Password input field</x:String>
|
||||
<x:String x:Key="Login_Password_Watermark">Password</x:String>
|
||||
<x:String x:Key="Login_Button_Tooltip">Log in to GalaxyViewer</x:String>
|
||||
<x:String x:Key="Login_Button_A11y">Login button</x:String>
|
||||
<x:String x:Key="MenuAndroid_Chat">Chat</x:String>
|
||||
<x:String x:Key="MenuAndroid_DevTools">Developer Tools</x:String>
|
||||
<x:String x:Key="MenuAndroid_Friends">Friends</x:String>
|
||||
<x:String x:Key="MenuAndroid_Groups">Groups</x:String>
|
||||
<x:String x:Key="MenuAndroid_Landmarks">Landmarks</x:String>
|
||||
<x:String x:Key="MenuAndroid_Login">Login</x:String>
|
||||
<x:String x:Key="MenuAndroid_Logout">Logout</x:String>
|
||||
<x:String x:Key="MenuAndroid_Preferences">Preferences</x:String>
|
||||
<x:String x:Key="MenuAndroid_TeleportHome">Teleport Home</x:String>
|
||||
<x:String x:Key="MenuAndroid_WorldMap">World Map</x:String>
|
||||
<x:String x:Key="MfaPrompt_EnterCode">Enter MFA Code:</x:String>
|
||||
<x:String x:Key="MfaPrompt_Code_Watermark">6-digit code</x:String>
|
||||
<x:String x:Key="MfaPrompt_Code_Tooltip">Enter your multi-factor authentication code</x:String>
|
||||
<x:String x:Key="MfaPrompt_Code_A11y">MFA code input field</x:String>
|
||||
<x:String x:Key="MfaPrompt_Submit">Submit</x:String>
|
||||
<x:String x:Key="MfaPrompt_Submit_Tooltip">Submit MFA code</x:String>
|
||||
<x:String x:Key="MfaPrompt_Submit_A11y">Submit button for MFA code</x:String>
|
||||
<x:String x:Key="Preferences_AccentColor">Accent Color</x:String>
|
||||
<x:String x:Key="Preferences_Appearance">Appearance</x:String>
|
||||
<x:String x:Key="Preferences_BackButton">Back</x:String>
|
||||
<x:String x:Key="Preferences_Font">Font</x:String>
|
||||
<x:String x:Key="Preferences_Language">Language</x:String>
|
||||
<x:String x:Key="Preferences_Localization">Localization</x:String>
|
||||
<x:String x:Key="Preferences_LoginLocation">Login Location</x:String>
|
||||
<x:String x:Key="Preferences_SaveButton">Save Preferences</x:String>
|
||||
<x:String x:Key="Preferences_Theme">Theme</x:String>
|
||||
<x:String x:Key="Preferences_Title">Preferences</x:String>
|
||||
<x:String x:Key="Preferences_Theme_Tooltip">Select your preferred theme</x:String>
|
||||
<x:String x:Key="Preferences_Theme_A11y">Theme selection</x:String>
|
||||
<x:String x:Key="Preferences_AccentColor_Tooltip">Choose an accent color</x:String>
|
||||
<x:String x:Key="Preferences_AccentColor_A11y">Accent color selection</x:String>
|
||||
<x:String x:Key="Preferences_Font_Tooltip">Select your preferred font</x:String>
|
||||
<x:String x:Key="Preferences_Font_A11y">Font selection</x:String>
|
||||
<x:String x:Key="Preferences_Language_Tooltip">Choose your language</x:String>
|
||||
<x:String x:Key="Preferences_Language_A11y">Language selection</x:String>
|
||||
<x:String x:Key="Preferences_SaveButton_Tooltip">Save your preferences</x:String>
|
||||
<x:String x:Key="Preferences_SaveButton_A11y">Save preferences button</x:String>
|
||||
<x:String x:Key="Preferences_BackButton_Tooltip">Go back to the previous screen</x:String>
|
||||
<x:String x:Key="Preferences_BackButton_A11y">Back button</x:String>
|
||||
</ResourceDictionary>
|
||||
<!-- ReSharper restore InconsistentNaming -->
|
||||
@@ -0,0 +1,675 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using GalaxyViewer.Models;
|
||||
using OpenMetaverse;
|
||||
using Serilog;
|
||||
using ChatType = OpenMetaverse.ChatType;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
|
||||
public sealed class ChatService : IDisposable
|
||||
{
|
||||
private readonly GridClient _client;
|
||||
private readonly LiteDbService _dbService;
|
||||
private bool _disposed;
|
||||
|
||||
private bool
|
||||
_hasShownConnectionMessage;
|
||||
|
||||
public ObservableCollection<ChatConversation> Conversations { get; } = [];
|
||||
public ChatConversation? LocalChatConversation { get; private set; }
|
||||
|
||||
public event EventHandler<ChatMessage>? MessageReceived;
|
||||
public event EventHandler<ChatConversation>? ConversationUpdated;
|
||||
public event EventHandler<ChatConversation?>? ActiveConversationChanged;
|
||||
|
||||
private readonly Dictionary<UUID, Queue<(InstantMessageEventArgs Args, DateTime Timestamp)>> _pendingGroupMessages = new();
|
||||
|
||||
public ChatService(GridClient client, LiteDbService dbService)
|
||||
{
|
||||
_client = client;
|
||||
_dbService = dbService;
|
||||
InitializeLocalChat();
|
||||
RegisterClientEvents();
|
||||
|
||||
_client.Network.EventQueueRunning += OnNetworkConnected;
|
||||
_client.Network.LoginProgress += OnLoginProgress;
|
||||
}
|
||||
|
||||
private void InitializeLocalChat()
|
||||
{
|
||||
LocalChatConversation = new ChatConversation
|
||||
{
|
||||
Name = "Local Chat",
|
||||
MessageType = ChatMessageType.LocalChat,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() => { Conversations.Add(LocalChatConversation); });
|
||||
}
|
||||
|
||||
private void RegisterClientEvents()
|
||||
{
|
||||
UnregisterClientEvents();
|
||||
|
||||
_client.Self.ChatFromSimulator += OnChatFromSimulator;
|
||||
_client.Self.IM += OnInstantMessage;
|
||||
}
|
||||
|
||||
private void UnregisterClientEvents()
|
||||
{
|
||||
_client.Self.ChatFromSimulator -= OnChatFromSimulator;
|
||||
_client.Self.IM -= OnInstantMessage;
|
||||
_client.Network.EventQueueRunning -= OnNetworkConnected;
|
||||
_client.Network.LoginProgress -= OnLoginProgress;
|
||||
}
|
||||
|
||||
private void OnChatFromSimulator(object? sender, ChatEventArgs e)
|
||||
{
|
||||
if (e.Type is ChatType.StartTyping or ChatType.StopTyping)
|
||||
{
|
||||
OnLocalChatTyping(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ShouldFilterMessage(e.Message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = e.FromName,
|
||||
SenderUuid = e.SourceID,
|
||||
Message = e.Message,
|
||||
MessageType = ChatMessageType.LocalChat,
|
||||
ChatType = e.Type,
|
||||
SourceType = e.SourceType,
|
||||
AudibleLevel = e.AudibleLevel,
|
||||
IsFromSelf = e.SourceID == _client.Self.AgentID
|
||||
};
|
||||
|
||||
if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message);
|
||||
}
|
||||
|
||||
private static bool ShouldFilterMessage(string message)
|
||||
{
|
||||
var lowerMessage = message.ToLowerInvariant();
|
||||
|
||||
var systemFilters = Array.Empty<object>();
|
||||
// TODO: Populate with actual system filter terms
|
||||
|
||||
// Check if message contains system filter terms
|
||||
if (systemFilters.Any(filter => lowerMessage.Contains((string)filter)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter empty messages
|
||||
return string.IsNullOrWhiteSpace(message) || message.Trim().Length < 1;
|
||||
}
|
||||
|
||||
private void OnInstantMessage(object? sender, InstantMessageEventArgs eventArgs)
|
||||
{
|
||||
// Group chats want to be special so we figure that out here, before any others
|
||||
if (eventArgs.IM.GroupIM || _client.Groups.GroupName2KeyCache.ContainsKey(eventArgs.IM.IMSessionID))
|
||||
{
|
||||
HandleGroupIm(eventArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (eventArgs.IM.Dialog)
|
||||
{
|
||||
case InstantMessageDialog.MessageFromAgent:
|
||||
HandlePersonalIm(eventArgs);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.MessageFromObject:
|
||||
HandleObjectIm(eventArgs);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.StartTyping:
|
||||
HandleTypingIndicator(eventArgs, true);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.StopTyping:
|
||||
HandleTypingIndicator(eventArgs, false);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.FriendshipOffered:
|
||||
HandleFriendshipOffer(eventArgs);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.FriendshipAccepted:
|
||||
HandleFriendshipAccepted(eventArgs);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.FriendshipDeclined:
|
||||
HandleFriendshipDeclined(eventArgs);
|
||||
break;
|
||||
|
||||
case InstantMessageDialog.InventoryOffered:
|
||||
HandleInventoryOffer(eventArgs);
|
||||
break;
|
||||
|
||||
default:
|
||||
Log.Information(
|
||||
"Unhandled InstantMessage Dialog: {ImDialog} from {ImFromAgentName}: {ImMessage}",
|
||||
eventArgs.IM.Dialog, eventArgs.IM.FromAgentName, eventArgs.IM.Message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGroupIm(InstantMessageEventArgs e)
|
||||
{
|
||||
var groupId = e.IM.GroupIM ? e.IM.ToAgentID : e.IM.IMSessionID;
|
||||
|
||||
if (!_client.Groups.GroupName2KeyCache.TryGetValue(groupId, out var groupName))
|
||||
{
|
||||
|
||||
if (!_pendingGroupMessages.ContainsKey(groupId))
|
||||
{
|
||||
_pendingGroupMessages[groupId] = new Queue<(InstantMessageEventArgs, DateTime)>();
|
||||
|
||||
EventHandler<GroupNamesEventArgs>? handler = null;
|
||||
handler = (_, args) =>
|
||||
{
|
||||
if (args.GroupNames.TryGetValue(groupId, out var name))
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ProcessQueuedGroupMessages(groupId, name);
|
||||
});
|
||||
}
|
||||
|
||||
_client.Groups.GroupNamesReply -= handler;
|
||||
};
|
||||
|
||||
_client.Groups.GroupNamesReply += handler;
|
||||
_client.Groups.RequestGroupName(groupId);
|
||||
}
|
||||
|
||||
_pendingGroupMessages[groupId].Enqueue((e, DateTime.Now));
|
||||
return;
|
||||
}
|
||||
|
||||
var conversation = GetOrCreateGroupConversation(groupId, groupName);
|
||||
AddMessageToGroupConversation(conversation, e);
|
||||
}
|
||||
|
||||
private void ProcessQueuedGroupMessages(UUID groupId, string groupName)
|
||||
{
|
||||
if (!_pendingGroupMessages.TryGetValue(groupId, out var messageQueue))
|
||||
return;
|
||||
|
||||
var conversation = GetOrCreateGroupConversation(groupId, groupName);
|
||||
|
||||
while (messageQueue.Count > 0)
|
||||
{
|
||||
var (e, _) = messageQueue.Dequeue();
|
||||
AddMessageToGroupConversation(conversation, e);
|
||||
}
|
||||
|
||||
_pendingGroupMessages.Remove(groupId);
|
||||
}
|
||||
|
||||
private ChatConversation GetOrCreateGroupConversation(UUID groupId, string groupName)
|
||||
{
|
||||
var conversation = Conversations.FirstOrDefault(c =>
|
||||
c.MessageType == ChatMessageType.GroupChat && c.GroupId == groupId);
|
||||
|
||||
if (conversation != null) return conversation;
|
||||
conversation = new ChatConversation
|
||||
{
|
||||
MessageType = ChatMessageType.GroupChat,
|
||||
GroupId = groupId,
|
||||
GroupName = groupName,
|
||||
Name = groupName
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Conversations.Add(conversation);
|
||||
});
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private void HandlePersonalIm(InstantMessageEventArgs e)
|
||||
{
|
||||
var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName);
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = e.IM.FromAgentName,
|
||||
SenderUuid = e.IM.FromAgentID,
|
||||
Message = e.IM.Message,
|
||||
MessageType = ChatMessageType.InstantMessage,
|
||||
ImDialog = e.IM.Dialog,
|
||||
IsFromSelf = e.IM.FromAgentID == _client.Self.AgentID
|
||||
};
|
||||
|
||||
AddMessageToConversation(conversation, message);
|
||||
}
|
||||
|
||||
private void HandleObjectIm(InstantMessageEventArgs e)
|
||||
{
|
||||
// Object messages might go to a separate "Objects" conversation for now,
|
||||
// but we'd like the ability for it to be filtered or grouped into local chat too
|
||||
// TODO: Implement filtering and preferences
|
||||
var conversation = GetOrCreateObjectConversation();
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = e.IM.FromAgentName,
|
||||
SenderUuid = e.IM.FromAgentID,
|
||||
Message = e.IM.Message,
|
||||
MessageType = ChatMessageType.Objects,
|
||||
ImDialog = e.IM.Dialog,
|
||||
IsFromSelf = false
|
||||
};
|
||||
|
||||
AddMessageToConversation(conversation, message);
|
||||
}
|
||||
|
||||
private void AddMessageToConversation(ChatConversation conversation, ChatMessage message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var isDuplicate = conversation.Messages.Any(m =>
|
||||
m.SenderUuid == message.SenderUuid &&
|
||||
m.Message.Equals(message.Message, StringComparison.Ordinal) &&
|
||||
m.MessageType == message.MessageType &&
|
||||
m.Timestamp == message.Timestamp);
|
||||
|
||||
if (isDuplicate)
|
||||
{
|
||||
Log.Debug("Skipping duplicate message from {SenderName}: {Message}",
|
||||
message.SenderName, message.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.Messages.Add(message);
|
||||
conversation.LastMessage = message.Message;
|
||||
conversation.LastActivity = message.Timestamp;
|
||||
|
||||
if (!message.IsFromSelf && !conversation.IsActive)
|
||||
{
|
||||
conversation.HasUnreadMessages = true;
|
||||
conversation.UnreadCount++;
|
||||
}
|
||||
|
||||
MessageReceived?.Invoke(this, message);
|
||||
ConversationUpdated?.Invoke(this, conversation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error adding message to conversation");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void AddMessageToGroupConversation(ChatConversation conversation, InstantMessageEventArgs e)
|
||||
{
|
||||
var m = new ChatMessage
|
||||
{
|
||||
SenderName = e.IM.FromAgentName,
|
||||
SenderUuid = e.IM.FromAgentID,
|
||||
Message = e.IM.Message,
|
||||
Timestamp = DateTime.Now,
|
||||
MessageType = ChatMessageType.GroupChat,
|
||||
GroupId = e.IM.ToAgentID,
|
||||
GroupName = conversation.GroupName,
|
||||
IsFromSelf = e.IM.FromAgentID == _client.Self.AgentID,
|
||||
ImDialog = e.IM.Dialog
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
conversation.Messages.Add(m);
|
||||
conversation.LastMessage = m.Message;
|
||||
conversation.LastActivity = m.Timestamp;
|
||||
|
||||
if (!m.IsFromSelf && !conversation.IsActive)
|
||||
{
|
||||
conversation.HasUnreadMessages = true;
|
||||
conversation.UnreadCount++;
|
||||
}
|
||||
|
||||
MessageReceived?.Invoke(this, m);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleTypingIndicator(InstantMessageEventArgs eventArgs, bool isTyping)
|
||||
{
|
||||
var conversation = Conversations.FirstOrDefault(c =>
|
||||
c.MessageType == ChatMessageType.InstantMessage && c.ParticipantId == eventArgs.IM.FromAgentID);
|
||||
|
||||
if (conversation == null) return;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
conversation.IsTyping = isTyping;
|
||||
if (isTyping)
|
||||
{
|
||||
conversation.LastMessage = $"{eventArgs.IM.FromAgentName} is typing...";
|
||||
}
|
||||
else
|
||||
{
|
||||
var lastActualMessage = conversation.Messages.LastOrDefault();
|
||||
conversation.LastMessage = lastActualMessage?.Message ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
ConversationUpdated?.Invoke(this, conversation);
|
||||
}
|
||||
|
||||
private void HandleFriendshipOffer(InstantMessageEventArgs e)
|
||||
{
|
||||
// This should trigger a notification/dialog, not create a chat
|
||||
// For now, we'll log it
|
||||
Log.Information("Friendship offer from {ImFromAgentName}: {ImMessage}", e.IM.FromAgentName,
|
||||
e.IM.Message);
|
||||
|
||||
// TODO: Show friendship offer dialog
|
||||
// TODO: Allow user to accept/decline
|
||||
}
|
||||
|
||||
private void HandleFriendshipAccepted(InstantMessageEventArgs e)
|
||||
{
|
||||
var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName);
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = "System",
|
||||
SenderUuid = e.IM.FromAgentID,
|
||||
Message = $"{e.IM.FromAgentName} has accepted your friendship request.",
|
||||
MessageType = ChatMessageType.InstantMessage,
|
||||
ImDialog = e.IM.Dialog,
|
||||
IsFromSelf = false
|
||||
};
|
||||
AddMessageToConversation(conversation, message);
|
||||
}
|
||||
|
||||
private void HandleFriendshipDeclined(InstantMessageEventArgs e)
|
||||
{
|
||||
var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName);
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = "System",
|
||||
SenderUuid = e.IM.FromAgentID,
|
||||
Message = $"{e.IM.FromAgentName} has declined your friendship request.",
|
||||
MessageType = ChatMessageType.InstantMessage,
|
||||
ImDialog = e.IM.Dialog,
|
||||
IsFromSelf = false
|
||||
};
|
||||
AddMessageToConversation(conversation, message);
|
||||
}
|
||||
|
||||
private void HandleInventoryOffer(InstantMessageEventArgs e)
|
||||
{
|
||||
// This should trigger an inventory offer dialog later, for now we'll log it
|
||||
Log.Information("Inventory offer from {ImFromAgentName}: {ImMessage}", e.IM.FromAgentName,
|
||||
e.IM.Message);
|
||||
|
||||
// TODO: Show inventory offer dialog
|
||||
// TODO: Allow user to accept/decline
|
||||
}
|
||||
|
||||
public void StartLocalChatTyping()
|
||||
{
|
||||
if (!_client.Network.Connected) return;
|
||||
_client.Self.AnimationStart(Animations.TYPE, true);
|
||||
_client.Self.Chat("", 0, ChatType.StartTyping);
|
||||
}
|
||||
|
||||
public void StopLocalChatTyping()
|
||||
{
|
||||
if (!_client.Network.Connected) return;
|
||||
_client.Self.AnimationStop(Animations.TYPE, true);
|
||||
_client.Self.Chat("", 0, ChatType.StopTyping);
|
||||
}
|
||||
|
||||
private void OnLocalChatTyping(ChatEventArgs e)
|
||||
{
|
||||
if (LocalChatConversation == null) return;
|
||||
|
||||
var isTyping = e.Type == ChatType.StartTyping;
|
||||
var typingUserName = e.FromName;
|
||||
|
||||
if (e.SourceID == _client.Self.AgentID) return;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (isTyping)
|
||||
{
|
||||
if (!LocalChatConversation.TypingUsers.Contains(typingUserName))
|
||||
{
|
||||
LocalChatConversation.TypingUsers.Add(typingUserName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
LocalChatConversation.TypingUsers.Remove(typingUserName);
|
||||
}
|
||||
|
||||
LocalChatConversation.IsTyping = LocalChatConversation.TypingUsers.Count > 0;
|
||||
|
||||
if (LocalChatConversation.IsTyping)
|
||||
{
|
||||
var typingMessage = LocalChatConversation.TypingUsers.Count switch
|
||||
{
|
||||
1 => $"{LocalChatConversation.TypingUsers.First()} is typing...",
|
||||
2 =>
|
||||
$"{LocalChatConversation.TypingUsers.First()} and {LocalChatConversation.TypingUsers.Last()} are typing...",
|
||||
_ =>
|
||||
$"{LocalChatConversation.TypingUsers.First()} and {LocalChatConversation.TypingUsers.Count - 1} others are typing..."
|
||||
};
|
||||
|
||||
LocalChatConversation.LastMessage = typingMessage;
|
||||
}
|
||||
else
|
||||
{
|
||||
var lastActualMessage = LocalChatConversation.Messages.LastOrDefault();
|
||||
LocalChatConversation.LastMessage = lastActualMessage?.Message ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
ConversationUpdated?.Invoke(this, LocalChatConversation);
|
||||
}
|
||||
|
||||
private ChatConversation GetOrCreateImConversation(UUID participantId, string participantName)
|
||||
{
|
||||
var existing = Conversations.FirstOrDefault(c =>
|
||||
c.MessageType == ChatMessageType.InstantMessage && c.ParticipantId == participantId);
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Name = participantName,
|
||||
MessageType = ChatMessageType.InstantMessage,
|
||||
ParticipantId = participantId
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); });
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private ChatConversation GetOrCreateObjectConversation()
|
||||
{
|
||||
var existing = Conversations.FirstOrDefault(c =>
|
||||
c is { MessageType: ChatMessageType.InstantMessage, Name: "Objects" });
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Name = "Objects",
|
||||
MessageType = ChatMessageType.InstantMessage,
|
||||
ParticipantId = UUID.Zero
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); });
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
public async Task SendLocalChatAsync(string message, ChatType chatType = ChatType.Normal)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
_client.Self.Chat(message, 0, chatType);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SendInstantMessageAsync(UUID targetId, string message)
|
||||
{
|
||||
await Task.Run(() => { _client.Self.InstantMessage(targetId, message); });
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(UUID sessionId, string message)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
_client.Self.InstantMessage(
|
||||
_client.Self.Name,
|
||||
sessionId,
|
||||
message,
|
||||
sessionId,
|
||||
InstantMessageDialog.SessionSend,
|
||||
InstantMessageOnline.Offline,
|
||||
Vector3.Zero,
|
||||
UUID.Zero,
|
||||
[]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public void MarkConversationAsRead(ChatConversation conversation)
|
||||
{
|
||||
conversation.HasUnreadMessages = false;
|
||||
conversation.UnreadCount = 0;
|
||||
ConversationUpdated?.Invoke(this, conversation);
|
||||
}
|
||||
|
||||
public void SetActiveConversation(ChatConversation? conversation)
|
||||
{
|
||||
foreach (var conv in Conversations)
|
||||
{
|
||||
conv.IsActive = conv == conversation;
|
||||
}
|
||||
|
||||
if (conversation != null)
|
||||
{
|
||||
MarkConversationAsRead(conversation);
|
||||
}
|
||||
|
||||
// Notify that the active conversation has changed
|
||||
ActiveConversationChanged?.Invoke(this, conversation);
|
||||
}
|
||||
|
||||
private void OnNetworkConnected(object? sender, EventQueueRunningEventArgs e)
|
||||
{
|
||||
if (_hasShownConnectionMessage) return;
|
||||
_hasShownConnectionMessage = true;
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
SenderName = "System",
|
||||
Message = "Connected to grid successfully.",
|
||||
MessageType = ChatMessageType.System,
|
||||
IsFromSelf = false,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
|
||||
if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message);
|
||||
}
|
||||
|
||||
private void OnLoginProgress(object? sender, LoginProgressEventArgs e)
|
||||
{
|
||||
switch (e.Status)
|
||||
{
|
||||
case LoginStatus.Success:
|
||||
{
|
||||
var session = _dbService.GetSession();
|
||||
if (!string.IsNullOrEmpty(session.LoginWelcomeMessage))
|
||||
{
|
||||
var welcomeMessage = new ChatMessage
|
||||
{
|
||||
SenderName = "Grid",
|
||||
Message = session.LoginWelcomeMessage,
|
||||
MessageType = ChatMessageType.System,
|
||||
IsFromSelf = false,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
|
||||
if (LocalChatConversation != null)
|
||||
AddMessageToConversation(LocalChatConversation, welcomeMessage);
|
||||
}
|
||||
|
||||
var loginMessage = new ChatMessage
|
||||
{
|
||||
SenderName = "System",
|
||||
Message = $"Successfully logged in as {_client.Self.Name}",
|
||||
MessageType = ChatMessageType.System,
|
||||
IsFromSelf = false,
|
||||
Timestamp = DateTime.Now
|
||||
};
|
||||
|
||||
if (LocalChatConversation != null)
|
||||
AddMessageToConversation(LocalChatConversation, loginMessage);
|
||||
break;
|
||||
}
|
||||
case LoginStatus.Failed:
|
||||
break;
|
||||
case LoginStatus.None:
|
||||
break;
|
||||
case LoginStatus.ConnectingToLogin:
|
||||
break;
|
||||
case LoginStatus.ReadingResponse:
|
||||
break;
|
||||
case LoginStatus.ConnectingToSim:
|
||||
break;
|
||||
case LoginStatus.Redirecting:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
#region Disposable Support
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (disposing)
|
||||
{
|
||||
UnregisterClientEvents();
|
||||
_pendingGroupMessages.Clear();
|
||||
_hasShownConnectionMessage = false;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~ChatService()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia.Markup.Xaml.MarkupExtensions;
|
||||
using LiteDB;
|
||||
using GalaxyViewer.Models;
|
||||
using OpenMetaverse;
|
||||
using Serilog;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
@@ -14,8 +18,18 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
|
||||
private LiteDatabase? _database;
|
||||
private readonly string _databasePath;
|
||||
public LiteDatabase? Database => _database;
|
||||
private readonly GridClient _client;
|
||||
|
||||
public LiteDbService(GridClient client)
|
||||
{
|
||||
_client = client;
|
||||
_databasePath = GetDatabasePath();
|
||||
InitializeDatabase();
|
||||
Session = GetSession();
|
||||
}
|
||||
|
||||
private SessionModel _session;
|
||||
|
||||
public SessionModel Session
|
||||
{
|
||||
get => _session;
|
||||
@@ -26,14 +40,7 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
public LiteDbService()
|
||||
{
|
||||
_databasePath = GetDatabasePath();
|
||||
InitializeDatabase();
|
||||
Session = GetSession();
|
||||
}
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private static string GetDatabasePath()
|
||||
{
|
||||
@@ -49,17 +56,19 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_databasePath) ??
|
||||
throw new InvalidOperationException());
|
||||
|
||||
_database = new LiteDatabase(_databasePath);
|
||||
Log.Information("LiteDbService initialized with database path: {DbPath}",
|
||||
_databasePath);
|
||||
Log.Information("LiteDbService initialized with database path: {DbPath}", _databasePath);
|
||||
|
||||
var gridsCollection = _database.GetCollection<GridModel>("grids");
|
||||
if (gridsCollection.Count() == 0)
|
||||
{
|
||||
SeedGrids();
|
||||
}
|
||||
|
||||
ClearSessionData();
|
||||
SeedDatabase();
|
||||
}
|
||||
catch (LiteException ex)
|
||||
{
|
||||
Log.Error(ex, "LiteDB exception occurred. Attempting to recreate the database.");
|
||||
HandleDatabaseCorruption();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to initialize LiteDbService");
|
||||
@@ -67,116 +76,86 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDatabaseCorruption()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_databasePath))
|
||||
{
|
||||
File.Move(_databasePath, _databasePath + ".bak");
|
||||
}
|
||||
|
||||
_database = new LiteDatabase(_databasePath);
|
||||
SeedDatabase();
|
||||
Log.Information("Database recreated successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to recreate the database.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedDatabase()
|
||||
{
|
||||
ClearSessionData();
|
||||
SeedPreferences();
|
||||
SeedGrids();
|
||||
}
|
||||
|
||||
private void SeedPreferences()
|
||||
{
|
||||
var preferencesCollection = _database.GetCollection<PreferencesModel>("preferences");
|
||||
if (preferencesCollection.Count() == 0)
|
||||
{
|
||||
var defaultPreferences = new PreferencesModel
|
||||
{
|
||||
Theme = "Default",
|
||||
LoginLocation = "Home",
|
||||
Font = "Atkinson Hyperlegible",
|
||||
Language = "en-US",
|
||||
SelectedGridNick = "agni",
|
||||
LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
};
|
||||
preferencesCollection.Insert(defaultPreferences);
|
||||
Log.Information("Database seeded with default preferences");
|
||||
}
|
||||
var preferencesCollection = _database?.GetCollection<PreferencesModel>("preferences");
|
||||
if (preferencesCollection != null && preferencesCollection.Count() != 0) return;
|
||||
var defaultPreferences = PreferencesManager.CreateDefaultPreferences();
|
||||
preferencesCollection?.Insert(defaultPreferences);
|
||||
// Log.Debug("Database seeded with default preferences");
|
||||
}
|
||||
|
||||
private void SeedGrids()
|
||||
{
|
||||
var gridsCollection = _database.GetCollection<GridModel>("grids");
|
||||
if (gridsCollection.Count() == 0)
|
||||
var gridsCollection = _database?.GetCollection<GridModel>("grids");
|
||||
if (gridsCollection != null && gridsCollection.Count() != 0) return;
|
||||
var grids = new[]
|
||||
{
|
||||
var grids = new[]
|
||||
new GridModel
|
||||
{
|
||||
new GridModel
|
||||
{
|
||||
GridNick = "agni",
|
||||
GridName = "Second Life (agni)",
|
||||
Platform = "SecondLife",
|
||||
LoginUri = "https://login.agni.lindenlab.com/cgi-bin/login.cgi",
|
||||
LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Release",
|
||||
HelperUri = "https://secondlife.com/helpers/",
|
||||
Website = "http://secondlife.com/",
|
||||
Support = "http://secondlife.com/support/",
|
||||
Register = "http://secondlife.com/registration/",
|
||||
Password = "http://secondlife.com/account/request.php",
|
||||
Version = "0"
|
||||
},
|
||||
new GridModel
|
||||
{
|
||||
GridNick = "aditi",
|
||||
GridName = "Second Life Beta (aditi)",
|
||||
Platform = "SecondLife",
|
||||
LoginUri = "https://login.aditi.lindenlab.com/cgi-bin/login.cgi",
|
||||
LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Beta",
|
||||
HelperUri = "http://aditi-secondlife.webdev.lindenlab.com/helpers/",
|
||||
Website = "http://secondlife.com/",
|
||||
Support = "http://secondlife.com/support/",
|
||||
Register = "http://secondlife.com/registration/",
|
||||
Password = "http://secondlife.com/account/request.php",
|
||||
Version = "1"
|
||||
}
|
||||
};
|
||||
gridsCollection.InsertBulk(grids);
|
||||
Log.Information("Database seeded with default grids");
|
||||
}
|
||||
GridNick = "agni",
|
||||
GridName = "Second Life (agni)",
|
||||
Platform = "SecondLife",
|
||||
LoginUri = "https://login.agni.lindenlab.com/cgi-bin/login.cgi",
|
||||
LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Release",
|
||||
HelperUri = "https://secondlife.com/helpers/",
|
||||
Website = "http://secondlife.com/",
|
||||
Support = "http://secondlife.com/support/",
|
||||
Register = "http://secondlife.com/registration/",
|
||||
Password = "http://secondlife.com/account/request.php",
|
||||
Version = "0"
|
||||
},
|
||||
new GridModel
|
||||
{
|
||||
GridNick = "aditi",
|
||||
GridName = "Second Life Beta (aditi)",
|
||||
Platform = "SecondLife",
|
||||
LoginUri = "https://login.aditi.lindenlab.com/cgi-bin/login.cgi",
|
||||
LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Beta",
|
||||
HelperUri = "http://aditi-secondlife.webdev.lindenlab.com/helpers/",
|
||||
Website = "http://secondlife.com/",
|
||||
Support = "http://secondlife.com/support/",
|
||||
Register = "http://secondlife.com/registration/",
|
||||
Password = "http://secondlife.com/account/request.php",
|
||||
Version = "1"
|
||||
}
|
||||
};
|
||||
gridsCollection?.InsertBulk(grids);
|
||||
// Log.Debug("Database seeded with default grids");
|
||||
}
|
||||
|
||||
private void ClearSessionData()
|
||||
{
|
||||
ILiteCollection<SessionModel>? sessionCollection;
|
||||
if (_database.CollectionExists("session"))
|
||||
var sessionCollection = _database?.GetCollection<SessionModel>("session");
|
||||
if (sessionCollection != null && sessionCollection.Count() > 0)
|
||||
{
|
||||
sessionCollection = _database.GetCollection<SessionModel>("session");
|
||||
sessionCollection.DeleteAll();
|
||||
Log.Information("Session data cleared on startup");
|
||||
// Log.Debug("Session data cleared");
|
||||
}
|
||||
|
||||
sessionCollection = _database.GetCollection<SessionModel>("session");
|
||||
sessionCollection.Insert(new SessionModel());
|
||||
Log.Information("Session data created on startup");
|
||||
if (sessionCollection != null && sessionCollection.Count() != 0) return;
|
||||
sessionCollection?.Insert(new SessionModel());
|
||||
// Log.Debug("Session data created");
|
||||
}
|
||||
|
||||
public void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
public event EventHandler? SessionChanged;
|
||||
|
||||
public SessionModel GetSession()
|
||||
{
|
||||
var collection = _database.GetCollection<SessionModel>("session");
|
||||
return collection.FindOne(Query.All()) ?? new SessionModel();
|
||||
var collection = _database?.GetCollection<SessionModel>("session");
|
||||
return collection?.FindOne(Query.All()) ?? new SessionModel();
|
||||
}
|
||||
|
||||
public bool HasSessionChanged(SessionModel currentSession)
|
||||
@@ -187,16 +166,16 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
|
||||
|
||||
public void SaveSession(SessionModel session)
|
||||
{
|
||||
var collection = _database.GetCollection<SessionModel>("session");
|
||||
collection.Upsert(session);
|
||||
Log.Information("Session data saved");
|
||||
var collection = _database?.GetCollection<SessionModel>("session");
|
||||
collection?.Upsert(session);
|
||||
// Log.Debug("Session data saved");
|
||||
|
||||
Session = session;
|
||||
SessionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_database?.Dispose();
|
||||
Log.Information("LiteDbService disposed");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
|
||||
public class NavigationService
|
||||
{
|
||||
private readonly Dictionary<string, Type> _routes = new();
|
||||
private readonly ContentControl _contentControl;
|
||||
|
||||
public NavigationService(ContentControl contentControl)
|
||||
{
|
||||
_contentControl = contentControl;
|
||||
}
|
||||
|
||||
public void RegisterRoute(string uri, Type viewType)
|
||||
{
|
||||
_routes[uri] = viewType;
|
||||
}
|
||||
|
||||
public void NavigateTo(string uri)
|
||||
{
|
||||
if (_routes.TryGetValue(uri, out var viewType))
|
||||
{
|
||||
var view = (Control)Activator.CreateInstance(viewType)!;
|
||||
_contentControl.Content = view;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"No view registered for URI: {uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,81 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using LiteDB;
|
||||
using GalaxyViewer.Models;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
|
||||
public sealed class PreferencesManager
|
||||
{
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private readonly ILiteCollection<PreferencesModel> _preferencesCollection;
|
||||
private readonly ILiteCollection<GridModel>? _gridsCollection;
|
||||
private PreferencesModel _currentPreferences;
|
||||
|
||||
public event EventHandler<PreferencesModel>? PreferencesChanged;
|
||||
|
||||
public PreferencesManager(LiteDbService? liteDbService)
|
||||
{
|
||||
Debug.Assert(liteDbService != null, nameof(liteDbService) + " != null");
|
||||
var database = liteDbService?.Database;
|
||||
Debug.Assert(database != null, nameof(database) + " != null");
|
||||
_liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService));
|
||||
var database = _liteDbService.Database ?? throw new InvalidOperationException("Database not initialized");
|
||||
_preferencesCollection = database.GetCollection<PreferencesModel>("preferences");
|
||||
_gridsCollection = database.GetCollection<GridModel>("grids");
|
||||
_currentPreferences = LoadRawPreferences();
|
||||
if (!string.IsNullOrEmpty(_currentPreferences.Version) &&
|
||||
_currentPreferences.Version.Contains('.')) return;
|
||||
_currentPreferences.Version = VersionHelper.GetInformationalVersion();
|
||||
_currentPreferences.LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
_preferencesCollection.Upsert(_currentPreferences);
|
||||
}
|
||||
|
||||
public PreferencesModel CurrentPreferences
|
||||
public PreferencesModel CurrentPreferences => _currentPreferences;
|
||||
|
||||
public Task<PreferencesModel> LoadPreferencesAsync() => Task.FromResult(_currentPreferences);
|
||||
|
||||
private PreferencesModel LoadRawPreferences()
|
||||
{
|
||||
get
|
||||
var rawColumn = _liteDbService.Database?.GetCollection<BsonDocument>("preferences");
|
||||
var column = rawColumn?.FindOne(Query.All());
|
||||
if (column == null)
|
||||
{
|
||||
var preferences = _preferencesCollection.FindOne(Query.All());
|
||||
return preferences ?? new PreferencesModel
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
Theme = "Default",
|
||||
LoginLocation = "Home",
|
||||
Font = "Atkinson Hyperlegible",
|
||||
Language = "en-US",
|
||||
LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
SelectedGridNick = string.Empty
|
||||
};
|
||||
return CreateDefaultPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PreferencesModel> LoadPreferencesAsync()
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
var preferences = new PreferencesModel
|
||||
{
|
||||
var preferences = _preferencesCollection.FindOne(Query.All());
|
||||
return preferences ?? new PreferencesModel
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
Theme = "Default",
|
||||
LoginLocation = "Home",
|
||||
Font = "Atkinson Hyperlegible",
|
||||
Language = "en-US",
|
||||
SelectedGridNick = "agni",
|
||||
LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
};
|
||||
});
|
||||
Id = column["_id"].AsObjectId,
|
||||
Theme = column.TryGetValue("Theme", out var value) ? value.AsString : "System",
|
||||
LoginLocation = column.TryGetValue("LoginLocation", out var value1) ? value1.AsString : "Home",
|
||||
Font = column.TryGetValue("Font", out var value2) ? value2.AsString : "Atkinson Hyperlegible",
|
||||
Language = column.TryGetValue("Language", out var value3) ? value3.AsString : "en-US",
|
||||
SelectedGridNick = column.TryGetValue("SelectedGridNick", out var value4) ? value4.AsString : "",
|
||||
AccentColor = column.TryGetValue("AccentColor", out var value5) ? value5.AsString : "System Default",
|
||||
LastSavedEpoch = column.TryGetValue("LastSavedEpoch", out var value6) ? value6.AsInt64 : 0,
|
||||
};
|
||||
if (column.TryGetValue("Version", out var v))
|
||||
{
|
||||
preferences.Version = v.IsString ? v.AsString : v.RawValue?.ToString() ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
preferences.Version = string.Empty;
|
||||
}
|
||||
return preferences;
|
||||
}
|
||||
|
||||
public async Task SavePreferencesAsync(PreferencesModel preferences)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
_preferencesCollection.Upsert(preferences);
|
||||
OnPreferencesChanged(preferences);
|
||||
preferences.LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
preferences.Version = VersionHelper.GetInformationalVersion();
|
||||
_preferencesCollection.DeleteAll();
|
||||
_preferencesCollection.Insert(preferences);
|
||||
_liteDbService.Database?.Checkpoint();
|
||||
_currentPreferences = preferences;
|
||||
PreferencesChanged?.Invoke(this, preferences);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,18 +86,29 @@ public sealed class PreferencesManager
|
||||
{ "ThemeOptions", PreferencesOptions.ThemeOptions },
|
||||
{ "LoginLocationOptions", PreferencesOptions.LoginLocationOptions },
|
||||
{ "FontOptions", PreferencesOptions.FontOptions },
|
||||
{ "LanguageOptions", PreferencesOptions.LanguageOptions }
|
||||
{ "LanguageOptions", PreferencesOptions.LanguageOptions },
|
||||
{ "AccentColorOptions", PreferencesOptions.AccentColorOptions }
|
||||
};
|
||||
}
|
||||
|
||||
public List<string> GetGridOptions()
|
||||
{
|
||||
return _gridsCollection?.FindAll().Select(grid => grid.GridNick).ToList() ??
|
||||
[];
|
||||
return _gridsCollection?.FindAll().Select(grid => grid.GridNick).ToList() ?? new List<string>();
|
||||
}
|
||||
|
||||
private void OnPreferencesChanged(PreferencesModel preferences)
|
||||
public static PreferencesModel CreateDefaultPreferences()
|
||||
{
|
||||
PreferencesChanged?.Invoke(this, preferences);
|
||||
return new PreferencesModel
|
||||
{
|
||||
Id = ObjectId.NewObjectId(),
|
||||
Theme = "System",
|
||||
LoginLocation = "Home",
|
||||
Font = "Atkinson Hyperlegible",
|
||||
Language = "en-US",
|
||||
AccentColor = "System Default",
|
||||
SelectedGridNick = "agni",
|
||||
LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
Version = VersionHelper.GetInformationalVersion()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using GalaxyViewer.Models;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
|
||||
public class SessionService
|
||||
{
|
||||
private readonly LiteDbService _dbService;
|
||||
|
||||
public SessionService(LiteDbService dbService, GridClient client)
|
||||
{
|
||||
_dbService = dbService;
|
||||
SetClient(client);
|
||||
}
|
||||
|
||||
private void SetClient(GridClient client)
|
||||
{
|
||||
client.Self.MoneyBalance += BalanceReceivedHandler;
|
||||
}
|
||||
|
||||
public event EventHandler<int>? BalanceChanged;
|
||||
private int _balance;
|
||||
|
||||
public int Balance
|
||||
{
|
||||
get => _balance;
|
||||
private set
|
||||
{
|
||||
if (_balance == value) return;
|
||||
_balance = value;
|
||||
BalanceChanged?.Invoke(this, _balance);
|
||||
}
|
||||
}
|
||||
|
||||
private void BalanceReceivedHandler(object? sender, BalanceEventArgs e)
|
||||
{
|
||||
Balance = e.Balance;
|
||||
}
|
||||
|
||||
public bool HasSessionChanged(SessionModel currentSession)
|
||||
=> !_dbService.GetSession().Equals(currentSession);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
namespace GalaxyViewer.Services;
|
||||
|
||||
public static class VersionHelper
|
||||
{
|
||||
private static string GetApplicationVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "Unknown";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetInformationalVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
return versionAttribute?.InformationalVersion ?? GetApplicationVersion();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return GetApplicationVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,58 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:GalaxyViewer.Controls">
|
||||
|
||||
<!-- Global font for emoji and accessibility -->
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="FontFamily" Value="avares://GalaxyViewer/Assets/Fonts/#Noto Emoji, avar://GalaxyViewer/Assets/Fonts/#Atkinson Hyperlegible" />
|
||||
<Setter Property="FontFamily"
|
||||
Value="avares://GalaxyViewer/Assets/Fonts/#Noto Emoji, avar://GalaxyViewer/Assets/Fonts/#Atkinson Hyperlegible" />
|
||||
</Style>
|
||||
|
||||
<!-- Chat bubble base style -->
|
||||
<Style Selector="Border.chat-message-bubble">
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<SolidColorBrush Color="{DynamicResource SystemAccentColor}" Opacity="0.15" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Margin" Value="0,6" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
</Style>
|
||||
|
||||
<!-- Self messages: accent color background, right aligned -->
|
||||
<Style Selector="Border.chat-message-bubble.from-self">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
|
||||
<!-- Other messages: subtle accent color background, left aligned -->
|
||||
<Style Selector="Border.chat-message-bubble.from-other">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<!-- System messages: even more subtle accent color background, left aligned -->
|
||||
<Style Selector="Border.chat-message-bubble.system-message">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<!-- System message text: semi-bold, smaller -->
|
||||
<Style Selector="Border.chat-message-bubble.system-message TextBlock">
|
||||
<Setter Property="FontStyle" Value="Oblique" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Chat bubble text: padding and wrapping for all bubbles -->
|
||||
<Style Selector="Border.chat-message-bubble TextBlock">
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
|
||||
<!-- Links use accent color -->
|
||||
<!-- TODO: Add this functionality
|
||||
<Style Selector="controls|ClickableTextBlock">
|
||||
<Setter Property="LinkBrush" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
-->
|
||||
|
||||
</Styles>
|
||||
@@ -4,7 +4,7 @@
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20">
|
||||
<!-- Add Controls for Previewer Here -->
|
||||
<local:EmojiTextBlock Text="🌌 Galaxy Viewer" FontSize="24" />
|
||||
<local:EmojiAwareTextBlock Text="🌌 Galaxy Viewer" FontSize="24" />
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
<Style Selector="local|EmojiTextBlock">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20">
|
||||
<!-- Add Controls for Previewer Here -->
|
||||
<local:EmojiTextBlock Text="🌌 Galaxy Viewer" FontSize="24" />
|
||||
<local:EmojiAwareTextBlock Text="🌌 Galaxy Viewer" FontSize="24" />
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
<Style Selector="local|EmojiTextBlock">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:drawing="clr-namespace:System.Drawing;assembly=System.Drawing.Primitives">
|
||||
|
||||
<!-- Define Colors -->
|
||||
|
||||
<!-- Define Brushes -->
|
||||
<SolidColorBrush x:Key="AddressBarTextBrush" Color="{DynamicResource LightTextColor}"/>
|
||||
<SolidColorBrush x:Key="AddressBarWatermarkBrush" Color="{DynamicResource LightMediumTextColor}"/>
|
||||
|
||||
<!-- Define Fonts -->
|
||||
<FontFamily x:Key="DefaultFontFamily">avares://GalaxyViewer/Assets/Fonts/#Atkinson Hyperlegible</FontFamily>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Style xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:GalaxyViewer.Controls">
|
||||
<Style.Resources>
|
||||
<ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock">
|
||||
<ControlTheme.BasedOn>
|
||||
<ControlTheme TargetType="controls:EmojiAwareTextBlock">
|
||||
<Setter Property="EmojiFontFamily" Value="{StaticResource EmojiFontFamily}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource DefaultFontFamily}" />
|
||||
<Setter Property="FlowDirection"
|
||||
Value="{Binding Text, RelativeSource={RelativeSource Self}, Converter={StaticResource TextDirectionConverter}}" />
|
||||
</ControlTheme>
|
||||
</ControlTheme.BasedOn>
|
||||
</ControlTheme>
|
||||
</Style.Resources>
|
||||
</Style>
|
||||
@@ -0,0 +1,672 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Reactive;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using ReactiveUI;
|
||||
using OpenMetaverse;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.Models;
|
||||
using Serilog;
|
||||
using Avalonia.Media;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace GalaxyViewer.ViewModels
|
||||
{
|
||||
public partial class AddressBarViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private readonly GridClient _client;
|
||||
private SessionModel _session;
|
||||
private readonly ICommand? _openPreferencesCommand;
|
||||
|
||||
private DateTime _lastLocationUpdate = DateTime.MinValue;
|
||||
private const int LocationUpdateThrottleMs = 100;
|
||||
|
||||
private readonly List<string> _locationHistory = [];
|
||||
private int _currentHistoryIndex = -1;
|
||||
|
||||
private Vector3 _lastKnownPosition = Vector3.Zero;
|
||||
private string _lastKnownRegion = string.Empty;
|
||||
|
||||
// Regex for parsing SLURL coordinates like "Region Name/128/128/23"
|
||||
[GeneratedRegex(@"^(.+?)\/(\d+)\/(\d+)\/(\d+)$")]
|
||||
private static partial Regex SlurlRegex();
|
||||
|
||||
public AddressBarViewModel(LiteDbService liteDbService,
|
||||
GridClient client,
|
||||
ICommand? openPreferencesCommand = null)
|
||||
{
|
||||
_liteDbService = liteDbService;
|
||||
_client = client;
|
||||
_session = _liteDbService.GetSession();
|
||||
_openPreferencesCommand = openPreferencesCommand;
|
||||
|
||||
HomeCommand = ReactiveCommand.Create(GoHome);
|
||||
BackCommand = ReactiveCommand.Create(GoBack, this.WhenAnyValue(x => x.CanGoBack));
|
||||
ForwardCommand =
|
||||
ReactiveCommand.Create(GoForward, this.WhenAnyValue(x => x.CanGoForward));
|
||||
SettingsCommand = ReactiveCommand.Create(OpenSettings);
|
||||
|
||||
StartEditCommand = ReactiveCommand.Create(StartEdit);
|
||||
CommitEditCommand = ReactiveCommand.Create(CommitEdit);
|
||||
CancelEditCommand = ReactiveCommand.Create(CancelEdit);
|
||||
|
||||
_liteDbService.PropertyChanged += OnLiteDbServicePropertyChanged;
|
||||
_client.Self.TeleportProgress += OnTeleportProgress;
|
||||
_client.Objects.AvatarUpdate += OnAvatarUpdate;
|
||||
_client.Network.SimConnected += OnSimConnected;
|
||||
_client.Network.SimDisconnected += OnSimDisconnected;
|
||||
|
||||
if (_client.Network.Connected)
|
||||
{
|
||||
UpdateLocationDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
public ReactiveCommand<Unit, Unit> HomeCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> BackCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> ForwardCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> SettingsCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> StartEditCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> CommitEditCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> CancelEditCommand { get; }
|
||||
|
||||
private string _currentLocationDisplay = string.Empty;
|
||||
|
||||
public string CurrentLocationDisplay
|
||||
{
|
||||
get => _currentLocationDisplay;
|
||||
set
|
||||
{
|
||||
if (_currentLocationDisplay == value) return;
|
||||
_currentLocationDisplay = value;
|
||||
OnPropertyChanged(nameof(CurrentLocationDisplay));
|
||||
}
|
||||
}
|
||||
|
||||
private string _currentCoordinatesDisplay = string.Empty;
|
||||
|
||||
public string CurrentCoordinatesDisplay
|
||||
{
|
||||
get => _currentCoordinatesDisplay;
|
||||
set
|
||||
{
|
||||
if (_currentCoordinatesDisplay == value) return;
|
||||
_currentCoordinatesDisplay = value;
|
||||
OnPropertyChanged(nameof(CurrentCoordinatesDisplay));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isEditing;
|
||||
|
||||
public bool IsEditing
|
||||
{
|
||||
get => _isEditing;
|
||||
set
|
||||
{
|
||||
if (_isEditing == value) return;
|
||||
_isEditing = value;
|
||||
OnPropertyChanged(nameof(IsEditing));
|
||||
OnPropertyChanged(nameof(IsDisplaying));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisplaying => !IsEditing;
|
||||
|
||||
private string _editableLocation = string.Empty;
|
||||
|
||||
public string EditableLocation
|
||||
{
|
||||
get => _editableLocation;
|
||||
set
|
||||
{
|
||||
if (_editableLocation == value) return;
|
||||
_editableLocation = value;
|
||||
OnPropertyChanged(nameof(EditableLocation));
|
||||
}
|
||||
}
|
||||
|
||||
private string _maturityRating = "G";
|
||||
|
||||
public string MaturityRating
|
||||
{
|
||||
get => _maturityRating;
|
||||
set
|
||||
{
|
||||
if (_maturityRating == value) return;
|
||||
_maturityRating = value;
|
||||
OnPropertyChanged(nameof(MaturityRating));
|
||||
OnPropertyChanged(nameof(MaturityRatingTooltip)); // Don't forget this!
|
||||
}
|
||||
}
|
||||
|
||||
public string MaturityRatingTooltip =>
|
||||
MaturityRating switch
|
||||
{
|
||||
"G" => Application.Current?.FindResource("AddressBar_Maturity_G") as string ??
|
||||
"General: Suitable for all ages - family-friendly content",
|
||||
"M" => Application.Current?.FindResource("AddressBar_Maturity_M") as string ??
|
||||
"Moderate: Suitable for ages 17 and above, may include moderate use of violence and 'sexy' content",
|
||||
"A" => Application.Current?.FindResource("AddressBar_Maturity_A") as string ??
|
||||
"Adult: Suitable for ages 18 and above, may include heavy use of violence, drugs, or sexual content",
|
||||
_ => Application.Current?.FindResource("AddressBar_Maturity_Default") as string ??
|
||||
"Content rating information"
|
||||
};
|
||||
|
||||
public string MaturityRatingColorHex =>
|
||||
MaturityRating switch
|
||||
{
|
||||
"G" => "#4CAF50", // Green
|
||||
"M" => "#FF9800", // Orange
|
||||
"A" => "#F44336", // Red
|
||||
_ => "#4CAF50"
|
||||
};
|
||||
|
||||
public SolidColorBrush MaturityRatingBrush =>
|
||||
new(Color.Parse(MaturityRatingColorHex));
|
||||
|
||||
public bool CanGoBack => _currentHistoryIndex > 0;
|
||||
public bool CanGoForward => _currentHistoryIndex < _locationHistory.Count - 1;
|
||||
|
||||
private void GoHome()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_client.Network.Connected) return;
|
||||
_client.Self.GoHome();
|
||||
Log.Information("Teleporting home");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to teleport home");
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
if (!CanGoBack) return;
|
||||
|
||||
_currentHistoryIndex--;
|
||||
var location = _locationHistory[_currentHistoryIndex];
|
||||
TeleportToLocation(location);
|
||||
|
||||
OnPropertyChanged(nameof(CanGoBack));
|
||||
OnPropertyChanged(nameof(CanGoForward));
|
||||
}
|
||||
|
||||
private void GoForward()
|
||||
{
|
||||
if (!CanGoForward) return;
|
||||
|
||||
_currentHistoryIndex++;
|
||||
var location = _locationHistory[_currentHistoryIndex];
|
||||
TeleportToLocation(location);
|
||||
|
||||
OnPropertyChanged(nameof(CanGoBack));
|
||||
OnPropertyChanged(nameof(CanGoForward));
|
||||
}
|
||||
|
||||
private string GetCurrentLocationForEditing()
|
||||
{
|
||||
if (_client.Network?.Connected != true || _client.Network.CurrentSim == null)
|
||||
return _session.CurrentLocation;
|
||||
// Return in the format expected by the dialog: "Region Name/X/Y/Z"
|
||||
var regionName = _client.Network.CurrentSim.Name;
|
||||
var x = (int)_client.Self.SimPosition.X;
|
||||
var y = (int)_client.Self.SimPosition.Y;
|
||||
var z = (int)_client.Self.SimPosition.Z;
|
||||
return $"{regionName}/{x}/{y}/{z}";
|
||||
}
|
||||
|
||||
public bool ShowMaturityRating => !string.IsNullOrEmpty(MaturityRating);
|
||||
|
||||
public string MaturityRatingColor => MaturityRatingColorHex;
|
||||
|
||||
private void StartEdit()
|
||||
{
|
||||
Log.Information("Starting address edit mode");
|
||||
var currentLocation = GetCurrentLocationForEditing();
|
||||
EditableLocation = currentLocation;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
private void CommitEdit()
|
||||
{
|
||||
Log.Information($"CommitEdit calling SearchCommand with: '{EditableLocation}'");
|
||||
if (!string.IsNullOrWhiteSpace(EditableLocation))
|
||||
{
|
||||
Search();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("EditableLocation is empty or null - no location to navigate to");
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
Log.Information("Cancelling address edit");
|
||||
EditableLocation = GetCurrentLocationForEditing();
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
private void Search()
|
||||
{
|
||||
var location = EditableLocation.Trim();
|
||||
Log.Information("Search/Go requested with location: '{Location}'", location);
|
||||
|
||||
if (string.IsNullOrEmpty(location))
|
||||
{
|
||||
Log.Warning("EditableLocation is empty or null - no location to navigate to");
|
||||
return;
|
||||
}
|
||||
|
||||
TeleportToLocation(location);
|
||||
IsEditing = false;
|
||||
}
|
||||
|
||||
private void TeleportToLocation(string location)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_client.Network.Connected)
|
||||
{
|
||||
Log.Warning("Not connected to grid - cannot teleport");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ParseLocationString(location, out var regionName, out var x, out var y,
|
||||
out var z))
|
||||
{
|
||||
Log.Information("Teleporting to {RegionName} ({F},{F1},{F2})", regionName, x, y,
|
||||
z);
|
||||
|
||||
IsTeleporting = true;
|
||||
TeleportStatusMessage = $"Teleporting to {regionName}...";
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_client.Self.Teleport(regionName, new Vector3(x, y, z));
|
||||
AddToHistory(location);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to initiate teleport to {Location}", location);
|
||||
IsTeleporting = false;
|
||||
TeleportStatusMessage = "Teleport failed";
|
||||
|
||||
await Task.Delay(3000);
|
||||
if (TeleportStatusMessage == "Teleport failed")
|
||||
{
|
||||
TeleportStatusMessage = "";
|
||||
}
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error("Failed to parse location: {Location}", location);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"Failed to teleport to location: {location}");
|
||||
IsTeleporting = false;
|
||||
TeleportStatusMessage = "";
|
||||
}
|
||||
}
|
||||
|
||||
private bool ParseLocationString(string location, out string regionName, out float x,
|
||||
out float y, out float z)
|
||||
{
|
||||
regionName = "";
|
||||
x = y = z = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
return false;
|
||||
|
||||
// Handle secondlife:// URLs
|
||||
if (location.StartsWith("secondlife://"))
|
||||
{
|
||||
return ParseSecondLifeUrl(location, out regionName, out x, out y, out z);
|
||||
}
|
||||
|
||||
// Handle maps.secondlife.com URLs
|
||||
if (location.Contains("maps.secondlife.com"))
|
||||
{
|
||||
return ParseMapsUrl(location, out regionName, out x, out y, out z);
|
||||
}
|
||||
|
||||
// Handle "Region Name/X/Y/Z" format
|
||||
var match = SlurlRegex().Match(location);
|
||||
if (match.Success)
|
||||
{
|
||||
regionName = Uri.UnescapeDataString(match.Groups[1].Value);
|
||||
if (float.TryParse(match.Groups[2].Value, out x) &&
|
||||
float.TryParse(match.Groups[3].Value, out y) &&
|
||||
float.TryParse(match.Groups[4].Value, out z))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle just region name
|
||||
regionName = location;
|
||||
x = y = 128; // Default to center
|
||||
z = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ParseSecondLifeUrl(string url, out string regionName, out float x, out float y,
|
||||
out float z)
|
||||
{
|
||||
regionName = "";
|
||||
x = y = z = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var path = url.Substring("secondlife://".Length);
|
||||
var parts = path.Split('/');
|
||||
|
||||
if (parts.Length >= 1)
|
||||
{
|
||||
regionName = Uri.UnescapeDataString(parts[0]);
|
||||
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
float.TryParse(parts[1], out x);
|
||||
float.TryParse(parts[2], out y);
|
||||
if (parts.Length >= 4)
|
||||
float.TryParse(parts[3], out z);
|
||||
}
|
||||
else
|
||||
{
|
||||
x = y = 128; // Default center
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to parse secondlife URL: {Url}", url);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ParseMapsUrl(string url, out string regionName, out float x,
|
||||
out float y, out float z)
|
||||
{
|
||||
regionName = "";
|
||||
x = y = z = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var secondlifeIndex = url.IndexOf("secondlife/", StringComparison.Ordinal);
|
||||
if (secondlifeIndex >= 0)
|
||||
{
|
||||
var path = url.Substring(secondlifeIndex + "secondlife/".Length);
|
||||
var parts = path.Split('/');
|
||||
|
||||
if (parts.Length >= 1)
|
||||
{
|
||||
regionName = Uri.UnescapeDataString(parts[0]);
|
||||
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
float.TryParse(parts[1], out x);
|
||||
float.TryParse(parts[2], out y);
|
||||
if (parts.Length >= 4)
|
||||
float.TryParse(parts[3], out z);
|
||||
}
|
||||
else
|
||||
{
|
||||
x = y = 128; // Default center
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to parse maps URL: {Url}", url);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void AddToHistory(string location)
|
||||
{
|
||||
// TODO: Implement proper history management
|
||||
if (_currentHistoryIndex < _locationHistory.Count - 1)
|
||||
{
|
||||
_locationHistory.RemoveRange(_currentHistoryIndex + 1,
|
||||
_locationHistory.Count - _currentHistoryIndex - 1);
|
||||
}
|
||||
|
||||
_locationHistory.Add(location);
|
||||
_currentHistoryIndex = _locationHistory.Count - 1;
|
||||
|
||||
OnPropertyChanged(nameof(CanGoBack));
|
||||
OnPropertyChanged(nameof(CanGoForward));
|
||||
}
|
||||
|
||||
private void OpenSettings()
|
||||
{
|
||||
Log.Information("Opening preferences window");
|
||||
if (_openPreferencesCommand?.CanExecute(null) == true)
|
||||
{
|
||||
_openPreferencesCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("No preferences navigation command available");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLocationDisplay()
|
||||
{
|
||||
if (_client.Network?.Connected != true || _client.Network.CurrentSim == null)
|
||||
{
|
||||
CurrentLocationDisplay = _session.CurrentLocation;
|
||||
CurrentCoordinatesDisplay = "";
|
||||
MaturityRating = "G";
|
||||
return;
|
||||
}
|
||||
|
||||
var regionName = _client.Network.CurrentSim.Name;
|
||||
var x = (int)_client.Self.SimPosition.X;
|
||||
var y = (int)_client.Self.SimPosition.Y;
|
||||
var z = (int)_client.Self.SimPosition.Z;
|
||||
|
||||
CurrentLocationDisplay = $"{regionName} ({x},{y},{z})";
|
||||
CurrentCoordinatesDisplay = $"{x}/{y}/{z}";
|
||||
|
||||
// Update maturity rating based on region access level
|
||||
MaturityRating = _client.Network.CurrentSim.Access switch
|
||||
{
|
||||
SimAccess.Mature => "M",
|
||||
SimAccess.Adult => "A",
|
||||
_ => "G"
|
||||
};
|
||||
|
||||
OnPropertyChanged(nameof(MaturityRatingColorHex));
|
||||
OnPropertyChanged(nameof(MaturityRatingColor));
|
||||
OnPropertyChanged(nameof(MaturityRatingBrush));
|
||||
OnPropertyChanged(nameof(ShowMaturityRating));
|
||||
}
|
||||
|
||||
private void OnLiteDbServicePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(LiteDbService.Session):
|
||||
_session = _liteDbService.GetSession();
|
||||
UpdateLocationDisplay();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAvatarUpdate(object? sender, AvatarUpdateEventArgs e)
|
||||
{
|
||||
if (e.Avatar.ID != _client.Self.AgentID)
|
||||
return;
|
||||
|
||||
// Check if we need to throttle updates to prevent performance issues
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLocationUpdate < TimeSpan.FromMilliseconds(LocationUpdateThrottleMs))
|
||||
return;
|
||||
|
||||
// Check if position or region has actually changed to avoid unnecessary updates
|
||||
var currentPosition = _client.Self.SimPosition;
|
||||
var currentRegion = _client.Network.CurrentSim?.Name ?? "";
|
||||
|
||||
if (!HasLocationChanged(currentPosition, currentRegion)) return;
|
||||
_lastLocationUpdate = now;
|
||||
_lastKnownPosition = currentPosition;
|
||||
_lastKnownRegion = currentRegion;
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(UpdateLocationDisplay, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void OnSimConnected(object? sender, EventArgs e)
|
||||
{
|
||||
Log.Information("Connected to simulator: {Simulator}",
|
||||
_client.Network.CurrentSim?.Name);
|
||||
UpdateLocationDisplay();
|
||||
}
|
||||
|
||||
private void OnSimDisconnected(object? sender, EventArgs e)
|
||||
{
|
||||
Log.Information("Disconnected from simulator");
|
||||
CurrentLocationDisplay = "Disconnected";
|
||||
CurrentCoordinatesDisplay = "";
|
||||
}
|
||||
|
||||
private void OnTeleportProgress(object? sender, TeleportEventArgs e)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
switch (e.Status)
|
||||
{
|
||||
case TeleportStatus.Start:
|
||||
IsTeleporting = true;
|
||||
TeleportStatusMessage = "Initializing teleport...";
|
||||
break;
|
||||
|
||||
case TeleportStatus.Progress:
|
||||
TeleportStatusMessage = e.Message ?? "Teleporting...";
|
||||
break;
|
||||
|
||||
case TeleportStatus.Failed:
|
||||
IsTeleporting = false;
|
||||
TeleportStatusMessage = $"Teleport failed: {e.Message}";
|
||||
Log.Warning("Teleport failed: {Message}", e.Message);
|
||||
|
||||
Task.Delay(5000).ContinueWith(_ =>
|
||||
{
|
||||
if (TeleportStatusMessage.StartsWith("Teleport failed"))
|
||||
{
|
||||
TeleportStatusMessage = "";
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
break;
|
||||
|
||||
case TeleportStatus.Finished:
|
||||
IsTeleporting = false;
|
||||
TeleportStatusMessage = "Teleport complete";
|
||||
Log.Information("Teleport completed successfully");
|
||||
|
||||
UpdateLocationDisplay();
|
||||
|
||||
Task.Delay(2000).ContinueWith(_ =>
|
||||
{
|
||||
if (TeleportStatusMessage == "Teleport complete")
|
||||
{
|
||||
TeleportStatusMessage = "";
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
break;
|
||||
|
||||
case TeleportStatus.Cancelled:
|
||||
IsTeleporting = false;
|
||||
TeleportStatusMessage = "Teleport cancelled";
|
||||
|
||||
Task.Delay(3000).ContinueWith(_ =>
|
||||
{
|
||||
if (TeleportStatusMessage == "Teleport cancelled")
|
||||
{
|
||||
TeleportStatusMessage = "";
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
break;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowTeleportStatus));
|
||||
}, DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
private bool _isTeleporting;
|
||||
|
||||
public bool IsTeleporting
|
||||
{
|
||||
get => _isTeleporting;
|
||||
set
|
||||
{
|
||||
if (_isTeleporting == value) return;
|
||||
_isTeleporting = value;
|
||||
OnPropertyChanged(nameof(IsTeleporting));
|
||||
OnPropertyChanged(nameof(ShowTeleportStatus));
|
||||
}
|
||||
}
|
||||
|
||||
private string _teleportStatusMessage = "";
|
||||
|
||||
public string TeleportStatusMessage
|
||||
{
|
||||
get => _teleportStatusMessage;
|
||||
set
|
||||
{
|
||||
if (_teleportStatusMessage == value) return;
|
||||
_teleportStatusMessage = value;
|
||||
OnPropertyChanged(nameof(TeleportStatusMessage));
|
||||
OnPropertyChanged(nameof(ShowTeleportStatus));
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLocationChanged(Vector3 currentPosition, string currentRegion)
|
||||
{
|
||||
if (currentRegion != _lastKnownRegion)
|
||||
return true;
|
||||
|
||||
const float
|
||||
minMovementThreshold =
|
||||
0.5f; // Minimum distance to consider as movement is 0.5 meters
|
||||
var distance = Vector3.Distance(currentPosition, _lastKnownPosition);
|
||||
return distance >= minMovementThreshold;
|
||||
}
|
||||
|
||||
public bool ShowTeleportStatus =>
|
||||
IsTeleporting && !string.IsNullOrEmpty(TeleportStatusMessage);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_liteDbService.PropertyChanged -= OnLiteDbServicePropertyChanged;
|
||||
_client.Self.TeleportProgress -= OnTeleportProgress;
|
||||
_client.Objects.AvatarUpdate -= OnAvatarUpdate;
|
||||
_client.Network.SimConnected -= OnSimConnected;
|
||||
_client.Network.SimDisconnected -= OnSimDisconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using GalaxyViewer.Models;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public class ChatViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly ChatService _chatService;
|
||||
private readonly ICommand? _backToDashboardCommand;
|
||||
private ChatConversation? _activeConversation;
|
||||
private string _messageText = string.Empty;
|
||||
private bool _isLoading;
|
||||
private bool _disposed;
|
||||
|
||||
private System.Timers.Timer? _typingTimer;
|
||||
private bool _isCurrentlyTyping;
|
||||
private DateTime _lastTypingTime;
|
||||
|
||||
public bool IsInChatWindow { get; set; }
|
||||
|
||||
public ObservableCollection<ChatConversation> Conversations => _chatService.Conversations;
|
||||
|
||||
public ChatConversation? ActiveConversation
|
||||
{
|
||||
get => _activeConversation;
|
||||
set
|
||||
{
|
||||
if (_activeConversation == value) return;
|
||||
_activeConversation = value;
|
||||
_chatService.SetActiveConversation(value);
|
||||
OnPropertyChanged(nameof(ActiveConversation));
|
||||
OnPropertyChanged(nameof(ActiveMessages));
|
||||
OnPropertyChanged(nameof(CanSendMessage));
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ChatMessage>? ActiveMessages => _activeConversation?.Messages;
|
||||
|
||||
public string MessageText
|
||||
{
|
||||
get => _messageText;
|
||||
set
|
||||
{
|
||||
_messageText = value;
|
||||
OnPropertyChanged(nameof(MessageText));
|
||||
OnPropertyChanged(nameof(CanSendMessage));
|
||||
|
||||
HandleTypingDetection();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set
|
||||
{
|
||||
_isLoading = value;
|
||||
OnPropertyChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanSendMessage => !string.IsNullOrWhiteSpace(MessageText) &&
|
||||
ActiveConversation != null && !IsLoading;
|
||||
|
||||
public bool CanTypeMessage =>
|
||||
ActiveConversation != null
|
||||
&& !IsLoading
|
||||
&& ActiveConversation.MessageType != ChatMessageType.Objects;
|
||||
|
||||
public bool ShowObjectImWarning => ActiveConversation?.MessageType == ChatMessageType.Objects;
|
||||
|
||||
public string ObjectImWarning => ShowObjectImWarning
|
||||
? Application.Current?.FindResource("Chat_ObjectImWarning") as string ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
public ReactiveCommand<Unit, Unit> SendMessageCommand { get; }
|
||||
public ReactiveCommand<ChatConversation, Unit> SelectConversationCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> PopOutChatCommand { get; }
|
||||
|
||||
public ChatViewModel(ChatService chatService, ICommand? backToDashboardCommand = null)
|
||||
{
|
||||
_chatService = chatService;
|
||||
_backToDashboardCommand = backToDashboardCommand;
|
||||
|
||||
_chatService.ActiveConversationChanged += OnActiveConversationChanged;
|
||||
_chatService.MessageReceived += OnMessageReceived;
|
||||
_chatService.ConversationUpdated += OnConversationUpdated;
|
||||
|
||||
SendMessageCommand = ReactiveCommand.CreateFromTask(SendMessageAsync);
|
||||
SelectConversationCommand = ReactiveCommand.Create<ChatConversation>(SelectConversation);
|
||||
PopOutChatCommand = ReactiveCommand.Create(PopOutChat);
|
||||
|
||||
ActiveConversation = _chatService.LocalChatConversation;
|
||||
|
||||
InitializeTypingTimer();
|
||||
}
|
||||
|
||||
private void InitializeTypingTimer()
|
||||
{
|
||||
_typingTimer = new System.Timers.Timer(3000); // 3 seconds
|
||||
_typingTimer.Elapsed += OnTypingTimerElapsed;
|
||||
_typingTimer.AutoReset = false;
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
if (!CanSendMessage || ActiveConversation == null) return;
|
||||
|
||||
StopTyping();
|
||||
|
||||
IsLoading = true;
|
||||
var message = MessageText;
|
||||
MessageText = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
switch (ActiveConversation.MessageType)
|
||||
{
|
||||
case ChatMessageType.LocalChat:
|
||||
await _chatService.SendLocalChatAsync(message);
|
||||
break;
|
||||
case ChatMessageType.InstantMessage when !false:
|
||||
await _chatService.SendInstantMessageAsync(ActiveConversation.ParticipantId,
|
||||
message);
|
||||
break;
|
||||
case ChatMessageType.GroupChat when !false:
|
||||
await _chatService.SendGroupMessageAsync(ActiveConversation.GroupId, message);
|
||||
break;
|
||||
case ChatMessageType.System:
|
||||
break;
|
||||
case ChatMessageType.Objects:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
MessageText = message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectConversation(ChatConversation conversation)
|
||||
{
|
||||
ActiveConversation = conversation;
|
||||
_chatService.MarkConversationAsRead(conversation);
|
||||
}
|
||||
|
||||
private void OnMessageReceived(object? sender, ChatMessage message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
OnPropertyChanged(nameof(ActiveMessages));
|
||||
|
||||
MessageReceived?.Invoke(this, message);
|
||||
});
|
||||
}
|
||||
|
||||
public event EventHandler<ChatMessage>? MessageReceived;
|
||||
|
||||
private void OnConversationUpdated(object? sender, ChatConversation conversation)
|
||||
{
|
||||
OnPropertyChanged(nameof(Conversations));
|
||||
}
|
||||
|
||||
private void OnActiveConversationChanged(object? sender, ChatConversation? conversation)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => { ActiveConversation = conversation; });
|
||||
}
|
||||
|
||||
private void PopOutChat()
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
desktop)
|
||||
return;
|
||||
|
||||
IsInChatWindow = true;
|
||||
var chatWindow = new ChatWindow(this)
|
||||
{
|
||||
DataContext = this,
|
||||
Title = "GalaxyViewer - Chat",
|
||||
Width = 800,
|
||||
Height = 600,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||
};
|
||||
|
||||
_backToDashboardCommand?.Execute(null);
|
||||
|
||||
if (desktop.MainWindow != null)
|
||||
{
|
||||
chatWindow.ShowDialog(desktop.MainWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
chatWindow.Show();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTypingDetection()
|
||||
{
|
||||
if (ActiveConversation?.MessageType != ChatMessageType.LocalChat)
|
||||
return;
|
||||
|
||||
var now = DateTime.Now;
|
||||
_lastTypingTime = now;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MessageText))
|
||||
{
|
||||
if (!_isCurrentlyTyping)
|
||||
{
|
||||
_isCurrentlyTyping = true;
|
||||
_chatService.StartLocalChatTyping();
|
||||
}
|
||||
|
||||
_typingTimer?.Stop();
|
||||
_typingTimer?.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_isCurrentlyTyping)
|
||||
{
|
||||
StopTyping();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTypingTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var timeSinceLastTyping = DateTime.Now - _lastTypingTime;
|
||||
if (timeSinceLastTyping.TotalSeconds >= 3)
|
||||
{
|
||||
StopTyping();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void StopTyping()
|
||||
{
|
||||
if (!_isCurrentlyTyping) return;
|
||||
_isCurrentlyTyping = false;
|
||||
_chatService.StopLocalChatTyping();
|
||||
_typingTimer?.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (disposing)
|
||||
{
|
||||
_chatService.ActiveConversationChanged -= OnActiveConversationChanged;
|
||||
_chatService.MessageReceived -= OnMessageReceived;
|
||||
_chatService.ConversationUpdated -= OnConversationUpdated;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~ChatViewModel()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using GalaxyViewer.Models;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public class ConversationDrawerViewModel : ObservableObject
|
||||
{
|
||||
private readonly ChatViewModel? _parentChatViewModel;
|
||||
|
||||
public ObservableCollection<ChatConversation> Conversations { get; }
|
||||
|
||||
public ICommand SelectConversationCommand { get; }
|
||||
|
||||
public ConversationDrawerViewModel(ChatViewModel? parentChatViewModel)
|
||||
{
|
||||
_parentChatViewModel = parentChatViewModel;
|
||||
Conversations = _parentChatViewModel?.Conversations ?? [];
|
||||
|
||||
SelectConversationCommand = new RelayCommand<ChatConversation>(SelectConversation);
|
||||
}
|
||||
|
||||
private void SelectConversation(ChatConversation? conversation)
|
||||
{
|
||||
if (conversation != null && _parentChatViewModel != null)
|
||||
{
|
||||
_parentChatViewModel.SelectConversationCommand.Execute(conversation).Subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using System.Linq;
|
||||
using ReactiveUI;
|
||||
using Avalonia.Controls;
|
||||
using OpenMetaverse;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.Models;
|
||||
using GalaxyViewer.Views;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public sealed class DashboardViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
{
|
||||
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private readonly SessionService _sessionService;
|
||||
private readonly GridClient _client;
|
||||
private readonly ChatService? _chatService;
|
||||
private SessionModel _session;
|
||||
private TabItem? _activeTab;
|
||||
private int _totalUnreadMessages;
|
||||
|
||||
public ObservableCollection<TabItem> Tabs { get; }
|
||||
|
||||
private TabItem? ActiveTab
|
||||
{
|
||||
get => _activeTab;
|
||||
set
|
||||
{
|
||||
if (_activeTab != null)
|
||||
_activeTab.IsActive = false;
|
||||
|
||||
this.RaiseAndSetIfChanged(ref _activeTab, value);
|
||||
|
||||
if (_activeTab != null)
|
||||
_activeTab.IsActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
public object? ActiveTabContent => ActiveTab?.Content;
|
||||
|
||||
public int CurrentBalance { get; private set; }
|
||||
public string FormattedBalance => $"{CurrencySymbol}{CurrentBalance:N0}";
|
||||
private string CurrencySymbol => "L$";
|
||||
|
||||
public AddressBarViewModel AddressBarViewModel { get; }
|
||||
|
||||
private int TotalUnreadMessages
|
||||
{
|
||||
get => _totalUnreadMessages;
|
||||
set
|
||||
{
|
||||
if (_totalUnreadMessages == value) return;
|
||||
|
||||
_totalUnreadMessages = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasUnreadMessages));
|
||||
OnPropertyChanged(nameof(UnreadMessagesText));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasUnreadMessages => TotalUnreadMessages > 0;
|
||||
public string UnreadMessagesText => TotalUnreadMessages > 99 ? "99+" : TotalUnreadMessages.ToString();
|
||||
|
||||
public ReactiveCommand<Unit, Unit> RefreshBalanceCommand { get; }
|
||||
|
||||
public ICommand ActivateTabCommand { get; }
|
||||
public ICommand CloseTabCommand { get; }
|
||||
|
||||
public DashboardViewModel(
|
||||
LiteDbService liteDbService,
|
||||
GridClient client,
|
||||
SessionService sessionService,
|
||||
ICommand? openPreferencesCommand = null,
|
||||
ChatService? chatService = null)
|
||||
{
|
||||
_liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_sessionService = sessionService ?? throw new ArgumentNullException(nameof(sessionService));
|
||||
_chatService = chatService;
|
||||
_session = _liteDbService.GetSession();
|
||||
|
||||
Tabs = [];
|
||||
|
||||
AddressBarViewModel = new AddressBarViewModel(_liteDbService, _client, openPreferencesCommand);
|
||||
|
||||
RefreshBalanceCommand = ReactiveCommand.Create(RequestBalance);
|
||||
ActivateTabCommand = ReactiveCommand.Create<TabItem>(ActivateTab);
|
||||
CloseTabCommand = ReactiveCommand.Create<TabItem>(CloseTab);
|
||||
|
||||
InitializeTabs();
|
||||
SubscribeToEvents();
|
||||
|
||||
CurrentBalance = _sessionService.Balance;
|
||||
OnPropertyChanged(nameof(CurrentBalance));
|
||||
|
||||
if (_chatService != null)
|
||||
{
|
||||
UpdateUnreadMessageCount();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void InitializeTabs()
|
||||
{
|
||||
if (_chatService == null) return;
|
||||
|
||||
// Main Chat/Local Chat tab (non-closeable for now)
|
||||
var chatViewModel = new ChatViewModel(_chatService);
|
||||
var mainChatTab = new TabItem("main_chat", "Local Chat",
|
||||
new ChatView { DataContext = chatViewModel }, false);
|
||||
Tabs.Add(mainChatTab);
|
||||
|
||||
// World/Map tab (placeholder)
|
||||
// TODO: Implement actual world/map functionality
|
||||
var worldTab = new TabItem("world", "World",
|
||||
new TextBlock { Text = "World/Map view - Coming Soon" }, false);
|
||||
Tabs.Add(worldTab);
|
||||
|
||||
// Inventory tab (placeholder)
|
||||
// TODO: Implement actual inventory functionality
|
||||
var inventoryTab = new TabItem("inventory", "Inventory",
|
||||
new TextBlock { Text = "Inventory view - Coming Soon" }, false);
|
||||
Tabs.Add(inventoryTab);
|
||||
|
||||
// People/Friends tab (placeholder)
|
||||
// TODO: Implement actual people/friends functionality
|
||||
var peopleTab = new TabItem("people", "People",
|
||||
new TextBlock { Text = "People/Friends view - Coming Soon" }, false);
|
||||
Tabs.Add(peopleTab);
|
||||
|
||||
if (Tabs.Count > 0)
|
||||
ActiveTab = Tabs[0];
|
||||
}
|
||||
|
||||
private void ActivateTab(TabItem tab)
|
||||
{
|
||||
ActiveTab = tab;
|
||||
tab.NotificationCount = 0;
|
||||
this.RaisePropertyChanged(nameof(ActiveTabContent));
|
||||
UpdateUnreadMessageCount();
|
||||
}
|
||||
|
||||
private void CloseTab(TabItem tab)
|
||||
{
|
||||
if (!tab.IsCloseable) return;
|
||||
|
||||
var index = Tabs.IndexOf(tab);
|
||||
Tabs.Remove(tab);
|
||||
|
||||
if (tab == ActiveTab && Tabs.Count > 0)
|
||||
{
|
||||
var newActiveIndex = Math.Min(index, Tabs.Count - 1);
|
||||
ActiveTab = Tabs[newActiveIndex];
|
||||
}
|
||||
|
||||
UpdateUnreadMessageCount();
|
||||
}
|
||||
|
||||
private void SubscribeToEvents()
|
||||
{
|
||||
_client.Network.LoginProgress += OnLoginProgress;
|
||||
_client.Network.Disconnected += OnDisconnected;
|
||||
_sessionService.BalanceChanged += OnBalanceChanged;
|
||||
}
|
||||
|
||||
private void UpdateUnreadMessageCount()
|
||||
{
|
||||
var totalUnread = Tabs.Sum(tab => tab.NotificationCount);
|
||||
TotalUnreadMessages = totalUnread;
|
||||
}
|
||||
|
||||
private void OnLoginProgress(object? sender, LoginProgressEventArgs e)
|
||||
{
|
||||
if (e.Status != LoginStatus.Success) return;
|
||||
CurrentBalance = _sessionService.Balance;
|
||||
OnPropertyChanged(nameof(CurrentBalance));
|
||||
OnPropertyChanged(nameof(FormattedBalance));
|
||||
}
|
||||
|
||||
private void OnDisconnected(object? sender, DisconnectedEventArgs e)
|
||||
{
|
||||
CurrentBalance = 0;
|
||||
OnPropertyChanged(nameof(CurrentBalance));
|
||||
OnPropertyChanged(nameof(FormattedBalance));
|
||||
}
|
||||
|
||||
private void OnBalanceChanged(object? sender, int newBalance)
|
||||
{
|
||||
CurrentBalance = newBalance;
|
||||
OnPropertyChanged(nameof(CurrentBalance));
|
||||
OnPropertyChanged(nameof(FormattedBalance));
|
||||
}
|
||||
|
||||
private void RequestBalance()
|
||||
{
|
||||
Task.Run(() => _client.Self.RequestBalance());
|
||||
}
|
||||
|
||||
public new event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private new void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using ReactiveUI;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.Models;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public class LoggedInViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
{
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private SessionModel _session;
|
||||
|
||||
public LoggedInViewModel(LiteDbService liteDbService)
|
||||
{
|
||||
_liteDbService = liteDbService;
|
||||
_session = _liteDbService.GetSession();
|
||||
_liteDbService.PropertyChanged += OnLiteDbServicePropertyChanged;
|
||||
}
|
||||
|
||||
public string CurrentLocation
|
||||
{
|
||||
get => _session.CurrentLocation;
|
||||
set
|
||||
{
|
||||
if (_session.CurrentLocation != value)
|
||||
{
|
||||
_session.CurrentLocation = value;
|
||||
_liteDbService.SaveSession(_session);
|
||||
OnPropertyChanged(nameof(CurrentLocation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string LoginWelcomeMessage
|
||||
{
|
||||
get => _session.LoginWelcomeMessage;
|
||||
set
|
||||
{
|
||||
if (_session.LoginWelcomeMessage != value)
|
||||
{
|
||||
_session.LoginWelcomeMessage = value;
|
||||
_liteDbService.SaveSession(_session);
|
||||
OnPropertyChanged(nameof(LoginWelcomeMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Balance
|
||||
{
|
||||
get => _session.Balance;
|
||||
set
|
||||
{
|
||||
if (_session.Balance != value)
|
||||
{
|
||||
_session.Balance = value;
|
||||
_liteDbService.SaveSession(_session);
|
||||
OnPropertyChanged(nameof(Balance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLiteDbServicePropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(LiteDbService.Session))
|
||||
{
|
||||
_session = _liteDbService.GetSession();
|
||||
OnPropertyChanged(nameof(CurrentLocation));
|
||||
OnPropertyChanged(nameof(LoginWelcomeMessage));
|
||||
OnPropertyChanged(nameof(Balance));
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged(string propertyName)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
@@ -13,10 +13,10 @@ using GalaxyViewer.Models;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.Views;
|
||||
using Newtonsoft.Json;
|
||||
using OpenMetaverse;
|
||||
using ReactiveUI;
|
||||
using Ursa.Controls;
|
||||
using Serilog;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
@@ -33,14 +33,6 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isLoggedIn;
|
||||
|
||||
public bool IsLoggedIn
|
||||
{
|
||||
get => App.IsLoggedIn;
|
||||
set => App.IsLoggedIn = value;
|
||||
}
|
||||
|
||||
private string _loginStatusMessage;
|
||||
|
||||
public string LoginStatusMessage
|
||||
@@ -49,38 +41,60 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
set => this.RaiseAndSetIfChanged(ref _loginStatusMessage, value);
|
||||
}
|
||||
|
||||
/*
|
||||
public static string SplashScreenUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var version = VersionHelper.GetInformationalVersion();
|
||||
var platform = GetFullPlatformString().ToLowerInvariant();
|
||||
return $"https://galaxyviewer-splash.pages.dev?version={version}&platform={platform}";
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
public bool ShowSplashScreen => false;
|
||||
// Hide in debug builds because you need a bunch of dependencies for Linux and why would I do all that for a tiny thing?
|
||||
// The built one has all the dependencies included so leave it in there
|
||||
#else
|
||||
public bool ShowSplashScreen => true; // Show in release builds
|
||||
#endif
|
||||
*/
|
||||
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private readonly PreferencesViewModel _preferencesViewModel;
|
||||
private readonly Timer _sessionCheckTimer;
|
||||
private SessionModel _currentSession;
|
||||
private string _username;
|
||||
private string _password;
|
||||
private readonly GridClient _client = new();
|
||||
private readonly GridClient _client;
|
||||
private IRoutableViewModel? _routableViewModelImplementation;
|
||||
private readonly GridService _gridService;
|
||||
private ObservableCollection<GridModel?> _grids;
|
||||
private ObservableCollection<GridModel> _grids;
|
||||
private GridModel _selectedGrid;
|
||||
|
||||
private const string DefaultGridUri =
|
||||
"https://login.agni.lindenlab.com/cgi-bin/login.cgi";
|
||||
|
||||
public WindowToastManager? ToastManager { get; set; }
|
||||
|
||||
public LoginViewModel(LiteDbService liteDbService)
|
||||
private SessionModel _currentSession;
|
||||
|
||||
public SessionModel CurrentSession
|
||||
{
|
||||
get => _currentSession;
|
||||
set => this.RaiseAndSetIfChanged(ref _currentSession, value);
|
||||
}
|
||||
|
||||
public LoginViewModel(LiteDbService liteDbService, GridClient client)
|
||||
{
|
||||
_liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_preferencesViewModel = new PreferencesViewModel();
|
||||
_currentSession = _liteDbService.GetSession() ??
|
||||
throw new InvalidOperationException("Session could not be retrieved.");
|
||||
_sessionCheckTimer =
|
||||
new Timer(CheckSessionChanges, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
|
||||
_username = string.Empty;
|
||||
_password = string.Empty;
|
||||
LoginLocations = _preferencesViewModel.LoginLocationOptions;
|
||||
SelectedLoginLocation = _preferencesViewModel.SelectedLoginLocation;
|
||||
TryLoginCommand = ReactiveCommand.CreateFromTask(TryLoginAsync);
|
||||
_gridService = new GridService();
|
||||
_grids = new ObservableCollection<GridModel?>();
|
||||
_grids = [];
|
||||
|
||||
LoadGrids();
|
||||
|
||||
@@ -89,30 +103,16 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
{
|
||||
Log.Error("An error occurred during login: {Error}", ex.Message);
|
||||
// Handle the error (e.g., show a dialog to the user)
|
||||
_ = ShowLoginErrorAsync("An error occurred during login. Please try again.");
|
||||
_ = ShowLoginErrorAsync();
|
||||
});
|
||||
|
||||
// Subscribe to the Network.LoginProgress event
|
||||
_client.Network.LoginProgress += OnLoginProgress;
|
||||
}
|
||||
|
||||
private void CheckSessionChanges(object? state)
|
||||
{
|
||||
if (_currentSession == null)
|
||||
{
|
||||
Log.Error("LiteDbService or current session is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_liteDbService.HasSessionChanged(_currentSession)) return;
|
||||
_currentSession = _liteDbService.GetSession();
|
||||
UpdateViewBindings();
|
||||
}
|
||||
|
||||
private void UpdateViewBindings()
|
||||
{
|
||||
// Update the properties bound to the view
|
||||
this.RaisePropertyChanged(nameof(IsLoggedIn));
|
||||
// Update the properties bound to the view\
|
||||
this.RaisePropertyChanged(nameof(LoginStatusMessage));
|
||||
}
|
||||
|
||||
@@ -140,48 +140,59 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private UserControl _mfaPromptContainer;
|
||||
private UserControl? _mfaPromptContainer;
|
||||
|
||||
public UserControl MfaPromptContainer
|
||||
public UserControl? MfaPromptContainer
|
||||
{
|
||||
get => _mfaPromptContainer;
|
||||
set => this.RaiseAndSetIfChanged(ref _mfaPromptContainer, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<GridModel> Grids { get; set; }
|
||||
|
||||
public GridModel? SelectedGrid
|
||||
public ObservableCollection<GridModel> Grids
|
||||
{
|
||||
get
|
||||
{
|
||||
var selectedGrid = _grids.FirstOrDefault(g =>
|
||||
g.GridNick == _preferencesViewModel.SelectedGridNick) ?? new GridModel
|
||||
{
|
||||
GridNick = "Second Life",
|
||||
LoginUri = DefaultGridUri
|
||||
};
|
||||
|
||||
return selectedGrid;
|
||||
}
|
||||
set
|
||||
{
|
||||
if (value == null) return;
|
||||
_preferencesViewModel.SelectedGridNick = value.GridNick;
|
||||
this.RaiseAndSetIfChanged(ref _selectedGrid, value);
|
||||
}
|
||||
get => _grids;
|
||||
set => this.RaiseAndSetIfChanged(ref _grids, value);
|
||||
}
|
||||
|
||||
private void LoadGrids()
|
||||
{
|
||||
var grids = _gridService.GetAllGrids();
|
||||
_grids = new ObservableCollection<GridModel?>(grids);
|
||||
SelectedGrid = _grids.FirstOrDefault(g =>
|
||||
g.GridNick == _preferencesViewModel.SelectedGridNick);
|
||||
Grids = new ObservableCollection<GridModel>(grids);
|
||||
|
||||
// If no selection, default to the Second Life grid
|
||||
if (Grids.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_preferencesViewModel.SelectedGridNick))
|
||||
_preferencesViewModel.SelectedGridNick = Grids[0].GridName;
|
||||
|
||||
var selected =
|
||||
Grids.FirstOrDefault(g => g.GridName == _preferencesViewModel.SelectedGridNick)
|
||||
?? Grids[0];
|
||||
|
||||
if (SelectedGrid != selected)
|
||||
SelectedGrid = selected;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedGrid = null;
|
||||
}
|
||||
}
|
||||
|
||||
public GridModel? SelectedGrid
|
||||
{
|
||||
get => _selectedGrid;
|
||||
set
|
||||
{
|
||||
if (_selectedGrid == value) return;
|
||||
_selectedGrid = value;
|
||||
if (value != null) _preferencesViewModel.SelectedGridNick = value.GridName;
|
||||
this.RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public ReactiveCommand<Unit, Unit> TryLoginCommand { get; }
|
||||
|
||||
private async Task ShowLoginErrorAsync(string errorMessage)
|
||||
private async Task ShowLoginErrorAsync()
|
||||
{
|
||||
// ToastManager?.Show(
|
||||
// new Toast(
|
||||
@@ -198,7 +209,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
Log.Warning("Username or password is empty");
|
||||
await ShowLoginErrorAsync("Username or password is empty");
|
||||
await ShowLoginErrorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,7 +221,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion ?? "Version not found";
|
||||
|
||||
var loginParams = _client?.Network?.DefaultLoginParams(
|
||||
var loginParams = _client.Network?.DefaultLoginParams(
|
||||
Username.Split(' ')[0], // firstName
|
||||
Username.Contains(' ') ? Username.Split(' ')[1] : "Resident", // lastName
|
||||
Password,
|
||||
@@ -221,7 +232,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
if (loginParams == null)
|
||||
{
|
||||
Log.Error("Failed to create login parameters");
|
||||
await ShowLoginErrorAsync("Failed to create login parameters");
|
||||
await ShowLoginErrorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,7 +240,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
loginParams.MfaEnabled = true;
|
||||
loginParams.Platform = ourPlatform;
|
||||
loginParams.PlatformVersion = Environment.OSVersion.VersionString;
|
||||
loginParams.LoginLocation = _preferencesViewModel?.SelectedLoginLocation switch
|
||||
loginParams.Start = _preferencesViewModel.SelectedLoginLocation switch
|
||||
{
|
||||
"Home" => "home",
|
||||
"Last Location" => "last",
|
||||
@@ -237,25 +248,25 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
};
|
||||
loginParams.UserAgent = "LibreMetaverse";
|
||||
|
||||
#if DEBUG
|
||||
Log.Information("Login parameters: {LoginParams}",
|
||||
JsonConvert.SerializeObject(loginParams, Formatting.Indented));
|
||||
#endif
|
||||
|
||||
var loginSuccess = await Task.Run(() => _client?.Network?.Login(loginParams) ?? false);
|
||||
var loginSuccess = await Task.Run(() => _client.Network?.Login(loginParams) ?? false);
|
||||
|
||||
if (loginSuccess)
|
||||
{
|
||||
#if DEBUG
|
||||
Log.Information("Login parameters: {LoginParams}",
|
||||
JsonConvert.SerializeObject(loginParams, Formatting.Indented));
|
||||
#endif
|
||||
|
||||
await HandleSuccessfulLogin();
|
||||
}
|
||||
else if (_client?.Network?.LoginMessage.Contains("multifactor") == true)
|
||||
else if (_client.Network?.LoginMessage.Contains("multifactor") == true)
|
||||
{
|
||||
Log.Information("MFA required for login");
|
||||
var mfaCode = await ShowMfaPromptDialogAsync();
|
||||
if (string.IsNullOrWhiteSpace(mfaCode))
|
||||
{
|
||||
Log.Warning("MFA code is empty");
|
||||
await ShowLoginErrorAsync("MFA code is required");
|
||||
await ShowLoginErrorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,7 +277,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
JsonConvert.SerializeObject(loginParams, Formatting.Indented));
|
||||
#endif
|
||||
|
||||
loginSuccess = await Task.Run(() => _client?.Network?.Login(loginParams) ?? false);
|
||||
loginSuccess = await Task.Run(() => _client.Network?.Login(loginParams) ?? false);
|
||||
|
||||
if (loginSuccess)
|
||||
{
|
||||
@@ -274,19 +285,24 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error("MFA login failed: {Error}", _client?.Network?.LoginMessage);
|
||||
IsLoggedIn = false;
|
||||
await ShowLoginErrorAsync($"MFA login failed: {_client?.Network?.LoginMessage}");
|
||||
Log.Error("MFA login failed: {Error}", _client.Network?.LoginMessage);
|
||||
await ShowLoginErrorAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error("Login failed: {Error}", _client?.Network?.LoginMessage);
|
||||
IsLoggedIn = false;
|
||||
await ShowLoginErrorAsync($"Login failed: {_client?.Network?.LoginMessage}");
|
||||
Log.Error("Login failed: {Error}", _client.Network?.LoginMessage);
|
||||
await ShowLoginErrorAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _isMfaPromptVisible;
|
||||
public bool IsMfaPromptVisible
|
||||
{
|
||||
get => _isMfaPromptVisible;
|
||||
set => this.RaiseAndSetIfChanged(ref _isMfaPromptVisible, value);
|
||||
}
|
||||
|
||||
private async Task<string> ShowMfaPromptDialogAsync()
|
||||
{
|
||||
var tcs = new TaskCompletionSource<string>();
|
||||
@@ -297,11 +313,12 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
};
|
||||
|
||||
MfaPromptContainer = mfaPromptDialog;
|
||||
IsMfaPromptVisible = true;
|
||||
|
||||
var mfaCode = await tcs.Task;
|
||||
|
||||
// Clear the MFA prompt container after getting the code
|
||||
MfaPromptContainer = null;
|
||||
IsMfaPromptVisible = false;
|
||||
|
||||
return mfaCode;
|
||||
}
|
||||
@@ -322,34 +339,62 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
"Unk";
|
||||
}
|
||||
|
||||
private static string GetFullPlatformString()
|
||||
{
|
||||
var platformMap = new Dictionary<OSPlatform, string>
|
||||
{
|
||||
{ OSPlatform.Windows, "Windows" },
|
||||
{ OSPlatform.Linux, "Linux" },
|
||||
{ OSPlatform.OSX, "MacOS" },
|
||||
{ OSPlatform.Create("BROWSER"), "Browser" },
|
||||
{ OSPlatform.Create("ANDROID"), "Android" },
|
||||
{ OSPlatform.Create("IOS"), "iOS" }
|
||||
};
|
||||
|
||||
return platformMap.FirstOrDefault(kv => RuntimeInformation.IsOSPlatform(kv.Key)).Value ??
|
||||
"Unknown";
|
||||
}
|
||||
|
||||
|
||||
private async Task HandleSuccessfulLogin()
|
||||
{
|
||||
Log.Information("Login successful as {Name}", _client.Self.Name);
|
||||
IsLoggedIn = true;
|
||||
App.IsLoggedIn = true;
|
||||
|
||||
var session = new SessionModel
|
||||
{
|
||||
Id = 1, // Assuming a single session record
|
||||
IsLoggedIn = true,
|
||||
Id = 1,
|
||||
AvatarName = _client.Self.Name,
|
||||
AvatarKey = _client.Self.AgentID,
|
||||
Balance = _client.Self.Balance,
|
||||
CurrentLocation = _client.Network.CurrentSim.Name,
|
||||
LoginWelcomeMessage = _client.Network.LoginMessage
|
||||
};
|
||||
|
||||
_liteDbService.SaveSession(session);
|
||||
CurrentSession = session;
|
||||
UpdateViewBindings();
|
||||
|
||||
Log.Information("Session updated on successful login");
|
||||
|
||||
try
|
||||
{
|
||||
_client.Self.RequestBalance();
|
||||
Log.Information("Balance request sent after successful login");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to request balance after login");
|
||||
}
|
||||
|
||||
await ProcessCapabilitiesAsync();
|
||||
}
|
||||
|
||||
private async Task ProcessCapabilitiesAsync()
|
||||
{
|
||||
var loginMessage = await Task.Run(() => _client?.Network?.LoginMessage);
|
||||
var loginMessage = await Task.Run(() => _client.Network?.LoginMessage);
|
||||
Log.Information("Login message: {Message}", loginMessage);
|
||||
|
||||
var currentSim = await Task.Run(() => _client?.Network?.CurrentSim);
|
||||
var currentSim = await Task.Run(() => _client.Network?.CurrentSim);
|
||||
Log.Information("Current location: {Sim}", currentSim);
|
||||
|
||||
await Task.CompletedTask;
|
||||
@@ -375,7 +420,6 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel
|
||||
case LoginStatus.Success:
|
||||
LoginStatusMessage =
|
||||
$"Logged in as {_client.Self.Name}, welcome to {_client.Network.CurrentSim?.Name}";
|
||||
IsLoggedIn = true;
|
||||
break;
|
||||
case LoginStatus.Failed:
|
||||
LoginStatusMessage = $"Login failed: {e.Message}";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using GalaxyViewer.Views;
|
||||
using GalaxyViewer.Services;
|
||||
using OpenMetaverse;
|
||||
@@ -12,13 +13,17 @@ using ReactiveUI;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public class MainViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
public class MainViewModel : ViewModelBase, INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
private UserControl _currentView;
|
||||
private readonly GridClient _client = new();
|
||||
private readonly LoginViewModel _loginViewModel;
|
||||
private readonly LoggedInViewModel _loggedInViewModel;
|
||||
private readonly GridClient _client;
|
||||
private readonly LiteDbService _liteDbService;
|
||||
private readonly SessionService _sessionService;
|
||||
private readonly ChatService _chatService;
|
||||
private readonly DashboardView _dashboardView;
|
||||
private bool _disposed;
|
||||
|
||||
private PreferencesWindow? _preferencesWindow;
|
||||
|
||||
public new event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
@@ -33,7 +38,7 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
private new void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
private new void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
@@ -45,29 +50,40 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
public ICommand NavToLoginViewCommand { get; }
|
||||
public ICommand NavToPreferencesViewCommand { get; }
|
||||
public ICommand NavToDevViewCommand { get; }
|
||||
public ICommand BackToDashboardViewCommand { get; }
|
||||
|
||||
public MainViewModel(LiteDbService liteDbService)
|
||||
|
||||
public MainViewModel(LiteDbService liteDbService, GridClient client,
|
||||
SessionService sessionService)
|
||||
{
|
||||
_liteDbService = liteDbService;
|
||||
_client = client;
|
||||
_sessionService = sessionService;
|
||||
_chatService = new ChatService(_client, _liteDbService);
|
||||
|
||||
App.StaticPropertyChanged += (sender, args) =>
|
||||
{
|
||||
if (args.PropertyName != nameof(App.IsLoggedIn)) return;
|
||||
OnPropertyChanged(nameof(IsLoggedIn));
|
||||
if (IsLoggedIn)
|
||||
{
|
||||
NavigateToLoggedInView();
|
||||
}
|
||||
};
|
||||
|
||||
_loginViewModel = new LoginViewModel(_liteDbService);
|
||||
_loggedInViewModel = new LoggedInViewModel(_liteDbService);
|
||||
_currentView = new LoginView(_liteDbService);
|
||||
ExitCommand = ReactiveCommand.Create(LogoutAndExit);
|
||||
LogoutCommand = ReactiveCommand.Create(Logout);
|
||||
NavToLoginViewCommand = ReactiveCommand.Create(NavigateToLoginView);
|
||||
NavToPreferencesViewCommand = ReactiveCommand.Create(NavigateToPreferencesView);
|
||||
NavToDevViewCommand = ReactiveCommand.Create(NavigateToDevView);
|
||||
BackToDashboardViewCommand = ReactiveCommand.Create(NavigateBackToDashboardView);
|
||||
|
||||
var dashboardViewModel = new DashboardViewModel(_liteDbService, _client, _sessionService,
|
||||
NavToPreferencesViewCommand, _chatService);
|
||||
_dashboardView = new DashboardView { DataContext = dashboardViewModel };
|
||||
|
||||
App.StaticPropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName != nameof(App.IsLoggedIn)) return;
|
||||
OnPropertyChanged(nameof(IsLoggedIn));
|
||||
if (IsLoggedIn)
|
||||
{
|
||||
Dispatcher.UIThread.Post(NavigateToDashboardView);
|
||||
}
|
||||
};
|
||||
|
||||
var loginViewModel = new LoginViewModel(_liteDbService, _client);
|
||||
_currentView = new LoginView { DataContext = loginViewModel };
|
||||
}
|
||||
|
||||
private void LogoutAndExit()
|
||||
@@ -82,40 +98,100 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged
|
||||
|
||||
private void Logout()
|
||||
{
|
||||
if (App.IsLoggedIn)
|
||||
{
|
||||
_client.Network.Logout();
|
||||
_loginViewModel.IsLoggedIn = false;
|
||||
}
|
||||
|
||||
_chatService.Dispose();
|
||||
_client.Network.Logout();
|
||||
App.IsLoggedIn = false;
|
||||
NavigateToLoginView();
|
||||
}
|
||||
|
||||
private void NavigateToLoginView()
|
||||
{
|
||||
CurrentView = new LoginView(_liteDbService);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var loginViewModel = new LoginViewModel(_liteDbService, _client);
|
||||
var loginView = new LoginView { DataContext = loginViewModel };
|
||||
CurrentView = loginView;
|
||||
|
||||
if (OperatingSystem.IsAndroid())
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
loginView.Focus();
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void NavigateToLoggedInView()
|
||||
private void NavigateToDashboardView()
|
||||
{
|
||||
CurrentView = new LoggedInView(_liteDbService);
|
||||
CurrentView = _dashboardView;
|
||||
}
|
||||
|
||||
private void NavigateToPreferencesView()
|
||||
{
|
||||
#if ANDROID
|
||||
CurrentView = new PreferencesView { DataContext = new PreferencesViewModel() };
|
||||
#else
|
||||
var preferencesWindow = new PreferencesWindow
|
||||
if (OperatingSystem.IsAndroid() || OperatingSystem.IsIOS())
|
||||
{
|
||||
DataContext = new PreferencesViewModel()
|
||||
};
|
||||
preferencesWindow.Show();
|
||||
#endif
|
||||
CurrentView = new PreferencesView { DataContext = new PreferencesViewModel(BackToDashboardViewCommand) };
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_preferencesWindow is not { IsVisible: true })
|
||||
{
|
||||
_preferencesWindow?.Close();
|
||||
_preferencesWindow = new PreferencesWindow
|
||||
{
|
||||
DataContext = new PreferencesViewModel()
|
||||
};
|
||||
_preferencesWindow.Closed += (_, _) => _preferencesWindow = null;
|
||||
_preferencesWindow.Show();
|
||||
}
|
||||
else
|
||||
{
|
||||
_preferencesWindow?.Activate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateBackToDashboardView()
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (App.IsLoggedIn)
|
||||
{
|
||||
NavigateToDashboardView();
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigateToLoginView();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void NavigateToDevView()
|
||||
{
|
||||
CurrentView = new DevView { DataContext = new DevViewModel() };
|
||||
}
|
||||
|
||||
|
||||
private readonly object _disposeLock = new();
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (disposing)
|
||||
{
|
||||
_chatService.Dispose();
|
||||
}
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~MainViewModel()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using GalaxyViewer.Commands;
|
||||
using GalaxyViewer.Models;
|
||||
using GalaxyViewer.Services;
|
||||
using Serilog;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
@@ -14,45 +17,120 @@ public class PreferencesViewModel : ViewModelBase
|
||||
private PreferencesModel _preferences;
|
||||
private bool _isLoadingPreferences;
|
||||
|
||||
public PreferencesViewModel()
|
||||
public ICommand? BackCommand { get; }
|
||||
|
||||
public PreferencesViewModel(ICommand? backCommand = null)
|
||||
{
|
||||
BackCommand = backCommand;
|
||||
|
||||
if (App.PreferencesManager != null) _preferencesManager = App.PreferencesManager;
|
||||
_preferences = App.PreferencesManager?.CurrentPreferences ?? new PreferencesModel();
|
||||
|
||||
_preferences = new PreferencesModel();
|
||||
var preferencesOptions = _preferencesManager?.GetCurrentPreferencesOptions();
|
||||
|
||||
ThemeOptions = new ObservableCollection<string>(preferencesOptions?["ThemeOptions"] ??
|
||||
[]);
|
||||
PreferencesOptions.ThemeOptions);
|
||||
LoginLocationOptions = new ObservableCollection<string>(
|
||||
preferencesOptions?["LoginLocationOptions"] ??
|
||||
[]);
|
||||
PreferencesOptions.LoginLocationOptions);
|
||||
LanguageOptions = new ObservableCollection<string>(
|
||||
preferencesOptions?["LanguageOptions"] ??
|
||||
[]);
|
||||
FontOptions =
|
||||
new ObservableCollection<string>(preferencesOptions?["FontOptions"] ?? []);
|
||||
_selectedTheme = _preferences.Theme;
|
||||
_selectedLoginLocation = _preferences.LoginLocation;
|
||||
_selectedLanguage = _preferences.Language;
|
||||
_selectedFont = _preferences.Font;
|
||||
_selectedGridNick = _preferences.SelectedGridNick;
|
||||
PreferencesOptions.LanguageOptions);
|
||||
FontOptions = new ObservableCollection<string>(preferencesOptions?["FontOptions"] ??
|
||||
PreferencesOptions.FontOptions);
|
||||
AccentColorOptions = new ObservableCollection<string>(preferencesOptions?["AccentColorOptions"] ??
|
||||
PreferencesOptions.AccentColorOptions);
|
||||
|
||||
// Initialize with default values - these will be overridden by LoadPreferences()
|
||||
_selectedTheme = ThemeOptions.First();
|
||||
_selectedLoginLocation = LoginLocationOptions.First();
|
||||
_selectedLanguage = LanguageOptions.First();
|
||||
_selectedFont = FontOptions.First();
|
||||
_selectedGridNick = "";
|
||||
_selectedAccentColor = AccentColorOptions.First();
|
||||
|
||||
SaveCommand = new RelayCommand(async () => await SavePreferencesAsync());
|
||||
LoadPreferences();
|
||||
|
||||
_ = LoadPreferencesAsync();
|
||||
}
|
||||
|
||||
private async void LoadPreferences()
|
||||
private async Task LoadPreferencesAsync()
|
||||
{
|
||||
_isLoadingPreferences = true;
|
||||
if (_preferencesManager != null)
|
||||
_preferences = await _preferencesManager.LoadPreferencesAsync();
|
||||
try
|
||||
{
|
||||
_isLoadingPreferences = true;
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
if (_preferencesManager != null)
|
||||
{
|
||||
var preferences = await _preferencesManager.LoadPreferencesAsync();
|
||||
|
||||
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
_preferences = preferences;
|
||||
UpdateUIFromPreferences();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load preferences in PreferencesViewModel");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoadingPreferences = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUIFromPreferences()
|
||||
{
|
||||
var loadedTheme = _preferences.Theme;
|
||||
|
||||
if (loadedTheme == "Default")
|
||||
{
|
||||
loadedTheme = "System";
|
||||
}
|
||||
|
||||
_selectedTheme = !string.IsNullOrEmpty(loadedTheme) &&
|
||||
ThemeOptions.Contains(loadedTheme)
|
||||
? loadedTheme
|
||||
: "System";
|
||||
|
||||
_selectedLoginLocation = !string.IsNullOrEmpty(_preferences.LoginLocation) &&
|
||||
LoginLocationOptions.Contains(_preferences.LoginLocation)
|
||||
? _preferences.LoginLocation
|
||||
: LoginLocationOptions.First();
|
||||
|
||||
_selectedLanguage = !string.IsNullOrEmpty(_preferences.Language) &&
|
||||
LanguageOptions.Contains(_preferences.Language)
|
||||
? _preferences.Language
|
||||
: LanguageOptions.First();
|
||||
|
||||
_selectedFont = !string.IsNullOrEmpty(_preferences.Font) &&
|
||||
FontOptions.Contains(_preferences.Font)
|
||||
? _preferences.Font
|
||||
: FontOptions.First();
|
||||
|
||||
_selectedGridNick = _preferences.SelectedGridNick ?? "";
|
||||
|
||||
_selectedAccentColor = !string.IsNullOrEmpty(_preferences.AccentColor) &&
|
||||
AccentColorOptions.Contains(_preferences.AccentColor)
|
||||
? _preferences.AccentColor
|
||||
: AccentColorOptions.First(); // "System Default" - not really working yet
|
||||
|
||||
SelectedTheme = _preferences.Theme;
|
||||
SelectedLoginLocation = _preferences.LoginLocation;
|
||||
SelectedLanguage = _preferences.Language;
|
||||
SelectedFont = _preferences.Font;
|
||||
SelectedGridNick = _preferences.SelectedGridNick;
|
||||
var gridOptions = _preferencesManager?.GetGridOptions();
|
||||
GridOptions = new ObservableCollection<string>(gridOptions ?? []);
|
||||
|
||||
_isLoadingPreferences = false;
|
||||
|
||||
OnPropertyChanged(nameof(SelectedTheme));
|
||||
OnPropertyChanged(nameof(SelectedLoginLocation));
|
||||
OnPropertyChanged(nameof(SelectedLanguage));
|
||||
OnPropertyChanged(nameof(SelectedFont));
|
||||
OnPropertyChanged(nameof(SelectedGridNick));
|
||||
OnPropertyChanged(nameof(SelectedAccentColor));
|
||||
}
|
||||
|
||||
public ObservableCollection<string> ThemeOptions { get; private set; }
|
||||
@@ -60,12 +138,14 @@ public class PreferencesViewModel : ViewModelBase
|
||||
public ObservableCollection<string> LanguageOptions { get; private set; }
|
||||
public ObservableCollection<string> FontOptions { get; private set; }
|
||||
public ObservableCollection<string> GridOptions { get; private set; }
|
||||
public ObservableCollection<string> AccentColorOptions { get; private set; }
|
||||
|
||||
private string _selectedLoginLocation;
|
||||
private string _selectedLanguage;
|
||||
private string _selectedFont;
|
||||
private string _selectedTheme;
|
||||
private string _selectedGridNick;
|
||||
private string _selectedAccentColor;
|
||||
|
||||
public string SelectedTheme
|
||||
{
|
||||
@@ -122,6 +202,17 @@ public class PreferencesViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
public string SelectedAccentColor
|
||||
{
|
||||
get => _selectedAccentColor;
|
||||
set
|
||||
{
|
||||
if (_selectedAccentColor == value || _isLoadingPreferences) return;
|
||||
_selectedAccentColor = value;
|
||||
OnPropertyChanged(nameof(SelectedAccentColor));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SavePreferencesAsync()
|
||||
{
|
||||
_preferences.Theme = SelectedTheme;
|
||||
@@ -129,9 +220,17 @@ public class PreferencesViewModel : ViewModelBase
|
||||
_preferences.Language = SelectedLanguage;
|
||||
_preferences.Font = SelectedFont;
|
||||
_preferences.SelectedGridNick = SelectedGridNick;
|
||||
_preferences.AccentColor = SelectedAccentColor;
|
||||
|
||||
if (_preferencesManager != null)
|
||||
{
|
||||
await _preferencesManager.SavePreferencesAsync(_preferences);
|
||||
Log.Information("Preferences saved successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("PreferencesManager is null - preferences not saved");
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand SaveCommand { get; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
@@ -6,7 +6,7 @@ public abstract partial class ViewModelBase : INotifyPropertyChanged
|
||||
{
|
||||
public new event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged(string propertyName)
|
||||
protected void OnPropertyChanged(string? propertyName)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public class TabItem : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isActive;
|
||||
private bool _hasNotification;
|
||||
private int _notificationCount;
|
||||
private string _title;
|
||||
private object _content;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
_title = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public object Content
|
||||
{
|
||||
get => _content;
|
||||
set
|
||||
{
|
||||
_content = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActive
|
||||
{
|
||||
get => _isActive;
|
||||
set
|
||||
{
|
||||
_isActive = value;
|
||||
OnPropertyChanged();
|
||||
if (value)
|
||||
{
|
||||
HasNotification = false;
|
||||
NotificationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCloseable { get; set; } = true;
|
||||
|
||||
public bool HasNotification
|
||||
{
|
||||
get => _hasNotification;
|
||||
set
|
||||
{
|
||||
_hasNotification = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public int NotificationCount
|
||||
{
|
||||
get => _notificationCount;
|
||||
set
|
||||
{
|
||||
_notificationCount = value;
|
||||
OnPropertyChanged();
|
||||
HasNotification = value > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasIcon => IconBrush != null;
|
||||
|
||||
public IBrush? IconBrush { get; set; }
|
||||
|
||||
public TabItem(string id, string title, object content, bool isCloseable = true)
|
||||
{
|
||||
Id = id;
|
||||
Title = title;
|
||||
Content = content;
|
||||
IsCloseable = isCloseable;
|
||||
}
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using ReactiveUI;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace GalaxyViewer.ViewModels;
|
||||
|
||||
public abstract partial class ViewModelBase : ReactiveObject
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,36 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Input;
|
||||
using GalaxyViewer.Models;
|
||||
using Ursa.ReactiveUIExtension;
|
||||
using Serilog;
|
||||
using GalaxyViewer.ViewModels;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public class BaseWindow : ReactiveUrsaWindow<PreferencesModel>, IStyleable
|
||||
public class BaseWindow : Window
|
||||
{
|
||||
Type IStyleable.StyleKey => typeof(Window);
|
||||
|
||||
protected BaseWindow()
|
||||
{
|
||||
Title = "GalaxyViewer";
|
||||
Icon = new WindowIcon("Assets/GalaxyViewerLogo.ico");
|
||||
CanResize = true;
|
||||
|
||||
// Use only native window decorations - no custom chrome
|
||||
SystemDecorations = SystemDecorations.Full;
|
||||
ExtendClientAreaToDecorationsHint = false;
|
||||
|
||||
if (App.PreferencesManager != null)
|
||||
App.PreferencesManager.PreferencesChanged += OnPreferencesChanged;
|
||||
|
||||
// Load preferences asynchronously without blocking the UI thread
|
||||
_ = LoadPreferencesAsync();
|
||||
}
|
||||
|
||||
|
||||
private async Task LoadPreferencesAsync()
|
||||
{
|
||||
if (App.PreferencesManager == null) return;
|
||||
@@ -49,12 +55,39 @@ public class BaseWindow : ReactiveUrsaWindow<PreferencesModel>, IStyleable
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
RequestedThemeVariant = theme switch
|
||||
{
|
||||
"Light" => ThemeVariant.Light,
|
||||
"Dark" => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default
|
||||
};
|
||||
RequestedThemeVariant = GetThemeVariant(theme);
|
||||
});
|
||||
}
|
||||
|
||||
private ThemeVariant GetThemeVariant(string themePreference)
|
||||
{
|
||||
return themePreference switch
|
||||
{
|
||||
"Light" => ThemeVariant.Light,
|
||||
"Dark" => ThemeVariant.Dark,
|
||||
"System" => DetectSystemTheme(),
|
||||
_ => ThemeVariant.Default
|
||||
};
|
||||
}
|
||||
|
||||
private ThemeVariant DetectSystemTheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
var platformSettings = PlatformSettings;
|
||||
if (platformSettings != null)
|
||||
{
|
||||
var colorValues = platformSettings.GetColorValues();
|
||||
return colorValues.ThemeVariant == PlatformThemeVariant.Dark
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to detect system theme in BaseWindow, falling back to default");
|
||||
}
|
||||
|
||||
return ThemeVariant.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:GalaxyViewer.ViewModels"
|
||||
x:Class="GalaxyViewer.Views.ChatArea"
|
||||
x:DataType="vm:ChatViewModel">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Conversation Header -->
|
||||
<Border Grid.Row="0" Padding="15,10"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
BorderThickness="0,2,0,0">
|
||||
<TextBlock Text="{Binding ActiveConversation.Name}" FontSize="16" FontWeight="SemiBold" />
|
||||
</Border>
|
||||
|
||||
<!-- Messages -->
|
||||
<ScrollViewer Grid.Row="1" Name="MessagesScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<StackPanel x:Name="MessagesPanel" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<StackPanel Grid.Row="2" MinHeight="40">
|
||||
<Border Padding="10,4" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}"
|
||||
IsVisible="{Binding ActiveConversation.IsTyping}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<Ellipse Width="4" Height="4"
|
||||
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||
<Ellipse Width="4" Height="4"
|
||||
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||
<Ellipse Width="4" Height="4"
|
||||
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ActiveConversation.LastMessage}" FontStyle="Italic" FontSize="11" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Input -->
|
||||
<Border Padding="15,10" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="{StaticResource Chat_Send}"
|
||||
Margin="8,0,0,0"
|
||||
IsEnabled="{Binding CanSendMessage}" />
|
||||
<TextBox Text="{Binding MessageText}"
|
||||
Watermark="{StaticResource Chat_TypeMessage}"
|
||||
AcceptsReturn="False"
|
||||
AcceptsTab="False"
|
||||
MinHeight="30"
|
||||
VerticalAlignment="Center"
|
||||
IsEnabled="{Binding CanTypeMessage}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SendMessageCommand}" />
|
||||
<KeyBinding Gesture="Return" Command="{Binding SendMessageCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using GalaxyViewer.Models;
|
||||
using GalaxyViewer.ViewModels;
|
||||
using Ursa.Controls;
|
||||
using Ursa.Common;
|
||||
using Ursa.Controls.Options;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class ChatArea : UserControl
|
||||
{
|
||||
private StackPanel? _messagesPanel;
|
||||
private ScrollViewer? _messagesScrollViewer;
|
||||
private ChatViewModel? _chatViewModel;
|
||||
|
||||
public ChatArea()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
|
||||
Loaded += (s, e) =>
|
||||
{
|
||||
_messagesScrollViewer = this.FindControl<ScrollViewer>("MessagesScrollViewer");
|
||||
};
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
_messagesPanel = this.FindControl<StackPanel>("MessagesPanel");
|
||||
|
||||
var openDrawerButton = this.FindControl<Button>("OpenDrawerButton");
|
||||
if (openDrawerButton != null)
|
||||
openDrawerButton.Click += (s, e) => OpenDrawer();
|
||||
}
|
||||
|
||||
private void OpenDrawer()
|
||||
{
|
||||
var options = new DrawerOptions
|
||||
{
|
||||
Position = Position.Left,
|
||||
CanLightDismiss = true,
|
||||
IsCloseButtonVisible = true,
|
||||
Title = "Conversations",
|
||||
CanResize = false
|
||||
};
|
||||
|
||||
var hostId = "ChatDrawer";
|
||||
var drawerViewModel = new ConversationDrawerViewModel(_chatViewModel);
|
||||
|
||||
Drawer.ShowCustom<ConversationDrawerView, ConversationDrawerViewModel>(drawerViewModel,
|
||||
hostId, options);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_chatViewModel?.ActiveConversation?.Messages != null)
|
||||
{
|
||||
_chatViewModel.ActiveConversation.Messages.CollectionChanged -= OnMessagesChanged;
|
||||
}
|
||||
|
||||
if (_chatViewModel?.Conversations != null)
|
||||
{
|
||||
_chatViewModel.Conversations.CollectionChanged -= OnConversationsChanged;
|
||||
}
|
||||
|
||||
if (_chatViewModel != null)
|
||||
{
|
||||
_chatViewModel.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
_chatViewModel = DataContext as ChatViewModel;
|
||||
|
||||
if (_chatViewModel == null) return;
|
||||
_chatViewModel.Conversations.CollectionChanged += OnConversationsChanged;
|
||||
|
||||
_chatViewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
|
||||
if (_chatViewModel.ActiveConversation?.Messages != null)
|
||||
{
|
||||
_chatViewModel.ActiveConversation.Messages.CollectionChanged += OnMessagesChanged;
|
||||
}
|
||||
|
||||
RefreshMessages();
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender,
|
||||
System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(ChatViewModel.ActiveConversation)) return;
|
||||
if (_chatViewModel?.ActiveConversation?.Messages != null)
|
||||
{
|
||||
foreach (var conv in _chatViewModel.Conversations)
|
||||
{
|
||||
if (conv != _chatViewModel.ActiveConversation)
|
||||
{
|
||||
conv.Messages.CollectionChanged -= OnMessagesChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_chatViewModel?.ActiveConversation?.Messages != null)
|
||||
{
|
||||
_chatViewModel.ActiveConversation.Messages.CollectionChanged += OnMessagesChanged;
|
||||
}
|
||||
|
||||
RefreshMessages();
|
||||
}
|
||||
|
||||
private void OnMessagesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
|
||||
private void OnConversationsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
// No specific refresh needed for the message panel here
|
||||
}
|
||||
|
||||
private void RefreshMessages()
|
||||
{
|
||||
if (_chatViewModel?.ActiveConversation?.Messages == null)
|
||||
return;
|
||||
|
||||
RefreshMessagePanel(_messagesPanel);
|
||||
|
||||
if (_messagesScrollViewer == null) return;
|
||||
double threshold = 50;
|
||||
var distanceFromBottom = _messagesScrollViewer.Extent.Height
|
||||
- _messagesScrollViewer.Offset.Y
|
||||
- _messagesScrollViewer.Viewport.Height;
|
||||
|
||||
if (distanceFromBottom < threshold)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => { _messagesScrollViewer.ScrollToEnd(); });
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshMessagePanel(StackPanel? messagesPanel)
|
||||
{
|
||||
if (messagesPanel == null || _chatViewModel?.ActiveConversation?.Messages == null)
|
||||
return;
|
||||
|
||||
messagesPanel.Children.Clear();
|
||||
|
||||
// Show placeholder only if there are no messages
|
||||
if (!_chatViewModel.ActiveConversation.Messages.Any())
|
||||
{
|
||||
var placeholder = new TextBlock
|
||||
{
|
||||
Text = "Chat messages will appear here...",
|
||||
Opacity = 0.5,
|
||||
FontStyle = FontStyle.Italic
|
||||
};
|
||||
messagesPanel.Children.Add(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add each message as a chat bubble
|
||||
foreach (var message in _chatViewModel.ActiveConversation.Messages)
|
||||
{
|
||||
var messageElement = CreateMessageElement(message);
|
||||
messagesPanel.Children.Add(messageElement);
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreateMessageElement(ChatMessage message)
|
||||
{
|
||||
var border = new Border();
|
||||
border.Classes.Add("chat-message-bubble");
|
||||
|
||||
if (message.MessageType == ChatMessageType.System)
|
||||
border.Classes.Add("system-message");
|
||||
else
|
||||
border.Classes.Add(message.IsFromSelf ? "from-self" : "from-other");
|
||||
|
||||
var messagePanel = new StackPanel();
|
||||
|
||||
// Header (only for non-system messages)
|
||||
if (message.MessageType != ChatMessageType.System)
|
||||
{
|
||||
var header = new DockPanel { Margin = new Thickness(0, 0, 0, 6) };
|
||||
|
||||
if (!string.IsNullOrEmpty(message.SenderName))
|
||||
{
|
||||
var senderText = new TextBlock
|
||||
{
|
||||
Text = message.SenderName,
|
||||
FontWeight = FontWeight.Bold,
|
||||
FontSize = 12,
|
||||
Margin = new Thickness(0, 0, 8, 0)
|
||||
};
|
||||
header.Children.Add(senderText);
|
||||
}
|
||||
|
||||
var timestamp = new TextBlock
|
||||
{
|
||||
Text = message.Timestamp.ToString("h:mm:ss tt"),
|
||||
FontSize = 10,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right
|
||||
};
|
||||
DockPanel.SetDock(timestamp, Dock.Right);
|
||||
header.Children.Add(timestamp);
|
||||
|
||||
messagePanel.Children.Add(header);
|
||||
}
|
||||
|
||||
var messageContent = new TextBlock
|
||||
{
|
||||
Text = message.Message,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
FontSize = 14,
|
||||
Margin = new Thickness(0, 4, 0, 0)
|
||||
};
|
||||
messagePanel.Children.Add(messageContent);
|
||||
|
||||
border.Child = messagePanel;
|
||||
|
||||
border.CornerRadius = message.IsFromSelf
|
||||
? new CornerRadius(12, 12, 8, 12)
|
||||
: new CornerRadius(12, 12, 12, 8);
|
||||
|
||||
return border;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:GalaxyViewer.ViewModels"
|
||||
xmlns:views="using:GalaxyViewer.Views"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
xmlns:models="clr-namespace:GalaxyViewer.Models"
|
||||
x:DataType="vm:ChatViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||
x:Class="GalaxyViewer.Views.ChatView">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Desktop Toolbar -->
|
||||
<Border x:Name="DesktopToolbar"
|
||||
DockPanel.Dock="Top"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="12,8"
|
||||
IsVisible="{OnPlatform Default=True, iOS=False, Android=False}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
|
||||
IsVisible="{Binding IsInChatWindow, Converter={StaticResource InverseBoolConverter}}">
|
||||
<Button Command="{Binding PopOutChatCommand}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="8,6"
|
||||
CornerRadius="4"
|
||||
ToolTip.Tip="{StaticResource Chat_PopOutTooltip}"
|
||||
AutomationProperties.Name="{StaticResource Chat_PopOutA11y}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon
|
||||
Data="M 9 4 V 8 H 21 V 20 H 11 V 16 H 7 V 4 H 9 Z M 11 6 H 19 V 10 H 11 V 6 Z M 15 12 V 14 H 17 V 12 H 15 Z"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}" />
|
||||
<TextBlock Text="{StaticResource Chat_PopOut}"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<Grid ColumnDefinitions="300,*"
|
||||
IsVisible="{OnPlatform Default=True, iOS=False, Android=False}">
|
||||
<ItemsControl ItemsSource="{Binding Conversations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="models:ChatConversation">
|
||||
<Button
|
||||
Command="{Binding $parent[UserControl].((vm:ChatViewModel)DataContext).SelectConversationCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Padding="15,12"
|
||||
CornerRadius="8"
|
||||
Margin="0,2"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left">
|
||||
<Button.Styles>
|
||||
<Style Selector="Button:pointerover">
|
||||
<Setter Property="Background"
|
||||
Value="{DynamicResource SystemControlBackgroundChromeLowBrush}" />
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center" ColumnSpacing="8">
|
||||
<!-- Avatar Image -->
|
||||
<u:Avatar Grid.Column="0"
|
||||
Width="36"
|
||||
Height="36"
|
||||
CornerRadius="18"
|
||||
Margin="0,0,8,0">
|
||||
<Image Source="{Binding AvatarImage}" />
|
||||
</u:Avatar>
|
||||
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="16"
|
||||
Foreground="{StaticResource SystemAccentColor}"
|
||||
FontWeight="{Binding IsActive, Converter={StaticResource BoolToFontWeightConverter}}" />
|
||||
<TextBlock
|
||||
Text="{Binding LastMessage}"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Margin="0,2,0,0"
|
||||
IsVisible="{Binding LastMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Foreground="{DynamicResource TextColor}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Notification Badge -->
|
||||
<u:Badge Grid.Column="2"
|
||||
Header="{Binding UnreadCount}"
|
||||
IsVisible="{Binding HasUnreadMessages}"
|
||||
Background="{StaticResource SystemAccentColor}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
|
||||
<!-- Mobile Drawer -->
|
||||
<u:OverlayDialogHost Name="ConversationDrawerHost" HostId="ChatDrawer"
|
||||
IsVisible="{OnPlatform Default=False, iOS=True, Android=True}" />
|
||||
|
||||
<!-- Shared Chat Area -->
|
||||
<Border x:Name="ChatContainer" Background="{DynamicResource CardBackground}">
|
||||
<views:ChatArea DataContext="{Binding}" />
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class ChatView : UserControl
|
||||
{
|
||||
public ChatView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<views:BaseWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:GalaxyViewer.Views"
|
||||
xmlns:vm="clr-namespace:GalaxyViewer.ViewModels"
|
||||
x:Class="GalaxyViewer.Views.ChatWindow"
|
||||
x:DataType="vm:ChatViewModel"
|
||||
Width="800"
|
||||
Height="600"
|
||||
Title="{StaticResource ChatWindow_Title}"
|
||||
mc:Ignorable="d">
|
||||
<views:ChatView DataContext="{Binding}" />
|
||||
</views:BaseWindow>
|
||||
@@ -1,16 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.ViewModels;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class LoggedInView : UserControl
|
||||
public partial class ChatWindow : BaseWindow
|
||||
{
|
||||
public LoggedInView(LiteDbService liteDbService)
|
||||
public ChatWindow(ChatViewModel viewModel)
|
||||
{
|
||||
DataContext = new LoggedInViewModel(liteDbService);
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
Closed += (_, __) => viewModel.IsInChatWindow = false;
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
@@ -0,0 +1,70 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:GalaxyViewer.ViewModels"
|
||||
xmlns:models="using:GalaxyViewer.Models"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
x:DataType="vm:ConversationDrawerViewModel"
|
||||
x:Class="GalaxyViewer.Views.ConversationDrawerView">
|
||||
|
||||
<Border Width="280"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,1,0">
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="1" Margin="8">
|
||||
<TextBlock Text="{StaticResource ConversationDrawer_Title}"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
Margin="0,0,0,8" />
|
||||
<ItemsControl ItemsSource="{Binding Conversations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="models:ChatConversation">
|
||||
<Button Padding="15,12"
|
||||
CornerRadius="8"
|
||||
Margin="0,2"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Command="{Binding $parent[UserControl].((vm:ConversationDrawerViewModel)DataContext).SelectConversationCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Button.Styles>
|
||||
<Style Selector="Button:pointerover">
|
||||
<Setter Property="Background"
|
||||
Value="{DynamicResource SystemControlBackgroundChromeLowBrush}" />
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center" ColumnSpacing="8">
|
||||
<!-- Avatar Image -->
|
||||
<u:Avatar Grid.Column="0"
|
||||
Width="36"
|
||||
Height="36"
|
||||
CornerRadius="18"
|
||||
Margin="0,0,8,0">
|
||||
<Image Source="{Binding AvatarImage}" />
|
||||
</u:Avatar>
|
||||
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="16"
|
||||
Foreground="{StaticResource SystemAccentColor}"
|
||||
FontWeight="{Binding IsActive, Converter={StaticResource BoolToFontWeightConverter}}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Notification Badge -->
|
||||
<u:Badge Grid.Column="2"
|
||||
Header="{Binding UnreadCount}"
|
||||
IsVisible="{Binding HasUnreadMessages}"
|
||||
Background="{StaticResource SystemAccentColor}"
|
||||
Foreground="White" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class ConversationDrawerView : UserControl
|
||||
{
|
||||
public ConversationDrawerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:GalaxyViewer.ViewModels"
|
||||
xmlns:controls="clr-namespace:GalaxyViewer.Controls"
|
||||
x:DataType="vm:DashboardViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800"
|
||||
x:Class="GalaxyViewer.Views.DashboardView">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- Top Bar with Address Bar and Balance Button -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource CardBackground}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Margin="0,0,0,4"
|
||||
Padding="10,8"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<!-- Top Bar with Address Bar and Balance Button -->
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<controls:AddressBar DataContext="{Binding AddressBarViewModel}" Grid.Column="0"
|
||||
HorizontalAlignment="Stretch" />
|
||||
<!-- Balance Button -->
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding RefreshBalanceCommand}"
|
||||
Background="{DynamicResource SystemControlBackgroundBaseLowBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="15"
|
||||
Padding="12,6"
|
||||
Margin="8,0,0,0"
|
||||
Cursor="Hand"
|
||||
ToolTip.Tip="{StaticResource Dashboard_RefreshBalance_Tooltip}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!-- Wallet Icon -->
|
||||
<PathIcon
|
||||
Data="M17,6H3A1,1 0 0,0 2,7V17A1,1 0 0,0 3,18H17A1,1 0 0,0 18,17V7A1,1 0 0,0 17,6M17,16H3V12H17V16M17,10H3V8H17V10M7,14H9V15H7V14Z"
|
||||
Width="16"
|
||||
Height="16"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<!-- Balance Text -->
|
||||
<TextBlock Text="{Binding FormattedBalance}"
|
||||
FontSize="14"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<!-- Refresh Icon -->
|
||||
<PathIcon
|
||||
Data="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"
|
||||
Width="12"
|
||||
Height="12"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Opacity="0.7" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Tab Strip -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1,1,1,0"
|
||||
CornerRadius="4,4,0,0">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
Padding="4,2">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="2" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TabItem">
|
||||
<Border Name="TabBorder"
|
||||
Background="{DynamicResource SystemControlBackgroundChromeLowBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4,4,0,0"
|
||||
Padding="8,4"
|
||||
Margin="1,0"
|
||||
Classes.active="{Binding IsActive}">
|
||||
<Border.Styles>
|
||||
<Style Selector="Border[Name=TabBorder]:not(.active)">
|
||||
<Setter Property="Background"
|
||||
Value="{DynamicResource SystemControlBackgroundChromeLowBrush}" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.2" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border[Name=TabBorder]:pointerover:not(.active)">
|
||||
<Setter Property="Background"
|
||||
Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
|
||||
<Setter Property="Opacity" Value="0.9" />
|
||||
</Style>
|
||||
<Style Selector="Border[Name=TabBorder].active">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
<Setter Property="BorderThickness" Value="2,2,2,0" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="4,2"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding $parent[UserControl].((vm:DashboardViewModel)DataContext).ActivateTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<!-- Tab Title -->
|
||||
<TextBlock Text="{Binding Title}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Foreground"
|
||||
Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.active TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground"
|
||||
Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
</TextBlock.Styles>
|
||||
</TextBlock>
|
||||
<!-- Notification Badge -->
|
||||
<Border Background="{DynamicResource SystemAccentColorBrush}"
|
||||
CornerRadius="10"
|
||||
MinWidth="18"
|
||||
Height="18"
|
||||
IsVisible="{Binding HasNotification}"
|
||||
Padding="4,1">
|
||||
<TextBlock Text="{Binding NotificationCount}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="11"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- Close Button -->
|
||||
<Button Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="2"
|
||||
Width="20"
|
||||
Height="20"
|
||||
IsVisible="{Binding IsCloseable}"
|
||||
Command="{Binding $parent[UserControl].((vm:DashboardViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Data="M6,6 L14,14 M6,14 L14,6"
|
||||
Width="12"
|
||||
Height="12"
|
||||
Foreground="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource CardBackground}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1,0,1,1"
|
||||
CornerRadius="0,0,4,4">
|
||||
<ContentControl Content="{Binding ActiveTabContent}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class DashboardView : UserControl
|
||||
{
|
||||
public DashboardView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.VisualTree;
|
||||
using GalaxyViewer.ViewModels;
|
||||
using Ursa.Controls;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:viewModels="clr-namespace:GalaxyViewer.ViewModels"
|
||||
x:Class="GalaxyViewer.Views.LoggedInView"
|
||||
x:DataType="viewModels:LoggedInViewModel">
|
||||
<StackPanel>
|
||||
<!-- Top Bar -->
|
||||
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="5" Padding="10" Margin="10">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding CurrentLocation}" DockPanel.Dock="Top" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="14" FontWeight="Medium" Foreground="Gray" />
|
||||
<TextBlock Text="{Binding Balance}" DockPanel.Dock="Right" HorizontalAlignment="Right" VerticalAlignment="Center" FontSize="14" FontWeight="Medium" Foreground="Gray" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Main Content -->
|
||||
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="5" Padding="10" Margin="10">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10">
|
||||
<TextBlock Text="Login Successful" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="20" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding LoginWelcomeMessage}" HorizontalAlignment="Center" VerticalAlignment="Top" Width="200" FontSize="16" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -4,51 +4,98 @@
|
||||
x:Class="GalaxyViewer.Views.LoginView"
|
||||
xmlns:viewModels="clr-namespace:GalaxyViewer.ViewModels"
|
||||
x:DataType="viewModels:LoginViewModel">
|
||||
|
||||
<!-- Add styles to use accent color for login buttons -->
|
||||
<UserControl.Styles>
|
||||
<!-- Login Button styling using accent color -->
|
||||
<Style Selector="Button.login-button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="Button.login-button:pointerover">
|
||||
<Setter Property="Opacity" Value="0.9" />
|
||||
</Style>
|
||||
<Style Selector="Button.login-button:pressed">
|
||||
<Setter Property="Opacity" Value="0.8" />
|
||||
</Style>
|
||||
|
||||
<!-- Focus styling for input controls -->
|
||||
<Style Selector="TextBox:focus">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ComboBox:focus">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid>
|
||||
<OnPlatform>
|
||||
<!-- On platform Windows or Linux -->
|
||||
<On Options="Windows, Linux">
|
||||
<Grid RowDefinitions="*,Auto,Auto,Auto">
|
||||
<Grid>
|
||||
<Image Source="/Assets/Images/banner.jpg" Stretch="UniformToFill" />
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image Source="/Assets/GalaxyViewerLogo.ico" Width="150" Height="150" />
|
||||
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="24"
|
||||
FontWeight="Bold">
|
||||
<Run
|
||||
Text="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=WelcomeMessage}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="1" Text="{Binding LoginStatusMessage}" Margin="20,20,0,0"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||
<ContentControl Grid.Row="0" Content="{Binding MfaPromptContainer}" />
|
||||
<Grid Grid.Row="2" Margin="20" ColumnDefinitions="Auto,240,Auto,240,Auto,Auto,*">
|
||||
<!-- Notice for Pre-Alpha Build -->
|
||||
<Grid RowDefinitions="*,Auto,Auto,Auto,Auto">
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Margin="10,10,10,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Live="Assertive"
|
||||
AutomationProperties.Name="Warning: Pre-Alpha Build. This software is barely functioning and not ready for external testing. Use at your own risk.">
|
||||
<TextBlock
|
||||
Text="⚠️ WARNING: PRE-ALPHA BUILD ⚠️"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<TextBlock
|
||||
Text="This software is barely functioning and not ready for external testing. Use at your own risk!"
|
||||
FontSize="14"
|
||||
Grid.Row="1"
|
||||
Margin="10,0,10,20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="2" Text="{Binding LoginStatusMessage}" Margin="20,20,0,0"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" TextWrapping="Wrap" />
|
||||
<Grid Grid.Row="3" Margin="20" ColumnDefinitions="Auto,240,Auto,240,Auto,Auto,*">
|
||||
<Label Grid.Column="0"
|
||||
Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenUsername}"
|
||||
Content="{StaticResource Login_Username}"
|
||||
ToolTip.Tip="{StaticResource Login_Username_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Username_A11y}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Name="UsernameBoxWindows"
|
||||
Watermark="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenUsername}"
|
||||
<TextBox Grid.Column="1" Name="UsernameBoxDesktop"
|
||||
Watermark="{StaticResource Login_Username_Watermark}"
|
||||
ToolTip.Tip="{StaticResource Login_Username_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Username_A11y}"
|
||||
Text="{Binding Username}"
|
||||
Margin="5,0" />
|
||||
<Label Grid.Column="2"
|
||||
Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenPassword}"
|
||||
Content="{StaticResource Login_Password}"
|
||||
ToolTip.Tip="{StaticResource Login_Password_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Password_A11y}"
|
||||
VerticalAlignment="Center" Margin="5,0" />
|
||||
<TextBox Grid.Column="3" Name="PasswordBoxWindows"
|
||||
Watermark="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenPassword}"
|
||||
<TextBox Grid.Column="3" Name="PasswordBoxDesktop"
|
||||
Watermark="{StaticResource Login_Password_Watermark}"
|
||||
ToolTip.Tip="{StaticResource Login_Password_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Password_A11y}"
|
||||
PasswordChar="*"
|
||||
Text="{Binding Password}"
|
||||
Margin="5,0" />
|
||||
<ComboBox Grid.Column="4" Name="LoginLocationWindows"
|
||||
<ComboBox Grid.Column="4" Name="LoginLocationDesktop"
|
||||
ItemsSource="{Binding LoginLocations}"
|
||||
SelectedItem="{Binding SelectedLoginLocation}"
|
||||
Margin="5,0" />
|
||||
<ComboBox Grid.Column="5" Name="GridSelectionWindows"
|
||||
<ComboBox Grid.Column="5" Name="GridSelectionDesktop"
|
||||
ItemsSource="{Binding Grids}"
|
||||
SelectedItem="{Binding SelectedGrid}"
|
||||
Margin="5,0" />
|
||||
<Button Grid.Column="6" Name="ButtonLoginWindows"
|
||||
Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenLoginButton}"
|
||||
<Button Grid.Column="6" Name="ButtonLoginDesktop"
|
||||
Classes="login-button"
|
||||
Content="{StaticResource Login_Button}"
|
||||
ToolTip.Tip="{StaticResource Login_Button_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Button_A11y}"
|
||||
Command="{Binding TryLoginCommand}"
|
||||
Margin="5,0" VerticalAlignment="Center" HorizontalAlignment="Left" />
|
||||
</Grid>
|
||||
@@ -57,18 +104,29 @@
|
||||
<!-- On platform Android -->
|
||||
<On Options="Android">
|
||||
<StackPanel>
|
||||
<Image Source="/Assets/Images/banner.jpg" Stretch="UniformToFill" Height="100" />
|
||||
<Image Source="/Assets/GalaxyViewerLogo.ico" Width="100" Height="100" HorizontalAlignment="Center" />
|
||||
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="18" FontWeight="Bold">
|
||||
<Run
|
||||
Text="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=WelcomeMessage}" />
|
||||
</TextBlock>
|
||||
<!-- Notice for Pre-Alpha Build -->
|
||||
<TextBlock Text="⚠️ WARNING: PRE-ALPHA BUILD ⚠️"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
Margin="10,10,10,0"
|
||||
HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
Text="This software is barely functioning and not ready for external testing. Use at your own risk!"
|
||||
FontSize="14"
|
||||
Margin="10,0,10,20"
|
||||
HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBox Name="UsernameBoxAndroid"
|
||||
Watermark="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenUsername}"
|
||||
Watermark="{StaticResource Login_Username_Watermark}"
|
||||
ToolTip.Tip="{StaticResource Login_Username_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Username_A11y}"
|
||||
Text="{Binding Username}"
|
||||
Margin="5" />
|
||||
<TextBox Name="PasswordBoxAndroid"
|
||||
Watermark="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenPassword}"
|
||||
Watermark="{StaticResource Login_Password_Watermark}"
|
||||
ToolTip.Tip="{StaticResource Login_Password_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Password_A11y}"
|
||||
PasswordChar="*"
|
||||
Text="{Binding Password}"
|
||||
Margin="5" />
|
||||
@@ -81,13 +139,32 @@
|
||||
SelectedItem="{Binding SelectedGrid}"
|
||||
Margin="5" />
|
||||
<Button Name="ButtonLoginAndroid"
|
||||
Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=LoginScreenLoginButton}"
|
||||
Classes="login-button"
|
||||
Content="{StaticResource Login_Button}"
|
||||
ToolTip.Tip="{StaticResource Login_Button_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Login_Button_A11y}"
|
||||
Command="{Binding TryLoginCommand}"
|
||||
Margin="5" VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||
<ContentControl Content="{Binding MfaPromptContainer}" />
|
||||
<TextBlock Text="{Binding LoginStatusMessage}" Margin="5" />
|
||||
<TextBlock Text="{Binding LoginStatusMessage}" Margin="5" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</On>
|
||||
</OnPlatform>
|
||||
<!-- MFA Overlay -->
|
||||
<Border
|
||||
Background="#CC000000"
|
||||
IsVisible="{Binding IsMfaPromptVisible}"
|
||||
ZIndex="100"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<ContentControl
|
||||
Content="{Binding MfaPromptContainer}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Width="400"
|
||||
Height="300"
|
||||
CornerRadius="12"
|
||||
Padding="32" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -1,16 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using GalaxyViewer.Services;
|
||||
using GalaxyViewer.ViewModels;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
public partial class LoginView : UserControl
|
||||
{
|
||||
public LoginView(LiteDbService liteDbService)
|
||||
public LoginView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new LoginViewModel(liteDbService);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:GalaxyViewer.ViewModels"
|
||||
xmlns:views="clr-namespace:GalaxyViewer.Views"
|
||||
mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400"
|
||||
x:Class="GalaxyViewer.Views.MainView"
|
||||
x:DataType="vm:MainViewModel">
|
||||
<Design.DataContext>
|
||||
<vm:MainViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<!-- Platform-specific menu handling -->
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- Show traditional menu bar on desktop, hamburger menu on mobile -->
|
||||
<OnPlatform>
|
||||
<!-- On platform Windows or Linux -->
|
||||
<On Options="Windows, Linux">
|
||||
<On Options="Windows, Linux, macOS">
|
||||
<views:MenuDesktopView DockPanel.Dock="Top" />
|
||||
</On>
|
||||
<!-- On platform Android -->
|
||||
<On Options="Android">
|
||||
<views:MenuAndroidView DockPanel.Dock="Top" />
|
||||
</On>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<views:BaseWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="clr-namespace:GalaxyViewer.Views"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="GalaxyViewer.Views.MainWindow"
|
||||
Width="1200"
|
||||
Height="900"
|
||||
Title="GalaxyViewer">
|
||||
<views:MainView />
|
||||
</views:BaseWindow>
|
||||
@@ -4,6 +4,126 @@
|
||||
xmlns:vm="clr-namespace:GalaxyViewer.ViewModels"
|
||||
x:Class="GalaxyViewer.Views.MenuAndroidView"
|
||||
x:DataType="vm:MainViewModel">
|
||||
<!-- TODO: Add hamburger menu -->
|
||||
<u:IconButton></u:IconButton>
|
||||
|
||||
<!-- Mobile-friendly toolbar with hamburger menu -->
|
||||
<Grid Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
Height="56">
|
||||
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="16,8">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Hamburger Menu Button -->
|
||||
<Button DockPanel.Dock="Left"
|
||||
Classes="icon-button"
|
||||
Background="Transparent"
|
||||
Padding="8"
|
||||
AutomationProperties.Name="Open menu">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout Placement="BottomEdgeAlignedLeft">
|
||||
|
||||
<!-- File Section -->
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Login}"
|
||||
Command="{Binding NavToLoginViewCommand}"
|
||||
IsEnabled="{Binding !IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon Data="M10,17V14H3V10H10V7L15,12L10,17Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Logout}"
|
||||
Command="{Binding LogoutCommand}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon Data="M14,12L10,8V11H2V13H10V16L14,12Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Preferences}"
|
||||
Command="{Binding NavToPreferencesViewCommand}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Communication Section -->
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Chat}"
|
||||
Command="{Binding BackToDashboardViewCommand}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M12,3C17.5,3 22,6.58 22,11C22,15.42 17.5,19 12,19C10.76,19 9.57,18.82 8.47,18.5C5.55,21 2,21 2,21C4.33,18.67 4.7,17.1 4.75,16.5C3.05,15.07 2,13.13 2,11C2,6.58 6.5,3 12,3Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Friends}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M16,4C18.2,4 20,5.8 20,8C20,10.2 18.2,12 16,12C13.8,12 12,10.2 12,8C12,5.8 13.8,4 16,4M16,6A2,2 0 0,0 14,8A2,2 0 0,0 16,10A2,2 0 0,0 18,8A2,2 0 0,0 16,6M16,13C18.67,13 22,14.33 22,17V20H10V17C10,14.33 13.33,13 16,13Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Groups}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- World Section -->
|
||||
<MenuItem Header="{StaticResource MenuAndroid_TeleportHome}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon Data="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_WorldMap}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M15,19L9,16.89V5L15,7.11M20.5,3C20.44,3 20.39,3 20.34,3L15,5.1L9,3L3.36,4.9C3.15,4.97 3,5.15 3,5.38V20.5A0.5,0.5 0 0,0 3.5,21C3.55,21 3.61,21 3.66,21L9,18.9L15,21L20.64,19.1C20.85,19.03 21,18.85 21,18.62V3.5A0.5,0.5 0 0,0 20.5,3Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{StaticResource MenuAndroid_Landmarks}"
|
||||
IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Developer -->
|
||||
<MenuItem Header="{StaticResource MenuAndroid_DevTools}"
|
||||
Command="{Binding NavToDevViewCommand}">
|
||||
<MenuItem.Icon>
|
||||
<PathIcon
|
||||
Data="M8,3A2,2 0 0,0 6,5V9A2,2 0 0,1 4,11H3V13H4A2,2 0 0,1 6,15V19A2,2 0 0,0 8,21H10V19H8V14A2,2 0 0,0 6,12A2,2 0 0,0 8,10V5H10V3M16,3A2,2 0 0,1 18,5V9A2,2 0 0,0 20,11H21V13H20A2,2 0 0,0 18,15V19A2,2 0 0,1 16,21H14V19H16V14A2,2 0 0,1 18,12A2,2 0 0,1 16,10V5H14V3H16Z" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
|
||||
<!-- Hamburger Icon -->
|
||||
<PathIcon Data="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z"
|
||||
Width="24" Height="24" />
|
||||
</Button>
|
||||
|
||||
<!-- App Title -->
|
||||
<TextBlock DockPanel.Dock="Left"
|
||||
Text="{StaticResource App_Title}"
|
||||
FontSize="20"
|
||||
FontWeight="Medium"
|
||||
VerticalAlignment="Center"
|
||||
Margin="16,0,0,0"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -3,56 +3,92 @@
|
||||
xmlns:vm="clr-namespace:GalaxyViewer.ViewModels"
|
||||
x:Class="GalaxyViewer.Views.MenuDesktopView"
|
||||
x:DataType="vm:MainViewModel">
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="File">
|
||||
<MenuItem Header="New Window" />
|
||||
|
||||
<!-- Desktop menu bar -->
|
||||
<Menu Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Height="28">
|
||||
|
||||
<!-- File Menu -->
|
||||
<MenuItem Header="{StaticResource DesktopMenu_File}">
|
||||
<MenuItem Header="{StaticResource DesktopMenu_NewWindow}"
|
||||
InputGesture="Ctrl+N" />
|
||||
<Separator />
|
||||
<MenuItem Header="Upload Blinn-Phong Texture" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="Upload PBR Material" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="Upload Mesh" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="Import Object" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="Script Editor" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_UploadBlinnPhong}"
|
||||
IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_UploadPBR}"
|
||||
IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_UploadMesh}"
|
||||
IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_ImportObject}"
|
||||
IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_ScriptEditor}"
|
||||
IsEnabled="{Binding IsLoggedIn}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Login" Command="{Binding NavToLoginViewCommand}" IsEnabled="{Binding !IsLoggedIn}" />
|
||||
<MenuItem Header="Logout" Command="{Binding LogoutCommand}" IsEnabled="{Binding IsLoggedIn}" />
|
||||
<MenuItem Header="Relog" IsEnabled="{Binding !IsLoggedIn}" />
|
||||
<MenuItem Header="Preferences" Command="{Binding NavToPreferencesViewCommand}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Login}"
|
||||
Command="{Binding NavToLoginViewCommand}"
|
||||
IsEnabled="{Binding !IsLoggedIn}"
|
||||
InputGesture="Ctrl+L" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Logout}"
|
||||
Command="{Binding LogoutCommand}"
|
||||
IsEnabled="{Binding IsLoggedIn}"
|
||||
InputGesture="Ctrl+Shift+L" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Relog}"
|
||||
IsEnabled="{Binding !IsLoggedIn}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Exit" Command="{Binding ExitCommand}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="World" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="Create new Landmark Here" />
|
||||
<MenuItem Header="Landmarks" />
|
||||
<MenuItem Header="Teleport History" />
|
||||
<MenuItem Header="Favorites" />
|
||||
<MenuItem Header="Set Home to Here" />
|
||||
<MenuItem Header="Teleport Home" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Preferences}"
|
||||
Command="{Binding NavToPreferencesViewCommand}"
|
||||
InputGesture="Ctrl+," />
|
||||
<Separator />
|
||||
<MenuItem Header="About Land" />
|
||||
<MenuItem Header="About Region" />
|
||||
<MenuItem Header="World Map" />
|
||||
<MenuItem Header="Mini-Map" />
|
||||
<MenuItem Header="People Nearby" />
|
||||
<MenuItem Header="Objects Nearby" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Exit}"
|
||||
Command="{Binding ExitCommand}"
|
||||
InputGesture="Alt+F4" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="Communicate" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="Chat" />
|
||||
<MenuItem Header="Friends List" />
|
||||
<MenuItem Header="Nearby People" />
|
||||
<MenuItem Header="Voice" />
|
||||
<MenuItem Header="Nearby Media" />
|
||||
<MenuItem Header="Nearby Objects" />
|
||||
|
||||
<!-- World Menu -->
|
||||
<MenuItem Header="{StaticResource DesktopMenu_World}" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="{StaticResource DesktopMenu_CreateLandmark}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Landmarks}" InputGesture="Ctrl+Shift+L" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_TeleportHistory}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Favorites}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_SetHome}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_TeleportHome}" InputGesture="Ctrl+Shift+H" />
|
||||
<Separator />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_AboutLand}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_AboutRegion}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_WorldMap}" InputGesture="Ctrl+M" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_MiniMap}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_PeopleNearby}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_ObjectsNearby}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="Community" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="Friends" />
|
||||
<MenuItem Header="Groups" />
|
||||
<MenuItem Header="Events" />
|
||||
<MenuItem Header="Classifieds" />
|
||||
<MenuItem Header="Marketplace" />
|
||||
<MenuItem Header="Search" />
|
||||
|
||||
<!-- Communicate Menu -->
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Communicate}" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Chat}"
|
||||
Command="{Binding BackToDashboardViewCommand}"
|
||||
InputGesture="Ctrl+T" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_FriendsList}" InputGesture="Ctrl+Shift+F" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_NearbyPeople}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Voice}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_NearbyMedia}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_NearbyObjects}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="Dev">
|
||||
<MenuItem Header="Dev View" Command="{Binding NavToDevViewCommand}" />
|
||||
|
||||
<!-- Community Menu -->
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Community}" IsEnabled="{Binding IsLoggedIn}">
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Friends}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Groups}" InputGesture="Ctrl+G" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Events}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Classifieds}" />
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Marketplace}" />
|
||||
</MenuItem>
|
||||
|
||||
<!-- Dev Menu -->
|
||||
<MenuItem Header="{StaticResource DesktopMenu_Dev}">
|
||||
<MenuItem Header="{StaticResource DesktopMenu_DevTools}"
|
||||
Command="{Binding NavToDevViewCommand}" />
|
||||
</MenuItem>
|
||||
|
||||
</Menu>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using GalaxyViewer.ViewModels;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
x:DataType="viewModels:MfaPromptDialogViewModel">
|
||||
<Grid Background="#000000" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<StackPanel Margin="20" MaxWidth="200" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="Enter MFA Code:" Margin="0,0,0,10" />
|
||||
<TextBox Text="{Binding MfaCode, Mode=TwoWay}" Name="TokenBox" Margin="0,0,0,10" MaxLength="6" />
|
||||
<Button Content="Submit" Command="{Binding SubmitMfaCodeCommand}" Name="BtnSubmit" />
|
||||
<TextBlock Text="{StaticResource MfaPrompt_EnterCode}" Margin="0,0,0,10" />
|
||||
<TextBox Text="{Binding MfaCode, Mode=TwoWay}"
|
||||
Name="TokenBox"
|
||||
Margin="0,0,0,10"
|
||||
MaxLength="6"
|
||||
Watermark="{StaticResource MfaPrompt_Code_Watermark}"
|
||||
ToolTip.Tip="{StaticResource MfaPrompt_Code_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource MfaPrompt_Code_A11y}" />
|
||||
<Button Content="{StaticResource MfaPrompt_Submit}"
|
||||
Command="{Binding SubmitMfaCodeCommand}"
|
||||
Name="BtnSubmit"
|
||||
ToolTip.Tip="{StaticResource MfaPrompt_Submit_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource MfaPrompt_Submit_A11y}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -3,22 +3,174 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:GalaxyViewer.ViewModels"
|
||||
mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400"
|
||||
xmlns:u="https://irihi.tech/ursa"
|
||||
xmlns:converters="clr-namespace:GalaxyViewer.Converters"
|
||||
mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="500"
|
||||
x:Class="GalaxyViewer.Views.PreferencesView"
|
||||
x:DataType="vm:PreferencesViewModel">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Label Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=PreferencesTitle}"
|
||||
VerticalAlignment="Center" />
|
||||
<Label Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=PreferencesTheme}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding ThemeOptions}" SelectedItem="{Binding SelectedTheme}" />
|
||||
<Label Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=PreferencesLanguage}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding LanguageOptions}" SelectedItem="{Binding SelectedLanguage}" />
|
||||
<Label Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=PreferencesFont}"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding FontOptions}" SelectedItem="{Binding SelectedFont}" />
|
||||
<Button Content="{Binding Converter={StaticResource LocalizedStringConverter}, ConverterParameter=PreferencesSaveButton}"
|
||||
Command="{Binding SaveCommand}" HorizontalAlignment="Left" Margin="0,10" />
|
||||
</StackPanel>
|
||||
|
||||
<UserControl.Resources>
|
||||
<converters:AccentColorConverter x:Key="AccentColorConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer Padding="24" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="450" HorizontalAlignment="Center" Spacing="32">
|
||||
|
||||
<!-- Header Section -->
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{StaticResource Preferences_Title}"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}" />
|
||||
<Rectangle Height="1"
|
||||
Fill="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Settings Groups -->
|
||||
<StackPanel Spacing="24">
|
||||
|
||||
<!-- Appearance Group -->
|
||||
<u:FormItem Label="{StaticResource Preferences_Appearance}"
|
||||
Classes="group-header">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<!-- Theme Setting -->
|
||||
<u:FormItem Label="{StaticResource Preferences_Theme}">
|
||||
<ComboBox ItemsSource="{Binding ThemeOptions}"
|
||||
SelectedItem="{Binding SelectedTheme}"
|
||||
MinWidth="200"
|
||||
HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="{StaticResource Preferences_Theme_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_Theme_A11y}" />
|
||||
</u:FormItem>
|
||||
|
||||
<!-- Accent Color Setting -->
|
||||
<u:FormItem Label="{StaticResource Preferences_AccentColor}">
|
||||
<ComboBox ItemsSource="{Binding AccentColorOptions}"
|
||||
SelectedItem="{Binding SelectedAccentColor}"
|
||||
MinWidth="200"
|
||||
HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="{StaticResource Preferences_AccentColor_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_AccentColor_A11y}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!-- Color Preview -->
|
||||
<Border Width="16" Height="16"
|
||||
CornerRadius="8"
|
||||
VerticalAlignment="Center">
|
||||
<Border.Background>
|
||||
<SolidColorBrush Color="{Binding Converter={StaticResource AccentColorConverter}}" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</u:FormItem>
|
||||
|
||||
<!-- Font Setting -->
|
||||
<u:FormItem Label="{StaticResource Preferences_Font}">
|
||||
<ComboBox ItemsSource="{Binding FontOptions}"
|
||||
SelectedItem="{Binding SelectedFont}"
|
||||
MinWidth="200"
|
||||
HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="{StaticResource Preferences_Font_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_Font_A11y}" />
|
||||
</u:FormItem>
|
||||
|
||||
</StackPanel>
|
||||
</u:FormItem>
|
||||
|
||||
<!-- Localization Group -->
|
||||
<u:FormItem Label="{StaticResource Preferences_Localization}"
|
||||
Classes="group-header">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<!-- Language Setting -->
|
||||
<u:FormItem Label="{StaticResource Preferences_Language}">
|
||||
<ComboBox ItemsSource="{Binding LanguageOptions}"
|
||||
SelectedItem="{Binding SelectedLanguage}"
|
||||
MinWidth="200"
|
||||
HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="{StaticResource Preferences_Language_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_Language_A11y}" />
|
||||
</u:FormItem>
|
||||
|
||||
</StackPanel>
|
||||
</u:FormItem>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeLowBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="20">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="12">
|
||||
|
||||
<!-- Save Button -->
|
||||
<Button Content="{StaticResource Preferences_SaveButton}"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes="accent"
|
||||
MinWidth="100"
|
||||
HorizontalContentAlignment="Center"
|
||||
ToolTip.Tip="{StaticResource Preferences_SaveButton_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_SaveButton_A11y}">
|
||||
<Button.Styles>
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover">
|
||||
<Setter Property="Opacity" Value="0.9" />
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
</Button>
|
||||
|
||||
<!-- Back Button -->
|
||||
<Button Content="{StaticResource Preferences_BackButton}"
|
||||
Command="{Binding BackCommand}"
|
||||
IsVisible="{Binding BackCommand, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
MinWidth="100"
|
||||
HorizontalContentAlignment="Center"
|
||||
ToolTip.Tip="{StaticResource Preferences_BackButton_Tooltip}"
|
||||
AutomationProperties.Name="{StaticResource Preferences_BackButton_A11y}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<UserControl.Styles>
|
||||
<Style Selector="u|FormItem.group-header">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="u|FormItem:not(.group-header)">
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
</UserControl>
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:GalaxyViewer.Views"
|
||||
x:Class="GalaxyViewer.Views.PreferencesWindow"
|
||||
Title="Preferences"
|
||||
Width="450" Height="350">
|
||||
Title="{StaticResource Preferences_Title}"
|
||||
Height="600" Width="500">
|
||||
<views:PreferencesView/>
|
||||
</views:BaseWindow>
|
||||
@@ -1,6 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using GalaxyViewer.ViewModels;
|
||||
|
||||
namespace GalaxyViewer.Views;
|
||||
|
||||
@@ -9,7 +8,6 @@ public partial class PreferencesWindow : BaseWindow
|
||||
public PreferencesWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new PreferencesViewModel();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using OpenMetaverse;
|
||||
|
||||
namespace GalaxyViewer.Wrappers;
|
||||
|
||||
public class AgentManagerWrapper
|
||||
{
|
||||
public AgentManagerWrapper(AgentManager agentManager)
|
||||
{
|
||||
var onChatEvent = agentManager.GetType().GetEvent("OnChat", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (onChatEvent == null) return;
|
||||
var handler = new EventHandler<ChatEventArgs>((sender, e) => OnChat?.Invoke(sender, e));
|
||||
onChatEvent.AddEventHandler(agentManager, handler);
|
||||
}
|
||||
|
||||
public event EventHandler<ChatEventArgs>? OnChat;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ Features unique to this viewer (compared to the stock viewer) will include:
|
||||
|
||||
- [ ] Communication
|
||||
|
||||
- [ ] Chat
|
||||
- [x] Chat
|
||||
- [ ] Voice Chat (WebRTC)
|
||||
|
||||
- [ ] User Interaction
|
||||
@@ -30,7 +30,7 @@ Features unique to this viewer (compared to the stock viewer) will include:
|
||||
|
||||
- [ ] World Interaction
|
||||
|
||||
- [ ] Teleporting
|
||||
- [x] Teleporting
|
||||
- [ ] 3D World View
|
||||
- [ ] Camera Controls
|
||||
- [ ] World Map
|
||||
@@ -53,7 +53,7 @@ Features unique to this viewer (compared to the stock viewer) will include:
|
||||
- [ ] User Interface
|
||||
|
||||
- [x] Light and Dark Modes
|
||||
- [ ] Customizable UI
|
||||
- [x] Customizable UI (Accent Color)
|
||||
- [ ] Customizable Keybinds
|
||||
- [ ] Customizable Notifications
|
||||
|
||||
@@ -68,7 +68,7 @@ Features unique to this viewer (compared to the stock viewer) will include:
|
||||
- [ ] Keyboard Navigation
|
||||
- [ ] Voice Commands
|
||||
- [ ] Text-to-Speech
|
||||
- [ ] Speech-to-Text
|
||||
- [ ] Speech-to-Text (chat input)
|
||||
- [x] Localization
|
||||
- [ ] Sending Abuse Reports
|
||||
- [ ] Discord Rich Presence (Desktop only)
|
||||
@@ -106,4 +106,4 @@ This project is licensed under the GNU Lesser General Public License - see the [
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is not provided or supported by Linden Lab, the makers of Second Life. Second Life and Linden Lab are trademarks or registered trademarks of Linden Research, Inc. All rights reserved. No infringement is intended.
|
||||
This software is not provided or supported by Linden Lab, the makers of Second Life. Second Life and Linden Lab are trademarks or registered trademarks of Linden Research, Inc. All rights reserved. No infringement is intended.
|
||||
Reference in New Issue
Block a user