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;
|
||||
}
|
||||
|
||||
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)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -45,8 +46,10 @@ public partial class App : Application
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
private bool _exitCleanupCompleted;
|
||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
@@ -92,6 +95,7 @@ public partial class App : Application
|
||||
ApplyThemeFromSettings();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
EnsureWeatherLocationRefreshService();
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
@@ -119,6 +123,8 @@ public partial class App : Application
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
StartWeatherLocationRefreshIfNeeded();
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
@@ -300,6 +306,35 @@ public partial class App : Application
|
||||
_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()
|
||||
{
|
||||
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="FluentIcons.Avalonia" 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="Microsoft.Extensions.DependencyInjection" 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_px_format": "≈ {0:F1}px",
|
||||
"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_desc": "Choose how weather widgets resolve location.",
|
||||
"settings.weather.mode_city_search": "City Search",
|
||||
@@ -119,6 +120,14 @@
|
||||
"settings.weather.apply_coordinates_button": "Apply Coordinates",
|
||||
"settings.weather.coordinates_saved_format": "Coordinates saved: {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_desc": "Send one test request to verify current settings.",
|
||||
"settings.weather.preview_button": "Test Fetch",
|
||||
@@ -127,6 +136,7 @@
|
||||
"settings.weather.preview_panel_header": "Weather Preview",
|
||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||
"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_missing_location": "Please apply one weather location before testing.",
|
||||
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
||||
@@ -335,12 +345,14 @@
|
||||
"launcher.context.hide_icon": "Hide Icon",
|
||||
"launcher.action.hide": "Hide",
|
||||
"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_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_empty": "No hidden items.",
|
||||
"settings.launcher.hidden_summary_format": "{0} hidden items",
|
||||
"settings.launcher.hidden_type_folder": "Folder",
|
||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
||||
"settings.launcher.hidden_type_shortcut": "App",
|
||||
"settings.launcher.restore_button": "Unhide",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||
@@ -459,6 +471,12 @@
|
||||
"component_library.drag_hint": "Drag to place",
|
||||
"component.delete": "Delete",
|
||||
"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.date": "Calendar",
|
||||
"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.button": "OK"
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
|
||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||
"settings.weather.title": "天气",
|
||||
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
|
||||
"settings.weather.location_source_header": "位置来源",
|
||||
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
||||
"settings.weather.mode_city_search": "城市搜索",
|
||||
@@ -124,6 +125,14 @@
|
||||
"settings.weather.apply_coordinates_button": "应用坐标",
|
||||
"settings.weather.coordinates_saved_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_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||
"settings.weather.preview_button": "测试获取",
|
||||
@@ -132,6 +141,7 @@
|
||||
"settings.weather.preview_panel_header": "天气预览",
|
||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||
"settings.weather.refresh_button": "刷新",
|
||||
"settings.weather.preview_updated_format": "更新于 {0}",
|
||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
||||
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
||||
@@ -340,12 +350,14 @@
|
||||
"launcher.context.hide_icon": "隐藏图标",
|
||||
"launcher.action.hide": "隐藏",
|
||||
"settings.launcher.title": "应用启动台",
|
||||
"settings.launcher.description": "管理应用启动台中已隐藏的应用与文件夹。",
|
||||
"settings.launcher.hidden_header": "已隐藏项目",
|
||||
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
||||
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||
"settings.launcher.hidden_summary_format": "共 {0} 个隐藏项目",
|
||||
"settings.launcher.hidden_type_folder": "文件夹",
|
||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
||||
"settings.launcher.hidden_type_shortcut": "应用",
|
||||
"settings.launcher.restore_button": "取消隐藏",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.runtime_header": "插件运行时",
|
||||
@@ -464,6 +476,12 @@
|
||||
"component_library.drag_hint": "拖动放置",
|
||||
"component.delete": "删除",
|
||||
"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.date": "日历",
|
||||
"component_category.weather": "天气",
|
||||
@@ -806,4 +824,3 @@
|
||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||
"single_instance.notice.button": "确定"
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||
|
||||
public string WeatherIconPackId { get; set; } = "FluentRegular";
|
||||
public string WeatherIconPackId { get; set; } = "HyperOS3";
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ public enum TaskbarActionId
|
||||
MinimizeToWindows,
|
||||
AddDesktopPage,
|
||||
DeleteDesktopPage,
|
||||
EditComponent,
|
||||
DeleteComponent,
|
||||
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
|
||||
{
|
||||
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
|
||||
|
||||
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(
|
||||
WeatherQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
|
||||
double latitude,
|
||||
double longitude,
|
||||
string? locale = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IWeatherSettingsService
|
||||
{
|
||||
WeatherSettingsState Get();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,15 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoSer
|
||||
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()
|
||||
{
|
||||
if (_weatherDataService is IDisposable disposable)
|
||||
@@ -380,7 +389,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
snapshot.WeatherLongitude,
|
||||
snapshot.WeatherAutoRefreshLocation,
|
||||
snapshot.WeatherExcludedAlerts,
|
||||
snapshot.WeatherIconPackId,
|
||||
NormalizeIconPackId(snapshot.WeatherIconPackId),
|
||||
snapshot.WeatherNoTlsRequests,
|
||||
snapshot.WeatherLocationQuery);
|
||||
}
|
||||
@@ -395,7 +404,7 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
snapshot.WeatherLongitude = state.Longitude;
|
||||
snapshot.WeatherAutoRefreshLocation = state.AutoRefreshLocation;
|
||||
snapshot.WeatherExcludedAlerts = state.ExcludedAlerts;
|
||||
snapshot.WeatherIconPackId = state.IconPackId;
|
||||
snapshot.WeatherIconPackId = NormalizeIconPackId(state.IconPackId);
|
||||
snapshot.WeatherNoTlsRequests = state.NoTlsRequests;
|
||||
snapshot.WeatherLocationQuery = state.LocationQuery;
|
||||
_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()
|
||||
{
|
||||
return _weatherProvider;
|
||||
@@ -425,6 +451,13 @@ internal sealed class WeatherSettingsService : IWeatherSettingsService, IDisposa
|
||||
{
|
||||
_weatherProvider.Dispose();
|
||||
}
|
||||
|
||||
private static string NormalizeIconPackId(string? iconPackId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(iconPackId)
|
||||
? "HyperOS3"
|
||||
: "HyperOS3";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RegionSettingsService : IRegionSettingsService
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.SettingsPages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -177,6 +178,8 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
||||
services.AddSingleton(_settingsFacade.Catalog);
|
||||
services.AddSingleton(_hostApplicationLifecycle);
|
||||
services.AddSingleton(_localizationService);
|
||||
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||
services.AddSingleton<WeatherLocationRefreshService>();
|
||||
|
||||
var pluginRuntime = _pluginRuntimeAccessor();
|
||||
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 CityGeoPath { get; init; } = "/wtr-v3/location/city/geo";
|
||||
|
||||
public string AppKey { get; init; } = "weather20151024";
|
||||
|
||||
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(
|
||||
WeatherQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -285,6 +344,44 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
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(
|
||||
JsonElement root,
|
||||
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"
|
||||
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">
|
||||
<Setter Property="Spacing" Value="0" />
|
||||
@@ -189,6 +190,41 @@
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
</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">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
<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 (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(
|
||||
TaskbarActionId.DeleteComponent,
|
||||
L("component.delete", "Delete"),
|
||||
@@ -606,15 +617,12 @@ public partial class MainWindow
|
||||
action.Id == TaskbarActionId.DeleteComponent;
|
||||
var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry;
|
||||
|
||||
Symbol iconSymbol;
|
||||
if (isDeleteAction || isHideAction)
|
||||
var iconSymbol = action.Id switch
|
||||
{
|
||||
iconSymbol = Symbol.Delete;
|
||||
}
|
||||
else
|
||||
{
|
||||
iconSymbol = Symbol.Add;
|
||||
}
|
||||
TaskbarActionId.EditComponent => Symbol.Edit,
|
||||
_ when isDeleteAction || isHideAction => Symbol.Delete,
|
||||
_ => Symbol.Add
|
||||
};
|
||||
|
||||
Control icon = new SymbolIcon
|
||||
{
|
||||
@@ -675,6 +683,9 @@ public partial class MainWindow
|
||||
case "component.delete":
|
||||
DeleteSelectedComponent();
|
||||
break;
|
||||
case "component.edit":
|
||||
OpenSelectedComponentEditor();
|
||||
break;
|
||||
case "launcher.hide":
|
||||
HideSelectedLauncherEntry();
|
||||
break;
|
||||
@@ -695,6 +706,11 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_componentEditorWindowService.Close();
|
||||
}
|
||||
|
||||
ClearTimeZoneServiceBindings(_selectedDesktopComponentHost);
|
||||
DisposeComponentIfNeeded(_selectedDesktopComponentHost);
|
||||
|
||||
@@ -713,6 +729,90 @@ public partial class MainWindow
|
||||
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)
|
||||
{
|
||||
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()
|
||||
{
|
||||
@@ -1680,6 +1780,9 @@ public partial class MainWindow
|
||||
ApplySelectionStateToHost(_selectedDesktopComponentHost, false);
|
||||
_selectedDesktopComponentHost = null;
|
||||
}
|
||||
|
||||
_componentEditorWindowService.Close();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
|
||||
private void BeginDesktopComponentMoveDrag(Border sourceHost, DesktopComponentPlacementSnapshot placement, PointerPressedEventArgs e)
|
||||
|
||||
@@ -158,7 +158,7 @@ public partial class MainWindow
|
||||
_weatherLongitude = snapshot.WeatherLongitude;
|
||||
_weatherAutoRefreshLocation = snapshot.WeatherAutoRefreshLocation;
|
||||
_weatherExcludedAlertsRaw = snapshot.WeatherExcludedAlerts ?? string.Empty;
|
||||
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "FluentRegular" : snapshot.WeatherIconPackId;
|
||||
_weatherIconPackId = string.IsNullOrWhiteSpace(snapshot.WeatherIconPackId) ? "HyperOS3" : snapshot.WeatherIconPackId;
|
||||
_weatherNoTlsRequests = snapshot.WeatherNoTlsRequests;
|
||||
}
|
||||
|
||||
@@ -488,6 +488,7 @@ public partial class MainWindow
|
||||
|
||||
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
||||
{
|
||||
var latestWeatherState = _weatherSettingsService.Get();
|
||||
return new AppSettingsSnapshot
|
||||
{
|
||||
GridShortSideCells = _targetShortSideCells,
|
||||
@@ -500,15 +501,16 @@ public partial class MainWindow
|
||||
WallpaperColor = _wallpaperSolidColor?.ToString(),
|
||||
LanguageCode = _languageCode,
|
||||
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
||||
WeatherLocationMode = _weatherLocationMode.ToString(),
|
||||
WeatherLocationKey = _weatherLocationKey,
|
||||
WeatherLocationName = _weatherLocationName,
|
||||
WeatherLatitude = _weatherLatitude,
|
||||
WeatherLongitude = _weatherLongitude,
|
||||
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
|
||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
||||
WeatherIconPackId = _weatherIconPackId,
|
||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
||||
WeatherLocationMode = latestWeatherState.LocationMode,
|
||||
WeatherLocationKey = latestWeatherState.LocationKey,
|
||||
WeatherLocationName = latestWeatherState.LocationName,
|
||||
WeatherLatitude = latestWeatherState.Latitude,
|
||||
WeatherLongitude = latestWeatherState.Longitude,
|
||||
WeatherAutoRefreshLocation = latestWeatherState.AutoRefreshLocation,
|
||||
WeatherLocationQuery = latestWeatherState.LocationQuery,
|
||||
WeatherExcludedAlerts = latestWeatherState.ExcludedAlerts,
|
||||
WeatherIconPackId = latestWeatherState.IconPackId,
|
||||
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
|
||||
AutoStartWithWindows = _autoStartWithWindows,
|
||||
AppRenderMode = _selectedAppRenderMode,
|
||||
TopStatusComponentIds = [.. _topStatusComponentIds],
|
||||
|
||||
@@ -95,7 +95,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
private readonly ComponentRegistry _componentRegistry;
|
||||
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
||||
private readonly DesktopComponentEditorRegistry _componentEditorRegistry;
|
||||
private readonly IComponentLibraryService _componentLibraryService;
|
||||
private readonly IComponentEditorWindowService _componentEditorWindowService;
|
||||
private readonly IEmbeddedComponentLibraryService _componentLibraryWindowService = new EmbeddedComponentLibraryService();
|
||||
private ComponentLibraryWindow? _detachedComponentLibraryWindow;
|
||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||
@@ -164,7 +166,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private double _weatherLongitude = 116.4074;
|
||||
private bool _weatherAutoRefreshLocation;
|
||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||
private string _weatherIconPackId = "FluentRegular";
|
||||
private string _weatherIconPackId = "HyperOS3";
|
||||
private bool _weatherNoTlsRequests;
|
||||
private bool _autoStartWithWindows;
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
@@ -197,7 +199,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
_componentEditorRegistry = DesktopComponentEditorRegistryFactory.Create(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService);
|
||||
_componentLibraryService = new ComponentLibraryService(_componentRegistry, _componentRuntimeRegistry);
|
||||
_componentEditorWindowService = new ComponentEditorWindowService(_settingsFacade);
|
||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
PropertyChanged += OnWindowPropertyChanged;
|
||||
@@ -307,6 +313,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
PersistSettings();
|
||||
_componentEditorWindowService.Close();
|
||||
if (_detachedComponentLibraryWindow is not null)
|
||||
{
|
||||
_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,
|
||||
"Image" => Symbol.Image,
|
||||
"WeatherMoon" => Symbol.WeatherMoon,
|
||||
"Apps" => Symbol.Apps,
|
||||
"GridDots" => Symbol.GridDots,
|
||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||
"Info" => Symbol.Info,
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
IServiceProvider services,
|
||||
IReadOnlyList<PluginSettingsSectionRegistration> settingsSections,
|
||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
|
||||
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
||||
IReadOnlyList<IHostedService> hostedServices,
|
||||
PluginLoadContext loadContext)
|
||||
@@ -36,6 +37,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
Services = services;
|
||||
SettingsSections = settingsSections;
|
||||
DesktopComponents = desktopComponents;
|
||||
DesktopComponentEditors = desktopComponentEditors;
|
||||
ExportedServices = exportedServices;
|
||||
HostedServices = hostedServices;
|
||||
LoadContext = loadContext;
|
||||
@@ -61,6 +63,8 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentEditorRegistration> DesktopComponentEditors { get; }
|
||||
|
||||
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
||||
|
||||
public PluginLoadContext LoadContext { get; }
|
||||
|
||||
@@ -10,3 +10,7 @@ public sealed record PluginSettingsSectionContribution(
|
||||
public sealed record PluginDesktopComponentContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginDesktopComponentRegistration Registration);
|
||||
|
||||
public sealed record PluginDesktopComponentEditorContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginDesktopComponentEditorRegistration Registration);
|
||||
|
||||
@@ -181,10 +181,14 @@ public sealed class PluginLoader
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var desktopComponentEditors = pluginServices
|
||||
.GetServices<PluginDesktopComponentEditorRegistration>()
|
||||
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var exportedServices = ResolveExports(manifest, pluginServices);
|
||||
AppLogger.Info(
|
||||
"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();
|
||||
StartHostedServices(hostedServices);
|
||||
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
|
||||
@@ -199,6 +203,7 @@ public sealed class PluginLoader
|
||||
pluginServices,
|
||||
settingsSections,
|
||||
desktopComponents,
|
||||
desktopComponentEditors,
|
||||
exportedServices,
|
||||
hostedServices,
|
||||
loadContext);
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
private readonly List<PluginSettingsSectionContribution> _settingsSections = [];
|
||||
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
|
||||
private readonly object _packageMutationGate = new();
|
||||
|
||||
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
||||
@@ -73,6 +74,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
public IReadOnlyList<PluginSettingsSectionContribution> SettingsSections => _settingsSections;
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||
public IReadOnlyList<PluginDesktopComponentEditorContribution> DesktopComponentEditors => _desktopComponentEditors;
|
||||
|
||||
public IPluginExportRegistry ExportRegistry => _exportRegistry;
|
||||
|
||||
@@ -193,7 +195,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||
AppLogger.Info(
|
||||
"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}'.");
|
||||
continue;
|
||||
}
|
||||
@@ -622,6 +624,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
entry.Plugin.Manifest.Id,
|
||||
loadedPlugin.Manifest.Id,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
_desktopComponentEditors.RemoveAll(entry => string.Equals(
|
||||
entry.Plugin.Manifest.Id,
|
||||
loadedPlugin.Manifest.Id,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var settingsSection in loadedPlugin.SettingsSections)
|
||||
{
|
||||
@@ -632,6 +638,11 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
||||
}
|
||||
|
||||
foreach (var desktopComponentEditor in loadedPlugin.DesktopComponentEditors)
|
||||
{
|
||||
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
||||
|
||||
Reference in New Issue
Block a user