Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
+43 -61
View File
@@ -92,6 +92,11 @@ public sealed class TitleWeatherService(
{
private const double DistrictSearchMaxDistanceKm = 180;
private const double CitySearchMaxDistanceKm = 500;
private static readonly TimeSpan WeatherOperationTimeout = TimeSpan.FromSeconds(12);
private static readonly HttpRequestPolicy WeatherRequestPolicy = new(
TimeSpan.FromSeconds(7),
MaxRetries: 0,
CacheMode: HttpCacheMode.UseProtocol);
private static readonly Uri IpApiLocationUri = new("https://ipapi.co/json/");
private static readonly Uri ClientLocationZhUri = BuildClientLocationUri("zh-Hans");
@@ -114,12 +119,15 @@ public sealed class TitleWeatherService(
public async Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default)
{
using var operationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
operationCts.CancelAfter(WeatherOperationTimeout);
var requestToken = operationCts.Token;
try
{
var location = await ResolveLocationAsync(cancellationToken).ConfigureAwait(false);
var weatherPlace = await ResolveWeatherPlaceAsync(location, cancellationToken).ConfigureAwait(false);
var location = await ResolveLocationAsync(requestToken).ConfigureAwait(false);
var weatherPlace = await ResolveWeatherPlaceAsync(location, requestToken).ConfigureAwait(false);
var uri = BuildForecastUri(weatherPlace);
var forecast = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var forecast = await GetWeatherStringAsync(uri, requestToken).ConfigureAwait(false);
var snapshot = ParseForecast(location, weatherPlace, forecast);
await WriteLogAsync(
"Information",
@@ -128,7 +136,7 @@ public sealed class TitleWeatherService(
$"{snapshot.Location}; {snapshot.Condition}; query={weatherPlace.QueryLevel}").ConfigureAwait(false);
return snapshot;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false);
@@ -177,10 +185,10 @@ public sealed class TitleWeatherService(
{
try
{
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
return ParseClientLocation(content);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -190,7 +198,7 @@ public sealed class TitleWeatherService(
{
try
{
var content = await httpService.GetStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var latitude = GetDouble(root, "latitude");
@@ -212,65 +220,20 @@ public sealed class TitleWeatherService(
latitude.Value,
longitude.Value);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
private async Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
private Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
{
if (location.DistrictGeoNameId is long districtId &&
await TryGetGeocodedPlaceByIdAsync(districtId, cancellationToken).ConfigureAwait(false) is { } districtPlace &&
IsNearExpectedLocation(districtPlace, location, DistrictSearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
districtPlace.Latitude,
districtPlace.Longitude);
}
if (location.CityGeoNameId is long cityId &&
await TryGetGeocodedPlaceByIdAsync(cityId, cancellationToken).ConfigureAwait(false) is { } cityPlace &&
IsNearExpectedLocation(cityPlace, location, CitySearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
cityPlace.Latitude,
cityPlace.Longitude);
}
foreach (var query in BuildNameSearchQueries(location.QueryDistrict))
{
if (await TrySearchGeocodedPlaceAsync(query, location, DistrictSearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
place.Latitude,
place.Longitude);
}
}
foreach (var query in BuildNameSearchQueries(location.QueryCity))
{
if (await TrySearchGeocodedPlaceAsync(query, location, CitySearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
place.Latitude,
place.Longitude);
}
}
return new WeatherPlace(
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("经纬度", "Coordinates"),
location.Latitude,
location.Longitude);
location.Longitude));
}
private async Task<GeocodedPlace?> TryGetGeocodedPlaceByIdAsync(long id, CancellationToken cancellationToken)
@@ -278,11 +241,11 @@ public sealed class TitleWeatherService(
try
{
var uri = new Uri($"https://geocoding-api.open-meteo.com/v1/get?id={id}&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -303,7 +266,7 @@ public sealed class TitleWeatherService(
{
var uri = new Uri("https://geocoding-api.open-meteo.com/v1/search" +
$"?name={Uri.EscapeDataString(query)}&count=10&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
if (!document.RootElement.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
@@ -327,12 +290,31 @@ public sealed class TitleWeatherService(
.Select(item => item.Place)
.FirstOrDefault();
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
private async Task<string> GetWeatherStringAsync(Uri uri, CancellationToken cancellationToken)
{
var response = await httpService.SendAsync(
uri,
policy: WeatherRequestPolicy,
cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Content;
}
private static bool IsRecoverableWeatherFailure(Exception exception)
=> exception is HttpRequestException or
HttpRequestTimeoutException or
TimeoutException or
OperationCanceledException or
JsonException or
InvalidOperationException or
FormatException or
IOException;
private static Uri BuildForecastUri(WeatherPlace place)
{
var latitude = place.Latitude.ToString("0.####", CultureInfo.InvariantCulture);