settings_re8

This commit is contained in:
lincube
2026-03-14 22:45:09 +08:00
parent 91f9f3d6fb
commit 689be7b585
54 changed files with 5356 additions and 30 deletions

View File

@@ -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;
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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

View 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;
}
}
}

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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)

View 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";
}
}

View File

@@ -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,