mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +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.preview_panel_header": "Weather Preview",
|
||||
"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.preview_updated_format": "Updated {0}",
|
||||
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
||||
|
||||
@@ -173,6 +173,19 @@
|
||||
"settings.weather.settings_section": "設定",
|
||||
"settings.weather.preview_panel_header": "天気プレビュー",
|
||||
"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.preview_updated_format": "{0}に更新",
|
||||
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
|
||||
|
||||
@@ -174,6 +174,19 @@
|
||||
"settings.weather.settings_section": "설정",
|
||||
"settings.weather.preview_panel_header": "날씨 미리보기",
|
||||
"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.preview_updated_format": "{0}에 업데이트됨",
|
||||
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
|
||||
|
||||
@@ -175,6 +175,19 @@
|
||||
"settings.weather.settings_section": "设置",
|
||||
"settings.weather.preview_panel_header": "天气预览",
|
||||
"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.preview_updated_format": "更新于 {0}",
|
||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||
|
||||
@@ -39,6 +39,14 @@ public sealed record WeatherHourlyForecast(
|
||||
int? WeatherCode,
|
||||
string? WeatherText);
|
||||
|
||||
public sealed record WeatherAlert(
|
||||
string Title,
|
||||
string? Detail,
|
||||
string? Type,
|
||||
string? Level,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string? IconUri);
|
||||
|
||||
public sealed record WeatherSnapshot(
|
||||
string Provider,
|
||||
string LocationKey,
|
||||
@@ -49,4 +57,7 @@ public sealed record WeatherSnapshot(
|
||||
DateTimeOffset? ObservationTime,
|
||||
WeatherCurrentCondition Current,
|
||||
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") ??
|
||||
TryGetNode(payload, "hourly") ??
|
||||
TryGetNode(payload, "hourlyForecast");
|
||||
var alertsNode = TryGetNode(payload, "alerts") ??
|
||||
TryGetNode(payload, "alert") ??
|
||||
TryGetNode(payload, "warning") ??
|
||||
TryGetNode(payload, "warnings");
|
||||
|
||||
var weatherCode = ReadWeatherCode(currentNode);
|
||||
|
||||
@@ -422,7 +426,84 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
ObservationTime: observationTime,
|
||||
Current: current,
|
||||
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)
|
||||
@@ -977,4 +1058,9 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
|
||||
? text
|
||||
: $"{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<WeatherPreviewMetric> PreviewMetrics { get; } = [];
|
||||
|
||||
public ObservableCollection<WeatherPreviewAlert> PreviewAlerts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pageTitle = string.Empty;
|
||||
|
||||
@@ -168,6 +172,15 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _footerHint = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _previewMetricsHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _previewAlertsHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _previewNoAlertsText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedLocationMode = new("CitySearch", "City Search");
|
||||
|
||||
@@ -246,6 +259,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _previewStatus = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasPreviewAlerts;
|
||||
|
||||
partial void OnSelectedLocationModeChanged(SelectionOption value)
|
||||
{
|
||||
UpdateModeVisibility();
|
||||
@@ -279,6 +295,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
_settingsFacade.Weather.Save(CreateEditableState());
|
||||
UpdatePreviewDetails(_previewSnapshot);
|
||||
}
|
||||
|
||||
partial void OnExcludedAlertsChanged(string value)
|
||||
@@ -447,6 +464,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
PreviewTemperature = "--";
|
||||
PreviewCondition = string.Empty;
|
||||
PreviewUpdated = string.Empty;
|
||||
UpdatePreviewDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,6 +484,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
result.ErrorMessage ?? result.ErrorCode ?? L("settings.weather.preview_unknown", "Unknown"));
|
||||
_previewSnapshot = null;
|
||||
UpdatePreviewIcon(null);
|
||||
UpdatePreviewDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -475,10 +494,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
PreviewLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
||||
? state.LocationName
|
||||
: snapshot.LocationName!;
|
||||
PreviewTemperature = snapshot.Current.TemperatureC.HasValue
|
||||
? string.Format(CultureInfo.InvariantCulture, "{0:0.#}°C", snapshot.Current.TemperatureC.Value)
|
||||
: "--";
|
||||
PreviewTemperature = FormatTemperatureWithUnit(snapshot.Current.TemperatureC);
|
||||
PreviewCondition = ResolveWeatherDisplayText(snapshot.Current.WeatherText, snapshot.Current.WeatherCode);
|
||||
UpdatePreviewDetails(snapshot);
|
||||
|
||||
var updatedAt = (snapshot.ObservationTime ?? snapshot.FetchedAt).ToLocalTime();
|
||||
PreviewUpdated = string.Format(
|
||||
@@ -546,10 +564,17 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
PreviewIcon = null;
|
||||
UpdatePreviewIcon(null);
|
||||
PreviewLocation = previewLocation.Name;
|
||||
PreviewTemperature = "24 deg C";
|
||||
PreviewTemperature = "24°C";
|
||||
PreviewCondition = ResolveWeatherDisplayText("Partly cloudy", 4);
|
||||
PreviewUpdated = "Updated 09:42";
|
||||
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()
|
||||
@@ -558,6 +583,9 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
PageDescription = L("settings.weather.description", "Configure weather location, weather preview, and startup positioning behavior.");
|
||||
PreviewHeader = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
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");
|
||||
LocationSourceDescription = L("settings.weather.location_source_desc", "Choose how weather widgets resolve location.");
|
||||
CitySearchHeader = L("settings.weather.city_search_header", "City Search");
|
||||
@@ -746,6 +774,131 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
: 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)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(weatherText))
|
||||
@@ -777,3 +930,7 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
|
||||
: "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">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
<Border Classes="settings-section-card-icon-host"
|
||||
Width="72"
|
||||
Height="72"
|
||||
Padding="10">
|
||||
<Image Source="{Binding PreviewIcon}"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="4"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Text="{Binding PreviewHeader}" />
|
||||
<TextBlock Classes="settings-card-description"
|
||||
Text="{Binding PreviewDescription}" />
|
||||
<TextBlock Classes="settings-section-title"
|
||||
FontSize="24"
|
||||
Margin="0,10,0,0"
|
||||
Text="{Binding PreviewTemperature}" />
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding PreviewLocation}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PreviewCondition}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PreviewUpdated}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PreviewStatus}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Spacing="12"
|
||||
VerticalAlignment="Center">
|
||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||
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}" />
|
||||
Content="{Binding RefreshButtonText}"
|
||||
HorizontalAlignment="Right"
|
||||
IsVisible="{Binding !IsRefreshingPreview}" />
|
||||
<ui:FAProgressRing IsIndeterminate="True"
|
||||
IsVisible="{Binding IsRefreshingPreview}"
|
||||
Width="28"
|
||||
Height="28"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
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"
|
||||
Width="76"
|
||||
Height="76"
|
||||
Padding="8">
|
||||
<Image Source="{Binding PreviewIcon}"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock Classes="settings-section-title"
|
||||
FontSize="30"
|
||||
Text="{Binding PreviewTemperature}" />
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding PreviewCondition}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding PreviewUpdated}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl Grid.Column="1"
|
||||
ItemsSource="{Binding PreviewMetrics}"
|
||||
VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WeatherPreviewMetric">
|
||||
<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>
|
||||
</Grid>
|
||||
</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"
|
||||
Header="{Binding VisualStyleHeader}"
|
||||
|
||||
Reference in New Issue
Block a user