mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 17:24:27 +08:00
changed.天气选项卡更新
This commit is contained in:
102
LanMountainDesktop.Tests/WeatherPreviewDataTests.cs
Normal file
102
LanMountainDesktop.Tests/WeatherPreviewDataTests.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class WeatherPreviewDataTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WeatherSnapshot_DefaultAlerts_IsEmpty()
|
||||||
|
{
|
||||||
|
var snapshot = new WeatherSnapshot(
|
||||||
|
Provider: "Test",
|
||||||
|
LocationKey: "test",
|
||||||
|
LocationName: "Test City",
|
||||||
|
Latitude: 0,
|
||||||
|
Longitude: 0,
|
||||||
|
FetchedAt: DateTimeOffset.UtcNow,
|
||||||
|
ObservationTime: null,
|
||||||
|
Current: new WeatherCurrentCondition(24, 25, 58, 42, 12, 180, 1, true, "Partly cloudy"),
|
||||||
|
DailyForecasts: [],
|
||||||
|
HourlyForecasts: []);
|
||||||
|
|
||||||
|
Assert.NotNull(snapshot.Alerts);
|
||||||
|
Assert.Empty(snapshot.Alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task XiaomiWeatherService_GetWeatherAsync_ParsesAlerts()
|
||||||
|
{
|
||||||
|
const string payload = """
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"current": {
|
||||||
|
"temperature": { "value": 24 },
|
||||||
|
"feelsLike": { "value": 26 },
|
||||||
|
"humidity": { "value": 58 },
|
||||||
|
"weather": { "value": 7, "text": "Light rain" },
|
||||||
|
"wind": { "speed": { "value": 12 }, "direction": { "value": 180 } },
|
||||||
|
"pubTime": "2026-05-18T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"aqi": { "value": 42 },
|
||||||
|
"forecastDaily": {
|
||||||
|
"temperature": { "value": [{ "from": 20, "to": 28 }] },
|
||||||
|
"weather": { "value": [{ "from": 7, "to": 7 }] },
|
||||||
|
"sunRiseSet": { "value": [{ "from": "05:42", "to": "18:54" }] },
|
||||||
|
"precipitationProbability": { "value": [{ "value": 60 }] }
|
||||||
|
},
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"title": "Heavy rain warning",
|
||||||
|
"detail": "Rain is expected within the next hour.",
|
||||||
|
"type": "Rain",
|
||||||
|
"level": "Yellow",
|
||||||
|
"pubTime": "2026-05-18T09:30:00+08:00",
|
||||||
|
"images": { "icon": "https://example.test/rain.webp" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var httpClient = new HttpClient(new StubHandler(payload));
|
||||||
|
var service = new XiaomiWeatherService(
|
||||||
|
new XiaomiWeatherApiOptions { BaseUrl = "https://example.test" },
|
||||||
|
httpClient);
|
||||||
|
|
||||||
|
var result = await service.GetWeatherAsync(new WeatherQuery(
|
||||||
|
"101010100",
|
||||||
|
39.9042,
|
||||||
|
116.4074,
|
||||||
|
ForecastDays: 3,
|
||||||
|
Locale: "en_us",
|
||||||
|
ForceRefresh: true));
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
|
var alert = Assert.Single(result.Data!.Alerts);
|
||||||
|
Assert.Equal("Heavy rain warning", alert.Title);
|
||||||
|
Assert.Equal("Rain is expected within the next hour.", alert.Detail);
|
||||||
|
Assert.Equal("Rain", alert.Type);
|
||||||
|
Assert.Equal("Yellow", alert.Level);
|
||||||
|
Assert.Equal("https://example.test/rain.webp", alert.IconUri);
|
||||||
|
Assert.NotNull(alert.PublishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(string responseText) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(responseText)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,6 +175,19 @@
|
|||||||
"settings.weather.settings_section": "Settings",
|
"settings.weather.settings_section": "Settings",
|
||||||
"settings.weather.preview_panel_header": "Weather Preview",
|
"settings.weather.preview_panel_header": "Weather Preview",
|
||||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||||
|
"settings.weather.preview_metrics_header": "Current conditions",
|
||||||
|
"settings.weather.preview_alerts_header": "Weather alerts",
|
||||||
|
"settings.weather.preview_no_alerts": "No active weather alerts.",
|
||||||
|
"settings.weather.metric_humidity": "Humidity",
|
||||||
|
"settings.weather.metric_aqi": "AQI",
|
||||||
|
"settings.weather.metric_wind": "Wind",
|
||||||
|
"settings.weather.metric_feels_like": "Feels like",
|
||||||
|
"settings.weather.metric_precipitation": "Precipitation",
|
||||||
|
"settings.weather.metric_sun": "Sunrise / sunset",
|
||||||
|
"settings.weather.alert_untitled": "Weather alert",
|
||||||
|
"settings.weather.alert_no_detail": "No details were provided.",
|
||||||
|
"settings.weather.alert_active": "Active alert",
|
||||||
|
"settings.weather.alert_published_format": "Published {0}",
|
||||||
"settings.weather.refresh_button": "Refresh",
|
"settings.weather.refresh_button": "Refresh",
|
||||||
"settings.weather.preview_updated_format": "Updated {0}",
|
"settings.weather.preview_updated_format": "Updated {0}",
|
||||||
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
||||||
|
|||||||
@@ -173,6 +173,19 @@
|
|||||||
"settings.weather.settings_section": "設定",
|
"settings.weather.settings_section": "設定",
|
||||||
"settings.weather.preview_panel_header": "天気プレビュー",
|
"settings.weather.preview_panel_header": "天気プレビュー",
|
||||||
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
|
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
|
||||||
|
"settings.weather.preview_metrics_header": "現在の気象データ",
|
||||||
|
"settings.weather.preview_alerts_header": "気象アラート",
|
||||||
|
"settings.weather.preview_no_alerts": "有効な気象アラートはありません。",
|
||||||
|
"settings.weather.metric_humidity": "湿度",
|
||||||
|
"settings.weather.metric_aqi": "AQI",
|
||||||
|
"settings.weather.metric_wind": "風",
|
||||||
|
"settings.weather.metric_feels_like": "体感",
|
||||||
|
"settings.weather.metric_precipitation": "降水確率",
|
||||||
|
"settings.weather.metric_sun": "日の出 / 日の入",
|
||||||
|
"settings.weather.alert_untitled": "気象アラート",
|
||||||
|
"settings.weather.alert_no_detail": "詳細はありません。",
|
||||||
|
"settings.weather.alert_active": "有効なアラート",
|
||||||
|
"settings.weather.alert_published_format": "{0}に発表",
|
||||||
"settings.weather.refresh_button": "更新",
|
"settings.weather.refresh_button": "更新",
|
||||||
"settings.weather.preview_updated_format": "{0}に更新",
|
"settings.weather.preview_updated_format": "{0}に更新",
|
||||||
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
|
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
|
||||||
|
|||||||
@@ -174,6 +174,19 @@
|
|||||||
"settings.weather.settings_section": "설정",
|
"settings.weather.settings_section": "설정",
|
||||||
"settings.weather.preview_panel_header": "날씨 미리보기",
|
"settings.weather.preview_panel_header": "날씨 미리보기",
|
||||||
"settings.weather.preview_panel_desc": "현재 날씨 서비스 상태를 새로고침하고 확인합니다.",
|
"settings.weather.preview_panel_desc": "현재 날씨 서비스 상태를 새로고침하고 확인합니다.",
|
||||||
|
"settings.weather.preview_metrics_header": "현재 날씨 데이터",
|
||||||
|
"settings.weather.preview_alerts_header": "기상 경보",
|
||||||
|
"settings.weather.preview_no_alerts": "활성 기상 경보가 없습니다.",
|
||||||
|
"settings.weather.metric_humidity": "습도",
|
||||||
|
"settings.weather.metric_aqi": "AQI",
|
||||||
|
"settings.weather.metric_wind": "바람",
|
||||||
|
"settings.weather.metric_feels_like": "체감",
|
||||||
|
"settings.weather.metric_precipitation": "강수 확률",
|
||||||
|
"settings.weather.metric_sun": "일출 / 일몰",
|
||||||
|
"settings.weather.alert_untitled": "기상 경보",
|
||||||
|
"settings.weather.alert_no_detail": "제공된 세부 정보가 없습니다.",
|
||||||
|
"settings.weather.alert_active": "활성 경보",
|
||||||
|
"settings.weather.alert_published_format": "{0}에 발표됨",
|
||||||
"settings.weather.refresh_button": "새로고침",
|
"settings.weather.refresh_button": "새로고침",
|
||||||
"settings.weather.preview_updated_format": "{0}에 업데이트됨",
|
"settings.weather.preview_updated_format": "{0}에 업데이트됨",
|
||||||
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
|
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
|
||||||
|
|||||||
@@ -175,6 +175,19 @@
|
|||||||
"settings.weather.settings_section": "设置",
|
"settings.weather.settings_section": "设置",
|
||||||
"settings.weather.preview_panel_header": "天气预览",
|
"settings.weather.preview_panel_header": "天气预览",
|
||||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||||
|
"settings.weather.preview_metrics_header": "当前天气数据",
|
||||||
|
"settings.weather.preview_alerts_header": "气象预警",
|
||||||
|
"settings.weather.preview_no_alerts": "暂无活动气象预警。",
|
||||||
|
"settings.weather.metric_humidity": "湿度",
|
||||||
|
"settings.weather.metric_aqi": "AQI",
|
||||||
|
"settings.weather.metric_wind": "风力",
|
||||||
|
"settings.weather.metric_feels_like": "体感",
|
||||||
|
"settings.weather.metric_precipitation": "降水概率",
|
||||||
|
"settings.weather.metric_sun": "日出 / 日落",
|
||||||
|
"settings.weather.alert_untitled": "气象预警",
|
||||||
|
"settings.weather.alert_no_detail": "暂无预警详情。",
|
||||||
|
"settings.weather.alert_active": "活动预警",
|
||||||
|
"settings.weather.alert_published_format": "发布于 {0}",
|
||||||
"settings.weather.refresh_button": "刷新",
|
"settings.weather.refresh_button": "刷新",
|
||||||
"settings.weather.preview_updated_format": "更新于 {0}",
|
"settings.weather.preview_updated_format": "更新于 {0}",
|
||||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ public sealed record WeatherHourlyForecast(
|
|||||||
int? WeatherCode,
|
int? WeatherCode,
|
||||||
string? WeatherText);
|
string? WeatherText);
|
||||||
|
|
||||||
|
public sealed record WeatherAlert(
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Type,
|
||||||
|
string? Level,
|
||||||
|
DateTimeOffset? PublishedAt,
|
||||||
|
string? IconUri);
|
||||||
|
|
||||||
public sealed record WeatherSnapshot(
|
public sealed record WeatherSnapshot(
|
||||||
string Provider,
|
string Provider,
|
||||||
string LocationKey,
|
string LocationKey,
|
||||||
@@ -49,4 +57,7 @@ public sealed record WeatherSnapshot(
|
|||||||
DateTimeOffset? ObservationTime,
|
DateTimeOffset? ObservationTime,
|
||||||
WeatherCurrentCondition Current,
|
WeatherCurrentCondition Current,
|
||||||
IReadOnlyList<WeatherDailyForecast> DailyForecasts,
|
IReadOnlyList<WeatherDailyForecast> DailyForecasts,
|
||||||
IReadOnlyList<WeatherHourlyForecast> HourlyForecasts);
|
IReadOnlyList<WeatherHourlyForecast> HourlyForecasts)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<WeatherAlert> Alerts { get; init; } = Array.Empty<WeatherAlert>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -375,6 +375,10 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
|||||||
var hourlyNode = TryGetNode(payload, "forecastHourly") ??
|
var hourlyNode = TryGetNode(payload, "forecastHourly") ??
|
||||||
TryGetNode(payload, "hourly") ??
|
TryGetNode(payload, "hourly") ??
|
||||||
TryGetNode(payload, "hourlyForecast");
|
TryGetNode(payload, "hourlyForecast");
|
||||||
|
var alertsNode = TryGetNode(payload, "alerts") ??
|
||||||
|
TryGetNode(payload, "alert") ??
|
||||||
|
TryGetNode(payload, "warning") ??
|
||||||
|
TryGetNode(payload, "warnings");
|
||||||
|
|
||||||
var weatherCode = ReadWeatherCode(currentNode);
|
var weatherCode = ReadWeatherCode(currentNode);
|
||||||
|
|
||||||
@@ -422,7 +426,84 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
|||||||
ObservationTime: observationTime,
|
ObservationTime: observationTime,
|
||||||
Current: current,
|
Current: current,
|
||||||
DailyForecasts: forecasts,
|
DailyForecasts: forecasts,
|
||||||
HourlyForecasts: hourlyForecasts);
|
HourlyForecasts: hourlyForecasts)
|
||||||
|
{
|
||||||
|
Alerts = ParseAlerts(alertsNode)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<WeatherAlert> ParseAlerts(JsonElement? alertsNode)
|
||||||
|
{
|
||||||
|
if (!alertsNode.HasValue)
|
||||||
|
{
|
||||||
|
return Array.Empty<WeatherAlert>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var array = alertsNode.Value.ValueKind == JsonValueKind.Array
|
||||||
|
? alertsNode
|
||||||
|
: ReadArray(alertsNode.Value, "value") ??
|
||||||
|
ReadArray(alertsNode.Value, "alerts") ??
|
||||||
|
ReadArray(alertsNode.Value, "alert") ??
|
||||||
|
(alertsNode.Value.ValueKind == JsonValueKind.Object ? alertsNode : null);
|
||||||
|
|
||||||
|
if (!array.HasValue)
|
||||||
|
{
|
||||||
|
return Array.Empty<WeatherAlert>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var alerts = new List<WeatherAlert>();
|
||||||
|
if (array.Value.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
AddAlert(array.Value, alerts);
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array.Value.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return Array.Empty<WeatherAlert>();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in array.Value.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
AddAlert(item, alerts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddAlert(JsonElement item, ICollection<WeatherAlert> alerts)
|
||||||
|
{
|
||||||
|
var title = ReadString(item, "title") ??
|
||||||
|
ReadString(item, "name") ??
|
||||||
|
ReadString(item, "headline") ??
|
||||||
|
ReadString(item, "text");
|
||||||
|
var detail = ReadString(item, "detail") ??
|
||||||
|
ReadString(item, "description") ??
|
||||||
|
ReadString(item, "content") ??
|
||||||
|
ReadString(item, "desc");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(detail))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts.Add(new WeatherAlert(
|
||||||
|
string.IsNullOrWhiteSpace(title) ? Truncate(detail, 60) : title.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(),
|
||||||
|
NullIfWhiteSpace(ReadString(item, "type") ?? ReadString(item, "category")),
|
||||||
|
NullIfWhiteSpace(ReadString(item, "level") ?? ReadString(item, "severity")),
|
||||||
|
ParseTime(ReadString(item, "pubTime") ??
|
||||||
|
ReadString(item, "publishTime") ??
|
||||||
|
ReadString(item, "publishedAt") ??
|
||||||
|
ReadString(item, "time")),
|
||||||
|
NullIfWhiteSpace(ReadString(item, "images", "icon") ??
|
||||||
|
ReadString(item, "image", "icon") ??
|
||||||
|
ReadString(item, "icon") ??
|
||||||
|
ReadString(item, "iconUri") ??
|
||||||
|
ReadString(item, "iconUrl"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<WeatherDailyForecast> ParseDailyForecasts(JsonElement? dailyNode, int days, string locale)
|
private IReadOnlyList<WeatherDailyForecast> ParseDailyForecasts(JsonElement? dailyNode, int days, string locale)
|
||||||
@@ -977,4 +1058,9 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
|||||||
? text
|
? text
|
||||||
: $"{text[..maxLength]}...";
|
: $"{text[..maxLength]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? text)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<WeatherLocation> SearchResults { get; } = [];
|
public ObservableCollection<WeatherLocation> SearchResults { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<WeatherPreviewMetric> PreviewMetrics { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<WeatherPreviewAlert> PreviewAlerts { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _pageTitle = string.Empty;
|
private string _pageTitle = string.Empty;
|
||||||
|
|
||||||
@@ -168,6 +172,15 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _footerHint = string.Empty;
|
private string _footerHint = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewMetricsHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewAlertsHeader = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _previewNoAlertsText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption _selectedLocationMode = new("CitySearch", "City Search");
|
private SelectionOption _selectedLocationMode = new("CitySearch", "City Search");
|
||||||
|
|
||||||
@@ -246,6 +259,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _previewStatus = string.Empty;
|
private string _previewStatus = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _hasPreviewAlerts;
|
||||||
|
|
||||||
partial void OnSelectedLocationModeChanged(SelectionOption value)
|
partial void OnSelectedLocationModeChanged(SelectionOption value)
|
||||||
{
|
{
|
||||||
UpdateModeVisibility();
|
UpdateModeVisibility();
|
||||||
@@ -279,6 +295,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
_settingsFacade.Weather.Save(CreateEditableState());
|
_settingsFacade.Weather.Save(CreateEditableState());
|
||||||
|
UpdatePreviewDetails(_previewSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnExcludedAlertsChanged(string value)
|
partial void OnExcludedAlertsChanged(string value)
|
||||||
@@ -447,6 +464,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
PreviewTemperature = "--";
|
PreviewTemperature = "--";
|
||||||
PreviewCondition = string.Empty;
|
PreviewCondition = string.Empty;
|
||||||
PreviewUpdated = string.Empty;
|
PreviewUpdated = string.Empty;
|
||||||
|
UpdatePreviewDetails(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +484,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
|
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
|
||||||
_previewSnapshot = null;
|
_previewSnapshot = null;
|
||||||
UpdatePreviewIcon(null);
|
UpdatePreviewIcon(null);
|
||||||
|
UpdatePreviewDetails(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,10 +494,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
||||||
? state.LocationName
|
? state.LocationName
|
||||||
: snapshot.LocationName!;
|
: snapshot.LocationName!;
|
||||||
PreviewTemperature = snapshot.Current.TemperatureC.HasValue
|
PreviewTemperature = FormatTemperatureWithUnit(snapshot.Current.TemperatureC);
|
||||||
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value)
|
|
||||||
: "--";
|
|
||||||
PreviewCondition = ResolveWeatherDisplayText(snapshot.Current.WeatherText, snapshot.Current.WeatherCode);
|
PreviewCondition = ResolveWeatherDisplayText(snapshot.Current.WeatherText, snapshot.Current.WeatherCode);
|
||||||
|
UpdatePreviewDetails(snapshot);
|
||||||
|
|
||||||
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
|
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
|
||||||
PreviewUpdated = string.Format(
|
PreviewUpdated = string.Format(
|
||||||
@@ -546,10 +564,17 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
PreviewIcon = null;
|
PreviewIcon = null;
|
||||||
UpdatePreviewIcon(null);
|
UpdatePreviewIcon(null);
|
||||||
PreviewLocation = previewLocation.Name;
|
PreviewLocation = previewLocation.Name;
|
||||||
PreviewTemperature = "24 deg C";
|
PreviewTemperature = "24°C";
|
||||||
PreviewCondition = ResolveWeatherDisplayText("Partly cloudy", 4);
|
PreviewCondition = ResolveWeatherDisplayText("Partly cloudy", 4);
|
||||||
PreviewUpdated = "Updated 09:42";
|
PreviewUpdated = "Updated 09:42";
|
||||||
PreviewStatus = "Preview data is mocked for Avalonia design mode.";
|
PreviewStatus = "Preview data is mocked for Avalonia design mode.";
|
||||||
|
PreviewMetrics.Clear();
|
||||||
|
PreviewMetrics.Add(new WeatherPreviewMetric("Humidity", "58%", "\uE794"));
|
||||||
|
PreviewMetrics.Add(new WeatherPreviewMetric("AQI", "42", "\uEA7D"));
|
||||||
|
PreviewMetrics.Add(new WeatherPreviewMetric("Wind", "12 km/h", "\uF43B"));
|
||||||
|
PreviewMetrics.Add(new WeatherPreviewMetric("Feels like", "25°C", "\uE706"));
|
||||||
|
PreviewAlerts.Clear();
|
||||||
|
HasPreviewAlerts = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
@@ -558,6 +583,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
PageDescription = L("settings.weather.description", "Configure weather location, weather preview, and startup positioning behavior.");
|
PageDescription = L("settings.weather.description", "Configure weather location, weather preview, and startup positioning behavior.");
|
||||||
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||||
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
PreviewDescription = L("settings.weather.preview_panel_desc", "Refresh and verify current weather service status.");
|
||||||
|
PreviewMetricsHeader = L("settings.weather.preview_metrics_header", "Current conditions");
|
||||||
|
PreviewAlertsHeader = L("settings.weather.preview_alerts_header", "Weather alerts");
|
||||||
|
PreviewNoAlertsText = L("settings.weather.preview_no_alerts", "No active weather alerts.");
|
||||||
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
LocationSourceHeader = L("settings.weather.location_source_header", "Location Source");
|
||||||
LocationSourceDescription = L("settings.weather.location_source_desc", "Choose how weather widgets resolve location.");
|
LocationSourceDescription = L("settings.weather.location_source_desc", "Choose how weather widgets resolve location.");
|
||||||
CitySearchHeader = L("settings.weather.city_search_header", "City Search");
|
CitySearchHeader = L("settings.weather.city_search_header", "City Search");
|
||||||
@@ -746,6 +774,131 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
: WeatherIconAssetResolver.LoadIcon(styleId, snapshot);
|
: WeatherIconAssetResolver.LoadIcon(styleId, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdatePreviewDetails(WeatherSnapshot? snapshot)
|
||||||
|
{
|
||||||
|
PreviewMetrics.Clear();
|
||||||
|
PreviewAlerts.Clear();
|
||||||
|
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
HasPreviewAlerts = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddPreviewMetric(
|
||||||
|
L("settings.weather.metric_humidity", "Humidity"),
|
||||||
|
snapshot.Current.RelativeHumidityPercent is int humidity
|
||||||
|
? string.Format(CultureInfo.InvariantCulture, "{0}%", humidity)
|
||||||
|
: "--",
|
||||||
|
"\uE794");
|
||||||
|
AddPreviewMetric(
|
||||||
|
L("settings.weather.metric_aqi", "AQI"),
|
||||||
|
snapshot.Current.AirQualityIndex?.ToString(CultureInfo.InvariantCulture) ?? "--",
|
||||||
|
"\uEA7D");
|
||||||
|
AddPreviewMetric(
|
||||||
|
L("settings.weather.metric_wind", "Wind"),
|
||||||
|
snapshot.Current.WindSpeedKph.HasValue
|
||||||
|
? string.Format(CultureInfo.InvariantCulture, "{0:0.#} km/h", snapshot.Current.WindSpeedKph.Value)
|
||||||
|
: "--",
|
||||||
|
"\uF43B");
|
||||||
|
AddPreviewMetric(
|
||||||
|
L("settings.weather.metric_feels_like", "Feels like"),
|
||||||
|
FormatTemperatureWithUnit(snapshot.Current.FeelsLikeC),
|
||||||
|
"\uE706");
|
||||||
|
|
||||||
|
var today = snapshot.DailyForecasts.FirstOrDefault();
|
||||||
|
if (today is not null)
|
||||||
|
{
|
||||||
|
AddPreviewMetric(
|
||||||
|
L("settings.weather.metric_precipitation", "Precipitation"),
|
||||||
|
today.PrecipitationProbabilityPercent is int precipitation
|
||||||
|
? string.Format(CultureInfo.InvariantCulture, "{0}%", precipitation)
|
||||||
|
: "--",
|
||||||
|
"\uE9C4");
|
||||||
|
|
||||||
|
var sunValue = string.IsNullOrWhiteSpace(today.SunriseTime) && string.IsNullOrWhiteSpace(today.SunsetTime)
|
||||||
|
? "--"
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0} / {1}",
|
||||||
|
string.IsNullOrWhiteSpace(today.SunriseTime) ? "--" : today.SunriseTime,
|
||||||
|
string.IsNullOrWhiteSpace(today.SunsetTime) ? "--" : today.SunsetTime);
|
||||||
|
AddPreviewMetric(L("settings.weather.metric_sun", "Sunrise / sunset"), sunValue, "\uE706");
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludedRules = SplitAlertExclusions();
|
||||||
|
foreach (var alert in snapshot.Alerts.Where(alert => !IsAlertExcluded(alert, excludedRules)))
|
||||||
|
{
|
||||||
|
PreviewAlerts.Add(new WeatherPreviewAlert(
|
||||||
|
string.IsNullOrWhiteSpace(alert.Title) ? L("settings.weather.alert_untitled", "Weather alert") : alert.Title,
|
||||||
|
string.IsNullOrWhiteSpace(alert.Detail) ? L("settings.weather.alert_no_detail", "No details were provided.") : alert.Detail!,
|
||||||
|
BuildAlertMetadata(alert),
|
||||||
|
alert.IconUri));
|
||||||
|
}
|
||||||
|
|
||||||
|
HasPreviewAlerts = PreviewAlerts.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPreviewMetric(string label, string value, string glyph)
|
||||||
|
{
|
||||||
|
PreviewMetrics.Add(new WeatherPreviewMetric(label, value, glyph));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] SplitAlertExclusions()
|
||||||
|
{
|
||||||
|
return (ExcludedAlerts ?? string.Empty)
|
||||||
|
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAlertExcluded(WeatherAlert alert, IReadOnlyList<string> excludedRules)
|
||||||
|
{
|
||||||
|
if (excludedRules.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var haystack = string.Join(
|
||||||
|
'\n',
|
||||||
|
alert.Title,
|
||||||
|
alert.Detail,
|
||||||
|
alert.Type,
|
||||||
|
alert.Level);
|
||||||
|
return excludedRules.Any(rule => haystack.Contains(rule, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildAlertMetadata(WeatherAlert alert)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(alert.Level))
|
||||||
|
{
|
||||||
|
parts.Add(alert.Level!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(alert.Type))
|
||||||
|
{
|
||||||
|
parts.Add(alert.Type!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert.PublishedAt.HasValue)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(
|
||||||
|
ResolveCulture(),
|
||||||
|
L("settings.weather.alert_published_format", "Published {0}"),
|
||||||
|
alert.PublishedAt.Value.ToLocalTime().ToString("g", ResolveCulture())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Count == 0
|
||||||
|
? L("settings.weather.alert_active", "Active alert")
|
||||||
|
: string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTemperatureWithUnit(double? value)
|
||||||
|
{
|
||||||
|
return value.HasValue
|
||||||
|
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", value.Value)
|
||||||
|
: "--";
|
||||||
|
}
|
||||||
|
|
||||||
private string ResolveWeatherDisplayText(string? weatherText, int? weatherCode)
|
private string ResolveWeatherDisplayText(string? weatherText, int? weatherCode)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(weatherText))
|
if (!string.IsNullOrWhiteSpace(weatherText))
|
||||||
@@ -777,3 +930,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
|||||||
: "zh_cn";
|
: "zh_cn";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record WeatherPreviewMetric(string Label, string Value, string Glyph);
|
||||||
|
|
||||||
|
public sealed record WeatherPreviewAlert(string Title, string Detail, string Metadata, string? IconUri);
|
||||||
|
|||||||
@@ -10,51 +10,149 @@
|
|||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
IsExpanded="True"
|
||||||
|
Header="{Binding PreviewHeader}"
|
||||||
|
Description="{Binding PreviewDescription}">
|
||||||
|
<ui:FASettingsExpander.IconSource>
|
||||||
|
<ui:FAFontIconSource Glyph="󰔄" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||||
|
</ui:FASettingsExpander.IconSource>
|
||||||
|
<ui:FASettingsExpander.Footer>
|
||||||
|
<Grid Width="104">
|
||||||
|
<Button Classes="settings-accent-button"
|
||||||
|
Command="{Binding RefreshPreviewCommand}"
|
||||||
|
Content="{Binding RefreshButtonText}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
IsVisible="{Binding !IsRefreshingPreview}" />
|
||||||
|
<ui:FAProgressRing IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsRefreshingPreview}"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</ui:FASettingsExpander.Footer>
|
||||||
|
<ui:FASettingsExpanderItem>
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
RowDefinitions="Auto,Auto"
|
||||||
|
ColumnSpacing="18"
|
||||||
|
RowSpacing="12">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="14"
|
||||||
|
VerticalAlignment="Center">
|
||||||
<Border Classes="settings-section-card-icon-host"
|
<Border Classes="settings-section-card-icon-host"
|
||||||
Width="72"
|
Width="76"
|
||||||
Height="72"
|
Height="76"
|
||||||
Padding="10">
|
Padding="8">
|
||||||
<Image Source="{Binding PreviewIcon}"
|
<Image Source="{Binding PreviewIcon}"
|
||||||
Stretch="Uniform" />
|
Stretch="Uniform" />
|
||||||
</Border>
|
</Border>
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
<StackPanel Grid.Column="1"
|
Spacing="2">
|
||||||
Spacing="4"
|
|
||||||
VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="settings-card-header"
|
|
||||||
Text="{Binding PreviewHeader}" />
|
|
||||||
<TextBlock Classes="settings-card-description"
|
|
||||||
Text="{Binding PreviewDescription}" />
|
|
||||||
<TextBlock Classes="settings-section-title"
|
<TextBlock Classes="settings-section-title"
|
||||||
FontSize="24"
|
FontSize="30"
|
||||||
Margin="0,10,0,0"
|
|
||||||
Text="{Binding PreviewTemperature}" />
|
Text="{Binding PreviewTemperature}" />
|
||||||
<TextBlock Classes="settings-item-label"
|
<TextBlock Classes="settings-item-label"
|
||||||
Text="{Binding PreviewLocation}" />
|
Text="{Binding PreviewCondition}"
|
||||||
<TextBlock Classes="settings-item-description"
|
TextTrimming="CharacterEllipsis" />
|
||||||
Text="{Binding PreviewCondition}" />
|
|
||||||
<TextBlock Classes="settings-item-description"
|
<TextBlock Classes="settings-item-description"
|
||||||
Text="{Binding PreviewUpdated}" />
|
Text="{Binding PreviewUpdated}" />
|
||||||
<TextBlock Classes="settings-item-description"
|
</StackPanel>
|
||||||
Text="{Binding PreviewStatus}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2"
|
<ItemsControl Grid.Column="1"
|
||||||
Spacing="12"
|
ItemsSource="{Binding PreviewMetrics}"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Button Classes="settings-accent-button"
|
<ItemsControl.ItemsPanel>
|
||||||
Command="{Binding RefreshPreviewCommand}"
|
<ItemsPanelTemplate>
|
||||||
Content="{Binding RefreshButtonText}" />
|
<WrapPanel Orientation="Horizontal" />
|
||||||
<ui:FAProgressRing IsIndeterminate="True"
|
</ItemsPanelTemplate>
|
||||||
IsVisible="{Binding IsRefreshingPreview}"
|
</ItemsControl.ItemsPanel>
|
||||||
Width="28"
|
<ItemsControl.ItemTemplate>
|
||||||
Height="28"
|
<DataTemplate x:DataType="vm:WeatherPreviewMetric">
|
||||||
HorizontalAlignment="Center" />
|
<StackPanel Width="112"
|
||||||
|
Margin="0,4,14,4"
|
||||||
|
Spacing="4">
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
FontSize="12"
|
||||||
|
Text="{Binding Label}"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Spacing="5">
|
||||||
|
<ui:FAFontIcon Glyph="{Binding Glyph}"
|
||||||
|
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons"
|
||||||
|
FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding Value}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="4"
|
||||||
|
MinWidth="120">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding PreviewLocation}"
|
||||||
|
TextAlignment="Right"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding PreviewStatus}"
|
||||||
|
TextAlignment="Right"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Grid.ColumnSpan="3"
|
||||||
|
Spacing="8">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding PreviewAlertsHeader}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding PreviewNoAlertsText}"
|
||||||
|
IsVisible="{Binding !HasPreviewAlerts}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding PreviewAlerts}"
|
||||||
|
IsVisible="{Binding HasPreviewAlerts}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WeatherPreviewAlert">
|
||||||
|
<Expander Margin="0,4,0,0"
|
||||||
|
Padding="10,6">
|
||||||
|
<Expander.Header>
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="10">
|
||||||
|
<ui:FAFontIcon Glyph="󰓨"
|
||||||
|
FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons"
|
||||||
|
FontSize="18"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="2">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding Metadata}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Expander.Header>
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Margin="28,6,8,4"
|
||||||
|
Text="{Binding Detail}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Expander>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ui:FASettingsExpanderItem>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
|
||||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||||
Header="{Binding VisualStyleHeader}"
|
Header="{Binding VisualStyleHeader}"
|
||||||
|
|||||||
Reference in New Issue
Block a user