From 689be7b5858f2341ad28f5cfbcbd310a258e1329 Mon Sep 17 00:00:00 2001 From: lincube Date: Sat, 14 Mar 2026 22:45:09 +0800 Subject: [PATCH] settings_re8 --- .../IComponentEditorHostContext.cs | 10 + .../PluginDesktopComponentEditorContext.cs | 71 ++ ...luginDesktopComponentEditorRegistration.cs | 77 ++ .../PluginServiceCollectionExtensions.cs | 21 + LanMountainDesktop/App.axaml.cs | 35 + .../DesktopComponentEditorRegistry.cs | 159 ++++ LanMountainDesktop/LanMountainDesktop.csproj | 1 + LanMountainDesktop/Localization/en-US.json | 21 +- LanMountainDesktop/Localization/zh-CN.json | 21 +- .../Models/AppSettingsSnapshot.cs | 2 +- LanMountainDesktop/Models/TaskbarActionId.cs | 1 + .../ComponentEditorMaterialThemeAdapter.cs | 157 ++++ .../Services/ComponentEditorWindowService.cs | 169 +++++ .../DesktopComponentEditorRegistryFactory.cs | 365 ++++++++++ .../Services/IWeatherDataService.cs | 6 + .../Services/LocationService.cs | 351 +++++++++ .../Services/Settings/SettingsContracts.cs | 15 + .../Settings/SettingsDomainServices.cs | 37 +- .../Services/Settings/SettingsPageRegistry.cs | 3 + .../Services/WeatherLocationRefreshService.cs | 158 ++++ .../Services/XiaomiWeatherService.cs | 97 +++ .../ComponentEditorThemeResources.axaml | 175 +++++ .../Styles/SettingsCardStyles.axaml | 38 +- .../LauncherSettingsPageViewModel.cs | 365 ++++++++++ .../WeatherSettingsPageViewModel.cs | 678 ++++++++++++++++++ .../Views/ComponentEditorWindow.axaml | 135 ++++ .../Views/ComponentEditorWindow.axaml.cs | 256 +++++++ .../ClassScheduleComponentEditor.axaml | 36 + .../ClassScheduleComponentEditor.axaml.cs | 284 ++++++++ .../ClockComponentEditor.axaml | 56 ++ .../ClockComponentEditor.axaml.cs | 139 ++++ .../ComponentEditorViewBase.cs | 44 ++ .../DailyArtworkComponentEditor.axaml | 30 + .../DailyArtworkComponentEditor.axaml.cs | 64 ++ .../InformationalComponentEditor.axaml | 37 + .../InformationalComponentEditor.axaml.cs | 31 + .../StudyEnvironmentComponentEditor.axaml | 45 ++ .../StudyEnvironmentComponentEditor.axaml.cs | 72 ++ .../ToggleIntervalComponentEditor.axaml | 56 ++ .../ToggleIntervalComponentEditor.axaml.cs | 204 ++++++ .../WorldClockComponentEditor.axaml | 92 +++ .../WorldClockComponentEditor.axaml.cs | 152 ++++ .../Views/MainWindow.ComponentSystem.cs | 121 +++- .../Views/MainWindow.SettingsHardCut.Stubs.cs | 22 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 9 +- .../SettingsPages/LauncherSettingsPage.axaml | 97 +++ .../LauncherSettingsPage.axaml.cs | 31 + .../SettingsPages/WeatherSettingsPage.axaml | 263 +++++++ .../WeatherSettingsPage.axaml.cs | 47 ++ .../Views/SettingsWindow.axaml.cs | 2 + LanMountainDesktop/plugins/LoadedPlugin.cs | 4 + .../plugins/PluginContributions.cs | 4 + LanMountainDesktop/plugins/PluginLoader.cs | 7 +- .../plugins/PluginRuntimeService.cs | 13 +- 54 files changed, 5356 insertions(+), 30 deletions(-) create mode 100644 LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorContext.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorRegistration.cs create mode 100644 LanMountainDesktop/ComponentSystem/DesktopComponentEditorRegistry.cs create mode 100644 LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs create mode 100644 LanMountainDesktop/Services/ComponentEditorWindowService.cs create mode 100644 LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs create mode 100644 LanMountainDesktop/Services/LocationService.cs create mode 100644 LanMountainDesktop/Services/WeatherLocationRefreshService.cs create mode 100644 LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml create mode 100644 LanMountainDesktop/ViewModels/LauncherSettingsPageViewModel.cs create mode 100644 LanMountainDesktop/ViewModels/WeatherSettingsPageViewModel.cs create mode 100644 LanMountainDesktop/Views/ComponentEditorWindow.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/ClassScheduleComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/ClassScheduleComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/ClockComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/ClockComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/ComponentEditorViewBase.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/DailyArtworkComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/DailyArtworkComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/InformationalComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/InformationalComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/StudyEnvironmentComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/StudyEnvironmentComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/ToggleIntervalComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/ToggleIntervalComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/ComponentEditors/WorldClockComponentEditor.axaml create mode 100644 LanMountainDesktop/Views/ComponentEditors/WorldClockComponentEditor.axaml.cs create mode 100644 LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml create mode 100644 LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml.cs create mode 100644 LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml create mode 100644 LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml.cs diff --git a/LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs b/LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs new file mode 100644 index 0000000..74dd7dc --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IComponentEditorHostContext +{ + void RequestRefresh(); + + void CloseEditor(); + + void RequestRestart(string? reason = null); +} diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorContext.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorContext.cs new file mode 100644 index 0000000..5851d41 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorContext.cs @@ -0,0 +1,71 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginDesktopComponentEditorContext +{ + public PluginDesktopComponentEditorContext( + PluginManifest manifest, + string pluginDirectory, + string dataDirectory, + IServiceProvider services, + IReadOnlyDictionary 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 Properties { get; } + + public string ComponentId { get; } + + public string? PlacementId { get; } + + public IPluginSettingsService? PluginSettings { get; } + + public IComponentEditorHostContext HostContext { get; } + + public T? GetService() + { + return (T?)Services.GetService(typeof(T)); + } + + public bool TryGetProperty(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; + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorRegistration.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorRegistration.cs new file mode 100644 index 0000000..340e2b2 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentEditorRegistration.cs @@ -0,0 +1,77 @@ +using Avalonia.Controls; + +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginDesktopComponentEditorRegistration +{ + public PluginDesktopComponentEditorRegistration( + string componentId, + Func 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 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 EditorFactory { get; } + + public double PreferredWidth { get; } + + public double PreferredHeight { get; } + + public double MinScale { get; } + + public double MaxScale { get; } + + public double AspectRatio { get; } +} diff --git a/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs index 322997c..a53e4f6 100644 --- a/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs +++ b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs @@ -61,6 +61,27 @@ public static class PluginServiceCollectionExtensions return services; } + public static IServiceCollection AddPluginDesktopComponentEditor( + 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(provider, context), + preferredWidth, + preferredHeight, + minScale, + maxScale)); + return services; + } + public static IServiceCollection AddPluginExport(this IServiceCollection services) where TContract : class where TImplementation : class, TContract diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 2e4f890..3a03541 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -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(); diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentEditorRegistry.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentEditorRegistry.cs new file mode 100644 index 0000000..1019739 --- /dev/null +++ b/LanMountainDesktop/ComponentSystem/DesktopComponentEditorRegistry.cs @@ -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 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 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 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 _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 _descriptors; + + public DesktopComponentEditorRegistry( + ComponentRegistry componentRegistry, + IEnumerable registrations) + { + ArgumentNullException.ThrowIfNull(componentRegistry); + ArgumentNullException.ThrowIfNull(registrations); + + _descriptors = new Dictionary(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 GetAll() + { + return _descriptors.Values.ToList(); + } +} diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index b2e7c78..4393448 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -46,6 +46,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index f366cc0..49c1da4 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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" } - diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index d51ec38..d9f586d 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -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": "确定" } - diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 1751a4a..1202fca 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -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; } diff --git a/LanMountainDesktop/Models/TaskbarActionId.cs b/LanMountainDesktop/Models/TaskbarActionId.cs index 0f1b434..ada1ef2 100644 --- a/LanMountainDesktop/Models/TaskbarActionId.cs +++ b/LanMountainDesktop/Models/TaskbarActionId.cs @@ -5,6 +5,7 @@ public enum TaskbarActionId MinimizeToWindows, AddDesktopPage, DeleteDesktopPage, + EditComponent, DeleteComponent, HideLauncherEntry } diff --git a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs new file mode 100644 index 0000000..a0a6848 --- /dev/null +++ b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs @@ -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 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 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; + } +} diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs new file mode 100644 index 0000000..bf5ef0e --- /dev/null +++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs @@ -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? 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? _restartAction; + + public HostContext( + ComponentEditorWindowService owner, + Action refreshAction, + Action? 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); + } + } +} diff --git a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs new file mode 100644 index 0000000..a4eb18f --- /dev/null +++ b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs @@ -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( + 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 GetBuiltInRegistrations(ComponentRegistry componentRegistry) + { + var registrations = new Dictionary(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 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); + } +} diff --git a/LanMountainDesktop/Services/IWeatherDataService.cs b/LanMountainDesktop/Services/IWeatherDataService.cs index 186a5f7..3be5a47 100644 --- a/LanMountainDesktop/Services/IWeatherDataService.cs +++ b/LanMountainDesktop/Services/IWeatherDataService.cs @@ -34,6 +34,12 @@ public sealed record WeatherQueryResult( public interface IWeatherInfoService { Task> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default); + + Task> ResolveLocationAsync( + double latitude, + double longitude, + string? locale = null, + CancellationToken cancellationToken = default); } public interface IWeatherDataService : IWeatherInfoService diff --git a/LanMountainDesktop/Services/LocationService.cs b/LanMountainDesktop/Services/LocationService.cs new file mode 100644 index 0000000..c605859 --- /dev/null +++ b/LanMountainDesktop/Services/LocationService.cs @@ -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 TryGetCurrentLocationAsync(CancellationToken cancellationToken = default); +} + +public sealed class UnsupportedLocationService : ILocationService +{ + public bool IsSupported => false; + + public Task 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 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 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. + } + } + } + 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; + } + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 882790c..b7c20ac 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -120,12 +120,27 @@ public interface IWeatherProvider Task> GetWeatherAsync( WeatherQuery query, CancellationToken cancellationToken = default); + + Task> ResolveLocationAsync( + double latitude, + double longitude, + string? locale = null, + CancellationToken cancellationToken = default); } public interface IWeatherSettingsService { WeatherSettingsState Get(); void Save(WeatherSettingsState state); + Task>> SearchLocationsAsync( + string keyword, + string? locale = null, + CancellationToken cancellationToken = default); + Task> ResolveLocationAsync( + double latitude, + double longitude, + string? locale = null, + CancellationToken cancellationToken = default); IWeatherInfoService GetWeatherInfoService(); } diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 0337cfe..ca6f12b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -350,6 +350,15 @@ internal sealed class WeatherProviderAdapter : IWeatherProvider, IWeatherInfoSer return _weatherDataService.GetWeatherAsync(query, cancellationToken); } + public Task> 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>> SearchLocationsAsync( + string keyword, + string? locale = null, + CancellationToken cancellationToken = default) + { + return _weatherProvider.SearchLocationsAsync(keyword, locale, cancellationToken); + } + + public Task> 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 diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs index 3b5ca9c..e24606f 100644 --- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs +++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs @@ -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(_ => HostLocationServiceProvider.GetOrCreate()); + services.AddSingleton(); var pluginRuntime = _pluginRuntimeAccessor(); if (pluginRuntime is not null) diff --git a/LanMountainDesktop/Services/WeatherLocationRefreshService.cs b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs new file mode 100644 index 0000000..3830e92 --- /dev/null +++ b/LanMountainDesktop/Services/WeatherLocationRefreshService.cs @@ -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 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 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 ?? ""}'."); + } + + 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"; + } +} diff --git a/LanMountainDesktop/Services/XiaomiWeatherService.cs b/LanMountainDesktop/Services/XiaomiWeatherService.cs index 53adba5..7985e75 100644 --- a/LanMountainDesktop/Services/XiaomiWeatherService.cs +++ b/LanMountainDesktop/Services/XiaomiWeatherService.cs @@ -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> ResolveLocationAsync( + double latitude, + double longitude, + string? locale = null, + CancellationToken cancellationToken = default) + { + var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? _options.Locale : locale.Trim(); + var parameters = new Dictionary + { + ["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.Fail( + "http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WeatherQueryResult.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.Fail("not_found", "No weather location could be resolved from the provided coordinates.") + : WeatherQueryResult.Ok(location); + } + catch (Exception ex) + { + return WeatherQueryResult.Fail("parse_error", ex.Message); + } + } + public async Task> 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, diff --git a/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml b/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml new file mode 100644 index 0000000..3fcbaf8 --- /dev/null +++ b/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Styles/SettingsCardStyles.axaml b/LanMountainDesktop/Styles/SettingsCardStyles.axaml index 47d6db8..c092ccd 100644 --- a/LanMountainDesktop/Styles/SettingsCardStyles.axaml +++ b/LanMountainDesktop/Styles/SettingsCardStyles.axaml @@ -1,6 +1,7 @@ + xmlns:ui="using:FluentAvalonia.UI.Controls" + xmlns:fi="using:FluentIcons.Avalonia.Fluent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs b/LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs new file mode 100644 index 0000000..febf883 --- /dev/null +++ b/LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs @@ -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 _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().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(); + } +} diff --git a/LanMountainDesktop/Views/ComponentEditors/ClassScheduleComponentEditor.axaml b/LanMountainDesktop/Views/ComponentEditors/ClassScheduleComponentEditor.axaml new file mode 100644 index 0000000..392dae9 --- /dev/null +++ b/LanMountainDesktop/Views/ComponentEditors/ClassScheduleComponentEditor.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + +