mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
settings_re8
This commit is contained in:
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IComponentEditorHostContext
|
||||||
|
{
|
||||||
|
void RequestRefresh();
|
||||||
|
|
||||||
|
void CloseEditor();
|
||||||
|
|
||||||
|
void RequestRestart(string? reason = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginDesktopComponentEditorContext
|
||||||
|
{
|
||||||
|
public PluginDesktopComponentEditorContext(
|
||||||
|
PluginManifest manifest,
|
||||||
|
string pluginDirectory,
|
||||||
|
string dataDirectory,
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyDictionary<string, object?> properties,
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
IPluginSettingsService? pluginSettings,
|
||||||
|
IComponentEditorHostContext hostContext)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
ArgumentNullException.ThrowIfNull(hostContext);
|
||||||
|
|
||||||
|
Manifest = manifest;
|
||||||
|
PluginDirectory = pluginDirectory;
|
||||||
|
DataDirectory = dataDirectory;
|
||||||
|
Services = services;
|
||||||
|
Properties = properties;
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||||
|
PluginSettings = pluginSettings;
|
||||||
|
HostContext = hostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
public string PluginDirectory { get; }
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
|
||||||
|
public IServiceProvider Services { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
|
public IPluginSettingsService? PluginSettings { get; }
|
||||||
|
|
||||||
|
public IComponentEditorHostContext HostContext { get; }
|
||||||
|
|
||||||
|
public T? GetService<T>()
|
||||||
|
{
|
||||||
|
return (T?)Services.GetService(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetProperty<T>(string key, out T? value)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
|
||||||
|
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||||
|
{
|
||||||
|
value = typedValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginDesktopComponentEditorRegistration
|
||||||
|
{
|
||||||
|
public PluginDesktopComponentEditorRegistration(
|
||||||
|
string componentId,
|
||||||
|
Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(editorFactory);
|
||||||
|
|
||||||
|
if (preferredWidth <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredHeight <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minScale <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxScale < minScale)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
EditorFactory = editorFactory;
|
||||||
|
PreferredWidth = preferredWidth;
|
||||||
|
PreferredHeight = preferredHeight;
|
||||||
|
MinScale = minScale;
|
||||||
|
MaxScale = maxScale;
|
||||||
|
AspectRatio = preferredWidth / preferredHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginDesktopComponentEditorRegistration(
|
||||||
|
string componentId,
|
||||||
|
Func<PluginDesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
: this(
|
||||||
|
componentId,
|
||||||
|
(_, context) => editorFactory(context),
|
||||||
|
preferredWidth,
|
||||||
|
preferredHeight,
|
||||||
|
minScale,
|
||||||
|
maxScale)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> EditorFactory { get; }
|
||||||
|
|
||||||
|
public double PreferredWidth { get; }
|
||||||
|
|
||||||
|
public double PreferredHeight { get; }
|
||||||
|
|
||||||
|
public double MinScale { get; }
|
||||||
|
|
||||||
|
public double MaxScale { get; }
|
||||||
|
|
||||||
|
public double AspectRatio { get; }
|
||||||
|
}
|
||||||
@@ -61,6 +61,27 @@ public static class PluginServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string componentId,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
where TControl : Control
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
|
||||||
|
componentId,
|
||||||
|
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||||
|
preferredWidth,
|
||||||
|
preferredHeight,
|
||||||
|
minScale,
|
||||||
|
maxScale));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
||||||
where TContract : class
|
where TContract : class
|
||||||
where TImplementation : class, TContract
|
where TImplementation : class, TContract
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
@@ -45,8 +46,10 @@ public partial class App : Application
|
|||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||||
private ISettingsWindowService? _settingsWindowService;
|
private ISettingsWindowService? _settingsWindowService;
|
||||||
|
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||||
private bool _exitCleanupCompleted;
|
private bool _exitCleanupCompleted;
|
||||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||||
private ShutdownIntent _shutdownIntent;
|
private ShutdownIntent _shutdownIntent;
|
||||||
@@ -92,6 +95,7 @@ public partial class App : Application
|
|||||||
ApplyThemeFromSettings();
|
ApplyThemeFromSettings();
|
||||||
ApplyCurrentCultureFromSettings();
|
ApplyCurrentCultureFromSettings();
|
||||||
EnsureSettingsWindowService();
|
EnsureSettingsWindowService();
|
||||||
|
EnsureWeatherLocationRefreshService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
@@ -119,6 +123,8 @@ public partial class App : Application
|
|||||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StartWeatherLocationRefreshIfNeeded();
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +306,35 @@ public partial class App : Application
|
|||||||
_settingsFacade);
|
_settingsFacade);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureWeatherLocationRefreshService()
|
||||||
|
{
|
||||||
|
_weatherLocationRefreshService ??= new WeatherLocationRefreshService(
|
||||||
|
_settingsFacade,
|
||||||
|
_locationService,
|
||||||
|
_localizationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartWeatherLocationRefreshIfNeeded()
|
||||||
|
{
|
||||||
|
EnsureWeatherLocationRefreshService();
|
||||||
|
if (_weatherLocationRefreshService is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _weatherLocationRefreshService.TryRefreshOnStartupAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Weather.Location", "Failed to refresh weather location during startup.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyThemeFromSettings()
|
private void ApplyThemeFromSettings()
|
||||||
{
|
{
|
||||||
var themeState = _settingsFacade.Theme.Get();
|
var themeState = _settingsFacade.Theme.Get();
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
public sealed record DesktopComponentEditorContext(
|
||||||
|
DesktopComponentDefinition Definition,
|
||||||
|
string ComponentId,
|
||||||
|
string? PlacementId,
|
||||||
|
ISettingsFacadeService SettingsFacade,
|
||||||
|
ISettingsService SettingsService,
|
||||||
|
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||||
|
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||||
|
IComponentEditorHostContext HostContext);
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorRegistration
|
||||||
|
{
|
||||||
|
public DesktopComponentEditorRegistration(
|
||||||
|
string componentId,
|
||||||
|
Func<DesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(editorFactory);
|
||||||
|
|
||||||
|
if (preferredWidth <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredHeight <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minScale <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxScale < minScale)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
EditorFactory = editorFactory;
|
||||||
|
PreferredWidth = preferredWidth;
|
||||||
|
PreferredHeight = preferredHeight;
|
||||||
|
MinScale = minScale;
|
||||||
|
MaxScale = maxScale;
|
||||||
|
AspectRatio = preferredWidth / preferredHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public Func<DesktopComponentEditorContext, Control> EditorFactory { get; }
|
||||||
|
|
||||||
|
public double PreferredWidth { get; }
|
||||||
|
|
||||||
|
public double PreferredHeight { get; }
|
||||||
|
|
||||||
|
public double MinScale { get; }
|
||||||
|
|
||||||
|
public double MaxScale { get; }
|
||||||
|
|
||||||
|
public double AspectRatio { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorDescriptor
|
||||||
|
{
|
||||||
|
internal DesktopComponentEditorDescriptor(
|
||||||
|
DesktopComponentDefinition definition,
|
||||||
|
Func<DesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth,
|
||||||
|
double preferredHeight,
|
||||||
|
double minScale,
|
||||||
|
double maxScale,
|
||||||
|
double aspectRatio)
|
||||||
|
{
|
||||||
|
Definition = definition;
|
||||||
|
_editorFactory = editorFactory;
|
||||||
|
PreferredWidth = preferredWidth;
|
||||||
|
PreferredHeight = preferredHeight;
|
||||||
|
MinScale = minScale;
|
||||||
|
MaxScale = maxScale;
|
||||||
|
AspectRatio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Func<DesktopComponentEditorContext, Control> _editorFactory;
|
||||||
|
|
||||||
|
public DesktopComponentDefinition Definition { get; }
|
||||||
|
|
||||||
|
public double PreferredWidth { get; }
|
||||||
|
|
||||||
|
public double PreferredHeight { get; }
|
||||||
|
|
||||||
|
public double MinScale { get; }
|
||||||
|
|
||||||
|
public double MaxScale { get; }
|
||||||
|
|
||||||
|
public double AspectRatio { get; }
|
||||||
|
|
||||||
|
public Control CreateEditor(DesktopComponentEditorContext context)
|
||||||
|
{
|
||||||
|
return _editorFactory(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, DesktopComponentEditorDescriptor> _descriptors;
|
||||||
|
|
||||||
|
public DesktopComponentEditorRegistry(
|
||||||
|
ComponentRegistry componentRegistry,
|
||||||
|
IEnumerable<DesktopComponentEditorRegistration> registrations)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(componentRegistry);
|
||||||
|
ArgumentNullException.ThrowIfNull(registrations);
|
||||||
|
|
||||||
|
_descriptors = new Dictionary<string, DesktopComponentEditorDescriptor>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var registration in registrations)
|
||||||
|
{
|
||||||
|
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_descriptors[registration.ComponentId] = new DesktopComponentEditorDescriptor(
|
||||||
|
definition,
|
||||||
|
registration.EditorFactory,
|
||||||
|
registration.PreferredWidth,
|
||||||
|
registration.PreferredHeight,
|
||||||
|
registration.MinScale,
|
||||||
|
registration.MaxScale,
|
||||||
|
registration.AspectRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetDescriptor(string componentId, out DesktopComponentEditorDescriptor descriptor)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
return _descriptors.TryGetValue(componentId.Trim(), out descriptor!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DesktopComponentEditorDescriptor> GetAll()
|
||||||
|
{
|
||||||
|
return _descriptors.Values.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||||
|
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
|
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
|
||||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
"settings.weather.title": "Weather",
|
"settings.weather.title": "Weather",
|
||||||
|
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
|
||||||
"settings.weather.location_source_header": "Location Source",
|
"settings.weather.location_source_header": "Location Source",
|
||||||
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
||||||
"settings.weather.mode_city_search": "City Search",
|
"settings.weather.mode_city_search": "City Search",
|
||||||
@@ -119,6 +120,14 @@
|
|||||||
"settings.weather.apply_coordinates_button": "Apply Coordinates",
|
"settings.weather.apply_coordinates_button": "Apply Coordinates",
|
||||||
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
|
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
|
||||||
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
|
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "Location Service",
|
||||||
|
"settings.weather.location_services_desc": "Use the current Windows location and decide whether it refreshes automatically on startup.",
|
||||||
|
"settings.weather.use_current_location": "Use Current Location",
|
||||||
|
"settings.weather.location_unsupported": "Current platform does not support retrieving the current location.",
|
||||||
|
"settings.weather.location_ready": "You can use the current Windows location.",
|
||||||
|
"settings.weather.location_refreshing": "Requesting current location...",
|
||||||
|
"settings.weather.location_refresh_success_format": "Current location applied: {0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "Failed to get current location: {0}",
|
||||||
"settings.weather.preview_header": "Connection Test",
|
"settings.weather.preview_header": "Connection Test",
|
||||||
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
||||||
"settings.weather.preview_button": "Test Fetch",
|
"settings.weather.preview_button": "Test Fetch",
|
||||||
@@ -127,6 +136,7 @@
|
|||||||
"settings.weather.preview_panel_header": "Weather Preview",
|
"settings.weather.preview_panel_header": "Weather Preview",
|
||||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||||
"settings.weather.refresh_button": "Refresh",
|
"settings.weather.refresh_button": "Refresh",
|
||||||
|
"settings.weather.preview_updated_format": "Updated {0}",
|
||||||
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
||||||
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
|
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
|
||||||
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
||||||
@@ -335,12 +345,14 @@
|
|||||||
"launcher.context.hide_icon": "Hide Icon",
|
"launcher.context.hide_icon": "Hide Icon",
|
||||||
"launcher.action.hide": "Hide",
|
"launcher.action.hide": "Hide",
|
||||||
"settings.launcher.title": "App Launcher",
|
"settings.launcher.title": "App Launcher",
|
||||||
|
"settings.launcher.description": "Manage hidden apps and folders in the App Launcher.",
|
||||||
"settings.launcher.hidden_header": "Hidden Items",
|
"settings.launcher.hidden_header": "Hidden Items",
|
||||||
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
||||||
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
||||||
"settings.launcher.hidden_empty": "No hidden items.",
|
"settings.launcher.hidden_empty": "No hidden items.",
|
||||||
|
"settings.launcher.hidden_summary_format": "{0} hidden items",
|
||||||
"settings.launcher.hidden_type_folder": "Folder",
|
"settings.launcher.hidden_type_folder": "Folder",
|
||||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
"settings.launcher.hidden_type_shortcut": "App",
|
||||||
"settings.launcher.restore_button": "Unhide",
|
"settings.launcher.restore_button": "Unhide",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||||
@@ -459,6 +471,12 @@
|
|||||||
"component_library.drag_hint": "Drag to place",
|
"component_library.drag_hint": "Drag to place",
|
||||||
"component.delete": "Delete",
|
"component.delete": "Delete",
|
||||||
"component.edit": "Edit",
|
"component.edit": "Edit",
|
||||||
|
"component.editor.instance_scope": "Changes apply to this component instance only.",
|
||||||
|
"component.editor.info_header": "Component Info",
|
||||||
|
"component.editor.id_label": "Component ID",
|
||||||
|
"component.editor.placement_label": "Placement ID",
|
||||||
|
"component.editor.scope_label": "Scope",
|
||||||
|
"component.editor.scope_instance": "Instance-scoped editor",
|
||||||
"component_category.clock": "Clock",
|
"component_category.clock": "Clock",
|
||||||
"component_category.date": "Calendar",
|
"component_category.date": "Calendar",
|
||||||
"component_category.weather": "Weather",
|
"component_category.weather": "Weather",
|
||||||
@@ -801,4 +819,3 @@
|
|||||||
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||||
"single_instance.notice.button": "OK"
|
"single_instance.notice.button": "OK"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
|
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
|
||||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
"settings.weather.title": "天气",
|
"settings.weather.title": "天气",
|
||||||
|
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
|
||||||
"settings.weather.location_source_header": "位置来源",
|
"settings.weather.location_source_header": "位置来源",
|
||||||
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
||||||
"settings.weather.mode_city_search": "城市搜索",
|
"settings.weather.mode_city_search": "城市搜索",
|
||||||
@@ -124,6 +125,14 @@
|
|||||||
"settings.weather.apply_coordinates_button": "应用坐标",
|
"settings.weather.apply_coordinates_button": "应用坐标",
|
||||||
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
|
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
|
||||||
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
|
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "定位服务",
|
||||||
|
"settings.weather.location_services_desc": "使用当前 Windows 定位,并决定是否在启动时自动刷新天气位置。",
|
||||||
|
"settings.weather.use_current_location": "使用当前位置",
|
||||||
|
"settings.weather.location_unsupported": "当前平台不支持获取当前位置。",
|
||||||
|
"settings.weather.location_ready": "可以使用当前 Windows 定位。",
|
||||||
|
"settings.weather.location_refreshing": "正在获取当前位置……",
|
||||||
|
"settings.weather.location_refresh_success_format": "已应用当前位置:{0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "获取当前位置失败:{0}",
|
||||||
"settings.weather.preview_header": "连接测试",
|
"settings.weather.preview_header": "连接测试",
|
||||||
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||||
"settings.weather.preview_button": "测试获取",
|
"settings.weather.preview_button": "测试获取",
|
||||||
@@ -132,6 +141,7 @@
|
|||||||
"settings.weather.preview_panel_header": "天气预览",
|
"settings.weather.preview_panel_header": "天气预览",
|
||||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||||
"settings.weather.refresh_button": "刷新",
|
"settings.weather.refresh_button": "刷新",
|
||||||
|
"settings.weather.preview_updated_format": "更新于 {0}",
|
||||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||||
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
||||||
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
||||||
@@ -340,12 +350,14 @@
|
|||||||
"launcher.context.hide_icon": "隐藏图标",
|
"launcher.context.hide_icon": "隐藏图标",
|
||||||
"launcher.action.hide": "隐藏",
|
"launcher.action.hide": "隐藏",
|
||||||
"settings.launcher.title": "应用启动台",
|
"settings.launcher.title": "应用启动台",
|
||||||
|
"settings.launcher.description": "管理应用启动台中已隐藏的应用与文件夹。",
|
||||||
"settings.launcher.hidden_header": "已隐藏项目",
|
"settings.launcher.hidden_header": "已隐藏项目",
|
||||||
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
||||||
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
||||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||||
|
"settings.launcher.hidden_summary_format": "共 {0} 个隐藏项目",
|
||||||
"settings.launcher.hidden_type_folder": "文件夹",
|
"settings.launcher.hidden_type_folder": "文件夹",
|
||||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
"settings.launcher.hidden_type_shortcut": "应用",
|
||||||
"settings.launcher.restore_button": "取消隐藏",
|
"settings.launcher.restore_button": "取消隐藏",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
"settings.plugins.runtime_header": "插件运行时",
|
"settings.plugins.runtime_header": "插件运行时",
|
||||||
@@ -464,6 +476,12 @@
|
|||||||
"component_library.drag_hint": "拖动放置",
|
"component_library.drag_hint": "拖动放置",
|
||||||
"component.delete": "删除",
|
"component.delete": "删除",
|
||||||
"component.edit": "编辑",
|
"component.edit": "编辑",
|
||||||
|
"component.editor.instance_scope": "设置仅对当前组件实例生效。",
|
||||||
|
"component.editor.info_header": "组件信息",
|
||||||
|
"component.editor.id_label": "组件 ID",
|
||||||
|
"component.editor.placement_label": "实例 ID",
|
||||||
|
"component.editor.scope_label": "作用域",
|
||||||
|
"component.editor.scope_instance": "实例级编辑器",
|
||||||
"component_category.clock": "时钟",
|
"component_category.clock": "时钟",
|
||||||
"component_category.date": "日历",
|
"component_category.date": "日历",
|
||||||
"component_category.weather": "天气",
|
"component_category.weather": "天气",
|
||||||
@@ -806,4 +824,3 @@
|
|||||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||||
"single_instance.notice.button": "确定"
|
"single_instance.notice.button": "确定"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string WeatherIconPackId { get; set; } = "FluentRegular";
|
public string WeatherIconPackId { get; set; } = "HyperOS3";
|
||||||
|
|
||||||
public bool WeatherNoTlsRequests { get; set; }
|
public bool WeatherNoTlsRequests { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public enum TaskbarActionId
|
|||||||
MinimizeToWindows,
|
MinimizeToWindows,
|
||||||
AddDesktopPage,
|
AddDesktopPage,
|
||||||
DeleteDesktopPage,
|
DeleteDesktopPage,
|
||||||
|
EditComponent,
|
||||||
DeleteComponent,
|
DeleteComponent,
|
||||||
HideLauncherEntry
|
HideLauncherEntry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal sealed record ComponentEditorThemePalette(
|
||||||
|
bool IsNightMode,
|
||||||
|
Color PrimaryColor,
|
||||||
|
Color SecondaryColor,
|
||||||
|
Color TertiaryColor,
|
||||||
|
Color WindowBackgroundColor,
|
||||||
|
Color SurfaceColor,
|
||||||
|
Color SurfaceContainerColor,
|
||||||
|
Color SurfaceContainerHighColor,
|
||||||
|
Color TopAppBarColor,
|
||||||
|
Color HeaderIconBackgroundColor,
|
||||||
|
Color TitleBarButtonHoverColor,
|
||||||
|
Color OutlineColor,
|
||||||
|
Color DividerColor,
|
||||||
|
Color OnSurfaceColor,
|
||||||
|
Color OnSurfaceVariantColor,
|
||||||
|
Color OnPrimaryColor);
|
||||||
|
|
||||||
|
internal static class ComponentEditorMaterialThemeAdapter
|
||||||
|
{
|
||||||
|
private static readonly Color DefaultPrimary = Color.Parse("#FF6750A4");
|
||||||
|
private static readonly Color DarkBackgroundBase = Color.Parse("#FF0B0F14");
|
||||||
|
private static readonly Color DarkSurfaceBase = Color.Parse("#FF10161D");
|
||||||
|
private static readonly Color DarkSurfaceContainerBase = Color.Parse("#FF151C24");
|
||||||
|
private static readonly Color DarkSurfaceContainerHighBase = Color.Parse("#FF1A232D");
|
||||||
|
private static readonly Color LightBackgroundBase = Color.Parse("#FFFCFCFF");
|
||||||
|
private static readonly Color LightSurfaceBase = Color.Parse("#FFFFFFFF");
|
||||||
|
private static readonly Color LightSurfaceContainerBase = Color.Parse("#FFF6F8FD");
|
||||||
|
private static readonly Color LightSurfaceContainerHighBase = Color.Parse("#FFF0F4FA");
|
||||||
|
private static readonly Color LightOnSurfaceBase = Color.Parse("#FF101316");
|
||||||
|
private static readonly Color DarkOnSurfaceBase = Color.Parse("#FFF6F8FC");
|
||||||
|
|
||||||
|
public static ComponentEditorThemePalette Build(
|
||||||
|
ThemeAppearanceSettingsState themeState,
|
||||||
|
WallpaperSettingsState wallpaperState,
|
||||||
|
MonetPalette monetPalette,
|
||||||
|
WallpaperMediaType wallpaperMediaType)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(monetPalette);
|
||||||
|
|
||||||
|
var isNightMode = themeState.IsNightMode;
|
||||||
|
var monetColors = monetPalette.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
|
||||||
|
var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
|
||||||
|
var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetColors.Length > 0;
|
||||||
|
|
||||||
|
var primary = useWallpaperPalette
|
||||||
|
? monetColors[0]
|
||||||
|
: fallbackThemeColor ?? monetColors.FirstOrDefault(DefaultPrimary);
|
||||||
|
var secondary = ResolveSecondaryColor(primary, monetColors, isNightMode);
|
||||||
|
var tertiary = ResolveTertiaryColor(primary, secondary, monetColors, isNightMode);
|
||||||
|
|
||||||
|
var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
|
||||||
|
var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
|
||||||
|
var surfaceContainerBase = isNightMode ? DarkSurfaceContainerBase : LightSurfaceContainerBase;
|
||||||
|
var surfaceContainerHighBase = isNightMode ? DarkSurfaceContainerHighBase : LightSurfaceContainerHighBase;
|
||||||
|
|
||||||
|
var background = ColorMath.Blend(backgroundBase, primary, isNightMode ? 0.10 : 0.025);
|
||||||
|
var surface = ColorMath.Blend(surfaceBase, primary, isNightMode ? 0.12 : 0.035);
|
||||||
|
var surfaceContainer = ColorMath.Blend(surfaceContainerBase, primary, isNightMode ? 0.18 : 0.065);
|
||||||
|
var surfaceContainerHigh = ColorMath.Blend(surfaceContainerHighBase, primary, isNightMode ? 0.24 : 0.09);
|
||||||
|
var topAppBar = ColorMath.Blend(surfaceContainerHigh, primary, isNightMode ? 0.10 : 0.06);
|
||||||
|
|
||||||
|
var onSurfaceBase = isNightMode ? DarkOnSurfaceBase : LightOnSurfaceBase;
|
||||||
|
var onSurface = ColorMath.EnsureContrast(onSurfaceBase, background, 7.0);
|
||||||
|
var onSurfaceVariantBase = ColorMath.Blend(
|
||||||
|
onSurface,
|
||||||
|
surfaceContainer,
|
||||||
|
isNightMode ? 0.30 : 0.42);
|
||||||
|
var onSurfaceVariant = ColorMath.EnsureContrast(onSurfaceVariantBase, surfaceContainer, 4.5);
|
||||||
|
var outlineBase = ColorMath.Blend(onSurface, surfaceContainer, isNightMode ? 0.74 : 0.82);
|
||||||
|
var outline = Color.FromArgb(
|
||||||
|
isNightMode ? (byte)0x66 : (byte)0x42,
|
||||||
|
outlineBase.R,
|
||||||
|
outlineBase.G,
|
||||||
|
outlineBase.B);
|
||||||
|
var divider = Color.FromArgb(
|
||||||
|
isNightMode ? (byte)0x52 : (byte)0x26,
|
||||||
|
outlineBase.R,
|
||||||
|
outlineBase.G,
|
||||||
|
outlineBase.B);
|
||||||
|
var headerIconBackground = Color.FromArgb(
|
||||||
|
isNightMode ? (byte)0x36 : (byte)0x1F,
|
||||||
|
primary.R,
|
||||||
|
primary.G,
|
||||||
|
primary.B);
|
||||||
|
var titleBarButtonHover = Color.FromArgb(
|
||||||
|
isNightMode ? (byte)0x24 : (byte)0x12,
|
||||||
|
onSurface.R,
|
||||||
|
onSurface.G,
|
||||||
|
onSurface.B);
|
||||||
|
var onPrimaryBase = isNightMode ? Color.Parse("#FF111318") : Color.Parse("#FFFFFFFF");
|
||||||
|
var onPrimary = ColorMath.EnsureContrast(onPrimaryBase, primary, 4.5);
|
||||||
|
|
||||||
|
return new ComponentEditorThemePalette(
|
||||||
|
isNightMode,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
tertiary,
|
||||||
|
background,
|
||||||
|
surface,
|
||||||
|
surfaceContainer,
|
||||||
|
surfaceContainerHigh,
|
||||||
|
topAppBar,
|
||||||
|
headerIconBackground,
|
||||||
|
titleBarButtonHover,
|
||||||
|
outline,
|
||||||
|
divider,
|
||||||
|
onSurface,
|
||||||
|
onSurfaceVariant,
|
||||||
|
onPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ResolveSecondaryColor(Color primary, IReadOnlyList<Color> monetColors, bool isNightMode)
|
||||||
|
{
|
||||||
|
if (monetColors.Count > 1)
|
||||||
|
{
|
||||||
|
return monetColors[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColorMath.Blend(
|
||||||
|
primary,
|
||||||
|
isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF1F1B24"),
|
||||||
|
isNightMode ? 0.18 : 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ResolveTertiaryColor(
|
||||||
|
Color primary,
|
||||||
|
Color secondary,
|
||||||
|
IReadOnlyList<Color> monetColors,
|
||||||
|
bool isNightMode)
|
||||||
|
{
|
||||||
|
if (monetColors.Count > 2)
|
||||||
|
{
|
||||||
|
return monetColors[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
|
||||||
|
return ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), blendTarget, isNightMode ? 0.12 : 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color? TryParseColor(string? value)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal file
169
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public readonly record struct ComponentEditorOpenRequest(
|
||||||
|
Window Owner,
|
||||||
|
DesktopComponentEditorDescriptor Descriptor,
|
||||||
|
string ComponentId,
|
||||||
|
string PlacementId,
|
||||||
|
Action RefreshAction,
|
||||||
|
Action<string?>? RestartAction = null);
|
||||||
|
|
||||||
|
public interface IComponentEditorWindowService
|
||||||
|
{
|
||||||
|
bool IsOpen { get; }
|
||||||
|
|
||||||
|
string? CurrentPlacementId { get; }
|
||||||
|
|
||||||
|
void Open(ComponentEditorOpenRequest request);
|
||||||
|
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private ComponentEditorWindow? _window;
|
||||||
|
private string? _currentPlacementId;
|
||||||
|
|
||||||
|
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOpen => _window is { IsVisible: true };
|
||||||
|
|
||||||
|
public string? CurrentPlacementId => _currentPlacementId;
|
||||||
|
|
||||||
|
public void Open(ComponentEditorOpenRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request.Owner);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.RefreshAction);
|
||||||
|
|
||||||
|
_window ??= CreateWindow();
|
||||||
|
|
||||||
|
var settingsService = _settingsFacade.Settings;
|
||||||
|
var accessor = settingsService.GetComponentAccessor(request.ComponentId, request.PlacementId);
|
||||||
|
var scopedStore = new ComponentSettingsService(settingsService);
|
||||||
|
scopedStore.SetScopedComponentContext(request.ComponentId, request.PlacementId);
|
||||||
|
|
||||||
|
var hostContext = new HostContext(this, request.RefreshAction, request.RestartAction);
|
||||||
|
var context = new DesktopComponentEditorContext(
|
||||||
|
request.Descriptor.Definition,
|
||||||
|
request.ComponentId,
|
||||||
|
request.PlacementId,
|
||||||
|
_settingsFacade,
|
||||||
|
settingsService,
|
||||||
|
accessor,
|
||||||
|
scopedStore,
|
||||||
|
hostContext);
|
||||||
|
|
||||||
|
_currentPlacementId = request.PlacementId;
|
||||||
|
_window.ApplyDescriptor(request.Descriptor, context);
|
||||||
|
|
||||||
|
if (!_window.IsVisible)
|
||||||
|
{
|
||||||
|
_window.Show(request.Owner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_window.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_window?.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentEditorWindow CreateWindow()
|
||||||
|
{
|
||||||
|
var window = new ComponentEditorWindow();
|
||||||
|
ApplyTheme(window);
|
||||||
|
window.ShowInTaskbar = false;
|
||||||
|
window.Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
_window = null;
|
||||||
|
_currentPlacementId = null;
|
||||||
|
};
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
if (_window is null || e.Scope != SettingsScope.App)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
|
||||||
|
if (changedKeys.Length > 0 &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyTheme(_window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTheme(ComponentEditorWindow window)
|
||||||
|
{
|
||||||
|
var themeState = _settingsFacade.Theme.Get();
|
||||||
|
var wallpaperState = _settingsFacade.Wallpaper.Get();
|
||||||
|
var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath);
|
||||||
|
var monetPalette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, wallpaperState.WallpaperPath);
|
||||||
|
var palette = ComponentEditorMaterialThemeAdapter.Build(
|
||||||
|
themeState,
|
||||||
|
wallpaperState,
|
||||||
|
monetPalette,
|
||||||
|
wallpaperMediaType);
|
||||||
|
|
||||||
|
window.ApplyTheme(palette);
|
||||||
|
window.ApplyChromeMode(themeState.UseSystemChrome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HostContext : IComponentEditorHostContext
|
||||||
|
{
|
||||||
|
private readonly ComponentEditorWindowService _owner;
|
||||||
|
private readonly Action _refreshAction;
|
||||||
|
private readonly Action<string?>? _restartAction;
|
||||||
|
|
||||||
|
public HostContext(
|
||||||
|
ComponentEditorWindowService owner,
|
||||||
|
Action refreshAction,
|
||||||
|
Action<string?>? restartAction)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_refreshAction = refreshAction;
|
||||||
|
_restartAction = restartAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestRefresh()
|
||||||
|
{
|
||||||
|
_refreshAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseEditor()
|
||||||
|
{
|
||||||
|
_owner.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestRestart(string? reason = null)
|
||||||
|
{
|
||||||
|
_restartAction?.Invoke(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Plugins;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public static class DesktopComponentEditorRegistryFactory
|
||||||
|
{
|
||||||
|
public static DesktopComponentEditorRegistry Create(
|
||||||
|
ComponentRegistry componentRegistry,
|
||||||
|
PluginRuntimeService? pluginRuntimeService)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(componentRegistry);
|
||||||
|
|
||||||
|
var registrations = GetBuiltInRegistrations(componentRegistry).ToList();
|
||||||
|
var registeredIds = new HashSet<string>(
|
||||||
|
registrations.Select(registration => registration.ComponentId),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (pluginRuntimeService is not null)
|
||||||
|
{
|
||||||
|
foreach (var contribution in pluginRuntimeService.DesktopComponentEditors)
|
||||||
|
{
|
||||||
|
var registration = contribution.Registration;
|
||||||
|
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition) ||
|
||||||
|
!definition.AllowDesktopPlacement ||
|
||||||
|
!registeredIds.Add(registration.ComponentId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations.Add(new DesktopComponentEditorRegistration(
|
||||||
|
registration.ComponentId,
|
||||||
|
context => CreatePluginEditor(contribution, context),
|
||||||
|
registration.PreferredWidth,
|
||||||
|
registration.PreferredHeight,
|
||||||
|
registration.MinScale,
|
||||||
|
registration.MaxScale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DesktopComponentEditorRegistry(componentRegistry, registrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<DesktopComponentEditorRegistration> GetBuiltInRegistrations(ComponentRegistry componentRegistry)
|
||||||
|
{
|
||||||
|
var registrations = new Dictionary<string, DesktopComponentEditorRegistration>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[BuiltInComponentIds.DesktopClock] = new(
|
||||||
|
BuiltInComponentIds.DesktopClock,
|
||||||
|
context => new ClockComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopWorldClock] = new(
|
||||||
|
BuiltInComponentIds.DesktopWorldClock,
|
||||||
|
context => new WorldClockComponentEditor(context),
|
||||||
|
preferredWidth: 820d,
|
||||||
|
preferredHeight: 620d),
|
||||||
|
[BuiltInComponentIds.DesktopClassSchedule] = new(
|
||||||
|
BuiltInComponentIds.DesktopClassSchedule,
|
||||||
|
context => new ClassScheduleComponentEditor(context),
|
||||||
|
preferredWidth: 860d,
|
||||||
|
preferredHeight: 640d),
|
||||||
|
[BuiltInComponentIds.DesktopDailyArtwork] = new(
|
||||||
|
BuiltInComponentIds.DesktopDailyArtwork,
|
||||||
|
context => new DailyArtworkComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
|
||||||
|
BuiltInComponentIds.DesktopStudyEnvironment,
|
||||||
|
context => new StudyEnvironmentComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||||
|
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||||
|
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||||
|
[BuiltInComponentIds.DesktopMultiDayWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopMultiDayWeather),
|
||||||
|
[BuiltInComponentIds.DesktopExtendedWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopExtendedWeather),
|
||||||
|
[BuiltInComponentIds.DesktopCnrDailyNews] = new(
|
||||||
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "cnr.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto rotation for this CNR news widget.",
|
||||||
|
ToggleLabelKey = "cnr.settings.auto_rotate",
|
||||||
|
ToggleLabelFallback = "Auto rotate",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "cnr.settings.rotate_interval",
|
||||||
|
IntervalLabelFallback = "Rotate interval",
|
||||||
|
DefaultInterval = 60,
|
||||||
|
GetEnabled = snapshot => snapshot.CnrDailyNewsAutoRotateEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateIntervalMinutes = value,
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateIntervalMinutes)
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
[BuiltInComponentIds.DesktopIfengNews] = new(
|
||||||
|
BuiltInComponentIds.DesktopIfengNews,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "ifeng.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto refresh and source channel for this iFeng widget.",
|
||||||
|
ToggleLabelKey = "ifeng.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "ifeng.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 20,
|
||||||
|
GetEnabled = snapshot => snapshot.IfengNewsAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.IfengNewsAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.IfengNewsAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.IfengNewsAutoRefreshIntervalMinutes = value,
|
||||||
|
ExtraSelectorLabelKey = "ifeng.settings.channel",
|
||||||
|
ExtraSelectorLabelFallback = "Channel",
|
||||||
|
ExtraOptions =
|
||||||
|
[
|
||||||
|
new ComponentEditorSelectionOption(
|
||||||
|
IfengNewsChannelTypes.Comprehensive,
|
||||||
|
"ifeng.settings.channel.comprehensive",
|
||||||
|
"Comprehensive"),
|
||||||
|
new ComponentEditorSelectionOption(
|
||||||
|
IfengNewsChannelTypes.Mainland,
|
||||||
|
"ifeng.settings.channel.mainland",
|
||||||
|
"Mainland"),
|
||||||
|
new ComponentEditorSelectionOption(
|
||||||
|
IfengNewsChannelTypes.Taiwan,
|
||||||
|
"ifeng.settings.channel.taiwan",
|
||||||
|
"Taiwan")
|
||||||
|
],
|
||||||
|
GetExtraValue = snapshot => IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType),
|
||||||
|
SetExtraValue = (snapshot, value) => snapshot.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(value),
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshIntervalMinutes),
|
||||||
|
nameof(ComponentSettingsSnapshot.IfengNewsChannelType)
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
[BuiltInComponentIds.DesktopDailyWord] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord),
|
||||||
|
[BuiltInComponentIds.DesktopDailyWord2x2] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord2x2),
|
||||||
|
[BuiltInComponentIds.DesktopBilibiliHotSearch] = new(
|
||||||
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "bilibili.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto refresh for this Bilibili hot search widget.",
|
||||||
|
ToggleLabelKey = "bilibili.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "bilibili.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 15,
|
||||||
|
GetEnabled = snapshot => snapshot.BilibiliHotSearchAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = value,
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes)
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
[BuiltInComponentIds.DesktopBaiduHotSearch] = new(
|
||||||
|
BuiltInComponentIds.DesktopBaiduHotSearch,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "baidu.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto refresh and source for this Baidu hot search widget.",
|
||||||
|
ToggleLabelKey = "baidu.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "baidu.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 15,
|
||||||
|
GetEnabled = snapshot => snapshot.BaiduHotSearchAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = value,
|
||||||
|
ExtraSelectorLabelKey = "baidu.settings.source",
|
||||||
|
ExtraSelectorLabelFallback = "Source",
|
||||||
|
ExtraOptions =
|
||||||
|
[
|
||||||
|
new ComponentEditorSelectionOption(
|
||||||
|
BaiduHotSearchSourceTypes.Official,
|
||||||
|
"baidu.settings.source.official",
|
||||||
|
"Official"),
|
||||||
|
new ComponentEditorSelectionOption(
|
||||||
|
BaiduHotSearchSourceTypes.ThirdPartyRss,
|
||||||
|
"baidu.settings.source.third_party",
|
||||||
|
"Third-party RSS")
|
||||||
|
],
|
||||||
|
GetExtraValue = snapshot => BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType),
|
||||||
|
SetExtraValue = (snapshot, value) => snapshot.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(value),
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes),
|
||||||
|
nameof(ComponentSettingsSnapshot.BaiduHotSearchSourceType)
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
[BuiltInComponentIds.DesktopStcn24Forum] = new(
|
||||||
|
BuiltInComponentIds.DesktopStcn24Forum,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "stcn.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto refresh and sort mode for this STCN forum widget.",
|
||||||
|
ToggleLabelKey = "stcn.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "stcn.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 20,
|
||||||
|
GetEnabled = snapshot => snapshot.Stcn24ForumAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.Stcn24ForumAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshIntervalMinutes = value,
|
||||||
|
ExtraSelectorLabelKey = "stcn.settings.sort_mode",
|
||||||
|
ExtraSelectorLabelFallback = "Sort mode",
|
||||||
|
ExtraOptions = Stcn24ForumSourceTypes.SupportedValues
|
||||||
|
.Select(value => new ComponentEditorSelectionOption(
|
||||||
|
value,
|
||||||
|
$"stcn.settings.source.{value}",
|
||||||
|
value))
|
||||||
|
.ToArray(),
|
||||||
|
GetExtraValue = snapshot => Stcn24ForumSourceTypes.Normalize(snapshot.Stcn24ForumSourceType),
|
||||||
|
SetExtraValue = (snapshot, value) => snapshot.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(value),
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
||||||
|
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||||
|
{
|
||||||
|
if (registrations.ContainsKey(componentId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations[componentId] = new DesktopComponentEditorRegistration(
|
||||||
|
componentId,
|
||||||
|
context => new InformationalComponentEditor(
|
||||||
|
context,
|
||||||
|
$"This {context.Definition.DisplayName} component currently exposes instance-scoped editor metadata only."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return registrations.Values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetBuiltInDesktopComponentIds(ComponentRegistry componentRegistry)
|
||||||
|
{
|
||||||
|
return typeof(BuiltInComponentIds)
|
||||||
|
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||||
|
.Where(field => field.FieldType == typeof(string))
|
||||||
|
.Select(field => field.GetRawConstantValue() as string)
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Select(id => id!)
|
||||||
|
.Where(id => componentRegistry.TryGetDefinition(id, out var definition) && definition.AllowDesktopPlacement)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DesktopComponentEditorRegistration CreateWeatherRegistration(string componentId)
|
||||||
|
{
|
||||||
|
return new DesktopComponentEditorRegistration(
|
||||||
|
componentId,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "weather.settings.desc",
|
||||||
|
DescriptionFallback = "Configure weather auto refresh for this component instance.",
|
||||||
|
ToggleLabelKey = "weather.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "weather.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 12,
|
||||||
|
GetEnabled = snapshot => snapshot.WeatherAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.WeatherAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.WeatherAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.WeatherAutoRefreshIntervalMinutes = value,
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshIntervalMinutes)
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DesktopComponentEditorRegistration CreateDailyWordRegistration(string componentId)
|
||||||
|
{
|
||||||
|
return new DesktopComponentEditorRegistration(
|
||||||
|
componentId,
|
||||||
|
context => new ToggleIntervalComponentEditor(
|
||||||
|
context,
|
||||||
|
new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "dailyword.settings.desc",
|
||||||
|
DescriptionFallback = "Configure auto refresh for this Daily Word component.",
|
||||||
|
ToggleLabelKey = "dailyword.settings.auto_refresh",
|
||||||
|
ToggleLabelFallback = "Auto refresh",
|
||||||
|
ToggleDescriptionKey = "component.editor.instance_scope",
|
||||||
|
ToggleDescriptionFallback = "Changes are stored per component instance.",
|
||||||
|
IntervalLabelKey = "dailyword.settings.refresh_interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 360,
|
||||||
|
GetEnabled = snapshot => snapshot.DailyWordAutoRefreshEnabled,
|
||||||
|
SetEnabled = (snapshot, value) => snapshot.DailyWordAutoRefreshEnabled = value,
|
||||||
|
GetInterval = snapshot => snapshot.DailyWordAutoRefreshIntervalMinutes,
|
||||||
|
SetInterval = (snapshot, value) => snapshot.DailyWordAutoRefreshIntervalMinutes = value,
|
||||||
|
ChangedKeys =
|
||||||
|
[
|
||||||
|
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshEnabled),
|
||||||
|
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshIntervalMinutes)
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control CreatePluginEditor(
|
||||||
|
PluginDesktopComponentEditorContribution contribution,
|
||||||
|
DesktopComponentEditorContext context)
|
||||||
|
{
|
||||||
|
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
|
||||||
|
?? context.SettingsService;
|
||||||
|
var pluginSettings = new PluginScopedSettingsService(
|
||||||
|
contribution.Plugin.Manifest.Id,
|
||||||
|
settingsService);
|
||||||
|
var pluginContext = new PluginDesktopComponentEditorContext(
|
||||||
|
contribution.Plugin.Manifest,
|
||||||
|
contribution.Plugin.Context.PluginDirectory,
|
||||||
|
contribution.Plugin.Context.DataDirectory,
|
||||||
|
contribution.Plugin.Services,
|
||||||
|
contribution.Plugin.Context.Properties,
|
||||||
|
context.ComponentId,
|
||||||
|
context.PlacementId,
|
||||||
|
pluginSettings,
|
||||||
|
context.HostContext);
|
||||||
|
|
||||||
|
return contribution.Registration.EditorFactory(contribution.Plugin.Services, pluginContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ public sealed record WeatherQueryResult<T>(
|
|||||||
public interface IWeatherInfoService
|
public interface IWeatherInfoService
|
||||||
{
|
{
|
||||||
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
|
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IWeatherDataService : IWeatherInfoService
|
public interface IWeatherDataService : IWeatherInfoService
|
||||||
|
|||||||
351
LanMountainDesktop/Services/LocationService.cs
Normal file
351
LanMountainDesktop/Services/LocationService.cs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public enum LocationFailureReason
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Unsupported = 1,
|
||||||
|
PermissionDenied = 2,
|
||||||
|
Disabled = 3,
|
||||||
|
Timeout = 4,
|
||||||
|
Cancelled = 5,
|
||||||
|
Unavailable = 6,
|
||||||
|
Unknown = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct LocationCoordinate(
|
||||||
|
double Latitude,
|
||||||
|
double Longitude,
|
||||||
|
double? AccuracyMeters = null);
|
||||||
|
|
||||||
|
public sealed record LocationRequestResult(
|
||||||
|
bool Success,
|
||||||
|
bool IsSupported,
|
||||||
|
LocationCoordinate? Coordinate = null,
|
||||||
|
LocationFailureReason FailureReason = LocationFailureReason.None,
|
||||||
|
string? ErrorMessage = null)
|
||||||
|
{
|
||||||
|
public static LocationRequestResult Unsupported(string? errorMessage = null)
|
||||||
|
=> new(false, false, null, LocationFailureReason.Unsupported, errorMessage);
|
||||||
|
|
||||||
|
public static LocationRequestResult Ok(LocationCoordinate coordinate)
|
||||||
|
=> new(true, true, coordinate, LocationFailureReason.None, null);
|
||||||
|
|
||||||
|
public static LocationRequestResult Fail(LocationFailureReason reason, string? errorMessage = null)
|
||||||
|
=> new(false, true, null, reason, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ILocationService
|
||||||
|
{
|
||||||
|
bool IsSupported { get; }
|
||||||
|
|
||||||
|
Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UnsupportedLocationService : ILocationService
|
||||||
|
{
|
||||||
|
public bool IsSupported => false;
|
||||||
|
|
||||||
|
public Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_ = cancellationToken;
|
||||||
|
return Task.FromResult(LocationRequestResult.Unsupported("Location service is not supported on this platform."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WindowsLocationService : ILocationService
|
||||||
|
{
|
||||||
|
private static readonly Type? GeolocatorType = ResolveWinRtType("Windows.Devices.Geolocation.Geolocator");
|
||||||
|
private static readonly MethodInfo? RequestAccessAsyncMethod =
|
||||||
|
GeolocatorType?.GetMethod("RequestAccessAsync", BindingFlags.Public | BindingFlags.Static);
|
||||||
|
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
|
||||||
|
|
||||||
|
public bool IsSupported =>
|
||||||
|
OperatingSystem.IsWindows() &&
|
||||||
|
GeolocatorType is not null &&
|
||||||
|
RequestAccessAsyncMethod is not null &&
|
||||||
|
AsTaskGenericMethodDefinition is not null;
|
||||||
|
|
||||||
|
public async Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsSupported)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Unsupported();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var access = await AwaitWinRtOperationAsync(RequestAccessAsyncMethod!.Invoke(null, null), cancellationToken);
|
||||||
|
var accessText = access?.ToString();
|
||||||
|
if (string.Equals(accessText, "Denied", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(
|
||||||
|
LocationFailureReason.PermissionDenied,
|
||||||
|
"Location permission was denied by the system.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(accessText, "Unspecified", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(
|
||||||
|
LocationFailureReason.Disabled,
|
||||||
|
"Location access is unavailable on this device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var geolocator = Activator.CreateInstance(GeolocatorType!);
|
||||||
|
if (geolocator is null)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Failed to create a Windows geolocator instance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SetPropertyValue(geolocator, "DesiredAccuracyInMeters", (uint)50);
|
||||||
|
SetPropertyValue(geolocator, "MovementThreshold", 0d);
|
||||||
|
SetPropertyValue(geolocator, "ReportInterval", (uint)0);
|
||||||
|
|
||||||
|
var geoposition = await AwaitWinRtOperationAsync(
|
||||||
|
InvokeMethod(geolocator, "GetGeopositionAsync"),
|
||||||
|
cancellationToken);
|
||||||
|
if (geoposition is null)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location request returned no position.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var coordinate = GetPropertyValue(geoposition, "Coordinate");
|
||||||
|
var point = GetPropertyValue(coordinate, "Point");
|
||||||
|
var position = GetPropertyValue(point, "Position");
|
||||||
|
|
||||||
|
var latitude = ReadDoubleProperty(position, "Latitude");
|
||||||
|
var longitude = ReadDoubleProperty(position, "Longitude");
|
||||||
|
if (!latitude.HasValue || !longitude.HasValue)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location coordinates are not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var accuracy = ReadDoubleProperty(coordinate, "Accuracy");
|
||||||
|
return LocationRequestResult.Ok(new LocationCoordinate(latitude.Value, longitude.Value, accuracy));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(
|
||||||
|
cancellationToken.IsCancellationRequested ? LocationFailureReason.Cancelled : LocationFailureReason.Timeout,
|
||||||
|
"Location request was cancelled.");
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||||
|
{
|
||||||
|
return MapException(ex.InnerException);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return MapException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationRequestResult MapException(Exception ex)
|
||||||
|
{
|
||||||
|
if (ex is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.PermissionDenied, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex is TimeoutException)
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Timeout, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hr = ex.HResult;
|
||||||
|
if (hr == unchecked((int)0x80070422))
|
||||||
|
{
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Disabled, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationRequestResult.Fail(LocationFailureReason.Unknown, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object?> AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (operation is null || AsTaskGenericMethodDefinition is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultType = ResolveWinRtOperationResultType(operation.GetType());
|
||||||
|
if (resultType is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType);
|
||||||
|
var taskObject = asTaskMethod.Invoke(null, [operation]) as Task;
|
||||||
|
if (taskObject is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await taskObject.WaitAsync(cancellationToken);
|
||||||
|
return taskObject
|
||||||
|
.GetType()
|
||||||
|
.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?
|
||||||
|
.GetValue(taskObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type? ResolveWinRtOperationResultType(Type operationType)
|
||||||
|
{
|
||||||
|
if (operationType.IsGenericType)
|
||||||
|
{
|
||||||
|
var genericArguments = operationType.GetGenericArguments();
|
||||||
|
if (genericArguments.Length == 1)
|
||||||
|
{
|
||||||
|
return genericArguments[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var iface in operationType.GetInterfaces())
|
||||||
|
{
|
||||||
|
if (!iface.IsGenericType)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericTypeDef = iface.GetGenericTypeDefinition();
|
||||||
|
if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return iface.GetGenericArguments()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInfo? ResolveAsTaskGenericMethod()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||||
|
if (type is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.Equals(method.Name, "AsTask", StringComparison.Ordinal) ||
|
||||||
|
!method.IsGenericMethodDefinition)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
if (parameters.Length == 1)
|
||||||
|
{
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
// Some WinRT bridge overloads throw during metadata inspection on unsupported runtimes.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore unusable overloads and keep probing for a compatible AsTask<T>.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If the WinRT bridge is unavailable, the location service will gracefully report unsupported.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type? ResolveWinRtType(string typeName)
|
||||||
|
{
|
||||||
|
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? InvokeMethod(object? target, string methodName)
|
||||||
|
{
|
||||||
|
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetPropertyValue(object? target, string propertyName)
|
||||||
|
{
|
||||||
|
return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetPropertyValue(object target, string propertyName, object value)
|
||||||
|
{
|
||||||
|
var property = target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
if (property is null || !property.CanWrite)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
property.SetValue(target, value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ReadDoubleProperty(object? target, string propertyName)
|
||||||
|
{
|
||||||
|
var value = GetPropertyValue(target, propertyName);
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToDouble(value);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class HostLocationServiceProvider
|
||||||
|
{
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
private static ILocationService? _instance;
|
||||||
|
|
||||||
|
public static ILocationService GetOrCreate()
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (_instance is not null)
|
||||||
|
{
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_instance = new UnsupportedLocationService();
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_instance = new WindowsLocationService();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Location", "Failed to initialize Windows location service. Falling back to unsupported mode.", ex);
|
||||||
|
_instance = new UnsupportedLocationService();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,12 +120,27 @@ public interface IWeatherProvider
|
|||||||
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||||
WeatherQuery query,
|
WeatherQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IWeatherSettingsService
|
public interface IWeatherSettingsService
|
||||||
{
|
{
|
||||||
WeatherSettingsState Get();
|
WeatherSettingsState Get();
|
||||||
void Save(WeatherSettingsState state);
|
void Save(WeatherSettingsState state);
|
||||||
|
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||||
|
string keyword,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
IWeatherInfoService GetWeatherInfoService();
|
IWeatherInfoService GetWeatherInfoService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,15 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoSer
|
|||||||
return _weatherDataService.GetWeatherAsync(query, cancellationToken);
|
return _weatherDataService.GetWeatherAsync(query, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _weatherDataService.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_weatherDataService is IDisposable disposable)
|
if (_weatherDataService is IDisposable disposable)
|
||||||
@@ -380,7 +389,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
|||||||
snapshot.WeatherLongitude,
|
snapshot.WeatherLongitude,
|
||||||
snapshot.WeatherAutoRefreshLocation,
|
snapshot.WeatherAutoRefreshLocation,
|
||||||
snapshot.WeatherExcludedAlerts,
|
snapshot.WeatherExcludedAlerts,
|
||||||
snapshot.WeatherIconPackId,
|
NormalizeIconPackId(snapshot.WeatherIconPackId),
|
||||||
snapshot.WeatherNoTlsRequests,
|
snapshot.WeatherNoTlsRequests,
|
||||||
snapshot.WeatherLocationQuery);
|
snapshot.WeatherLocationQuery);
|
||||||
}
|
}
|
||||||
@@ -395,7 +404,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
|||||||
snapshot.WeatherLongitude = state.Longitude;
|
snapshot.WeatherLongitude = state.Longitude;
|
||||||
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
|
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
|
||||||
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
|
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
|
||||||
snapshot.WeatherIconPackId = state.IconPackId;
|
snapshot.WeatherIconPackId = NormalizeIconPackId(state.IconPackId);
|
||||||
snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
|
snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
|
||||||
snapshot.WeatherLocationQuery = state.LocationQuery;
|
snapshot.WeatherLocationQuery = state.LocationQuery;
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
@@ -416,6 +425,23 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||||
|
string keyword,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _weatherProvider.SearchLocationsAsync(keyword, locale, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _weatherProvider.ResolveLocationAsync(latitude, longitude, locale, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public IWeatherInfoService GetWeatherInfoService()
|
public IWeatherInfoService GetWeatherInfoService()
|
||||||
{
|
{
|
||||||
return _weatherProvider;
|
return _weatherProvider;
|
||||||
@@ -425,6 +451,13 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
|||||||
{
|
{
|
||||||
_weatherProvider.Dispose();
|
_weatherProvider.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeIconPackId(string? iconPackId)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(iconPackId)
|
||||||
|
? "HyperOS3"
|
||||||
|
: "HyperOS3";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class RegionSettingsService : IRegionSettingsService
|
internal sealed class RegionSettingsService : IRegionSettingsService
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Reflection;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views.SettingsPages;
|
using LanMountainDesktop.Views.SettingsPages;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -177,6 +178,8 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
services.AddSingleton(_settingsFacade.Catalog);
|
services.AddSingleton(_settingsFacade.Catalog);
|
||||||
services.AddSingleton(_hostApplicationLifecycle);
|
services.AddSingleton(_hostApplicationLifecycle);
|
||||||
services.AddSingleton(_localizationService);
|
services.AddSingleton(_localizationService);
|
||||||
|
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||||
|
services.AddSingleton<WeatherLocationRefreshService>();
|
||||||
|
|
||||||
var pluginRuntime = _pluginRuntimeAccessor();
|
var pluginRuntime = _pluginRuntimeAccessor();
|
||||||
if (pluginRuntime is not null)
|
if (pluginRuntime is not null)
|
||||||
|
|||||||
158
LanMountainDesktop/Services/WeatherLocationRefreshService.cs
Normal file
158
LanMountainDesktop/Services/WeatherLocationRefreshService.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record WeatherLocationRefreshResult(
|
||||||
|
bool Success,
|
||||||
|
bool IsSupported,
|
||||||
|
WeatherSettingsState? AppliedState = null,
|
||||||
|
WeatherLocation? ResolvedLocation = null,
|
||||||
|
LocationRequestResult? LocationResult = null,
|
||||||
|
string? ErrorMessage = null)
|
||||||
|
{
|
||||||
|
public static WeatherLocationRefreshResult Unsupported(LocationRequestResult result)
|
||||||
|
=> new(false, false, null, null, result, result.ErrorMessage);
|
||||||
|
|
||||||
|
public static WeatherLocationRefreshResult Fail(LocationRequestResult? locationResult, string? errorMessage)
|
||||||
|
=> new(false, locationResult?.IsSupported ?? true, null, null, locationResult, errorMessage);
|
||||||
|
|
||||||
|
public static WeatherLocationRefreshResult Ok(
|
||||||
|
WeatherSettingsState state,
|
||||||
|
WeatherLocation? resolvedLocation,
|
||||||
|
LocationRequestResult locationResult)
|
||||||
|
=> new(true, true, state, resolvedLocation, locationResult, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WeatherLocationRefreshService
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly ILocationService _locationService;
|
||||||
|
private readonly LocalizationService _localizationService;
|
||||||
|
|
||||||
|
public WeatherLocationRefreshService(
|
||||||
|
ISettingsFacadeService settingsFacade,
|
||||||
|
ILocationService locationService,
|
||||||
|
LocalizationService localizationService)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
|
||||||
|
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSupported => _locationService.IsSupported;
|
||||||
|
|
||||||
|
public async Task<WeatherLocationRefreshResult> RefreshCurrentLocationAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var locationResult = await _locationService.TryGetCurrentLocationAsync(cancellationToken);
|
||||||
|
if (!locationResult.IsSupported)
|
||||||
|
{
|
||||||
|
return WeatherLocationRefreshResult.Unsupported(locationResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationResult.Success || locationResult.Coordinate is null)
|
||||||
|
{
|
||||||
|
return WeatherLocationRefreshResult.Fail(locationResult, locationResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var coordinate = locationResult.Coordinate.Value;
|
||||||
|
var settingsState = _settingsFacade.Weather.Get();
|
||||||
|
var languageCode = _settingsFacade.Region.Get().LanguageCode;
|
||||||
|
var locale = NormalizeWeatherLocale(languageCode);
|
||||||
|
|
||||||
|
WeatherLocation? resolvedLocation = null;
|
||||||
|
var weatherService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||||
|
var resolvedResult = await weatherService.ResolveLocationAsync(
|
||||||
|
coordinate.Latitude,
|
||||||
|
coordinate.Longitude,
|
||||||
|
locale,
|
||||||
|
cancellationToken);
|
||||||
|
if (resolvedResult.Success && resolvedResult.Data is not null)
|
||||||
|
{
|
||||||
|
resolvedLocation = resolvedResult.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationKey = resolvedLocation?.LocationKey?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(locationKey))
|
||||||
|
{
|
||||||
|
locationKey = BuildCoordinateKey(coordinate.Latitude, coordinate.Longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationName = resolvedLocation?.Name?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(locationName))
|
||||||
|
{
|
||||||
|
locationName = BuildCoordinateDisplayName(languageCode, coordinate.Latitude, coordinate.Longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextState = settingsState with
|
||||||
|
{
|
||||||
|
LocationMode = "Coordinates",
|
||||||
|
LocationKey = locationKey,
|
||||||
|
LocationName = locationName,
|
||||||
|
Latitude = Math.Round(coordinate.Latitude, 6),
|
||||||
|
Longitude = Math.Round(coordinate.Longitude, 6),
|
||||||
|
LocationQuery = resolvedLocation?.Name?.Trim() ?? settingsState.LocationQuery,
|
||||||
|
IconPackId = NormalizeIconPackId(settingsState.IconPackId)
|
||||||
|
};
|
||||||
|
|
||||||
|
_settingsFacade.Weather.Save(nextState);
|
||||||
|
return WeatherLocationRefreshResult.Ok(nextState, resolvedLocation, locationResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryRefreshOnStartupAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var state = _settingsFacade.Weather.Get();
|
||||||
|
var isCoordinatesMode = string.Equals(state.LocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!isCoordinatesMode || !state.AutoRefreshLocation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await RefreshCurrentLocationAsync(cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"Weather.Location",
|
||||||
|
$"Automatic weather location refresh failed. Reason='{result.LocationResult?.FailureReason}'. Message='{result.ErrorMessage ?? "<none>"}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeIconPackId(string? iconPackId)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(iconPackId)
|
||||||
|
? "HyperOS3"
|
||||||
|
: "HyperOS3";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCoordinateDisplayName(string? languageCode, double latitude, double longitude)
|
||||||
|
{
|
||||||
|
var normalizedLanguage = _localizationService.NormalizeLanguageCode(languageCode);
|
||||||
|
var format = _localizationService.GetString(
|
||||||
|
normalizedLanguage,
|
||||||
|
"settings.weather.coordinates_default_name_format",
|
||||||
|
"Coordinate {0:F4}, {1:F4}");
|
||||||
|
return string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
format,
|
||||||
|
latitude,
|
||||||
|
longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCoordinateKey(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
return FormattableString.Invariant($"coord:{latitude:F4},{longitude:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeWeatherLocale(string? languageCode)
|
||||||
|
{
|
||||||
|
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "en_us"
|
||||||
|
: "zh_cn";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public sealed record XiaomiWeatherApiOptions
|
|||||||
|
|
||||||
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
|
public string CitySearchPath { get; init; } = "/wtr-v3/location/city/search";
|
||||||
|
|
||||||
|
public string CityGeoPath { get; init; } = "/wtr-v3/location/city/geo";
|
||||||
|
|
||||||
public string AppKey { get; init; } = "weather20151024";
|
public string AppKey { get; init; } = "weather20151024";
|
||||||
|
|
||||||
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
|
||||||
@@ -173,6 +175,63 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||||
|
double latitude,
|
||||||
|
double longitude,
|
||||||
|
string? locale = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim();
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["longitude"] = longitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||||
|
["latitude"] = latitude.ToString("F6", CultureInfo.InvariantCulture),
|
||||||
|
["locale"] = normalizedLocale
|
||||||
|
};
|
||||||
|
|
||||||
|
var requestUri = BuildUri(_options.CityGeoPath, parameters);
|
||||||
|
string responseText;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||||
|
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherLocation>.Fail(
|
||||||
|
"http_error",
|
||||||
|
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherLocation>.Fail("network_error", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (TryGetProperty(root, out var dataNode, "data"))
|
||||||
|
{
|
||||||
|
root = dataNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = ParseSingleLocation(root, latitude, longitude);
|
||||||
|
return location is null
|
||||||
|
? WeatherQueryResult<WeatherLocation>.Fail("not_found", "No weather location could be resolved from the provided coordinates.")
|
||||||
|
: WeatherQueryResult<WeatherLocation>.Ok(location);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return WeatherQueryResult<WeatherLocation>.Fail("parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
public async Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
|
||||||
WeatherQuery query,
|
WeatherQuery query,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -285,6 +344,44 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WeatherLocation? ParseSingleLocation(JsonElement root, double latitude, double longitude)
|
||||||
|
{
|
||||||
|
if (TryResolveLocationArray(root, out var locationArray))
|
||||||
|
{
|
||||||
|
foreach (var item in locationArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
var location = ParseLocationItem(item);
|
||||||
|
if (location is not null)
|
||||||
|
{
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseLocationItem(root, latitude, longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeatherLocation? ParseLocationItem(JsonElement item, double? fallbackLatitude = null, double? fallbackLongitude = null)
|
||||||
|
{
|
||||||
|
var locationKey = ReadString(item, "locationKey") ??
|
||||||
|
ReadString(item, "key") ??
|
||||||
|
ReadString(item, "id");
|
||||||
|
if (string.IsNullOrWhiteSpace(locationKey))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = ReadString(item, "name") ??
|
||||||
|
ReadString(item, "city") ??
|
||||||
|
locationKey;
|
||||||
|
var affiliation = ReadString(item, "affiliation") ?? ReadString(item, "province");
|
||||||
|
var latitude = ReadDouble(item, "latitude") ?? fallbackLatitude ?? 0;
|
||||||
|
var longitude = ReadDouble(item, "longitude") ?? fallbackLongitude ?? 0;
|
||||||
|
return new WeatherLocation(name, locationKey, latitude, longitude, affiliation);
|
||||||
|
}
|
||||||
|
|
||||||
private WeatherSnapshot ParseWeatherSnapshot(
|
private WeatherSnapshot ParseWeatherSnapshot(
|
||||||
JsonElement root,
|
JsonElement root,
|
||||||
string locationKey,
|
string locationKey,
|
||||||
|
|||||||
175
LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml
Normal file
175
LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Window.component-editor-window">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window PathIcon">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="18" />
|
||||||
|
<Setter Property="Padding" Value="16,14,12,14" />
|
||||||
|
<Setter Property="MinHeight" Value="56" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldHoverBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select:focus">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldFocusBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select /template/ ToggleButton">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="18" />
|
||||||
|
<Setter Property="Padding" Value="16,14,12,14" />
|
||||||
|
<Setter Property="MinHeight" Value="56" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select:pointerover /template/ ToggleButton">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldHoverBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.component-editor-select:focus /template/ ToggleButton">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldFocusBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBoxItem.component-editor-select-item">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Padding" Value="16,12" />
|
||||||
|
<Setter Property="Margin" Value="6,4" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBoxItem.component-editor-select-item:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBoxItem.component-editor-select-item:selected">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemSelectedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window RadioButton">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window.component-editor-window ToggleSwitch">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.component-editor-segmented-host">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="20" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.component-editor-segmented-choice">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="16" />
|
||||||
|
<Setter Property="Padding" Value="18,12" />
|
||||||
|
<Setter Property="MinHeight" Value="48" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.component-editor-segmented-choice:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorSelectMenuItemHoverBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.component-editor-segmented-choice:checked">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.component-editor-segmented-choice:checked:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.component-editor-hero-card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="28" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.component-editor-card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ComponentEditorCardBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="24" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.component-editor-headline">
|
||||||
|
<Setter Property="FontSize" Value="22" />
|
||||||
|
<Setter Property="FontWeight" Value="Normal" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.component-editor-section-title">
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.component-editor-secondary-text">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.component-editor-titlebar-button">
|
||||||
|
<Setter Property="Width" Value="36" />
|
||||||
|
<Setter Property="Height" Value="36" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.component-editor-titlebar-button:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorTitleBarButtonHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.component-editor-footer-button">
|
||||||
|
<Setter Property="MinWidth" Value="96" />
|
||||||
|
<Setter Property="Padding" Value="16,10" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ScrollViewer.component-editor-scroll-host">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<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:ui="using:FluentAvalonia.UI.Controls">
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
||||||
|
|
||||||
<Style Selector="StackPanel.settings-page-container">
|
<Style Selector="StackPanel.settings-page-container">
|
||||||
<Setter Property="Spacing" Value="0" />
|
<Setter Property="Spacing" Value="0" />
|
||||||
@@ -189,6 +190,41 @@
|
|||||||
<Setter Property="Padding" Value="14,8" />
|
<Setter Property="Padding" Value="14,8" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Margin" Value="0,2,0,0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results ListBoxItem">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Padding" Value="14,12" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,8" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected TextBlock.settings-item-label, ListBox.weather-settings-search-results ListBoxItem:selected TextBlock.settings-item-description">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.weather-settings-search-results ListBoxItem:selected fi|SymbolIcon">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.settings-accent-button">
|
<Style Selector="Button.settings-accent-button">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
|
||||||
|
|||||||
365
LanMountainDesktop/ViewModels/LauncherSettingsPageViewModel.cs
Normal file
365
LanMountainDesktop/ViewModels/LauncherSettingsPageViewModel.cs
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
public enum LauncherHiddenItemKind
|
||||||
|
{
|
||||||
|
Folder,
|
||||||
|
Shortcut
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class LauncherHiddenItemViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Action<LauncherHiddenItemViewModel> _restoreAction;
|
||||||
|
|
||||||
|
public LauncherHiddenItemViewModel(
|
||||||
|
LauncherHiddenItemKind kind,
|
||||||
|
string key,
|
||||||
|
string displayName,
|
||||||
|
string typeLabel,
|
||||||
|
Symbol iconSymbol,
|
||||||
|
string restoreButtonText,
|
||||||
|
Action<LauncherHiddenItemViewModel> restoreAction)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
Key = key;
|
||||||
|
DisplayName = displayName;
|
||||||
|
TypeLabel = typeLabel;
|
||||||
|
IconSymbol = iconSymbol;
|
||||||
|
RestoreButtonText = restoreButtonText;
|
||||||
|
_restoreAction = restoreAction ?? throw new ArgumentNullException(nameof(restoreAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherHiddenItemKind Kind { get; }
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public string TypeLabel { get; }
|
||||||
|
|
||||||
|
public Symbol IconSymbol { get; }
|
||||||
|
|
||||||
|
public string RestoreButtonText { get; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Restore()
|
||||||
|
{
|
||||||
|
_restoreAction(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private readonly string _languageCode;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public LauncherSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
|
|
||||||
|
RefreshLocalizedText();
|
||||||
|
ReloadData();
|
||||||
|
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<LauncherHiddenItemViewModel> HiddenItems { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageTitle = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _launcherHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _launcherSubtitle = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenHint = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenEmptyText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenSummary = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hiddenCountText = "0";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _hasHiddenItems;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isHiddenItemsEmpty = true;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
if (e.Scope != SettingsScope.Launcher)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(ReloadData, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReloadData()
|
||||||
|
{
|
||||||
|
var root = LoadCatalogSafe();
|
||||||
|
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
|
||||||
|
var hiddenItems = BuildHiddenItems(root, snapshot);
|
||||||
|
|
||||||
|
HiddenItems.Clear();
|
||||||
|
foreach (var hiddenItem in hiddenItems)
|
||||||
|
{
|
||||||
|
HiddenItems.Add(hiddenItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
HasHiddenItems = HiddenItems.Count > 0;
|
||||||
|
IsHiddenItemsEmpty = !HasHiddenItems;
|
||||||
|
HiddenCountText = HiddenItems.Count.ToString(CultureInfo.CurrentCulture);
|
||||||
|
HiddenSummary = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.launcher.hidden_summary_format", "{0} hidden items"),
|
||||||
|
HiddenItems.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StartMenuFolderNode LoadCatalogSafe()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _settingsFacade.LauncherCatalog.LoadCatalog() ?? new StartMenuFolderNode(L("launcher.title", "App Launcher"), string.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Launcher.Settings", "Failed to load launcher catalog for settings page.", ex);
|
||||||
|
return new StartMenuFolderNode(L("launcher.title", "App Launcher"), string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<LauncherHiddenItemViewModel> BuildHiddenItems(StartMenuFolderNode root, LauncherSettingsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherHiddenItemViewModel>();
|
||||||
|
var seenFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seenApps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
CollectHiddenItems(root, snapshot, items, seenFolders, seenApps);
|
||||||
|
|
||||||
|
foreach (var key in snapshot.HiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var normalizedKey = NormalizeLauncherHiddenKey(key);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedKey) || !seenFolders.Add(normalizedKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(CreateHiddenItem(
|
||||||
|
LauncherHiddenItemKind.Folder,
|
||||||
|
normalizedKey,
|
||||||
|
BuildLauncherHiddenFallbackDisplayName(normalizedKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in snapshot.HiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var normalizedKey = NormalizeLauncherHiddenKey(key);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedKey) || !seenApps.Add(normalizedKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(CreateHiddenItem(
|
||||||
|
LauncherHiddenItemKind.Shortcut,
|
||||||
|
normalizedKey,
|
||||||
|
BuildLauncherHiddenFallbackDisplayName(normalizedKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
.OrderBy(item => item.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollectHiddenItems(
|
||||||
|
StartMenuFolderNode folder,
|
||||||
|
LauncherSettingsSnapshot snapshot,
|
||||||
|
List<LauncherHiddenItemViewModel> items,
|
||||||
|
HashSet<string> seenFolders,
|
||||||
|
HashSet<string> seenApps)
|
||||||
|
{
|
||||||
|
foreach (var subFolder in folder.Folders)
|
||||||
|
{
|
||||||
|
var folderKey = NormalizeLauncherHiddenKey(subFolder.RelativePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(folderKey) &&
|
||||||
|
snapshot.HiddenLauncherFolderPaths.Contains(folderKey, StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
seenFolders.Add(folderKey))
|
||||||
|
{
|
||||||
|
items.Add(CreateHiddenItem(
|
||||||
|
LauncherHiddenItemKind.Folder,
|
||||||
|
folderKey,
|
||||||
|
subFolder.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectHiddenItems(subFolder, snapshot, items, seenFolders, seenApps);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var app in folder.Apps)
|
||||||
|
{
|
||||||
|
var appKey = NormalizeLauncherHiddenKey(app.RelativePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(appKey) ||
|
||||||
|
!snapshot.HiddenLauncherAppPaths.Contains(appKey, StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
!seenApps.Add(appKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(CreateHiddenItem(
|
||||||
|
LauncherHiddenItemKind.Shortcut,
|
||||||
|
appKey,
|
||||||
|
app.DisplayName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LauncherHiddenItemViewModel CreateHiddenItem(
|
||||||
|
LauncherHiddenItemKind kind,
|
||||||
|
string key,
|
||||||
|
string displayName)
|
||||||
|
{
|
||||||
|
var typeLabel = kind == LauncherHiddenItemKind.Folder
|
||||||
|
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||||
|
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
||||||
|
var iconSymbol = kind == LauncherHiddenItemKind.Folder
|
||||||
|
? Symbol.Folder
|
||||||
|
: Symbol.Apps;
|
||||||
|
|
||||||
|
return new LauncherHiddenItemViewModel(
|
||||||
|
kind,
|
||||||
|
key,
|
||||||
|
displayName,
|
||||||
|
typeLabel,
|
||||||
|
iconSymbol,
|
||||||
|
L("settings.launcher.restore_button", "Unhide"),
|
||||||
|
RestoreHiddenItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreHiddenItem(LauncherHiddenItemViewModel item)
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
|
||||||
|
var normalizedKey = NormalizeLauncherHiddenKey(item.Key);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyCollection<string>? changedKeys = item.Kind switch
|
||||||
|
{
|
||||||
|
LauncherHiddenItemKind.Folder => RemoveKey(snapshot.HiddenLauncherFolderPaths, normalizedKey)
|
||||||
|
? [nameof(LauncherSettingsSnapshot.HiddenLauncherFolderPaths)]
|
||||||
|
: null,
|
||||||
|
LauncherHiddenItemKind.Shortcut => RemoveKey(snapshot.HiddenLauncherAppPaths, normalizedKey)
|
||||||
|
? [nameof(LauncherSettingsSnapshot.HiddenLauncherAppPaths)]
|
||||||
|
: null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changedKeys is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(SettingsScope.Launcher, snapshot, changedKeys: changedKeys);
|
||||||
|
ReloadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLocalizedText()
|
||||||
|
{
|
||||||
|
PageTitle = L("settings.launcher.title", "App Launcher");
|
||||||
|
PageDescription = L("settings.launcher.description", "Manage hidden apps and folders in the App Launcher.");
|
||||||
|
LauncherHeader = L("launcher.title", "App Launcher");
|
||||||
|
LauncherSubtitle = OperatingSystem.IsLinux()
|
||||||
|
? L("launcher.subtitle_linux", "Displays installed apps discovered from Linux desktop entries.")
|
||||||
|
: L("launcher.subtitle", "Displays all apps and folders based on the Windows Start menu structure.");
|
||||||
|
HiddenHeader = L("settings.launcher.hidden_header", "Hidden Items");
|
||||||
|
HiddenDescription = L("settings.launcher.hidden_desc", "Review hidden launcher entries and show them again.");
|
||||||
|
HiddenHint = L("settings.launcher.hidden_hint", "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.");
|
||||||
|
HiddenEmptyText = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CultureInfo ResolveCulture()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CultureInfo.GetCultureInfo(_languageCode);
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException)
|
||||||
|
{
|
||||||
|
return CultureInfo.InvariantCulture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
|
||||||
|
private static string NormalizeLauncherHiddenKey(string? key)
|
||||||
|
=> string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim();
|
||||||
|
|
||||||
|
private static string BuildLauncherHiddenFallbackDisplayName(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = key.Replace('\\', '/');
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(normalized);
|
||||||
|
return string.IsNullOrWhiteSpace(fileName)
|
||||||
|
? key
|
||||||
|
: fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RemoveKey(ICollection<string> values, string key)
|
||||||
|
{
|
||||||
|
var existing = values.FirstOrDefault(value => string.Equals(value, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Remove(existing);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
678
LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs
Normal file
678
LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly LocalizationService _localizationService;
|
||||||
|
private readonly ILocationService _locationService;
|
||||||
|
private readonly WeatherLocationRefreshService _weatherLocationRefreshService;
|
||||||
|
private string _languageCode;
|
||||||
|
private bool _isInitializing;
|
||||||
|
|
||||||
|
public WeatherSettingsPageViewModel(
|
||||||
|
ISettingsFacadeService settingsFacade,
|
||||||
|
LocalizationService localizationService,
|
||||||
|
ILocationService locationService,
|
||||||
|
WeatherLocationRefreshService weatherLocationRefreshService)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
|
||||||
|
_locationService = locationService ?? throw new ArgumentNullException(nameof(locationService));
|
||||||
|
_weatherLocationRefreshService = weatherLocationRefreshService ?? throw new ArgumentNullException(nameof(weatherLocationRefreshService));
|
||||||
|
|
||||||
|
var regionState = _settingsFacade.Region.Get();
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(regionState.LanguageCode);
|
||||||
|
|
||||||
|
RefreshLocalizedText();
|
||||||
|
LocationModes = CreateLocationModes();
|
||||||
|
|
||||||
|
var weatherState = _settingsFacade.Weather.Get();
|
||||||
|
SearchKeyword = weatherState.LocationQuery;
|
||||||
|
SelectedLocationMode = LocationModes.FirstOrDefault(option =>
|
||||||
|
string.Equals(option.Value, weatherState.LocationMode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? LocationModes[0];
|
||||||
|
|
||||||
|
_isInitializing = true;
|
||||||
|
Latitude = weatherState.Latitude;
|
||||||
|
Longitude = weatherState.Longitude;
|
||||||
|
LocationKey = weatherState.LocationKey;
|
||||||
|
LocationName = weatherState.LocationName;
|
||||||
|
AutoRefreshLocation = weatherState.AutoRefreshLocation;
|
||||||
|
ExcludedAlerts = weatherState.ExcludedAlerts;
|
||||||
|
NoTlsRequests = weatherState.NoTlsRequests;
|
||||||
|
_isInitializing = false;
|
||||||
|
|
||||||
|
IsLocationSupported = _locationService.IsSupported;
|
||||||
|
UpdateModeVisibility();
|
||||||
|
UpdateCurrentLocationSummary();
|
||||||
|
LocationActionStatus = IsLocationSupported
|
||||||
|
? LocationReadyText
|
||||||
|
: LocationUnsupportedText;
|
||||||
|
|
||||||
|
_ = RefreshPreviewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SelectionOption> LocationModes { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<WeatherLocation> SearchResults { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageTitle = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationSourceHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationSourceDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _citySearchHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _citySearchDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _coordinatesHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _coordinatesDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationServicesHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationServicesDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _alertFilterHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _alertFilterDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _requestHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _requestDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchPlaceholder = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _applyCityButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _refreshButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _applyCoordinatesButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _useCurrentLocationButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _autoRefreshLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _latitudeLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _longitudeLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationKeyPlaceholder = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationNamePlaceholder = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _noTlsToggleText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationUnsupportedText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationReadyText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationRefreshingText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _footerHint = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private SelectionOption _selectedLocationMode = new("CitySearch", "City Search");
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isCitySearchMode = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isCoordinatesMode;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isLocationSupported;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchKeyword = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private WeatherLocation? _selectedSearchResult;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchStatus = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isSearching;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _latitude;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _longitude;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationKey = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationName = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _autoRefreshLocation;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _excludedAlerts = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _noTlsRequests;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _currentLocationSummary = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _locationActionStatus = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isRefreshingLocation;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isRefreshingPreview;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private IImage? _previewIcon;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewLocation = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewTemperature = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewCondition = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewUpdated = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewStatus = string.Empty;
|
||||||
|
|
||||||
|
partial void OnSelectedLocationModeChanged(SelectionOption value)
|
||||||
|
{
|
||||||
|
UpdateModeVisibility();
|
||||||
|
UpdateCurrentLocationSummary();
|
||||||
|
if (_isInitializing || value is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Weather.Save(CreateEditableState(value.Value));
|
||||||
|
_ = RefreshPreviewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnAutoRefreshLocationChanged(bool value)
|
||||||
|
{
|
||||||
|
_ = value;
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Weather.Save(CreateEditableState());
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnExcludedAlertsChanged(string value)
|
||||||
|
{
|
||||||
|
_ = value;
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Weather.Save(CreateEditableState());
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnNoTlsRequestsChanged(bool value)
|
||||||
|
{
|
||||||
|
_ = value;
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Weather.Save(CreateEditableState());
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SearchAsync()
|
||||||
|
{
|
||||||
|
SearchStatus = string.Empty;
|
||||||
|
SearchResults.Clear();
|
||||||
|
SelectedSearchResult = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(SearchKeyword))
|
||||||
|
{
|
||||||
|
SearchStatus = L("settings.weather.search_required", "Please enter a city keyword first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsSearching = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _settingsFacade.Weather.SearchLocationsAsync(
|
||||||
|
SearchKeyword.Trim(),
|
||||||
|
NormalizeWeatherLocale(_languageCode));
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
SearchStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.search_failed_format", "Search failed: {0}"),
|
||||||
|
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in result.Data ?? [])
|
||||||
|
{
|
||||||
|
SearchResults.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchStatus = SearchResults.Count == 0
|
||||||
|
? L("settings.weather.search_no_results", "No locations were found.")
|
||||||
|
: string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.search_result_count_format", "Found {0} locations."),
|
||||||
|
SearchResults.Count);
|
||||||
|
|
||||||
|
SelectedSearchResult = SearchResults.FirstOrDefault();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ApplyCitySelectionAsync()
|
||||||
|
{
|
||||||
|
if (SelectedSearchResult is null)
|
||||||
|
{
|
||||||
|
SearchStatus = L("settings.weather.search_select_required", "Please select one location from search results.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = SelectedSearchResult;
|
||||||
|
var nextState = new WeatherSettingsState(
|
||||||
|
"CitySearch",
|
||||||
|
selected.LocationKey,
|
||||||
|
selected.Name,
|
||||||
|
selected.Latitude,
|
||||||
|
selected.Longitude,
|
||||||
|
AutoRefreshLocation,
|
||||||
|
ExcludedAlerts ?? string.Empty,
|
||||||
|
"HyperOS3",
|
||||||
|
NoTlsRequests,
|
||||||
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
|
|
||||||
|
ApplySavedState(nextState);
|
||||||
|
SearchStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.search_applied_format", "Location applied: {0}"),
|
||||||
|
selected.Name);
|
||||||
|
await RefreshPreviewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ApplyCoordinatesAsync()
|
||||||
|
{
|
||||||
|
var nextState = CreateEditableState("Coordinates");
|
||||||
|
_settingsFacade.Weather.Save(nextState);
|
||||||
|
ApplySavedState(nextState, save: false);
|
||||||
|
SearchStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.coordinates_saved_format", "Coordinates saved: {0:F4}, {1:F4}"),
|
||||||
|
nextState.Latitude,
|
||||||
|
nextState.Longitude);
|
||||||
|
await RefreshPreviewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task UseCurrentLocationAsync()
|
||||||
|
{
|
||||||
|
if (!IsLocationSupported)
|
||||||
|
{
|
||||||
|
LocationActionStatus = LocationUnsupportedText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsRefreshingLocation = true;
|
||||||
|
LocationActionStatus = LocationRefreshingText;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _weatherLocationRefreshService.RefreshCurrentLocationAsync();
|
||||||
|
if (!result.Success || result.AppliedState is null)
|
||||||
|
{
|
||||||
|
LocationActionStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.location_refresh_failed_format", "Failed to get current location: {0}"),
|
||||||
|
result.ErrorMessage ?? result.LocationResult?.FailureReason.ToString() ?? L("settings.weather.preview_unknown", "Unknown"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySavedState(result.AppliedState, save: false);
|
||||||
|
LocationActionStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.location_refresh_success_format", "Current location applied: {0}"),
|
||||||
|
result.AppliedState.LocationName);
|
||||||
|
await RefreshPreviewAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsRefreshingLocation = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshPreviewAsync()
|
||||||
|
{
|
||||||
|
IsRefreshingPreview = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = ResolvePreviewState();
|
||||||
|
if (string.IsNullOrWhiteSpace(state.LocationKey))
|
||||||
|
{
|
||||||
|
PreviewStatus = L("settings.weather.preview_missing_location", "Please apply one weather location before testing.");
|
||||||
|
PreviewIcon = null;
|
||||||
|
PreviewLocation = CurrentLocationSummary;
|
||||||
|
PreviewTemperature = "--";
|
||||||
|
PreviewCondition = string.Empty;
|
||||||
|
PreviewUpdated = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _settingsFacade.Weather.GetWeatherInfoService().GetWeatherAsync(
|
||||||
|
new WeatherQuery(
|
||||||
|
state.LocationKey,
|
||||||
|
state.Latitude,
|
||||||
|
state.Longitude,
|
||||||
|
ForecastDays: 3,
|
||||||
|
Locale: NormalizeWeatherLocale(_languageCode),
|
||||||
|
ForceRefresh: true));
|
||||||
|
if (!result.Success || result.Data is null)
|
||||||
|
{
|
||||||
|
PreviewStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.preview_failed_format", "Test fetch failed: {0}"),
|
||||||
|
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
|
||||||
|
PreviewIcon = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = result.Data;
|
||||||
|
var isNight = snapshot.Current.IsDaylight.HasValue
|
||||||
|
? !snapshot.Current.IsDaylight.Value
|
||||||
|
: _settingsFacade.Theme.Get().IsNightMode;
|
||||||
|
var visualKind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight);
|
||||||
|
PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(visualKind));
|
||||||
|
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
||||||
|
? state.LocationName
|
||||||
|
: snapshot.LocationName!;
|
||||||
|
PreviewTemperature = snapshot.Current.TemperatureC.HasValue
|
||||||
|
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value)
|
||||||
|
: "--";
|
||||||
|
PreviewCondition = snapshot.Current.WeatherText ?? L("settings.weather.preview_unknown", "Unknown");
|
||||||
|
|
||||||
|
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
|
||||||
|
PreviewUpdated = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.preview_updated_format", "Updated {0}"),
|
||||||
|
updatedAt.ToString("g", ResolveCulture()));
|
||||||
|
PreviewStatus = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.preview_success_format", "Test success: {0} · {1} · {2}"),
|
||||||
|
PreviewLocation,
|
||||||
|
PreviewTemperature,
|
||||||
|
PreviewCondition);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsRefreshingPreview = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLocalizedText()
|
||||||
|
{
|
||||||
|
PageTitle = L("settings.weather.title", "Weather");
|
||||||
|
PageDescription = L("settings.weather.description", "Configure weather location, automatic positioning, and Xiaomi weather preview.");
|
||||||
|
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||||
|
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
||||||
|
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
||||||
|
LocationSourceDescription = L("settings.weather.location_source_desc", "Choose how weather widgets resolve location.");
|
||||||
|
CitySearchHeader = L("settings.weather.city_search_header", "City Search");
|
||||||
|
CitySearchDescription = L("settings.weather.city_search_desc", "Search cities and apply one weather location.");
|
||||||
|
CoordinatesHeader = L("settings.weather.coordinates_header", "Coordinates");
|
||||||
|
CoordinatesDescription = L("settings.weather.coordinates_desc", "Set latitude/longitude and optional key/name.");
|
||||||
|
LocationServicesHeader = L("settings.weather.location_services_header", "Location Service");
|
||||||
|
LocationServicesDescription = L("settings.weather.location_services_desc", "Use the current Windows location and decide whether it refreshes automatically at startup.");
|
||||||
|
AlertFilterHeader = L("settings.weather.alert_filter_header", "Excluded Alerts");
|
||||||
|
AlertFilterDescription = L("settings.weather.alert_filter_desc", "Alerts containing these words will not be shown. One rule per line.");
|
||||||
|
RequestHeader = L("settings.weather.no_tls_header", "No TLS Weather Request");
|
||||||
|
RequestDescription = L("settings.weather.no_tls_desc", "Not recommended. Enable only for incompatible network environments.");
|
||||||
|
SearchPlaceholder = L("settings.weather.search_placeholder", "e.g. Beijing");
|
||||||
|
SearchButtonText = L("settings.weather.search_button", "Search");
|
||||||
|
ApplyCityButtonText = L("settings.weather.apply_city_button", "Apply City");
|
||||||
|
RefreshButtonText = L("settings.weather.refresh_button", "Refresh");
|
||||||
|
ApplyCoordinatesButtonText = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
|
||||||
|
UseCurrentLocationButtonText = L("settings.weather.use_current_location", "Use Current Location");
|
||||||
|
AutoRefreshLabel = L("settings.weather.auto_refresh", "Auto refresh location on startup");
|
||||||
|
LatitudeLabel = L("settings.weather.latitude_label", "Latitude");
|
||||||
|
LongitudeLabel = L("settings.weather.longitude_label", "Longitude");
|
||||||
|
LocationKeyPlaceholder = L("settings.weather.location_key_placeholder", "Location key (optional)");
|
||||||
|
LocationNamePlaceholder = L("settings.weather.location_name_placeholder", "Display name (optional)");
|
||||||
|
NoTlsToggleText = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
|
||||||
|
LocationUnsupportedText = L("settings.weather.location_unsupported", "Current platform does not support retrieving the current location.");
|
||||||
|
LocationReadyText = L("settings.weather.location_ready", "You can use the current Windows location.");
|
||||||
|
LocationRefreshingText = L("settings.weather.location_refreshing", "Requesting current location…");
|
||||||
|
FooterHint = L("settings.weather.footer_hint", "Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<SelectionOption> CreateLocationModes()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new SelectionOption("CitySearch", L("settings.weather.mode_city_search", "City Search")),
|
||||||
|
new SelectionOption("Coordinates", L("settings.weather.mode_coordinates", "Coordinates"))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateModeVisibility()
|
||||||
|
{
|
||||||
|
var mode = SelectedLocationMode?.Value ?? "CitySearch";
|
||||||
|
IsCitySearchMode = string.Equals(mode, "CitySearch", StringComparison.OrdinalIgnoreCase);
|
||||||
|
IsCoordinatesMode = string.Equals(mode, "Coordinates", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCurrentLocationSummary()
|
||||||
|
{
|
||||||
|
var state = CreateEditableState();
|
||||||
|
var modeLabel = SelectedLocationMode?.Label ?? state.LocationMode;
|
||||||
|
if (string.Equals(state.LocationMode, "CitySearch", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CurrentLocationSummary = string.IsNullOrWhiteSpace(state.LocationKey)
|
||||||
|
? L("settings.weather.status_city_empty", "No city location is configured.")
|
||||||
|
: string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.status_city_format", "Mode: {0} | {1} | Key: {2}"),
|
||||||
|
modeLabel,
|
||||||
|
string.IsNullOrWhiteSpace(state.LocationName) ? L("settings.weather.location_not_selected", "No location selected") : state.LocationName,
|
||||||
|
state.LocationKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentLocationSummary = string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.status_coordinates_format", "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}"),
|
||||||
|
modeLabel,
|
||||||
|
state.Latitude,
|
||||||
|
state.Longitude,
|
||||||
|
state.LocationKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeatherSettingsState CreateEditableState(string? locationMode = null)
|
||||||
|
{
|
||||||
|
var mode = locationMode ?? SelectedLocationMode?.Value ?? "CitySearch";
|
||||||
|
var locationKey = LocationKey?.Trim() ?? string.Empty;
|
||||||
|
var locationName = LocationName?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.Equals(mode, "Coordinates", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(locationKey))
|
||||||
|
{
|
||||||
|
locationKey = BuildCoordinateKey(Latitude, Longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(locationName))
|
||||||
|
{
|
||||||
|
locationName = BuildCoordinateDisplayName(Latitude, Longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeatherSettingsState(
|
||||||
|
mode,
|
||||||
|
locationKey,
|
||||||
|
locationName,
|
||||||
|
Latitude,
|
||||||
|
Longitude,
|
||||||
|
AutoRefreshLocation,
|
||||||
|
ExcludedAlerts ?? string.Empty,
|
||||||
|
"HyperOS3",
|
||||||
|
NoTlsRequests,
|
||||||
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeatherSettingsState ResolvePreviewState()
|
||||||
|
{
|
||||||
|
if (IsCitySearchMode && SelectedSearchResult is not null)
|
||||||
|
{
|
||||||
|
return new WeatherSettingsState(
|
||||||
|
"CitySearch",
|
||||||
|
SelectedSearchResult.LocationKey,
|
||||||
|
SelectedSearchResult.Name,
|
||||||
|
SelectedSearchResult.Latitude,
|
||||||
|
SelectedSearchResult.Longitude,
|
||||||
|
AutoRefreshLocation,
|
||||||
|
ExcludedAlerts ?? string.Empty,
|
||||||
|
"HyperOS3",
|
||||||
|
NoTlsRequests,
|
||||||
|
SearchKeyword?.Trim() ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateEditableState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySavedState(WeatherSettingsState state, bool save = true)
|
||||||
|
{
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
_settingsFacade.Weather.Save(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitializing = true;
|
||||||
|
SelectedLocationMode = LocationModes.FirstOrDefault(option =>
|
||||||
|
string.Equals(option.Value, state.LocationMode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? LocationModes[0];
|
||||||
|
Latitude = state.Latitude;
|
||||||
|
Longitude = state.Longitude;
|
||||||
|
LocationKey = state.LocationKey;
|
||||||
|
LocationName = state.LocationName;
|
||||||
|
AutoRefreshLocation = state.AutoRefreshLocation;
|
||||||
|
ExcludedAlerts = state.ExcludedAlerts;
|
||||||
|
NoTlsRequests = state.NoTlsRequests;
|
||||||
|
SearchKeyword = state.LocationQuery;
|
||||||
|
_isInitializing = false;
|
||||||
|
|
||||||
|
UpdateModeVisibility();
|
||||||
|
UpdateCurrentLocationSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCoordinateDisplayName(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
return string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
L("settings.weather.coordinates_default_name_format", "Coordinate {0:F4}, {1:F4}"),
|
||||||
|
latitude,
|
||||||
|
longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCoordinateKey(double latitude, double longitude)
|
||||||
|
{
|
||||||
|
return FormattableString.Invariant($"coord:{latitude:F4},{longitude:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CultureInfo ResolveCulture()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CultureInfo.GetCultureInfo(_languageCode);
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException)
|
||||||
|
{
|
||||||
|
return CultureInfo.InvariantCulture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeWeatherLocale(string? languageCode)
|
||||||
|
{
|
||||||
|
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "en_us"
|
||||||
|
: "zh_cn";
|
||||||
|
}
|
||||||
|
}
|
||||||
135
LanMountainDesktop/Views/ComponentEditorWindow.axaml
Normal file
135
LanMountainDesktop/Views/ComponentEditorWindow.axaml
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<Window 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:fa="clr-namespace:FluentIcons.Avalonia.Fluent;assembly=FluentIcons.Avalonia.Fluent"
|
||||||
|
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
|
xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditorWindow"
|
||||||
|
x:Name="RootWindow"
|
||||||
|
Classes="component-editor-window"
|
||||||
|
Width="720"
|
||||||
|
Height="540"
|
||||||
|
MinWidth="420"
|
||||||
|
MinHeight="320"
|
||||||
|
CanResize="True"
|
||||||
|
SizeToContent="Manual"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
SystemDecorations="BorderOnly"
|
||||||
|
Background="{DynamicResource EditorWindowBackgroundBrush}"
|
||||||
|
Title="Component Editor">
|
||||||
|
<Window.Resources>
|
||||||
|
<!-- Material Design 3 Brushes -->
|
||||||
|
<SolidColorBrush x:Key="EditorWindowBackgroundBrush" Color="#FFFEF7FF" />
|
||||||
|
<SolidColorBrush x:Key="EditorSurfaceBrush" Color="#FFFEF7FF" />
|
||||||
|
<SolidColorBrush x:Key="EditorSurfaceContainerBrush" Color="#FFF3EDF7" />
|
||||||
|
<SolidColorBrush x:Key="EditorSurfaceContainerHighBrush" Color="#FFE6E0E9" />
|
||||||
|
<SolidColorBrush x:Key="EditorTopAppBarBackgroundBrush" Color="#FFF3EDF7" />
|
||||||
|
<SolidColorBrush x:Key="EditorHeaderIconBackgroundBrush" Color="#FFEADDFF" />
|
||||||
|
<SolidColorBrush x:Key="EditorTitleBarButtonHoverBrush" Color="#121D1B20" />
|
||||||
|
<SolidColorBrush x:Key="EditorPrimaryBrush" Color="#FF6750A4" />
|
||||||
|
<SolidColorBrush x:Key="EditorOnPrimaryBrush" Color="#FFFFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="EditorSecondaryBrush" Color="#FF625B71" />
|
||||||
|
<SolidColorBrush x:Key="EditorTertiaryBrush" Color="#FF7D5260" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectFieldBackgroundBrush" Color="#FFE6E0E9" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectFieldHoverBrush" Color="#FFE0DAE4" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectFieldFocusBrush" Color="#FFDDD3E6" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectOutlineBrush" Color="#FF79747E" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectOutlineStrongBrush" Color="#FF6750A4" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectMenuItemHoverBrush" Color="#1F6750A4" />
|
||||||
|
<SolidColorBrush x:Key="EditorSelectMenuItemSelectedBrush" Color="#306750A4" />
|
||||||
|
<SolidColorBrush x:Key="ComponentEditorHeroBackgroundBrush" Color="#FFEADDFF" />
|
||||||
|
<SolidColorBrush x:Key="ComponentEditorCardBackgroundBrush" Color="#FFF3EDF7" />
|
||||||
|
<SolidColorBrush x:Key="ComponentEditorCardBorderBrush" Color="#FFCAC4D0" />
|
||||||
|
<SolidColorBrush x:Key="ComponentEditorPrimaryTextBrush" Color="#FF1D1B20" />
|
||||||
|
<SolidColorBrush x:Key="ComponentEditorSecondaryTextBrush" Color="#FF49454F" />
|
||||||
|
<SolidColorBrush x:Key="EditorDividerBrush" Color="#FFCAC4D0" />
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<themes:CustomMaterialTheme BaseTheme="Light" PrimaryColor="#6750A4" SecondaryColor="#625B71" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml" />
|
||||||
|
|
||||||
|
<!-- MD3 Button Styles -->
|
||||||
|
<Style Selector="Button.component-editor-footer-button">
|
||||||
|
<Setter Property="CornerRadius" Value="20" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<Grid Background="{DynamicResource EditorWindowBackgroundBrush}"
|
||||||
|
RowDefinitions="Auto,*">
|
||||||
|
<Border x:Name="CustomTitleBarHost"
|
||||||
|
Padding="24,16"
|
||||||
|
Background="{DynamicResource EditorWindowBackgroundBrush}"
|
||||||
|
IsVisible="False"
|
||||||
|
PointerPressed="OnWindowTitleBarPointerPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="16">
|
||||||
|
<mi:MaterialIcon x:Name="HeaderIcon"
|
||||||
|
Width="28"
|
||||||
|
Height="28"
|
||||||
|
Foreground="{DynamicResource EditorPrimaryBrush}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="component-editor-titlebar-button"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Width="40" Height="40"
|
||||||
|
Padding="8"
|
||||||
|
Click="OnCloseClick">
|
||||||
|
<mi:MaterialIcon Kind="Close"
|
||||||
|
Width="24" Height="24" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Panel Grid.Row="1">
|
||||||
|
<ScrollViewer Classes="component-editor-scroll-host"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ContentControl x:Name="EditorContentHost"
|
||||||
|
Margin="24,0,24,100"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
VerticalContentAlignment="Stretch" />
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Floating Save Button (MD3 Style) -->
|
||||||
|
<Button x:Name="SaveFAB"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="28"
|
||||||
|
Width="64"
|
||||||
|
Height="64"
|
||||||
|
Background="{DynamicResource EditorPrimaryBrush}"
|
||||||
|
Foreground="{DynamicResource EditorOnPrimaryBrush}"
|
||||||
|
CornerRadius="18"
|
||||||
|
Classes="accent"
|
||||||
|
Click="OnCloseClick">
|
||||||
|
<Button.Styles>
|
||||||
|
<Style Selector="Button:pointerover">
|
||||||
|
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
||||||
|
</Style>
|
||||||
|
</Button.Styles>
|
||||||
|
<mi:MaterialIcon Kind="Check"
|
||||||
|
Width="32"
|
||||||
|
Height="32" />
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
256
LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs
Normal file
256
LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
|
using Material.Styles.Themes;
|
||||||
|
using Material.Styles.Themes.Base;
|
||||||
|
|
||||||
|
using Material.Icons;
|
||||||
|
using Material.Icons.Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class ComponentEditorWindow : Window
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Size> _sizeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly CustomMaterialTheme _materialTheme;
|
||||||
|
private DesktopComponentEditorDescriptor? _descriptor;
|
||||||
|
private string? _currentComponentId;
|
||||||
|
private bool _suppressAspectRatioCorrection;
|
||||||
|
private Size _lastStableSize;
|
||||||
|
|
||||||
|
public ComponentEditorWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_materialTheme = Styles.OfType<CustomMaterialTheme>().FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException("Component editor Material theme is missing.");
|
||||||
|
_lastStableSize = new Size(Width, Height);
|
||||||
|
ApplyChromeMode(useSystemChrome: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyDescriptor(
|
||||||
|
DesktopComponentEditorDescriptor descriptor,
|
||||||
|
DesktopComponentEditorContext context)
|
||||||
|
{
|
||||||
|
_descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
|
||||||
|
_currentComponentId = context.ComponentId;
|
||||||
|
|
||||||
|
var editor = descriptor.CreateEditor(context);
|
||||||
|
EditorContentHost.Content = editor;
|
||||||
|
TitleTextBlock.Text = descriptor.Definition.DisplayName;
|
||||||
|
HeaderIcon.Kind = ResolveSymbol(descriptor.Definition.IconKey);
|
||||||
|
Title = descriptor.Definition.DisplayName;
|
||||||
|
|
||||||
|
ApplyPreferredSize(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyChromeMode(bool useSystemChrome)
|
||||||
|
{
|
||||||
|
var preferSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
|
||||||
|
if (preferSystemChrome)
|
||||||
|
{
|
||||||
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||||
|
ExtendClientAreaTitleBarHeightHint = -1;
|
||||||
|
SystemDecorations = SystemDecorations.Full;
|
||||||
|
CustomTitleBarHost.IsVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemDecorations = SystemDecorations.BorderOnly;
|
||||||
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||||
|
ExtendClientAreaTitleBarHeightHint = 52;
|
||||||
|
CustomTitleBarHost.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ApplyTheme(ComponentEditorThemePalette palette)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(palette);
|
||||||
|
|
||||||
|
RequestedThemeVariant = palette.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||||
|
_materialTheme.BaseTheme = palette.IsNightMode ? BaseThemeMode.Dark : BaseThemeMode.Light;
|
||||||
|
_materialTheme.PrimaryColor = palette.PrimaryColor;
|
||||||
|
_materialTheme.SecondaryColor = palette.SecondaryColor;
|
||||||
|
|
||||||
|
SetBrushResource("EditorPrimaryBrush", palette.PrimaryColor);
|
||||||
|
SetBrushResource("EditorOnPrimaryBrush", palette.IsNightMode ? Colors.Black : Colors.White);
|
||||||
|
SetBrushResource("EditorSecondaryBrush", palette.SecondaryColor);
|
||||||
|
SetBrushResource("EditorTertiaryBrush", palette.TertiaryColor);
|
||||||
|
SetBrushResource("EditorWindowBackgroundBrush", palette.WindowBackgroundColor);
|
||||||
|
SetBrushResource("EditorSurfaceBrush", palette.SurfaceColor);
|
||||||
|
SetBrushResource("EditorSurfaceContainerBrush", palette.SurfaceContainerColor);
|
||||||
|
SetBrushResource("EditorSurfaceContainerHighBrush", palette.SurfaceContainerHighColor);
|
||||||
|
SetBrushResource("EditorSelectFieldBackgroundBrush", palette.SurfaceContainerHighColor);
|
||||||
|
SetBrushResource(
|
||||||
|
"EditorSelectFieldHoverBrush",
|
||||||
|
ColorMath.Blend(palette.SurfaceContainerHighColor, palette.PrimaryColor, palette.IsNightMode ? 0.18 : 0.08));
|
||||||
|
SetBrushResource(
|
||||||
|
"EditorSelectFieldFocusBrush",
|
||||||
|
ColorMath.Blend(palette.SurfaceContainerHighColor, palette.PrimaryColor, palette.IsNightMode ? 0.24 : 0.12));
|
||||||
|
SetBrushResource("EditorSelectOutlineBrush", palette.OutlineColor);
|
||||||
|
SetBrushResource(
|
||||||
|
"EditorSelectOutlineStrongBrush",
|
||||||
|
ColorMath.EnsureContrast(palette.PrimaryColor, palette.SurfaceContainerHighColor, 3.0));
|
||||||
|
SetBrushResource(
|
||||||
|
"EditorSelectMenuItemHoverBrush",
|
||||||
|
ColorMath.Blend(palette.SurfaceContainerColor, palette.PrimaryColor, palette.IsNightMode ? 0.20 : 0.10));
|
||||||
|
SetBrushResource(
|
||||||
|
"EditorSelectMenuItemSelectedBrush",
|
||||||
|
ColorMath.Blend(palette.SurfaceContainerColor, palette.PrimaryColor, palette.IsNightMode ? 0.30 : 0.16));
|
||||||
|
SetBrushResource("EditorTopAppBarBackgroundBrush", palette.TopAppBarColor);
|
||||||
|
SetBrushResource("EditorHeaderIconBackgroundBrush", palette.HeaderIconBackgroundColor);
|
||||||
|
SetBrushResource("EditorTitleBarButtonHoverBrush", palette.TitleBarButtonHoverColor);
|
||||||
|
SetBrushResource("ComponentEditorHeroBackgroundBrush", palette.SurfaceContainerHighColor);
|
||||||
|
SetBrushResource("ComponentEditorCardBackgroundBrush", palette.SurfaceContainerColor);
|
||||||
|
SetBrushResource("ComponentEditorCardBorderBrush", palette.OutlineColor);
|
||||||
|
SetBrushResource("ComponentEditorPrimaryTextBrush", palette.OnSurfaceColor);
|
||||||
|
SetBrushResource("ComponentEditorSecondaryTextBrush", palette.OnSurfaceVariantColor);
|
||||||
|
SetBrushResource("EditorDividerBrush", palette.DividerColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnSizeChanged(SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSizeChanged(e);
|
||||||
|
|
||||||
|
if (_descriptor is null || _suppressAspectRatioCorrection)
|
||||||
|
{
|
||||||
|
_lastStableSize = e.NewSize;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var correctedSize = CoerceSize(e.NewSize, e.PreviousSize, _descriptor);
|
||||||
|
if (Math.Abs(correctedSize.Width - e.NewSize.Width) < 0.5 &&
|
||||||
|
Math.Abs(correctedSize.Height - e.NewSize.Height) < 0.5)
|
||||||
|
{
|
||||||
|
_lastStableSize = correctedSize;
|
||||||
|
CacheCurrentSize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressAspectRatioCorrection = true;
|
||||||
|
Width = correctedSize.Width;
|
||||||
|
Height = correctedSize.Height;
|
||||||
|
_suppressAspectRatioCorrection = false;
|
||||||
|
_lastStableSize = correctedSize;
|
||||||
|
CacheCurrentSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetBrushResource(string key, Color color)
|
||||||
|
{
|
||||||
|
Resources[key] = new SolidColorBrush(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPreferredSize(DesktopComponentEditorDescriptor descriptor)
|
||||||
|
{
|
||||||
|
var width = descriptor.PreferredWidth;
|
||||||
|
var height = descriptor.PreferredHeight;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_currentComponentId) &&
|
||||||
|
_sizeCache.TryGetValue(_currentComponentId, out var cached))
|
||||||
|
{
|
||||||
|
width = cached.Width;
|
||||||
|
height = cached.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressAspectRatioCorrection = true;
|
||||||
|
MinWidth = descriptor.PreferredWidth * descriptor.MinScale;
|
||||||
|
MinHeight = descriptor.PreferredHeight * descriptor.MinScale;
|
||||||
|
MaxWidth = descriptor.PreferredWidth * descriptor.MaxScale;
|
||||||
|
MaxHeight = descriptor.PreferredHeight * descriptor.MaxScale;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
_lastStableSize = new Size(width, height);
|
||||||
|
_suppressAspectRatioCorrection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CacheCurrentSize()
|
||||||
|
{
|
||||||
|
if (_descriptor is null || string.IsNullOrWhiteSpace(_currentComponentId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sizeCache[_currentComponentId] = _lastStableSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Size CoerceSize(Size currentSize, Size previousSize, DesktopComponentEditorDescriptor descriptor)
|
||||||
|
{
|
||||||
|
var preferredWidth = descriptor.PreferredWidth;
|
||||||
|
var preferredHeight = descriptor.PreferredHeight;
|
||||||
|
var aspectRatio = descriptor.AspectRatio;
|
||||||
|
var minWidth = preferredWidth * descriptor.MinScale;
|
||||||
|
var maxWidth = preferredWidth * descriptor.MaxScale;
|
||||||
|
var minHeight = preferredHeight * descriptor.MinScale;
|
||||||
|
var maxHeight = preferredHeight * descriptor.MaxScale;
|
||||||
|
|
||||||
|
var deltaWidth = Math.Abs(currentSize.Width - previousSize.Width);
|
||||||
|
var deltaHeight = Math.Abs(currentSize.Height - previousSize.Height);
|
||||||
|
|
||||||
|
double width;
|
||||||
|
double height;
|
||||||
|
if (deltaWidth >= deltaHeight)
|
||||||
|
{
|
||||||
|
width = Math.Clamp(currentSize.Width, minWidth, maxWidth);
|
||||||
|
height = Math.Clamp(width / aspectRatio, minHeight, maxHeight);
|
||||||
|
width = Math.Clamp(height * aspectRatio, minWidth, maxWidth);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
height = Math.Clamp(currentSize.Height, minHeight, maxHeight);
|
||||||
|
width = Math.Clamp(height * aspectRatio, minWidth, maxWidth);
|
||||||
|
height = Math.Clamp(width / aspectRatio, minHeight, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Size(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MaterialIconKind ResolveSymbol(string iconKey)
|
||||||
|
{
|
||||||
|
return iconKey switch
|
||||||
|
{
|
||||||
|
"Clock" => MaterialIconKind.Clock,
|
||||||
|
"Timer" => MaterialIconKind.Timer,
|
||||||
|
"WeatherSunny" => MaterialIconKind.WeatherSunny,
|
||||||
|
"CalendarDate" => MaterialIconKind.CalendarRange,
|
||||||
|
"CalendarMonth" => MaterialIconKind.CalendarMonth,
|
||||||
|
"MicOn" => MaterialIconKind.Microphone,
|
||||||
|
"News" => MaterialIconKind.Newspaper,
|
||||||
|
"Image" => MaterialIconKind.Image,
|
||||||
|
"Book" => MaterialIconKind.BookOpenVariant,
|
||||||
|
"History" => MaterialIconKind.History,
|
||||||
|
"DataLine" => MaterialIconKind.ChartLine,
|
||||||
|
"Edit" => MaterialIconKind.Pencil,
|
||||||
|
"Calculator" => MaterialIconKind.Calculator,
|
||||||
|
"Globe" => MaterialIconKind.Web,
|
||||||
|
"Play" => MaterialIconKind.Play,
|
||||||
|
_ => MaterialIconKind.Settings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ClassScheduleComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<Button x:Name="AddScheduleButton"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Classes="accent"
|
||||||
|
Padding="16,10"
|
||||||
|
Click="OnAddScheduleClick" />
|
||||||
|
<TextBlock x:Name="EmptyStateTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<StackPanel x:Name="ScheduleItemsPanel"
|
||||||
|
Spacing="10" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||||
|
private string _activeScheduleId = string.Empty;
|
||||||
|
|
||||||
|
public ClassScheduleComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassScheduleComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
LoadState();
|
||||||
|
ApplyState();
|
||||||
|
RenderImportedSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
_importedSchedules.Clear();
|
||||||
|
foreach (var item in snapshot.ImportedClassSchedules)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Id) || string.IsNullOrWhiteSpace(item.FilePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_importedSchedules.Add(new ImportedClassScheduleSnapshot
|
||||||
|
{
|
||||||
|
Id = item.Id.Trim(),
|
||||||
|
DisplayName = item.DisplayName?.Trim() ?? string.Empty,
|
||||||
|
FilePath = item.FilePath.Trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
|
||||||
|
if (_importedSchedules.Count > 0 &&
|
||||||
|
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
_activeScheduleId = _importedSchedules[0].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||||
|
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
||||||
|
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
||||||
|
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel?.StorageProvider is not { } storageProvider)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter =
|
||||||
|
[
|
||||||
|
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
|
||||||
|
{
|
||||||
|
Patterns = ["*.cses", "*.yaml", "*.yml"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var importedPath = await ImportScheduleFileAsync(files[0]);
|
||||||
|
if (string.IsNullOrWhiteSpace(importedPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = _importedSchedules.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.FilePath, importedPath, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
_activeScheduleId = existing.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_importedSchedules.Add(new ImportedClassScheduleSnapshot
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
|
||||||
|
?? L("schedule.settings.unnamed", "未命名课表"),
|
||||||
|
FilePath = importedPath
|
||||||
|
});
|
||||||
|
_activeScheduleId = _importedSchedules[^1].Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistState();
|
||||||
|
RenderImportedSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ImportScheduleFileAsync(IStorageFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(file.Name);
|
||||||
|
if (string.IsNullOrWhiteSpace(extension))
|
||||||
|
{
|
||||||
|
extension = ".cses";
|
||||||
|
}
|
||||||
|
|
||||||
|
var importedDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Schedules");
|
||||||
|
Directory.CreateDirectory(importedDirectory);
|
||||||
|
|
||||||
|
var destinationPath = Path.Combine(
|
||||||
|
importedDirectory,
|
||||||
|
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
|
||||||
|
|
||||||
|
await using var sourceStream = await file.OpenReadAsync();
|
||||||
|
await using var destinationStream = File.Create(destinationPath);
|
||||||
|
await sourceStream.CopyToAsync(destinationStream);
|
||||||
|
return destinationPath;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderImportedSchedules()
|
||||||
|
{
|
||||||
|
ScheduleItemsPanel.Children.Clear();
|
||||||
|
EmptyStateTextBlock.IsVisible = _importedSchedules.Count == 0;
|
||||||
|
if (_importedSchedules.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in _importedSchedules)
|
||||||
|
{
|
||||||
|
var selector = new RadioButton
|
||||||
|
{
|
||||||
|
GroupName = "class_schedule_imports",
|
||||||
|
IsChecked = string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Tag = item.Id
|
||||||
|
};
|
||||||
|
selector.IsCheckedChanged += OnScheduleSelectionChanged;
|
||||||
|
|
||||||
|
var title = new TextBlock
|
||||||
|
{
|
||||||
|
Text = string.IsNullOrWhiteSpace(item.DisplayName)
|
||||||
|
? L("schedule.settings.unnamed", "未命名课表")
|
||||||
|
: item.DisplayName,
|
||||||
|
FontWeight = FontWeight.SemiBold
|
||||||
|
};
|
||||||
|
|
||||||
|
var path = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.FilePath,
|
||||||
|
FontSize = 11,
|
||||||
|
Opacity = 0.7,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis
|
||||||
|
};
|
||||||
|
|
||||||
|
var deleteButton = new Button
|
||||||
|
{
|
||||||
|
Content = L("schedule.settings.delete", "删除"),
|
||||||
|
Tag = item.Id,
|
||||||
|
Padding = new Thickness(12, 8),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right
|
||||||
|
};
|
||||||
|
deleteButton.Click += OnDeleteScheduleClick;
|
||||||
|
|
||||||
|
var row = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||||
|
ColumnSpacing = 12
|
||||||
|
};
|
||||||
|
var details = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 4,
|
||||||
|
Children = { title, path }
|
||||||
|
};
|
||||||
|
row.Children.Add(selector);
|
||||||
|
row.Children.Add(details);
|
||||||
|
row.Children.Add(deleteButton);
|
||||||
|
Grid.SetColumn(details, 1);
|
||||||
|
Grid.SetColumn(deleteButton, 2);
|
||||||
|
|
||||||
|
ScheduleItemsPanel.Children.Add(new Border
|
||||||
|
{
|
||||||
|
Padding = new Thickness(12, 10),
|
||||||
|
CornerRadius = new CornerRadius(16),
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = Brushes.Gray,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Child = row
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScheduleSelectionChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = e;
|
||||||
|
if (sender is not RadioButton radioButton || radioButton.IsChecked != true || radioButton.Tag is not string scheduleId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeScheduleId = scheduleId;
|
||||||
|
PersistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = e;
|
||||||
|
if (sender is not Button button || button.Tag is not string scheduleId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = _importedSchedules.RemoveAll(item =>
|
||||||
|
string.Equals(item.Id, scheduleId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (removed == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(_activeScheduleId, scheduleId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_activeScheduleId = _importedSchedules.FirstOrDefault()?.Id ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistState();
|
||||||
|
RenderImportedSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ImportedClassSchedules = _importedSchedules
|
||||||
|
.Select(item => new ImportedClassScheduleSnapshot
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
DisplayName = item.DisplayName,
|
||||||
|
FilePath = item.FilePath
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
snapshot.ActiveImportedClassScheduleId = _activeScheduleId;
|
||||||
|
SaveSnapshot(
|
||||||
|
snapshot,
|
||||||
|
nameof(ComponentSettingsSnapshot.ImportedClassSchedules),
|
||||||
|
nameof(ComponentSettingsSnapshot.ActiveImportedClassScheduleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ClockComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="TimeZoneLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="TimeZoneComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SecondHandLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
|
||||||
|
<Border Classes="component-editor-segmented-host"
|
||||||
|
HorizontalAlignment="Left">
|
||||||
|
<Grid ColumnDefinitions="*,*"
|
||||||
|
ColumnSpacing="4">
|
||||||
|
<ToggleButton x:Name="TickRadioButton"
|
||||||
|
Classes="component-editor-segmented-choice"
|
||||||
|
Checked="OnSecondHandChanged"
|
||||||
|
Unchecked="OnSecondHandChanged" />
|
||||||
|
<ToggleButton x:Name="SweepRadioButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Classes="component-editor-segmented-choice"
|
||||||
|
Checked="OnSecondHandChanged"
|
||||||
|
Unchecked="OnSecondHandChanged" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class ClockComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public ClockComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClockComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
if (context is not null)
|
||||||
|
{
|
||||||
|
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Clock";
|
||||||
|
DescriptionTextBlock.Text = L("clock.settings.desc", "配置时区和秒针动画。");
|
||||||
|
TimeZoneLabelTextBlock.Text = L("clock.settings.timezone", "时区");
|
||||||
|
SecondHandLabelTextBlock.Text = L("clock.settings.second_mode_label", "秒针方式");
|
||||||
|
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
|
||||||
|
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||||
|
? TimeZoneInfo.Local.Id
|
||||||
|
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
TimeZoneComboBox.Items.Clear();
|
||||||
|
foreach (var timeZone in _timeZoneService.GetAllTimeZones()
|
||||||
|
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
|
||||||
|
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Tag = timeZone.Id,
|
||||||
|
Content = FormatTimeZone(timeZone)
|
||||||
|
};
|
||||||
|
item.Classes.Add("component-editor-select-item");
|
||||||
|
TimeZoneComboBox.Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeZoneComboBox.SelectedItem = TimeZoneComboBox.Items
|
||||||
|
.OfType<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag as string, configuredTimeZoneId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||||
|
var secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||||
|
TickRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
|
||||||
|
SweepRadioButton.IsChecked = string.Equals(secondHandMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
if (sender == TickRadioButton)
|
||||||
|
{
|
||||||
|
TickRadioButton.IsChecked = true;
|
||||||
|
SweepRadioButton.IsChecked = false;
|
||||||
|
}
|
||||||
|
else if (sender == SweepRadioButton)
|
||||||
|
{
|
||||||
|
SweepRadioButton.IsChecked = true;
|
||||||
|
TickRadioButton.IsChecked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
|
||||||
|
{
|
||||||
|
TickRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressEvents = false;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.DesktopClockTimeZoneId = TimeZoneComboBox.SelectedItem is ComboBoxItem item && item.Tag is string timeZoneId
|
||||||
|
? timeZoneId
|
||||||
|
: TimeZoneInfo.Local.Id;
|
||||||
|
snapshot.DesktopClockSecondHandMode = SweepRadioButton.IsChecked == true
|
||||||
|
? ClockSecondHandMode.Sweep
|
||||||
|
: ClockSecondHandMode.Tick;
|
||||||
|
|
||||||
|
SaveSnapshot(
|
||||||
|
snapshot,
|
||||||
|
nameof(ComponentSettingsSnapshot.DesktopClockTimeZoneId),
|
||||||
|
nameof(ComponentSettingsSnapshot.DesktopClockSecondHandMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTimeZone(TimeZoneInfo timeZone)
|
||||||
|
{
|
||||||
|
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
|
||||||
|
var sign = offset >= TimeSpan.Zero ? "+" : "-";
|
||||||
|
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
|
||||||
|
var hours = totalMinutes / 60;
|
||||||
|
var minutes = totalMinutes % 60;
|
||||||
|
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public abstract class ComponentEditorViewBase : UserControl
|
||||||
|
{
|
||||||
|
protected ComponentEditorViewBase(DesktopComponentEditorContext? context)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected DesktopComponentEditorContext? Context { get; }
|
||||||
|
|
||||||
|
protected LocalizationService LocalizationService { get; } = new();
|
||||||
|
|
||||||
|
protected string LanguageCode =>
|
||||||
|
LocalizationService.NormalizeLanguageCode(Context?.SettingsFacade.Region.Get().LanguageCode);
|
||||||
|
|
||||||
|
protected string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
return LocalizationService.GetString(LanguageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ComponentSettingsSnapshot LoadSnapshot()
|
||||||
|
{
|
||||||
|
return Context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>() ?? new ComponentSettingsSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void SaveSnapshot(ComponentSettingsSnapshot snapshot, params string[] changedKeys)
|
||||||
|
{
|
||||||
|
if (Context is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.ComponentSettingsAccessor.SaveSnapshot(
|
||||||
|
snapshot,
|
||||||
|
changedKeys.Length == 0 ? null : changedKeys);
|
||||||
|
Context.HostContext.RequestRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.DailyArtworkComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SourceLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="SourceComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnSourceSelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="DomesticItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="Domestic" />
|
||||||
|
<ComboBoxItem x:Name="OverseasItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="Overseas" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class DailyArtworkComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public DailyArtworkComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DailyArtworkComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
SourceLabelTextBlock.Text = L("artwork.settings.source_label", "Mirror Source");
|
||||||
|
DomesticItem.Content = L("artwork.settings.source_domestic", "Domestic Mirror");
|
||||||
|
OverseasItem.Content = L("artwork.settings.source_overseas", "Overseas Mirror");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
var source = DailyArtworkMirrorSources.Normalize(LoadSnapshot().DailyArtworkMirrorSource);
|
||||||
|
SourceComboBox.SelectedItem = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? DomesticItem
|
||||||
|
: OverseasItem;
|
||||||
|
UpdateStatus(source);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
|
? DailyArtworkMirrorSources.Normalize(tag)
|
||||||
|
: DailyArtworkMirrorSources.Overseas;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.DailyArtworkMirrorSource = source;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.DailyArtworkMirrorSource));
|
||||||
|
UpdateStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatus(string source)
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? L("artwork.settings.source_status_domestic", "当前源:国内镜像(优先中国网络)")
|
||||||
|
: L("artwork.settings.source_status_overseas", "当前源:国外镜像(艺术馆推荐)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.InformationalComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
RowDefinitions="Auto,Auto,Auto"
|
||||||
|
ColumnSpacing="12"
|
||||||
|
RowSpacing="8">
|
||||||
|
<TextBlock x:Name="ComponentLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="ComponentValueTextBlock"
|
||||||
|
Grid.Column="1" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
x:Name="PlacementLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="PlacementValueTextBlock"
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
x:Name="ScopeLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="ScopeValueTextBlock"
|
||||||
|
Grid.Row="2"
|
||||||
|
Grid.Column="1"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class InformationalComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private readonly string _description;
|
||||||
|
|
||||||
|
public InformationalComponentEditor()
|
||||||
|
: this(null, "This component currently exposes instance information only.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InformationalComponentEditor(DesktopComponentEditorContext? context, string description)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
_description = description;
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
ComponentLabelTextBlock.Text = L("component.editor.id_label", "Component");
|
||||||
|
ComponentValueTextBlock.Text = Context?.ComponentId ?? "-";
|
||||||
|
PlacementLabelTextBlock.Text = L("component.editor.placement_label", "Placement");
|
||||||
|
PlacementValueTextBlock.Text = Context?.PlacementId ?? "-";
|
||||||
|
ScopeLabelTextBlock.Text = L("component.editor.scope_label", "Scope");
|
||||||
|
ScopeValueTextBlock.Text = L("component.editor.scope_instance", "Instance-scoped editor");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="DisplayDbHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ToggleSwitch x:Name="DisplayDbToggleSwitch"
|
||||||
|
IsCheckedChanged="OnToggleChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ToggleSwitch x:Name="DbfsToggleSwitch"
|
||||||
|
IsCheckedChanged="OnToggleChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="HintTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
Margin="12,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public StudyEnvironmentComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudyEnvironmentComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
||||||
|
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
||||||
|
if (!showDisplayDb && !showDbfs)
|
||||||
|
{
|
||||||
|
showDisplayDb = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||||
|
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
||||||
|
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
|
||||||
|
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
|
||||||
|
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
||||||
|
DbfsToggleSwitch.IsChecked = showDbfs;
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showDisplayDb = DisplayDbToggleSwitch.IsChecked == true;
|
||||||
|
var showDbfs = DbfsToggleSwitch.IsChecked == true;
|
||||||
|
if (!showDisplayDb && !showDbfs)
|
||||||
|
{
|
||||||
|
_suppressEvents = true;
|
||||||
|
DisplayDbToggleSwitch.IsChecked = true;
|
||||||
|
_suppressEvents = false;
|
||||||
|
showDisplayDb = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
|
||||||
|
snapshot.StudyEnvironmentShowDbfs = showDbfs;
|
||||||
|
SaveSnapshot(
|
||||||
|
snapshot,
|
||||||
|
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDisplayDb),
|
||||||
|
nameof(ComponentSettingsSnapshot.StudyEnvironmentShowDbfs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ToggleIntervalComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<Grid RowDefinitions="Auto,Auto"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="16"
|
||||||
|
RowSpacing="6">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock x:Name="ToggleLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="ToggleDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch x:Name="EnabledToggleSwitch"
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Checked="OnEnabledChanged"
|
||||||
|
Unchecked="OnEnabledChanged" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="IntervalCard"
|
||||||
|
Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="IntervalLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="IntervalComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnIntervalSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="ExtraSelectorCard"
|
||||||
|
Classes="component-editor-card"
|
||||||
|
Padding="20"
|
||||||
|
IsVisible="False">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ExtraSelectorLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ExtraSelectorComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnExtraSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public sealed record ComponentEditorSelectionOption(
|
||||||
|
string Value,
|
||||||
|
string LabelKey,
|
||||||
|
string FallbackLabel);
|
||||||
|
|
||||||
|
public sealed class ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
public required string DescriptionKey { get; init; }
|
||||||
|
public required string DescriptionFallback { get; init; }
|
||||||
|
public required string ToggleLabelKey { get; init; }
|
||||||
|
public required string ToggleLabelFallback { get; init; }
|
||||||
|
public string ToggleDescriptionKey { get; init; } = string.Empty;
|
||||||
|
public string ToggleDescriptionFallback { get; init; } = string.Empty;
|
||||||
|
public required string IntervalLabelKey { get; init; }
|
||||||
|
public required string IntervalLabelFallback { get; init; }
|
||||||
|
public required Func<ComponentSettingsSnapshot, bool> GetEnabled { get; init; }
|
||||||
|
public required Action<ComponentSettingsSnapshot, bool> SetEnabled { get; init; }
|
||||||
|
public required Func<ComponentSettingsSnapshot, int> GetInterval { get; init; }
|
||||||
|
public required Action<ComponentSettingsSnapshot, int> SetInterval { get; init; }
|
||||||
|
public required int DefaultInterval { get; init; }
|
||||||
|
public IReadOnlyList<string> ChangedKeys { get; init; } = Array.Empty<string>();
|
||||||
|
public string ExtraSelectorLabelKey { get; init; } = string.Empty;
|
||||||
|
public string ExtraSelectorLabelFallback { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<ComponentEditorSelectionOption> ExtraOptions { get; init; } = Array.Empty<ComponentEditorSelectionOption>();
|
||||||
|
public Func<ComponentSettingsSnapshot, string>? GetExtraValue { get; init; }
|
||||||
|
public Action<ComponentSettingsSnapshot, string>? SetExtraValue { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ToggleIntervalComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||||
|
private readonly ToggleIntervalComponentEditorOptions _options;
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public ToggleIntervalComponentEditor()
|
||||||
|
: this(null, new ToggleIntervalComponentEditorOptions
|
||||||
|
{
|
||||||
|
DescriptionKey = "component.editor.desc",
|
||||||
|
DescriptionFallback = "Configure this component.",
|
||||||
|
ToggleLabelKey = "component.editor.toggle",
|
||||||
|
ToggleLabelFallback = "Enabled",
|
||||||
|
IntervalLabelKey = "component.editor.interval",
|
||||||
|
IntervalLabelFallback = "Refresh interval",
|
||||||
|
DefaultInterval = 15,
|
||||||
|
GetEnabled = _ => true,
|
||||||
|
SetEnabled = (_, _) => { },
|
||||||
|
GetInterval = _ => 15,
|
||||||
|
SetInterval = (_, _) => { }
|
||||||
|
})
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToggleIntervalComponentEditor(
|
||||||
|
DesktopComponentEditorContext? context,
|
||||||
|
ToggleIntervalComponentEditorOptions options)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
InitializeComponent();
|
||||||
|
BuildOptions();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildOptions()
|
||||||
|
{
|
||||||
|
IntervalComboBox.Items.Clear();
|
||||||
|
foreach (var minutes in SupportedIntervals)
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Tag = minutes.ToString(),
|
||||||
|
Content = L("refresh.frequency." + RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes), RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes))
|
||||||
|
};
|
||||||
|
item.Classes.Add("component-editor-select-item");
|
||||||
|
IntervalComboBox.Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtraSelectorComboBox.Items.Clear();
|
||||||
|
foreach (var option in _options.ExtraOptions)
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Tag = option.Value,
|
||||||
|
Content = L(option.LabelKey, option.FallbackLabel)
|
||||||
|
};
|
||||||
|
item.Classes.Add("component-editor-select-item");
|
||||||
|
ExtraSelectorComboBox.Items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
ToggleLabelTextBlock.Text = L(_options.ToggleLabelKey, _options.ToggleLabelFallback);
|
||||||
|
ToggleDescriptionTextBlock.Text = string.IsNullOrWhiteSpace(_options.ToggleDescriptionKey)
|
||||||
|
? L("component.editor.instance_scope", "Changes are stored per component instance.")
|
||||||
|
: L(_options.ToggleDescriptionKey, _options.ToggleDescriptionFallback);
|
||||||
|
IntervalLabelTextBlock.Text = L(_options.IntervalLabelKey, _options.IntervalLabelFallback);
|
||||||
|
ExtraSelectorLabelTextBlock.Text = L(_options.ExtraSelectorLabelKey, _options.ExtraSelectorLabelFallback);
|
||||||
|
ExtraSelectorCard.IsVisible = _options.ExtraOptions.Count > 0;
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var enabled = _options.GetEnabled(snapshot);
|
||||||
|
EnabledToggleSwitch.IsChecked = enabled;
|
||||||
|
IntervalCard.IsVisible = enabled;
|
||||||
|
|
||||||
|
var normalizedInterval = RefreshIntervalCatalog.Normalize(_options.GetInterval(snapshot), _options.DefaultInterval);
|
||||||
|
IntervalComboBox.SelectedItem = IntervalComboBox.Items
|
||||||
|
.OfType<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item => item.Tag is string tag && int.TryParse(tag, out var minutes) && minutes == normalizedInterval);
|
||||||
|
|
||||||
|
if (_options.GetExtraValue is not null && _options.ExtraOptions.Count > 0)
|
||||||
|
{
|
||||||
|
var extraValue = _options.GetExtraValue(snapshot);
|
||||||
|
ExtraSelectorComboBox.SelectedItem = ExtraSelectorComboBox.Items
|
||||||
|
.OfType<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag as string, extraValue, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? ExtraSelectorComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnabledChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntervalCard.IsVisible = EnabledToggleSwitch.IsChecked == true;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIntervalSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExtraSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
_options.SetEnabled(snapshot, EnabledToggleSwitch.IsChecked == true);
|
||||||
|
_options.SetInterval(snapshot, GetSelectedInterval());
|
||||||
|
|
||||||
|
if (_options.SetExtraValue is not null &&
|
||||||
|
ExtraSelectorComboBox.SelectedItem is ComboBoxItem extraItem &&
|
||||||
|
extraItem.Tag is string extraValue)
|
||||||
|
{
|
||||||
|
_options.SetExtraValue(snapshot, extraValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveSnapshot(snapshot, _options.ChangedKeys.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetSelectedInterval()
|
||||||
|
{
|
||||||
|
if (IntervalComboBox.SelectedItem is ComboBoxItem item &&
|
||||||
|
item.Tag is string tag &&
|
||||||
|
int.TryParse(tag, out var minutes))
|
||||||
|
{
|
||||||
|
return RefreshIntervalCatalog.Normalize(minutes, _options.DefaultInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _options.DefaultInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<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"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.WorldClockComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ClockOneLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ClockOneComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ClockTwoLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ClockTwoComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ClockThreeLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ClockThreeComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="ClockFourLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="ClockFourComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SecondHandLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
|
||||||
|
<Border Classes="component-editor-segmented-host"
|
||||||
|
HorizontalAlignment="Left">
|
||||||
|
<Grid ColumnDefinitions="*,*"
|
||||||
|
ColumnSpacing="4">
|
||||||
|
<ToggleButton x:Name="TickRadioButton"
|
||||||
|
Classes="component-editor-segmented-choice"
|
||||||
|
Checked="OnSecondHandChanged"
|
||||||
|
Unchecked="OnSecondHandChanged" />
|
||||||
|
<ToggleButton x:Name="SweepRadioButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Classes="component-editor-segmented-choice"
|
||||||
|
Checked="OnSecondHandChanged"
|
||||||
|
Unchecked="OnSecondHandChanged" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class WorldClockComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private readonly TimeZoneService _timeZoneService = new();
|
||||||
|
private readonly ComboBox[] _timeZoneCombos;
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public WorldClockComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorldClockComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
if (context is not null)
|
||||||
|
{
|
||||||
|
_timeZoneService.CurrentTimeZone = context.SettingsFacade.Region.GetTimeZoneService().CurrentTimeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
InitializeComponent();
|
||||||
|
_timeZoneCombos = [ClockOneComboBox, ClockTwoComboBox, ClockThreeComboBox, ClockFourComboBox];
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "World Clock";
|
||||||
|
DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。");
|
||||||
|
ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1");
|
||||||
|
ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2");
|
||||||
|
ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3");
|
||||||
|
ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4");
|
||||||
|
SecondHandLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式");
|
||||||
|
TickRadioButton.Content = L("clock.second_mode.tick", "跳针");
|
||||||
|
SweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
|
||||||
|
|
||||||
|
var allTimeZones = _timeZoneService.GetAllTimeZones()
|
||||||
|
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
|
||||||
|
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var selectedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(LoadSnapshot().WorldClockTimeZoneIds, allTimeZones);
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
foreach (var combo in _timeZoneCombos)
|
||||||
|
{
|
||||||
|
combo.Items.Clear();
|
||||||
|
foreach (var zone in allTimeZones)
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Tag = zone.Id,
|
||||||
|
Content = FormatTimeZone(zone)
|
||||||
|
};
|
||||||
|
item.Classes.Add("component-editor-select-item");
|
||||||
|
combo.Items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < _timeZoneCombos.Length; index++)
|
||||||
|
{
|
||||||
|
var combo = _timeZoneCombos[index];
|
||||||
|
var targetId = index < selectedIds.Count ? selectedIds[index] : TimeZoneInfo.Local.Id;
|
||||||
|
combo.SelectedItem = combo.Items
|
||||||
|
.OfType<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? combo.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondMode = ClockSecondHandMode.Normalize(LoadSnapshot().WorldClockSecondHandMode);
|
||||||
|
TickRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Tick, StringComparison.OrdinalIgnoreCase);
|
||||||
|
SweepRadioButton.IsChecked = string.Equals(secondMode, ClockSecondHandMode.Sweep, StringComparison.OrdinalIgnoreCase);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSecondHandChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
if (sender == TickRadioButton)
|
||||||
|
{
|
||||||
|
TickRadioButton.IsChecked = true;
|
||||||
|
SweepRadioButton.IsChecked = false;
|
||||||
|
}
|
||||||
|
else if (sender == SweepRadioButton)
|
||||||
|
{
|
||||||
|
SweepRadioButton.IsChecked = true;
|
||||||
|
TickRadioButton.IsChecked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TickRadioButton.IsChecked != true && SweepRadioButton.IsChecked != true)
|
||||||
|
{
|
||||||
|
TickRadioButton.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressEvents = false;
|
||||||
|
SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.WorldClockTimeZoneIds = _timeZoneCombos
|
||||||
|
.Select(combo => combo.SelectedItem is ComboBoxItem item && item.Tag is string tag ? tag : TimeZoneInfo.Local.Id)
|
||||||
|
.ToList();
|
||||||
|
snapshot.WorldClockSecondHandMode = SweepRadioButton.IsChecked == true
|
||||||
|
? ClockSecondHandMode.Sweep
|
||||||
|
: ClockSecondHandMode.Tick;
|
||||||
|
|
||||||
|
SaveSnapshot(
|
||||||
|
snapshot,
|
||||||
|
nameof(ComponentSettingsSnapshot.WorldClockTimeZoneIds),
|
||||||
|
nameof(ComponentSettingsSnapshot.WorldClockSecondHandMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTimeZone(TimeZoneInfo timeZone)
|
||||||
|
{
|
||||||
|
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
|
||||||
|
var sign = offset >= TimeSpan.Zero ? "+" : "-";
|
||||||
|
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
|
||||||
|
var hours = totalMinutes / 60;
|
||||||
|
var minutes = totalMinutes % 60;
|
||||||
|
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {timeZone.StandardName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -508,6 +508,17 @@ public partial class MainWindow
|
|||||||
|
|
||||||
if (_selectedDesktopComponentHost is not null)
|
if (_selectedDesktopComponentHost is not null)
|
||||||
{
|
{
|
||||||
|
if (TryGetSelectedDesktopPlacement(out var selectedPlacement) &&
|
||||||
|
_componentEditorRegistry.TryGetDescriptor(selectedPlacement.ComponentId, out _))
|
||||||
|
{
|
||||||
|
actions.Add(new TaskbarActionItem(
|
||||||
|
TaskbarActionId.EditComponent,
|
||||||
|
L("component.edit", "Edit"),
|
||||||
|
"Edit",
|
||||||
|
IsVisible: true,
|
||||||
|
CommandKey: "component.edit"));
|
||||||
|
}
|
||||||
|
|
||||||
actions.Add(new TaskbarActionItem(
|
actions.Add(new TaskbarActionItem(
|
||||||
TaskbarActionId.DeleteComponent,
|
TaskbarActionId.DeleteComponent,
|
||||||
L("component.delete", "Delete"),
|
L("component.delete", "Delete"),
|
||||||
@@ -606,15 +617,12 @@ public partial class MainWindow
|
|||||||
action.Id == TaskbarActionId.DeleteComponent;
|
action.Id == TaskbarActionId.DeleteComponent;
|
||||||
var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry;
|
var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry;
|
||||||
|
|
||||||
Symbol iconSymbol;
|
var iconSymbol = action.Id switch
|
||||||
if (isDeleteAction || isHideAction)
|
|
||||||
{
|
{
|
||||||
iconSymbol = Symbol.Delete;
|
TaskbarActionId.EditComponent => Symbol.Edit,
|
||||||
}
|
_ when isDeleteAction || isHideAction => Symbol.Delete,
|
||||||
else
|
_ => Symbol.Add
|
||||||
{
|
};
|
||||||
iconSymbol = Symbol.Add;
|
|
||||||
}
|
|
||||||
|
|
||||||
Control icon = new SymbolIcon
|
Control icon = new SymbolIcon
|
||||||
{
|
{
|
||||||
@@ -675,6 +683,9 @@ public partial class MainWindow
|
|||||||
case "component.delete":
|
case "component.delete":
|
||||||
DeleteSelectedComponent();
|
DeleteSelectedComponent();
|
||||||
break;
|
break;
|
||||||
|
case "component.edit":
|
||||||
|
OpenSelectedComponentEditor();
|
||||||
|
break;
|
||||||
case "launcher.hide":
|
case "launcher.hide":
|
||||||
HideSelectedLauncherEntry();
|
HideSelectedLauncherEntry();
|
||||||
break;
|
break;
|
||||||
@@ -695,6 +706,11 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_componentEditorWindowService.Close();
|
||||||
|
}
|
||||||
|
|
||||||
ClearTimeZoneServiceBindings(_selectedDesktopComponentHost);
|
ClearTimeZoneServiceBindings(_selectedDesktopComponentHost);
|
||||||
DisposeComponentIfNeeded(_selectedDesktopComponentHost);
|
DisposeComponentIfNeeded(_selectedDesktopComponentHost);
|
||||||
|
|
||||||
@@ -713,6 +729,90 @@ public partial class MainWindow
|
|||||||
PersistSettings();
|
PersistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenSelectedComponentEditor()
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedDesktopPlacement(out var placement) ||
|
||||||
|
!_componentEditorRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentEditorWindowService.Open(new ComponentEditorOpenRequest(
|
||||||
|
Owner: this,
|
||||||
|
Descriptor: descriptor,
|
||||||
|
ComponentId: placement.ComponentId,
|
||||||
|
PlacementId: placement.PlacementId,
|
||||||
|
RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement)
|
||||||
|
{
|
||||||
|
placement = null!;
|
||||||
|
if (_selectedDesktopComponentHost?.Tag is not string placementId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedPlacement = _desktopComponentPlacements.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matchedPlacement is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
placement = matchedPlacement;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDesktopComponentPlacement(string placementId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var placement = _desktopComponentPlacements.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (placement is null ||
|
||||||
|
!_desktopPageComponentGrids.TryGetValue(placement.PageIndex, out var pageGrid))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = pageGrid.Children
|
||||||
|
.OfType<Border>()
|
||||||
|
.FirstOrDefault(candidate => string.Equals(candidate.Tag as string, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (host is null)
|
||||||
|
{
|
||||||
|
RestoreDesktopPageComponents(placement.PageIndex);
|
||||||
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId, placement.PageIndex);
|
||||||
|
if (component is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetContentHost(host) is not Border contentHost)
|
||||||
|
{
|
||||||
|
RestoreDesktopPageComponents(placement.PageIndex);
|
||||||
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearTimeZoneServiceBindings(host);
|
||||||
|
DisposeComponentIfNeeded(host);
|
||||||
|
contentHost.Child = component;
|
||||||
|
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
|
||||||
|
UpdateDesktopPageAwareComponentContext();
|
||||||
|
if (_selectedDesktopComponentHost == host)
|
||||||
|
{
|
||||||
|
ApplySelectionStateToHost(host, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void DisposeComponentIfNeeded(Border host)
|
private static void DisposeComponentIfNeeded(Border host)
|
||||||
{
|
{
|
||||||
if (TryGetContentHost(host) is Border contentHost && contentHost.Child is Control componentControl)
|
if (TryGetContentHost(host) is Border contentHost && contentHost.Child is Control componentControl)
|
||||||
@@ -724,7 +824,7 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component settings popup UI is removed in API-only settings hard-cut mode.
|
// Legacy in-window popup editor is removed; component editing now routes through the Material editor window service.
|
||||||
|
|
||||||
private void AddDesktopPage()
|
private void AddDesktopPage()
|
||||||
{
|
{
|
||||||
@@ -1680,6 +1780,9 @@ public partial class MainWindow
|
|||||||
ApplySelectionStateToHost(_selectedDesktopComponentHost, false);
|
ApplySelectionStateToHost(_selectedDesktopComponentHost, false);
|
||||||
_selectedDesktopComponentHost = null;
|
_selectedDesktopComponentHost = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_componentEditorWindowService.Close();
|
||||||
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e)
|
private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e)
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ public partial class MainWindow
|
|||||||
_weatherLongitude = snapshot.WeatherLongitude;
|
_weatherLongitude = snapshot.WeatherLongitude;
|
||||||
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
||||||
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
||||||
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "FluentRegular" : snapshot.WeatherIconPackId;
|
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
|
||||||
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +488,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
||||||
{
|
{
|
||||||
|
var latestWeatherState = _weatherSettingsService.Get();
|
||||||
return new AppSettingsSnapshot
|
return new AppSettingsSnapshot
|
||||||
{
|
{
|
||||||
GridShortSideCells = _targetShortSideCells,
|
GridShortSideCells = _targetShortSideCells,
|
||||||
@@ -500,15 +501,16 @@ public partial class MainWindow
|
|||||||
WallpaperColor = _wallpaperSolidColor?.ToString(),
|
WallpaperColor = _wallpaperSolidColor?.ToString(),
|
||||||
LanguageCode = _languageCode,
|
LanguageCode = _languageCode,
|
||||||
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
||||||
WeatherLocationMode = _weatherLocationMode.ToString(),
|
WeatherLocationMode = latestWeatherState.LocationMode,
|
||||||
WeatherLocationKey = _weatherLocationKey,
|
WeatherLocationKey = latestWeatherState.LocationKey,
|
||||||
WeatherLocationName = _weatherLocationName,
|
WeatherLocationName = latestWeatherState.LocationName,
|
||||||
WeatherLatitude = _weatherLatitude,
|
WeatherLatitude = latestWeatherState.Latitude,
|
||||||
WeatherLongitude = _weatherLongitude,
|
WeatherLongitude = latestWeatherState.Longitude,
|
||||||
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
|
WeatherAutoRefreshLocation = latestWeatherState.AutoRefreshLocation,
|
||||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
WeatherLocationQuery = latestWeatherState.LocationQuery,
|
||||||
WeatherIconPackId = _weatherIconPackId,
|
WeatherExcludedAlerts = latestWeatherState.ExcludedAlerts,
|
||||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
WeatherIconPackId = latestWeatherState.IconPackId,
|
||||||
|
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
|
||||||
AutoStartWithWindows = _autoStartWithWindows,
|
AutoStartWithWindows = _autoStartWithWindows,
|
||||||
AppRenderMode = _selectedAppRenderMode,
|
AppRenderMode = _selectedAppRenderMode,
|
||||||
TopStatusComponentIds = [.. _topStatusComponentIds],
|
TopStatusComponentIds = [.. _topStatusComponentIds],
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
private readonly ComponentRegistry _componentRegistry;
|
private readonly ComponentRegistry _componentRegistry;
|
||||||
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
||||||
|
private readonly DesktopComponentEditorRegistry _componentEditorRegistry;
|
||||||
private readonly IComponentLibraryService _componentLibraryService;
|
private readonly IComponentLibraryService _componentLibraryService;
|
||||||
|
private readonly IComponentEditorWindowService _componentEditorWindowService;
|
||||||
private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService();
|
private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService();
|
||||||
private ComponentLibraryWindow? _detachedComponentLibraryWindow;
|
private ComponentLibraryWindow? _detachedComponentLibraryWindow;
|
||||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||||
@@ -164,7 +166,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
private double _weatherLongitude = 116.4074;
|
private double _weatherLongitude = 116.4074;
|
||||||
private bool _weatherAutoRefreshLocation;
|
private bool _weatherAutoRefreshLocation;
|
||||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||||
private string _weatherIconPackId = "FluentRegular";
|
private string _weatherIconPackId = "HyperOS3";
|
||||||
private bool _weatherNoTlsRequests;
|
private bool _weatherNoTlsRequests;
|
||||||
private bool _autoStartWithWindows;
|
private bool _autoStartWithWindows;
|
||||||
private bool _suppressAutoStartToggleEvents;
|
private bool _suppressAutoStartToggleEvents;
|
||||||
@@ -197,7 +199,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
_componentRegistry,
|
_componentRegistry,
|
||||||
pluginRuntimeService,
|
pluginRuntimeService,
|
||||||
_settingsFacade);
|
_settingsFacade);
|
||||||
|
_componentEditorRegistry = DesktopComponentEditorRegistryFactory.Create(
|
||||||
|
_componentRegistry,
|
||||||
|
pluginRuntimeService);
|
||||||
_componentLibraryService = new ComponentLibraryService(_componentRegistry, _componentRuntimeRegistry);
|
_componentLibraryService = new ComponentLibraryService(_componentRegistry, _componentRuntimeRegistry);
|
||||||
|
_componentEditorWindowService = new ComponentEditorWindowService(_settingsFacade);
|
||||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||||
_settingsService.Changed += OnSettingsChanged;
|
_settingsService.Changed += OnSettingsChanged;
|
||||||
PropertyChanged += OnWindowPropertyChanged;
|
PropertyChanged += OnWindowPropertyChanged;
|
||||||
@@ -307,6 +313,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
_componentEditorWindowService.Close();
|
||||||
if (_detachedComponentLibraryWindow is not null)
|
if (_detachedComponentLibraryWindow is not null)
|
||||||
{
|
{
|
||||||
_detachedComponentLibraryWindow.AddComponentRequested -= OnDetachedComponentLibraryAddComponentRequested;
|
_detachedComponentLibraryWindow.AddComponentRequested -= OnDetachedComponentLibraryAddComponentRequested;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
||||||
|
x:DataType="vm:LauncherSettingsPageViewModel">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Classes="settings-page-container">
|
||||||
|
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="18">
|
||||||
|
<Border Classes="settings-section-card-icon-host"
|
||||||
|
Width="72"
|
||||||
|
Height="72"
|
||||||
|
Padding="10">
|
||||||
|
<fi:SymbolIcon Symbol="Apps"
|
||||||
|
FontSize="34"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settings-card-header"
|
||||||
|
Text="{Binding LauncherHeader}" />
|
||||||
|
<TextBlock Classes="settings-card-description"
|
||||||
|
Text="{Binding LauncherSubtitle}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Margin="0,10,0,0"
|
||||||
|
Text="{Binding HiddenHint}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2"
|
||||||
|
Spacing="4"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settings-section-title"
|
||||||
|
FontSize="28"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0"
|
||||||
|
Text="{Binding HiddenCountText}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Text="{Binding HiddenSummary}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<controls:IconText Icon="Apps"
|
||||||
|
Text="{Binding HiddenHeader}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding HiddenHeader}"
|
||||||
|
Description="{Binding HiddenDescription}"
|
||||||
|
IsExpanded="True">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="Apps" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
IsVisible="{Binding IsHiddenItemsEmpty}"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
Text="{Binding HiddenEmptyText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding HiddenItems}"
|
||||||
|
IsVisible="{Binding HasHiddenItems}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:LauncherHiddenItemViewModel">
|
||||||
|
<ui:SettingsExpanderItem Content="{Binding DisplayName}"
|
||||||
|
Description="{Binding TypeLabel}"
|
||||||
|
IsClickEnabled="False">
|
||||||
|
<ui:SettingsExpanderItem.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="{Binding IconSymbol}" />
|
||||||
|
</ui:SettingsExpanderItem.IconSource>
|
||||||
|
<ui:SettingsExpanderItem.Footer>
|
||||||
|
<Button Command="{Binding RestoreCommand}"
|
||||||
|
Content="{Binding RestoreButtonText}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</ui:SettingsExpanderItem.Footer>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
[SettingsPageInfo(
|
||||||
|
"launcher",
|
||||||
|
"App Launcher",
|
||||||
|
SettingsPageCategory.Components,
|
||||||
|
IconKey = "Apps",
|
||||||
|
SortOrder = 10,
|
||||||
|
Scope = SettingsScope.Launcher,
|
||||||
|
TitleLocalizationKey = "settings.launcher.title",
|
||||||
|
DescriptionLocalizationKey = "settings.launcher.description")]
|
||||||
|
public partial class LauncherSettingsPage : SettingsPageBase
|
||||||
|
{
|
||||||
|
public LauncherSettingsPage()
|
||||||
|
: this(new LauncherSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherSettingsPage(LauncherSettingsPageViewModel viewModel)
|
||||||
|
{
|
||||||
|
ViewModel = viewModel;
|
||||||
|
DataContext = ViewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherSettingsPageViewModel ViewModel { get; }
|
||||||
|
}
|
||||||
263
LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml
Normal file
263
LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
xmlns:models="using:LanMountainDesktop.Models"
|
||||||
|
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
||||||
|
x:DataType="vm:WeatherSettingsPageViewModel">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Classes="settings-page-container">
|
||||||
|
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||||
|
<Border Classes="settings-section-card-icon-host"
|
||||||
|
Width="72"
|
||||||
|
Height="72"
|
||||||
|
Padding="10">
|
||||||
|
<Image Source="{Binding PreviewIcon}"
|
||||||
|
Stretch="Uniform" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settings-card-header"
|
||||||
|
Text="{Binding PreviewHeader}" />
|
||||||
|
<TextBlock Classes="settings-card-description"
|
||||||
|
Text="{Binding PreviewDescription}" />
|
||||||
|
<TextBlock Classes="settings-section-title"
|
||||||
|
FontSize="24"
|
||||||
|
Margin="0,10,0,0"
|
||||||
|
Text="{Binding PreviewTemperature}" />
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding PreviewLocation}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding PreviewCondition}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding PreviewUpdated}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding PreviewStatus}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2"
|
||||||
|
Spacing="12"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button Classes="settings-accent-button"
|
||||||
|
Command="{Binding RefreshPreviewCommand}"
|
||||||
|
Content="{Binding RefreshButtonText}" />
|
||||||
|
<ui:ProgressRing IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsRefreshingPreview}"
|
||||||
|
Width="28"
|
||||||
|
Height="28"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding LocationSourceHeader}"
|
||||||
|
Description="{Binding LocationSourceDescription}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="WeatherMoon" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<ComboBox Width="220"
|
||||||
|
ItemsSource="{Binding LocationModes}"
|
||||||
|
SelectedItem="{Binding SelectedLocationMode}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SelectionOption">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding CurrentLocationSummary}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding CitySearchHeader}"
|
||||||
|
Description="{Binding CitySearchDescription}"
|
||||||
|
IsVisible="{Binding IsCitySearchMode}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="Search" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<Button Classes="settings-accent-button"
|
||||||
|
Command="{Binding ApplyCitySelectionCommand}"
|
||||||
|
Content="{Binding ApplyCityButtonText}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<Grid ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBox Text="{Binding SearchKeyword}"
|
||||||
|
Watermark="{Binding SearchPlaceholder}" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Command="{Binding SearchCommand}"
|
||||||
|
Content="{Binding SearchButtonText}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ui:ProgressRing IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsSearching}"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding SearchStatus}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<ListBox Classes="weather-settings-search-results"
|
||||||
|
ItemsSource="{Binding SearchResults}"
|
||||||
|
SelectedItem="{Binding SelectedSearchResult}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:WeatherLocation">
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<fi:SymbolIcon Classes="icon-s"
|
||||||
|
Margin="0,2,0,0"
|
||||||
|
Symbol="City" />
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding Name}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding Affiliation}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding LocationKey}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</StackPanel>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding CoordinatesHeader}"
|
||||||
|
Description="{Binding CoordinatesDescription}"
|
||||||
|
IsVisible="{Binding IsCoordinatesMode}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="Location" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<Button Classes="settings-accent-button"
|
||||||
|
Command="{Binding ApplyCoordinatesCommand}"
|
||||||
|
Content="{Binding ApplyCoordinatesButtonText}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<Grid ColumnDefinitions="*,*"
|
||||||
|
Classes="settings-inline-pair">
|
||||||
|
<StackPanel Classes="settings-item">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding LatitudeLabel}" />
|
||||||
|
<NumericUpDown Minimum="-90"
|
||||||
|
Maximum="90"
|
||||||
|
Increment="0.0001"
|
||||||
|
FormatString="F4"
|
||||||
|
Value="{Binding Latitude}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Classes="settings-item">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding LongitudeLabel}" />
|
||||||
|
<NumericUpDown Minimum="-180"
|
||||||
|
Maximum="180"
|
||||||
|
Increment="0.0001"
|
||||||
|
FormatString="F4"
|
||||||
|
Value="{Binding Longitude}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<TextBox Text="{Binding LocationKey}"
|
||||||
|
Watermark="{Binding LocationKeyPlaceholder}" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<TextBox Text="{Binding LocationName}"
|
||||||
|
Watermark="{Binding LocationNamePlaceholder}" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding LocationServicesHeader}"
|
||||||
|
Description="{Binding LocationServicesDescription}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="Location" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<Button Classes="settings-accent-button"
|
||||||
|
Command="{Binding UseCurrentLocationCommand}"
|
||||||
|
Content="{Binding UseCurrentLocationButtonText}"
|
||||||
|
IsVisible="{Binding IsLocationSupported}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<Grid ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="16">
|
||||||
|
<StackPanel Classes="settings-item">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding AutoRefreshLabel}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding LocationActionStatus}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch Grid.Column="1"
|
||||||
|
IsChecked="{Binding AutoRefreshLocation}"
|
||||||
|
IsEnabled="{Binding IsLocationSupported}" />
|
||||||
|
</Grid>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
<ui:SettingsExpanderItem IsVisible="{Binding IsRefreshingLocation}">
|
||||||
|
<ui:ProgressRing IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsRefreshingLocation}"
|
||||||
|
Width="28"
|
||||||
|
Height="28"
|
||||||
|
HorizontalAlignment="Left" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding AlertFilterHeader}"
|
||||||
|
Description="{Binding AlertFilterDescription}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="Warning" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<TextBox Width="360"
|
||||||
|
MinHeight="120"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding ExcludedAlerts}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding RequestHeader}"
|
||||||
|
Description="{Binding RequestDescription}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="ShieldDismiss" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding NoTlsRequests}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding NoTlsToggleText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
Text="{Binding FooterHint}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
[SettingsPageInfo(
|
||||||
|
"weather",
|
||||||
|
"Weather",
|
||||||
|
SettingsPageCategory.Appearance,
|
||||||
|
IconKey = "WeatherMoon",
|
||||||
|
SortOrder = 18,
|
||||||
|
TitleLocalizationKey = "settings.weather.title",
|
||||||
|
DescriptionLocalizationKey = "settings.weather.description")]
|
||||||
|
public partial class WeatherSettingsPage : SettingsPageBase
|
||||||
|
{
|
||||||
|
public WeatherSettingsPage()
|
||||||
|
: this(CreateDefaultViewModel())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeatherSettingsPage(WeatherSettingsPageViewModel viewModel)
|
||||||
|
{
|
||||||
|
ViewModel = viewModel;
|
||||||
|
DataContext = ViewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeatherSettingsPageViewModel ViewModel { get; }
|
||||||
|
|
||||||
|
private static WeatherSettingsPageViewModel CreateDefaultViewModel()
|
||||||
|
{
|
||||||
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
var localizationService = new LocalizationService();
|
||||||
|
var locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
|
var weatherLocationRefreshService = new WeatherLocationRefreshService(
|
||||||
|
settingsFacade,
|
||||||
|
locationService,
|
||||||
|
localizationService);
|
||||||
|
return new WeatherSettingsPageViewModel(
|
||||||
|
settingsFacade,
|
||||||
|
localizationService,
|
||||||
|
locationService,
|
||||||
|
weatherLocationRefreshService);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -634,6 +634,8 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
{
|
{
|
||||||
"DesignIdeas" => Symbol.Color,
|
"DesignIdeas" => Symbol.Color,
|
||||||
"Image" => Symbol.Image,
|
"Image" => Symbol.Image,
|
||||||
|
"WeatherMoon" => Symbol.WeatherMoon,
|
||||||
|
"Apps" => Symbol.Apps,
|
||||||
"GridDots" => Symbol.GridDots,
|
"GridDots" => Symbol.GridDots,
|
||||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||||
"Info" => Symbol.Info,
|
"Info" => Symbol.Info,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
IReadOnlyList<PluginSettingsSectionRegistration> settingsSections,
|
IReadOnlyList<PluginSettingsSectionRegistration> settingsSections,
|
||||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||||
|
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
|
||||||
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
||||||
IReadOnlyList<IHostedService> hostedServices,
|
IReadOnlyList<IHostedService> hostedServices,
|
||||||
PluginLoadContext loadContext)
|
PluginLoadContext loadContext)
|
||||||
@@ -36,6 +37,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
Services = services;
|
Services = services;
|
||||||
SettingsSections = settingsSections;
|
SettingsSections = settingsSections;
|
||||||
DesktopComponents = desktopComponents;
|
DesktopComponents = desktopComponents;
|
||||||
|
DesktopComponentEditors = desktopComponentEditors;
|
||||||
ExportedServices = exportedServices;
|
ExportedServices = exportedServices;
|
||||||
HostedServices = hostedServices;
|
HostedServices = hostedServices;
|
||||||
LoadContext = loadContext;
|
LoadContext = loadContext;
|
||||||
@@ -61,6 +63,8 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
|
|
||||||
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginDesktopComponentEditorRegistration> DesktopComponentEditors { get; }
|
||||||
|
|
||||||
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
||||||
|
|
||||||
public PluginLoadContext LoadContext { get; }
|
public PluginLoadContext LoadContext { get; }
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ public sealed record PluginSettingsSectionContribution(
|
|||||||
public sealed record PluginDesktopComponentContribution(
|
public sealed record PluginDesktopComponentContribution(
|
||||||
LoadedPlugin Plugin,
|
LoadedPlugin Plugin,
|
||||||
PluginDesktopComponentRegistration Registration);
|
PluginDesktopComponentRegistration Registration);
|
||||||
|
|
||||||
|
public sealed record PluginDesktopComponentEditorContribution(
|
||||||
|
LoadedPlugin Plugin,
|
||||||
|
PluginDesktopComponentEditorRegistration Registration);
|
||||||
|
|||||||
@@ -181,10 +181,14 @@ public sealed class PluginLoader
|
|||||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
var desktopComponentEditors = pluginServices
|
||||||
|
.GetServices<PluginDesktopComponentEditorRegistration>()
|
||||||
|
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
var exportedServices = ResolveExports(manifest, pluginServices);
|
var exportedServices = ResolveExports(manifest, pluginServices);
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginLoader",
|
"PluginLoader",
|
||||||
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Exports={exportedServices.Count}.");
|
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}.");
|
||||||
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
|
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
|
||||||
StartHostedServices(hostedServices);
|
StartHostedServices(hostedServices);
|
||||||
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
|
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
|
||||||
@@ -199,6 +203,7 @@ public sealed class PluginLoader
|
|||||||
pluginServices,
|
pluginServices,
|
||||||
settingsSections,
|
settingsSections,
|
||||||
desktopComponents,
|
desktopComponents,
|
||||||
|
desktopComponentEditors,
|
||||||
exportedServices,
|
exportedServices,
|
||||||
hostedServices,
|
hostedServices,
|
||||||
loadContext);
|
loadContext);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||||
private readonly List<PluginSettingsSectionContribution> _settingsSections = [];
|
private readonly List<PluginSettingsSectionContribution> _settingsSections = [];
|
||||||
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||||
|
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
|
||||||
private readonly object _packageMutationGate = new();
|
private readonly object _packageMutationGate = new();
|
||||||
|
|
||||||
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
||||||
@@ -73,6 +74,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections => _settingsSections;
|
public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections => _settingsSections;
|
||||||
|
|
||||||
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||||
|
public IReadOnlyList<PluginDesktopComponentEditorContribution> DesktopComponentEditors => _desktopComponentEditors;
|
||||||
|
|
||||||
public IPluginExportRegistry ExportRegistry => _exportRegistry;
|
public IPluginExportRegistry ExportRegistry => _exportRegistry;
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginRuntime",
|
"PluginRuntime",
|
||||||
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}.");
|
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
|
||||||
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
|
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -622,6 +624,10 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
entry.Plugin.Manifest.Id,
|
entry.Plugin.Manifest.Id,
|
||||||
loadedPlugin.Manifest.Id,
|
loadedPlugin.Manifest.Id,
|
||||||
StringComparison.OrdinalIgnoreCase));
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
_desktopComponentEditors.RemoveAll(entry => string.Equals(
|
||||||
|
entry.Plugin.Manifest.Id,
|
||||||
|
loadedPlugin.Manifest.Id,
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
foreach (var settingsSection in loadedPlugin.SettingsSections)
|
foreach (var settingsSection in loadedPlugin.SettingsSections)
|
||||||
{
|
{
|
||||||
@@ -632,6 +638,11 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
{
|
{
|
||||||
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var desktopComponentEditor in loadedPlugin.DesktopComponentEditors)
|
||||||
|
{
|
||||||
|
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
||||||
|
|||||||
Reference in New Issue
Block a user