changed.天气选项卡更新

This commit is contained in:
lincube
2026-05-18 19:43:15 +08:00
parent 68dc17f863
commit cc1c040203
9 changed files with 553 additions and 47 deletions

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

View File

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

View File

@@ -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": "テスト取得を使用して天気の設定を確認します。",

View File

@@ -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": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",

View File

@@ -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": "可通过测试获取快速验证天气配置。",

View File

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

View File

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

View File

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

View File

@@ -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="&#xF0504;" 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="&#xF04E8;"
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}"