Compare commits
9 Commits
v1.0.0-tes
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8768fa1ed2 | ||
|
|
24f1b896e1 | ||
|
|
3cdb4bbd98 | ||
|
|
f3e7f88a39 | ||
|
|
d182925b58 | ||
|
|
2e49602bff | ||
|
|
c720d16e81 | ||
|
|
469f7e1132 | ||
|
|
00694e715f |
28
.github/WORKFLOWS_GUIDE.md
vendored
@@ -36,26 +36,24 @@ QODANA_ENDPOINT=https://qodana.cloud
|
||||
```
|
||||
|
||||
### 3. Release & Publish (`release.yml`)
|
||||
**Trigger:** Push git tags (v1.0.0, release-1.0.0), or manual workflow dispatch
|
||||
**Trigger:** Push git tags (`v*`, e.g. `v1.0.0`), or manual workflow dispatch
|
||||
|
||||
**What it does:**
|
||||
- Builds for **Windows** (x64, x86) - self-contained executables
|
||||
- Builds for **Linux** (x64) - tar.gz packages
|
||||
- Builds for **macOS** (x64, arm64) - universal support
|
||||
- Builds **Windows** installers (x64, x86) via Inno Setup
|
||||
- Builds **Linux** packages (x64) as `.deb`
|
||||
- Builds **macOS** packages (x64, arm64) as `.dmg`
|
||||
- Publishes optimized release builds for all platforms
|
||||
- Generates GitHub Release with all platform artifacts
|
||||
- Generates GitHub Release with installer/package assets
|
||||
- Supports pre-release versions
|
||||
|
||||
**Supported Platforms:**
|
||||
| Platform | Architectures | Output Format | Status |
|
||||
|----------|---------------|---------------|--------|
|
||||
| Windows | x64, x86 | .zip | ✅ Full support |
|
||||
| Linux | x64 | .tar.gz | ✅ Full support |
|
||||
| macOS | x64, arm64 (Apple Silicon) | .tar.gz | ✅ Full support |
|
||||
| Windows | x64, x86 | .exe (installer) | ✅ Full support |
|
||||
| Linux | x64 | .deb | ✅ Full support |
|
||||
| macOS | x64, arm64 (Apple Silicon) | .dmg | ✅ Full support |
|
||||
|
||||
**Build Scripts:**
|
||||
- Windows: Uses PowerShell (`LanMountainDesktop\scripts\package.ps1`)
|
||||
- Linux/macOS: Uses Bash (`scripts/build.sh`)
|
||||
> Note: GitHub Actions artifacts are downloaded as zip containers. The actual packaged files inside are `.exe`, `.deb`, and `.dmg`.
|
||||
|
||||
**Usage:**
|
||||
|
||||
@@ -66,13 +64,9 @@ git push origin v1.0.0
|
||||
# Automatically triggers Windows + Linux + macOS builds
|
||||
```
|
||||
|
||||
*Manual trigger with selective platforms:*
|
||||
*Manual trigger:*
|
||||
Go to GitHub > Actions > Release & Publish > Run workflow
|
||||
- Specify version: `1.0.0`
|
||||
- Toggle build targets as needed:
|
||||
- ✅ Build Windows (x64/x86)
|
||||
- ✅ Build Linux (x64)
|
||||
- ✅ Build macOS (x64/arm64)
|
||||
- Specify release tag: `v1.0.0` (or `1.0.0`, workflow will normalize to `v1.0.0`)
|
||||
- Check pre-release option if needed
|
||||
|
||||
### 4. Issue Management (`issue-management.yml`)
|
||||
|
||||
2
.github/workflows/build.yml
vendored
@@ -2,6 +2,8 @@
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
187
.github/workflows/release.yml
vendored
@@ -26,24 +26,36 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||
|
||||
steps:
|
||||
- name: Get release info
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
CHECKOUT_REF="${GITHUB_REF}"
|
||||
else
|
||||
TAG=${{ github.event.inputs.tag }}
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
|
||||
TAG="${RAW_TAG#refs/tags/}"
|
||||
elif [[ "${RAW_TAG}" == v* ]]; then
|
||||
TAG="${RAW_TAG}"
|
||||
else
|
||||
TAG="v${RAW_TAG}"
|
||||
fi
|
||||
CHECKOUT_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
VERSION=${TAG#v}
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
||||
|
||||
build-windows:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, x86]
|
||||
name: Build_Windows_${{ matrix.arch }}
|
||||
@@ -54,7 +66,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -89,13 +101,12 @@ jobs:
|
||||
-o ./publish/windows-${{ matrix.arch }} `
|
||||
--self-contained `
|
||||
-r win-${{ matrix.arch }} `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:SelfContained=true `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:PublishTrimmed=true `
|
||||
-p:TrimMode=partial `
|
||||
-p:PublishReadyToRun=true
|
||||
-p:PublishTrimmed=false `
|
||||
-p:PublishReadyToRun=false
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup
|
||||
@@ -126,14 +137,36 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find Inno Setup compiler
|
||||
$isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
if (-not (Test-Path -Path $isccPath)) {
|
||||
$isccPath = "C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
# Find Inno Setup compiler (choco may install a shim in PATH)
|
||||
$isccPath = $null
|
||||
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
|
||||
if ($isccCommand) {
|
||||
$isccPath = $isccCommand.Source
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $isccPath)) {
|
||||
Write-Error "Inno Setup compiler not found at: $isccPath"
|
||||
|
||||
$candidatePaths = @(
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
||||
"$env:ChocolateyInstall\bin\ISCC.exe",
|
||||
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
|
||||
)
|
||||
|
||||
if (-not $isccPath) {
|
||||
foreach ($candidate in $candidatePaths) {
|
||||
if ($candidate -and (Test-Path -Path $candidate)) {
|
||||
$isccPath = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isccPath) {
|
||||
Write-Host "ISCC.exe was not found in PATH or known locations."
|
||||
Write-Host "Checked locations:"
|
||||
$candidatePaths | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host "Chocolatey bin listing (if exists):"
|
||||
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
|
||||
Write-Error "Inno Setup compiler not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -142,20 +175,26 @@ jobs:
|
||||
# Build installer with iscc.exe
|
||||
Write-Host "Building installer for Windows $arch with version $version..."
|
||||
|
||||
$compileCmd = @(
|
||||
"`"$isccPath`"",
|
||||
$publishDir = (Resolve-Path $publishDir).Path
|
||||
$outputDir = (Resolve-Path $outputDir).Path
|
||||
$installerScript = (Resolve-Path $installerScript).Path
|
||||
|
||||
$compileArgs = @(
|
||||
"/DMyAppVersion=$version",
|
||||
"/DPublishDir=..\$publishDir",
|
||||
"/DMyOutputDir=..\$outputDir",
|
||||
"/DPublishDir=$publishDir",
|
||||
"/DMyOutputDir=$outputDir",
|
||||
"/DMyAppArch=$arch",
|
||||
"`"$installerScript`""
|
||||
) -join " "
|
||||
$installerScript
|
||||
)
|
||||
|
||||
Write-Host "Compile command: $compileCmd"
|
||||
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
|
||||
|
||||
# Execute the compiler
|
||||
$output = Invoke-Expression $compileCmd 2>&1
|
||||
Write-Host $output
|
||||
& $isccPath @compileArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if build was successful
|
||||
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
@@ -173,6 +212,7 @@ jobs:
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: build-installer/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
@@ -186,7 +226,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -221,13 +261,12 @@ jobs:
|
||||
-o ./publish/linux-x64 \
|
||||
--self-contained \
|
||||
-r linux-x64 \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:SelfContained=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:PublishTrimmed=true \
|
||||
-p:TrimMode=partial \
|
||||
-p:PublishReadyToRun=true
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
@@ -263,14 +302,14 @@ jobs:
|
||||
fi
|
||||
|
||||
# Create control file (NOTE: No leading spaces in control file)
|
||||
cat > "build-deb/DEBIAN/control" << EOF
|
||||
Package: $package_name
|
||||
Version: $package_version
|
||||
Architecture: $arch
|
||||
Maintainer: LanMountain Team <dev@example.com>
|
||||
Description: LanMountain Desktop Application
|
||||
A desktop application for LanMountain.
|
||||
EOF
|
||||
{
|
||||
printf '%s\n' "Package: $package_name"
|
||||
printf '%s\n' "Version: $package_version"
|
||||
printf '%s\n' "Architecture: $arch"
|
||||
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
|
||||
printf '%s\n' "Description: LanMountain Desktop Application"
|
||||
printf '%s\n' " A desktop application for LanMountain."
|
||||
} > "build-deb/DEBIAN/control"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
|
||||
@@ -289,6 +328,7 @@ EOF
|
||||
with:
|
||||
name: release-linux
|
||||
path: "*.deb"
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
@@ -305,7 +345,7 @@ EOF
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -331,13 +371,12 @@ EOF
|
||||
-o ./publish/macos-${{ matrix.arch }} \
|
||||
--self-contained \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:SelfContained=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:PublishTrimmed=true \
|
||||
-p:TrimMode=partial \
|
||||
-p:PublishReadyToRun=true
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false
|
||||
|
||||
- name: Package as DMG
|
||||
run: |
|
||||
@@ -370,27 +409,27 @@ EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create Info.plist - NOTE: Using unquoted EOF to allow variable expansion
|
||||
cat > "${app_name}.app/Contents/Info.plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>LanMountainDesktop</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LanMountain Desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$version</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$version</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.lanmountain.desktop</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
# Create Info.plist
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
printf '%s\n' '<plist version="1.0">'
|
||||
printf '%s\n' '<dict>'
|
||||
printf '%s\n' ' <key>CFBundleExecutable</key>'
|
||||
printf '%s\n' ' <string>LanMountainDesktop</string>'
|
||||
printf '%s\n' ' <key>CFBundleName</key>'
|
||||
printf '%s\n' ' <string>LanMountain Desktop</string>'
|
||||
printf '%s\n' ' <key>CFBundleVersion</key>'
|
||||
printf '%s\n' " <string>$version</string>"
|
||||
printf '%s\n' ' <key>CFBundleShortVersionString</key>'
|
||||
printf '%s\n' " <string>$version</string>"
|
||||
printf '%s\n' ' <key>CFBundleIdentifier</key>'
|
||||
printf '%s\n' ' <string>com.lanmountain.desktop</string>'
|
||||
printf '%s\n' ' <key>CFBundlePackageType</key>'
|
||||
printf '%s\n' ' <string>APPL</string>'
|
||||
printf '%s\n' '</dict>'
|
||||
printf '%s\n' '</plist>'
|
||||
} > "${app_name}.app/Contents/Info.plist"
|
||||
|
||||
# Create DMG
|
||||
mkdir -p dmg-temp
|
||||
@@ -412,12 +451,12 @@ EOF
|
||||
with:
|
||||
name: release-macos-${{ matrix.arch }}
|
||||
path: "*.dmg"
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
github-release:
|
||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -443,17 +482,29 @@ EOF
|
||||
run: |
|
||||
echo "📦 Organizing artifacts..."
|
||||
mkdir -p release-files
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp {} release-files/ \;
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "✅ Files ready for release:"
|
||||
ls -lh release-files/
|
||||
ls -lh release-files/ || echo "⚠️ No files found in release-files"
|
||||
echo ""
|
||||
echo "📋 Total files:"
|
||||
file_count=$(find release-files -type f | wc -l)
|
||||
echo "$file_count"
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "Error: No installer/package files found for release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
name: ${{ needs.prepare.outputs.tag }}
|
||||
commit: ${{ github.sha }}
|
||||
allowUpdates: true
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
|
||||
artifacts: release-files/*
|
||||
artifacts: "release-files/**"
|
||||
body: |
|
||||
## Release ${{ needs.prepare.outputs.version }}
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -481,3 +481,4 @@ $RECYCLE.BIN/
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
nul
|
||||
/publish-test
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
using AvaloniaWebView;
|
||||
@@ -14,6 +16,7 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -46,4 +49,31 @@ public partial class App : Application
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureWebViewUserDataFolder()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER";
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder();
|
||||
Environment.SetEnvironmentVariable(
|
||||
userDataFolderEnvVar,
|
||||
userDataFolder,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,3 +36,20 @@ Extracted weather icon paths inside APK (`res/*.webp`):
|
||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
|
||||
## Soft Widget Icon Set (2026-03-05)
|
||||
|
||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
- `Icons/icon_mini_partly_cloudy_day_soft.png`
|
||||
- `Icons/icon_mini_partly_cloudy_night_soft.png`
|
||||
- `Icons/icon_mini_cloudy_soft.png`
|
||||
- `Icons/icon_mini_rain_light_soft.png`
|
||||
- `Icons/icon_mini_rain_heavy_soft.png`
|
||||
- `Icons/icon_mini_storm_soft.png`
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 910 B |
|
After Width: | Height: | Size: 988 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Behaviors;
|
||||
|
||||
@@ -109,7 +110,7 @@ public class PanelIntroAnimationBehavior
|
||||
var index = 0;
|
||||
var timer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(24)
|
||||
Interval = FluttermotionToken.StaggerStepInterval
|
||||
};
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -4,11 +4,14 @@ using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Rendering.Composition;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Behaviors;
|
||||
|
||||
public class PopupIntroAnimationBehavior
|
||||
{
|
||||
private static readonly Easing StandardEasing = Easing.Parse(FluttermotionToken.StandardBezier);
|
||||
|
||||
public static readonly AttachedProperty<bool> IsEnabledProperty =
|
||||
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("IsEnabled");
|
||||
|
||||
@@ -94,16 +97,16 @@ public class PopupIntroAnimationBehavior
|
||||
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.Target = nameof(compositionVisual.Opacity);
|
||||
opacityAnimation.Duration = TimeSpan.FromMilliseconds(160);
|
||||
opacityAnimation.Duration = FluttermotionToken.Standard;
|
||||
opacityAnimation.InsertKeyFrame(0f, 0f);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f, Easing.Parse("0.22, 1, 0.36, 1"));
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f, StandardEasing);
|
||||
compositionVisual.StartAnimation(nameof(compositionVisual.Opacity), opacityAnimation);
|
||||
|
||||
var scaleAnimation = compositor.CreateVector3DKeyFrameAnimation();
|
||||
scaleAnimation.Target = nameof(compositionVisual.Scale);
|
||||
scaleAnimation.Duration = TimeSpan.FromMilliseconds(160);
|
||||
scaleAnimation.Duration = FluttermotionToken.Standard;
|
||||
scaleAnimation.InsertKeyFrame(0f, compositionVisual.Scale with { X = 0.94, Y = 0.94 });
|
||||
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, Easing.Parse("0.22, 1, 0.36, 1"));
|
||||
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, StandardEasing);
|
||||
compositionVisual.StartAnimation(nameof(compositionVisual.Scale), scaleAnimation);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ public static class BuiltInComponentIds
|
||||
public const string Clock = "Clock";
|
||||
public const string DesktopClock = "DesktopClock";
|
||||
public const string DesktopWeatherClock = "DesktopWeatherClock";
|
||||
public const string DesktopWorldClock = "DesktopWorldClock";
|
||||
public const string DesktopTimer = "DesktopTimer";
|
||||
public const string DesktopWeather = "DesktopWeather";
|
||||
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
|
||||
|
||||
@@ -48,6 +48,15 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"World Clock",
|
||||
"Clock",
|
||||
"Clock",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopTimer,
|
||||
"Timer",
|
||||
|
||||
@@ -6,30 +6,17 @@
|
||||
<Version>1.0.0</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
<!-- Release build optimizations -->
|
||||
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
|
||||
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
|
||||
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
|
||||
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Release build optimizations for smaller, faster packages -->
|
||||
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>partial</TrimMode>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Self-contained runtime settings -->
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@@ -162,6 +162,21 @@
|
||||
"schedule.settings.delete": "Delete",
|
||||
"schedule.settings.picker_title": "Select ClassIsland schedule file",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
|
||||
"worldclock.settings.title": "World Clock Settings",
|
||||
"worldclock.settings.desc": "Choose a time zone for each of the four clocks.",
|
||||
"worldclock.settings.clock_1": "Clock 1",
|
||||
"worldclock.settings.clock_2": "Clock 2",
|
||||
"worldclock.settings.clock_3": "Clock 3",
|
||||
"worldclock.settings.clock_4": "Clock 4",
|
||||
"worldclock.settings.second_mode_label": "Second Hand",
|
||||
"worldclock.widget.today": "Today",
|
||||
"worldclock.widget.yesterday": "Yesterday",
|
||||
"worldclock.widget.tomorrow": "Tomorrow",
|
||||
"worldclock.widget.offset_same": "0h",
|
||||
"worldclock.widget.offset_ahead_hours": "Ahead {0}h",
|
||||
"worldclock.widget.offset_behind_hours": "Behind {0}h",
|
||||
"worldclock.widget.offset_ahead_hm": "Ahead {0}h {1}m",
|
||||
"worldclock.widget.offset_behind_hm": "Behind {0}h {1}m",
|
||||
"weather.widget.aqi_unknown": "AQI --",
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "Updated {0:HH:mm}",
|
||||
@@ -184,6 +199,9 @@
|
||||
"settings.about.version_format": "Version: {0}",
|
||||
"settings.about.codename_format": "Code Name: {0}",
|
||||
"settings.about.font_format": "Font: {0}",
|
||||
"settings.about.startup_header": "Windows Startup",
|
||||
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
|
||||
"settings.about.startup_toggle": "Launch at Windows sign-in",
|
||||
"settings.footer": "LanMountainDesktop Settings",
|
||||
"filepicker.title": "Select wallpaper",
|
||||
"filepicker.image_files": "Image files",
|
||||
@@ -219,6 +237,7 @@
|
||||
"component.lunar_calendar": "Lunar Calendar",
|
||||
"component.desktop_clock": "Clock",
|
||||
"component.weather_clock": "Weather Clock",
|
||||
"component.world_clock": "World Clock",
|
||||
"component.desktop_timer": "Timer",
|
||||
"component.desktop_weather": "Weather",
|
||||
"component.hourly_weather": "Hourly Weather",
|
||||
@@ -241,6 +260,12 @@
|
||||
"component.study_score_overview": "Study Score Overview",
|
||||
"component.study_deduction_reasons": "Deduction Reasons",
|
||||
"component.study_interrupt_density": "Interrupt Density",
|
||||
"desktop_clock.settings.title": "Clock Settings",
|
||||
"desktop_clock.settings.desc": "Choose the time zone for the single clock.",
|
||||
"desktop_clock.settings.timezone_label": "Time Zone",
|
||||
"desktop_clock.settings.second_mode_label": "Second Hand",
|
||||
"clock.second_mode.tick": "Tick",
|
||||
"clock.second_mode.sweep": "Sweep",
|
||||
"poetry.widget.loading_content": "Loading poetry...",
|
||||
"poetry.widget.loading_author": "Loading...",
|
||||
"poetry.widget.fetch_failed": "Poetry fetch failed",
|
||||
@@ -255,6 +280,13 @@
|
||||
"artwork.widget.fallback_artist": "Recommendation service unavailable",
|
||||
"artwork.widget.fallback_year": "Try again later",
|
||||
"artwork.widget.unknown_artist": "Unknown artist",
|
||||
"artwork.settings.title": "Daily Artwork Settings",
|
||||
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
||||
"artwork.settings.source_label": "Mirror Source",
|
||||
"artwork.settings.source_domestic": "Domestic Mirror",
|
||||
"artwork.settings.source_overseas": "Overseas Mirror",
|
||||
"artwork.settings.source_status_domestic": "Current source: Domestic mirror (optimized for China network)",
|
||||
"artwork.settings.source_status_overseas": "Current source: Overseas mirror (art museum recommendations)",
|
||||
"music.widget.unsupported": "Music control is not supported on this platform",
|
||||
"music.widget.unsupported_hint": "This widget requires Windows SMTC",
|
||||
"music.widget.no_session": "No music source",
|
||||
|
||||
@@ -162,6 +162,21 @@
|
||||
"schedule.settings.delete": "删除",
|
||||
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
|
||||
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
|
||||
"worldclock.settings.title": "世界时钟设置",
|
||||
"worldclock.settings.desc": "分别为四个时钟选择时区。",
|
||||
"worldclock.settings.clock_1": "时钟 1",
|
||||
"worldclock.settings.clock_2": "时钟 2",
|
||||
"worldclock.settings.clock_3": "时钟 3",
|
||||
"worldclock.settings.clock_4": "时钟 4",
|
||||
"worldclock.settings.second_mode_label": "秒针方式",
|
||||
"worldclock.widget.today": "今天",
|
||||
"worldclock.widget.yesterday": "昨天",
|
||||
"worldclock.widget.tomorrow": "明天",
|
||||
"worldclock.widget.offset_same": "0 小时",
|
||||
"worldclock.widget.offset_ahead_hours": "早 {0} 小时",
|
||||
"worldclock.widget.offset_behind_hours": "晚 {0} 小时",
|
||||
"worldclock.widget.offset_ahead_hm": "早 {0} 小时 {1} 分",
|
||||
"worldclock.widget.offset_behind_hm": "晚 {0} 小时 {1} 分",
|
||||
"weather.widget.aqi_unknown": "AQI --",
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "更新于 {0:HH:mm}",
|
||||
@@ -184,6 +199,9 @@
|
||||
"settings.about.version_format": "版本号: {0}",
|
||||
"settings.about.codename_format": "版本代号: {0}",
|
||||
"settings.about.font_format": "字体: {0}",
|
||||
"settings.about.startup_header": "Windows 自启动",
|
||||
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
|
||||
"settings.about.startup_toggle": "登录 Windows 时启动",
|
||||
"settings.footer": "LanMountainDesktop 设置",
|
||||
"filepicker.title": "选择壁纸",
|
||||
"filepicker.image_files": "图片文件",
|
||||
@@ -219,6 +237,7 @@
|
||||
"component.lunar_calendar": "农历",
|
||||
"component.desktop_clock": "时钟",
|
||||
"component.weather_clock": "天气时钟",
|
||||
"component.world_clock": "世界时钟",
|
||||
"component.desktop_timer": "计时器",
|
||||
"component.desktop_weather": "天气",
|
||||
"component.hourly_weather": "小时天气",
|
||||
@@ -241,6 +260,12 @@
|
||||
"component.study_score_overview": "自习评分总览",
|
||||
"component.study_deduction_reasons": "扣分原因",
|
||||
"component.study_interrupt_density": "打断密度",
|
||||
"desktop_clock.settings.title": "时钟设置",
|
||||
"desktop_clock.settings.desc": "为单时钟选择时区。",
|
||||
"desktop_clock.settings.timezone_label": "时区",
|
||||
"desktop_clock.settings.second_mode_label": "秒针方式",
|
||||
"clock.second_mode.tick": "跳针",
|
||||
"clock.second_mode.sweep": "扫针",
|
||||
"poetry.widget.loading_content": "正在加载诗词",
|
||||
"poetry.widget.loading_author": "加载中",
|
||||
"poetry.widget.fetch_failed": "诗词获取失败",
|
||||
@@ -255,6 +280,13 @@
|
||||
"artwork.widget.fallback_artist": "推荐服务不可用",
|
||||
"artwork.widget.fallback_year": "稍后重试",
|
||||
"artwork.widget.unknown_artist": "未知作者",
|
||||
"artwork.settings.title": "每日图片设置",
|
||||
"artwork.settings.desc": "切换每日图片的数据源。",
|
||||
"artwork.settings.source_label": "镜像源",
|
||||
"artwork.settings.source_domestic": "国内镜像",
|
||||
"artwork.settings.source_overseas": "国外镜像",
|
||||
"artwork.settings.source_status_domestic": "当前源:国内镜像(优先中国网络)",
|
||||
"artwork.settings.source_status_overseas": "当前源:国外镜像(艺术馆推荐)",
|
||||
"music.widget.unsupported": "当前平台不支持音乐控制",
|
||||
"music.widget.unsupported_hint": "该组件仅支持 Windows SMTC",
|
||||
"music.widget.no_session": "暂无音源",
|
||||
|
||||
@@ -44,6 +44,10 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public bool AutoStartWithWindows { get; set; }
|
||||
|
||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||
|
||||
public List<string> PinnedTaskbarActions { get; set; } =
|
||||
@@ -76,4 +80,77 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
|
||||
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
|
||||
public string DesktopClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public List<string> WorldClockTimeZoneIds { get; set; } =
|
||||
[
|
||||
"China Standard Time",
|
||||
"GMT Standard Time",
|
||||
"AUS Eastern Standard Time",
|
||||
"Eastern Standard Time"
|
||||
];
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
clone.TopStatusComponentIds = TopStatusComponentIds is { Count: > 0 }
|
||||
? new List<string>(TopStatusComponentIds)
|
||||
: [];
|
||||
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
|
||||
? new List<string>(PinnedTaskbarActions)
|
||||
: [];
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
|
||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
||||
if (ImportedClassSchedules is not null)
|
||||
{
|
||||
foreach (var schedule in ImportedClassSchedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedules.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = schedule.Id,
|
||||
DisplayName = schedule.DisplayName,
|
||||
FilePath = schedule.FilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.ImportedClassSchedules = schedules;
|
||||
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
16
LanMountainDesktop/Models/DailyArtworkMirrorSources.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class DailyArtworkMirrorSources
|
||||
{
|
||||
public const string Domestic = "Domestic";
|
||||
public const string Overseas = "Overseas";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return string.Equals(value, Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? Domestic
|
||||
: Overseas;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public sealed record DailyArtworkSnapshot(
|
||||
string? Museum,
|
||||
string? ArtworkUrl,
|
||||
string? ImageUrl,
|
||||
string? ThumbnailDataUrl,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record DailyPoetrySnapshot(
|
||||
|
||||
@@ -11,6 +11,13 @@ public sealed class AppSettingsService
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static AppSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
@@ -25,14 +32,32 @@ public sealed class AppSettingsService
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
lock (CacheGate)
|
||||
{
|
||||
return new AppSettingsSnapshot();
|
||||
}
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions);
|
||||
return snapshot ?? new AppSettingsSnapshot();
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var loadedSnapshot = hasFile
|
||||
? LoadSnapshotFromDisk()
|
||||
: new AppSettingsSnapshot();
|
||||
|
||||
UpdateCache(loadedSnapshot, writeTimeUtc, nowUtc);
|
||||
return loadedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -42,6 +67,8 @@ public sealed class AppSettingsService
|
||||
|
||||
public void Save(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = snapshot?.Clone() ?? new AppSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
@@ -50,13 +77,70 @@ public sealed class AppSettingsService
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
var json = JsonSerializer.Serialize(snapshotToPersist, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
var writeTimeUtc = File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out AppSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out AppSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private AppSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AppSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCache(AppSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
21
LanMountainDesktop/Services/ClockSecondHandMode.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class ClockSecondHandMode
|
||||
{
|
||||
public const string Tick = "Tick";
|
||||
public const string Sweep = "Sweep";
|
||||
|
||||
public static string Normalize(string? mode)
|
||||
{
|
||||
return string.Equals(mode?.Trim(), Sweep, StringComparison.OrdinalIgnoreCase)
|
||||
? Sweep
|
||||
: Tick;
|
||||
}
|
||||
|
||||
public static bool IsSweep(string? mode)
|
||||
{
|
||||
return string.Equals(Normalize(mode), Sweep, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record DailyArtworkQuery(
|
||||
string? Locale = null,
|
||||
string? MirrorSource = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyPoetryQuery(
|
||||
@@ -35,11 +36,16 @@ public sealed record RecommendationApiOptions
|
||||
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
|
||||
|
||||
public string ArtInstituteArtworkApiTemplate { get; init; } =
|
||||
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
|
||||
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link,thumbnail";
|
||||
|
||||
public string ArtInstituteImageUrlTemplate { get; init; } =
|
||||
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
|
||||
|
||||
public string DomesticArtworkApiUrl { get; init; } =
|
||||
"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=8&mkt=zh-CN";
|
||||
|
||||
public string DomesticArtworkHost { get; init; } = "https://cn.bing.com";
|
||||
|
||||
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable
|
||||
{
|
||||
private const string UserAgent = "Mozilla/5.0";
|
||||
|
||||
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record ArtworkCandidate(
|
||||
@@ -19,13 +21,16 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
string? Artist,
|
||||
string? Year,
|
||||
string? ArtworkUrl,
|
||||
string? ImageId);
|
||||
string? ImageId,
|
||||
string? ThumbnailDataUrl);
|
||||
|
||||
private readonly RecommendationApiOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly object _cacheGate = new();
|
||||
private DailyArtworkCacheEntry? _dailyArtworkCache;
|
||||
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DailyPoetryCacheEntry? _dailyPoetryCache;
|
||||
|
||||
public RecommendationDataService(
|
||||
@@ -60,7 +65,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dailyArtworkCache = null;
|
||||
_dailyArtworkCacheBySource.Clear();
|
||||
_dailyPoetryCache = null;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -132,45 +137,25 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = query ?? new DailyArtworkQuery();
|
||||
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
|
||||
var mirrorSource = ResolveArtworkMirrorSource(normalizedQuery);
|
||||
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(mirrorSource, out var cached))
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
|
||||
return string.Equals(mirrorSource, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? await GetDailyArtworkFromDomesticSourceAsync(mirrorSource, cancellationToken)
|
||||
: await GetDailyArtworkFromOverseasSourceAsync(mirrorSource, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromOverseasSourceAsync(
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var localDate = GetChinaLocalDate();
|
||||
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
|
||||
var requestUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_options.ArtInstituteArtworkApiTemplate,
|
||||
page,
|
||||
candidateCount);
|
||||
|
||||
string responseText;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
|
||||
"upstream_http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var responseText = await FetchOverseasArtworkPayloadAsync(localDate, cancellationToken);
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
|
||||
@@ -183,7 +168,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
var title = ReadString(item, "title");
|
||||
var imageId = ReadString(item, "image_id");
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
|
||||
var thumbnailDataUrl = ReadString(item, "thumbnail", "lqip");
|
||||
if (string.IsNullOrWhiteSpace(title) ||
|
||||
(string.IsNullOrWhiteSpace(imageId) && string.IsNullOrWhiteSpace(thumbnailDataUrl)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -199,7 +186,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
artist,
|
||||
ReadString(item, "date_display"),
|
||||
ReadString(item, "api_link"),
|
||||
imageId.Trim()));
|
||||
string.IsNullOrWhiteSpace(imageId) ? null : imageId.Trim(),
|
||||
string.IsNullOrWhiteSpace(thumbnailDataUrl) ? null : thumbnailDataUrl.Trim()));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
@@ -217,24 +205,121 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
Museum: "The Art Institute of Chicago",
|
||||
ArtworkUrl: selected.ArtworkUrl,
|
||||
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
|
||||
ThumbnailDataUrl: selected.ThumbnailDataUrl,
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
SetDailyArtworkCache(snapshot);
|
||||
SetDailyArtworkCache(mirrorSource, snapshot);
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
|
||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromDomesticSourceAsync(
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.DomesticArtworkApiUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
|
||||
"upstream_http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("images", out var images) || images.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image list is missing.");
|
||||
}
|
||||
|
||||
var candidates = images.EnumerateArray().ToArray();
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No daily image candidates were returned.");
|
||||
}
|
||||
|
||||
var localDate = GetChinaLocalDate();
|
||||
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
|
||||
var selected = candidates[Math.Abs(indexSeed) % candidates.Length];
|
||||
|
||||
var imageUrl = BuildDomesticImageUrl(
|
||||
ReadString(selected, "url"),
|
||||
_options.DomesticArtworkHost);
|
||||
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image URL is missing.");
|
||||
}
|
||||
|
||||
var title = ReadString(selected, "title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = ExtractDomesticTitle(ReadString(selected, "copyright"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = "Bing Daily Image";
|
||||
}
|
||||
|
||||
var dateText = ParseDomesticDateText(ReadString(selected, "startdate"));
|
||||
var artworkUrl = BuildDomesticImageUrl(
|
||||
ReadString(selected, "copyrightlink"),
|
||||
_options.DomesticArtworkHost);
|
||||
if (string.IsNullOrWhiteSpace(artworkUrl) ||
|
||||
artworkUrl.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
artworkUrl = null;
|
||||
}
|
||||
|
||||
var snapshot = new DailyArtworkSnapshot(
|
||||
Provider: "BingCN",
|
||||
Title: title.Trim(),
|
||||
Artist: "Bing China",
|
||||
Year: dateText,
|
||||
Museum: "Bing China",
|
||||
ArtworkUrl: artworkUrl,
|
||||
ImageUrl: imageUrl,
|
||||
ThumbnailDataUrl: null,
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
SetDailyArtworkCache(mirrorSource, snapshot);
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetDailyArtworkFromCache(string mirrorSource, out DailyArtworkSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
|
||||
if (_dailyArtworkCacheBySource.TryGetValue(mirrorSource, out var cacheEntry) &&
|
||||
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = _dailyArtworkCache.Snapshot;
|
||||
snapshot = cacheEntry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -243,11 +328,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
|
||||
private void SetDailyArtworkCache(string mirrorSource, DailyArtworkSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dailyArtworkCache = new DailyArtworkCacheEntry(
|
||||
_dailyArtworkCacheBySource[mirrorSource] = new DailyArtworkCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
|
||||
}
|
||||
@@ -325,6 +410,105 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
imageId.Trim());
|
||||
}
|
||||
|
||||
private string ResolveArtworkMirrorSource(DailyArtworkQuery query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query.MirrorSource))
|
||||
{
|
||||
return DailyArtworkMirrorSources.Normalize(query.MirrorSource);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DailyArtworkMirrorSources.Overseas;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> FetchOverseasArtworkPayloadAsync(DateOnly localDate, CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
|
||||
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
|
||||
var requestUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_options.ArtInstituteArtworkApiTemplate,
|
||||
page,
|
||||
candidateCount);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
private static string? BuildDomesticImageUrl(string? rawValue, string fallbackHost)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawValue.Trim();
|
||||
if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
return absoluteUri.ToString();
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(fallbackHost, UriKind.Absolute, out var hostUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedPath = candidate.StartsWith("/", StringComparison.Ordinal) ? candidate : $"/{candidate}";
|
||||
return new Uri(hostUri, normalizedPath).ToString();
|
||||
}
|
||||
|
||||
private static string ExtractDomesticTitle(string? copyrightText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(copyrightText))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var compact = copyrightText.Trim();
|
||||
var bracketIndex = compact.IndexOf('(');
|
||||
if (bracketIndex <= 0)
|
||||
{
|
||||
return compact;
|
||||
}
|
||||
|
||||
return compact[..bracketIndex].Trim();
|
||||
}
|
||||
|
||||
private static string? ParseDomesticDateText(string? rawDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawDate) || rawDate.Length < 8)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
rawDate[..8],
|
||||
"yyyyMMdd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var date))
|
||||
{
|
||||
return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ReadFirstNonEmptyLine(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
|
||||
126
LanMountainDesktop/Services/WebView2RuntimeProbe.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record WebView2RuntimeAvailability(
|
||||
bool IsAvailable,
|
||||
string? Version,
|
||||
string Message);
|
||||
|
||||
public static class WebView2RuntimeProbe
|
||||
{
|
||||
private const string WebView2RuntimeClientId = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
|
||||
private const string WebView2RuntimeKeyPath = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\" + WebView2RuntimeClientId;
|
||||
public const string RuntimeDownloadUrl = "https://go.microsoft.com/fwlink/p/?LinkId=2124703";
|
||||
|
||||
public static WebView2RuntimeAvailability GetAvailability()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: true,
|
||||
Version: null,
|
||||
Message: string.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var version = TryGetVersionFromWebView2Api();
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
version = TryGetVersionFromRegistry();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: true,
|
||||
Version: version.Trim(),
|
||||
Message: string.Empty);
|
||||
}
|
||||
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: false,
|
||||
Version: null,
|
||||
Message: $"WebView2 Runtime is missing. Install it from {RuntimeDownloadUrl} and restart the app.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new WebView2RuntimeAvailability(
|
||||
IsAvailable: false,
|
||||
Version: null,
|
||||
Message: $"WebView2 runtime check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string ResolveUserDataFolder()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
localAppData = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
var userDataFolder = Path.Combine(localAppData, "LanMountainDesktop", "WebView2");
|
||||
Directory.CreateDirectory(userDataFolder);
|
||||
return userDataFolder;
|
||||
}
|
||||
|
||||
private static string? TryGetVersionFromWebView2Api()
|
||||
{
|
||||
var type = Type.GetType(
|
||||
"Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core",
|
||||
throwOnError: false);
|
||||
if (type is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var method = type.GetMethod(
|
||||
"GetAvailableBrowserVersionString",
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
binder: null,
|
||||
types: Type.EmptyTypes,
|
||||
modifiers: null);
|
||||
if (method is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return method.Invoke(null, null) as string;
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static string? TryGetVersionFromRegistry()
|
||||
{
|
||||
return TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64)
|
||||
?? TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32)
|
||||
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64)
|
||||
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static string? TryReadVersionFromRegistry(RegistryHive hive, RegistryView view)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
|
||||
using var runtimeKey = baseKey.OpenSubKey(WebView2RuntimeKeyPath, writable: false);
|
||||
if (runtimeKey is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = runtimeKey.GetValue("pv") as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
LanMountainDesktop/Services/WindowsStartupService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class WindowsStartupService
|
||||
{
|
||||
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string ValueName = "LanMountainDesktop";
|
||||
private readonly string _startupCommand;
|
||||
|
||||
public WindowsStartupService()
|
||||
{
|
||||
var processPath = Environment.ProcessPath;
|
||||
_startupCommand = string.IsNullOrWhiteSpace(processPath)
|
||||
? string.Empty
|
||||
: $"\"{processPath}\"";
|
||||
}
|
||||
|
||||
public bool IsEnabled()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
|
||||
return runKey?.GetValue(ValueName) is string value &&
|
||||
!string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetEnabled(bool enabled)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enabled && string.IsNullOrWhiteSpace(_startupCommand))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||||
if (runKey is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String);
|
||||
}
|
||||
else
|
||||
{
|
||||
runKey.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
}
|
||||
|
||||
return IsEnabled() == enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class WorldClockTimeZoneCatalog
|
||||
{
|
||||
public const int ClockCount = 4;
|
||||
|
||||
private static readonly string[][] DefaultTimeZoneCandidates =
|
||||
[
|
||||
["China Standard Time", "Asia/Shanghai"],
|
||||
["GMT Standard Time", "Europe/London", "UTC"],
|
||||
["AUS Eastern Standard Time", "Australia/Sydney"],
|
||||
["Eastern Standard Time", "America/New_York"]
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, string[]> CrossPlatformAliases =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = ["Asia/Shanghai"],
|
||||
["Asia/Shanghai"] = ["China Standard Time"],
|
||||
["GMT Standard Time"] = ["Europe/London", "UTC"],
|
||||
["Europe/London"] = ["GMT Standard Time", "UTC"],
|
||||
["AUS Eastern Standard Time"] = ["Australia/Sydney"],
|
||||
["Australia/Sydney"] = ["AUS Eastern Standard Time"],
|
||||
["Eastern Standard Time"] = ["America/New_York"],
|
||||
["America/New_York"] = ["Eastern Standard Time"],
|
||||
["UTC"] = ["Etc/UTC"],
|
||||
["Etc/UTC"] = ["UTC"],
|
||||
["Tokyo Standard Time"] = ["Asia/Tokyo"],
|
||||
["Asia/Tokyo"] = ["Tokyo Standard Time"]
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> NormalizeTimeZoneIds(IEnumerable<string>? configuredIds)
|
||||
{
|
||||
var available = TimeZoneInfo.GetSystemTimeZones();
|
||||
return NormalizeTimeZoneIds(configuredIds, available);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeTimeZoneIds(
|
||||
IEnumerable<string>? configuredIds,
|
||||
IReadOnlyList<TimeZoneInfo> availableTimeZones)
|
||||
{
|
||||
var availableById = BuildAvailableTimeZoneLookup(availableTimeZones);
|
||||
var requested = configuredIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.ToList() ?? [];
|
||||
|
||||
var normalized = new List<string>(ClockCount);
|
||||
for (var index = 0; index < ClockCount; index++)
|
||||
{
|
||||
var requestedId = index < requested.Count ? requested[index] : null;
|
||||
var resolved = ResolveAvailableId(requestedId, availableById) ??
|
||||
ResolveDefaultId(index, availableById) ??
|
||||
TimeZoneInfo.Local.Id;
|
||||
normalized.Add(resolved);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static TimeZoneInfo ResolveTimeZoneOrLocal(string? timeZoneId)
|
||||
{
|
||||
if (TryResolveTimeZone(timeZoneId, out var resolved))
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return TimeZoneInfo.Local;
|
||||
}
|
||||
|
||||
private static Dictionary<string, TimeZoneInfo> BuildAvailableTimeZoneLookup(
|
||||
IReadOnlyList<TimeZoneInfo> availableTimeZones)
|
||||
{
|
||||
return availableTimeZones
|
||||
.Where(zone => !string.IsNullOrWhiteSpace(zone.Id))
|
||||
.GroupBy(zone => zone.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ResolveDefaultId(
|
||||
int slotIndex,
|
||||
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
|
||||
{
|
||||
var clampedIndex = Math.Clamp(slotIndex, 0, ClockCount - 1);
|
||||
foreach (var candidateId in DefaultTimeZoneCandidates[clampedIndex])
|
||||
{
|
||||
var resolved = ResolveAvailableId(candidateId, availableById);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveAvailableId(
|
||||
string? candidateId,
|
||||
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidateId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedCandidate = candidateId.Trim();
|
||||
if (availableById.TryGetValue(normalizedCandidate, out var exact))
|
||||
{
|
||||
return exact.Id;
|
||||
}
|
||||
|
||||
if (TryResolveTimeZone(normalizedCandidate, out var resolvedZone) &&
|
||||
availableById.TryGetValue(resolvedZone.Id, out var resolved))
|
||||
{
|
||||
return resolved.Id;
|
||||
}
|
||||
|
||||
if (!CrossPlatformAliases.TryGetValue(normalizedCandidate, out var aliases))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (availableById.TryGetValue(alias, out var aliasZone))
|
||||
{
|
||||
return aliasZone.Id;
|
||||
}
|
||||
|
||||
if (TryResolveTimeZone(alias, out var aliasResolvedZone) &&
|
||||
availableById.TryGetValue(aliasResolvedZone.Id, out var mappedAlias))
|
||||
{
|
||||
return mappedAlias.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryResolveTimeZone(string? timeZoneId, out TimeZoneInfo timeZone)
|
||||
{
|
||||
timeZone = TimeZoneInfo.Local;
|
||||
if (string.IsNullOrWhiteSpace(timeZoneId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedId = timeZoneId.Trim();
|
||||
if (TryFindTimeZone(normalizedId, out timeZone))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!CrossPlatformAliases.TryGetValue(normalizedId, out var aliases))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (TryFindTimeZone(alias, out timeZone))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone)
|
||||
{
|
||||
timeZone = TimeZoneInfo.Local;
|
||||
try
|
||||
{
|
||||
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop/Styles/FluttermotionToken.axaml
Normal file
@@ -0,0 +1,12 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Styles.Resources>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
|
||||
|
||||
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
||||
</Styles.Resources>
|
||||
</Styles>
|
||||
@@ -25,9 +25,9 @@
|
||||
<Setter Property="Padding" Value="16,10" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -150,7 +150,7 @@
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<ThicknessTransition Property="Padding" Duration="0:0:0.2" Easing="QuarticEaseOut" />
|
||||
<ThicknessTransition Property="Padding" Duration="{StaticResource FluttermotionToken.Duration.Slow}" Easing="QuarticEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</Setter>
|
||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||
<Style.Animations>
|
||||
<Animation Duration="0:0:0.32"
|
||||
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
|
||||
FillMode="Both"
|
||||
Easing="0.22,1,0.36,1">
|
||||
<KeyFrame Cue="0%">
|
||||
@@ -53,9 +53,9 @@
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -74,8 +74,8 @@
|
||||
<Style Selector="Grid.settings-scope ComboBox">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -87,8 +87,8 @@
|
||||
<Style Selector="Grid.settings-scope ToggleSwitch">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
17
LanMountainDesktop/Theme/FluttermotionToken.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Theme;
|
||||
|
||||
public static class FluttermotionToken
|
||||
{
|
||||
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
||||
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
|
||||
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
|
||||
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
|
||||
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
|
||||
|
||||
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(24);
|
||||
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
|
||||
|
||||
public const string StandardBezier = "0.22, 1, 0.36, 1";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
@@ -12,6 +13,40 @@ namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "\u5317\u4EAC",
|
||||
["Asia/Shanghai"] = "\u5317\u4EAC",
|
||||
["GMT Standard Time"] = "\u4F26\u6566",
|
||||
["Europe/London"] = "\u4F26\u6566",
|
||||
["AUS Eastern Standard Time"] = "\u6089\u5C3C",
|
||||
["Australia/Sydney"] = "\u6089\u5C3C",
|
||||
["Eastern Standard Time"] = "\u7EBD\u7EA6",
|
||||
["America/New_York"] = "\u7EBD\u7EA6",
|
||||
["Tokyo Standard Time"] = "\u4E1C\u4EAC",
|
||||
["Asia/Tokyo"] = "\u4E1C\u4EAC",
|
||||
["UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6",
|
||||
["Etc/UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EnCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "Beijing",
|
||||
["Asia/Shanghai"] = "Beijing",
|
||||
["GMT Standard Time"] = "London",
|
||||
["Europe/London"] = "London",
|
||||
["AUS Eastern Standard Time"] = "Sydney",
|
||||
["Australia/Sydney"] = "Sydney",
|
||||
["Eastern Standard Time"] = "New York",
|
||||
["America/New_York"] = "New York",
|
||||
["Tokyo Standard Time"] = "Tokyo",
|
||||
["Asia/Tokyo"] = "Tokyo",
|
||||
["UTC"] = "UTC",
|
||||
["Etc/UTC"] = "UTC"
|
||||
};
|
||||
|
||||
private readonly DispatcherTimer _timer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
@@ -20,11 +55,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
private const double DialSize = 258;
|
||||
private const double Center = DialSize / 2;
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _dialInitialized;
|
||||
private bool _handsInitialized;
|
||||
private bool? _isNightModeApplied;
|
||||
private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time");
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12);
|
||||
private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8);
|
||||
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4);
|
||||
@@ -40,6 +80,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
InitializeDialIfNeeded();
|
||||
InitializeHandsIfNeeded();
|
||||
LoadClockSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
@@ -62,10 +104,19 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
_timeZoneService = null;
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadClockSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
InitializeDialIfNeeded();
|
||||
InitializeHandsIfNeeded();
|
||||
LoadClockSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
UpdateClock();
|
||||
_timer.Start();
|
||||
}
|
||||
@@ -187,17 +238,22 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
{
|
||||
ApplyModeVisualIfNeeded();
|
||||
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
|
||||
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
|
||||
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
|
||||
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
|
||||
var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode)
|
||||
? now.Second + now.Millisecond / 1000d
|
||||
: now.Second;
|
||||
var minuteValue = now.Minute + secondValue / 60d;
|
||||
var hourValue = (now.Hour % 12) + minuteValue / 60d;
|
||||
|
||||
var hourAngle = hourValue * 30d;
|
||||
var minuteAngle = minuteValue * 6d;
|
||||
var secondAngle = secondValue * 6d;
|
||||
|
||||
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 52, backwardLength: 6);
|
||||
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 76, backwardLength: 8);
|
||||
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 94, backwardLength: 18);
|
||||
|
||||
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
|
||||
CityTextBlock.Text = isZh ? "\u5317\u4eac" : "Beijing";
|
||||
CityTextBlock.Text = ResolveCityName(_clockTimeZone);
|
||||
}
|
||||
|
||||
private void ApplyModeVisualIfNeeded()
|
||||
@@ -299,6 +355,53 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
};
|
||||
}
|
||||
|
||||
private void LoadClockSettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
|
||||
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplySecondHandTimerInterval()
|
||||
{
|
||||
_timer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode)
|
||||
? TimeSpan.FromMilliseconds(16)
|
||||
: TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
private string ResolveCityName(TimeZoneInfo timeZone)
|
||||
{
|
||||
var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? ZhCityNames
|
||||
: EnCityNames;
|
||||
if (cityNames.TryGetValue(timeZone.Id, out var cityName))
|
||||
{
|
||||
return cityName;
|
||||
}
|
||||
|
||||
var normalized = timeZone.Id;
|
||||
var slashIndex = normalized.LastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
|
||||
{
|
||||
normalized = normalized[(slashIndex + 1)..];
|
||||
}
|
||||
|
||||
normalized = normalized.Replace('_', ' ').Trim();
|
||||
normalized = normalized
|
||||
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
|
||||
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
|
||||
}
|
||||
|
||||
private bool ResolveIsNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="560"
|
||||
d:DesignHeight="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.AnalogClockWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="时钟设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="为单时钟选择时区。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="TimeZoneLabelTextBlock"
|
||||
Text="时区"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="TimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SecondHandModeLabelTextBlock"
|
||||
Text="秒针方式"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<RadioButton x:Name="SecondHandTickRadioButton"
|
||||
GroupName="desktop_clock_second_mode"
|
||||
Content="跳针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
<RadioButton x:Name="SecondHandSweepRadioButton"
|
||||
GroupName="desktop_clock_second_mode"
|
||||
Content="扫针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "中国标准时间",
|
||||
["Asia/Shanghai"] = "中国标准时间",
|
||||
["GMT Standard Time"] = "格林威治标准时间",
|
||||
["Europe/London"] = "格林威治标准时间",
|
||||
["AUS Eastern Standard Time"] = "澳大利亚东部标准时间",
|
||||
["Australia/Sydney"] = "澳大利亚东部标准时间",
|
||||
["Eastern Standard Time"] = "美国东部标准时间",
|
||||
["America/New_York"] = "美国东部标准时间",
|
||||
["Tokyo Standard Time"] = "日本标准时间",
|
||||
["Asia/Tokyo"] = "日本标准时间",
|
||||
["UTC"] = "协调世界时",
|
||||
["Etc/UTC"] = "协调世界时"
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private string _selectedTimeZoneId = string.Empty;
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public AnalogClockWidgetSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
.GetAllTimeZones()
|
||||
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
|
||||
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("desktop_clock.settings.title", "时钟设置");
|
||||
DescriptionTextBlock.Text = L("desktop_clock.settings.desc", "为单时钟选择时区。");
|
||||
TimeZoneLabelTextBlock.Text = L("desktop_clock.settings.timezone_label", "时区");
|
||||
SecondHandModeLabelTextBlock.Text = L("desktop_clock.settings.second_mode_label", "秒针方式");
|
||||
SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针");
|
||||
SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
|
||||
}
|
||||
|
||||
private void PopulateTimeZoneComboBox()
|
||||
{
|
||||
_suppressEvents = true;
|
||||
try
|
||||
{
|
||||
TimeZoneComboBox.Items.Clear();
|
||||
foreach (var timeZone in _allTimeZones)
|
||||
{
|
||||
TimeZoneComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = timeZone.Id,
|
||||
Content = GetLocalizedTimeZoneDisplayName(timeZone)
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
||||
new[] { _selectedTimeZoneId },
|
||||
_allTimeZones)[0];
|
||||
_selectedTimeZoneId = normalizedId;
|
||||
|
||||
var selected = TimeZoneComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag as string, normalizedId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
TimeZoneComboBox.SelectedItem = selected ?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
|
||||
var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode);
|
||||
SecondHandTickRadioButton.IsChecked = string.Equals(
|
||||
normalizedMode,
|
||||
ClockSecondHandMode.Tick,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
SecondHandSweepRadioButton.IsChecked = string.Equals(
|
||||
normalizedMode,
|
||||
ClockSecondHandMode.Sweep,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var selectedId = (TimeZoneComboBox.SelectedItem as ComboBoxItem)?.Tag as string;
|
||||
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
||||
new[] { selectedId ?? _selectedTimeZoneId },
|
||||
_allTimeZones)[0];
|
||||
_selectedTimeZoneId = normalizedId;
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
snapshot.DesktopClockTimeZoneId = normalizedId;
|
||||
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
||||
_appSettingsService.Save(snapshot);
|
||||
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private string GetSelectedSecondHandMode()
|
||||
{
|
||||
return SecondHandSweepRadioButton.IsChecked == true
|
||||
? ClockSecondHandMode.Sweep
|
||||
: ClockSecondHandMode.Tick;
|
||||
}
|
||||
|
||||
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
|
||||
var sign = offset >= TimeSpan.Zero ? "+" : "-";
|
||||
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
|
||||
var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? ResolveZhDisplayName(timeZone)
|
||||
: ResolveEnDisplayName(timeZone);
|
||||
|
||||
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}";
|
||||
}
|
||||
|
||||
private static string ResolveZhDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
|
||||
{
|
||||
return localizedName;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(timeZone.StandardName)
|
||||
? timeZone.DisplayName
|
||||
: timeZone.StandardName;
|
||||
}
|
||||
|
||||
private static string ResolveEnDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timeZone.StandardName))
|
||||
{
|
||||
return timeZone.StandardName;
|
||||
}
|
||||
|
||||
return timeZone.DisplayName;
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,23 @@
|
||||
Background="#FFFFFFFF"
|
||||
BorderBrush="#22000000"
|
||||
BorderThickness="1">
|
||||
<webview:WebView x:Name="BrowserWebView" />
|
||||
<Grid>
|
||||
<webview:WebView x:Name="BrowserWebView" />
|
||||
<Border x:Name="UnavailableOverlay"
|
||||
IsVisible="False"
|
||||
Background="#CC0F172A"
|
||||
Padding="16">
|
||||
<TextBlock x:Name="UnavailableMessageTextBlock"
|
||||
Foreground="#F8FAFC"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="360"
|
||||
FontSize="13"
|
||||
Text="Browser runtime unavailable." />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="AddressBarBorder"
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.Services;
|
||||
using WebViewCore.Events;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
@@ -20,6 +21,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
private bool _isOnActiveDesktopPage;
|
||||
private bool _isEditMode;
|
||||
private bool _isWebViewActive = true;
|
||||
private readonly WebView2RuntimeAvailability _runtimeAvailability;
|
||||
|
||||
public BrowserWidget()
|
||||
{
|
||||
@@ -31,7 +33,17 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyTheme(force: true);
|
||||
BrowserWebView.NavigationStarting += OnBrowserWebViewNavigationStarting;
|
||||
|
||||
_runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
|
||||
if (_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
BrowserWebView.NavigationStarting += OnBrowserWebViewNavigationStarting;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyRuntimeUnavailableState();
|
||||
}
|
||||
|
||||
UpdateWebViewActiveState();
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
@@ -169,6 +181,11 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isWebViewActive)
|
||||
{
|
||||
return;
|
||||
@@ -185,11 +202,21 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateFromAddressBar();
|
||||
}
|
||||
|
||||
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key != Key.Enter)
|
||||
{
|
||||
return;
|
||||
@@ -201,6 +228,11 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void NavigateFromAddressBar()
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = TryNormalizeUri(AddressTextBox.Text);
|
||||
if (target is null)
|
||||
{
|
||||
@@ -240,6 +272,15 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void UpdateWebViewActiveState()
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
_isWebViewActive = false;
|
||||
BrowserWebView.Url = null;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||
if (_isWebViewActive == shouldBeActive)
|
||||
{
|
||||
@@ -265,6 +306,24 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
BrowserWebView.Url = _lastKnownUri;
|
||||
}
|
||||
|
||||
private void ApplyRuntimeUnavailableState()
|
||||
{
|
||||
_isWebViewActive = false;
|
||||
BrowserWebView.Url = null;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
|
||||
RefreshButton.IsEnabled = false;
|
||||
GoButton.IsEnabled = false;
|
||||
AddressTextBox.IsEnabled = false;
|
||||
AddressTextBox.Text = string.Empty;
|
||||
|
||||
UnavailableMessageTextBlock.Text = string.IsNullOrWhiteSpace(_runtimeAvailability.Message)
|
||||
? "WebView runtime unavailable."
|
||||
: _runtimeAvailability.Message;
|
||||
UnavailableOverlay.IsVisible = true;
|
||||
}
|
||||
|
||||
private static Uri? TryNormalizeUri(string? rawText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyArtworkSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="每日图片设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Text="切换每日图片的数据源。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="MirrorSourceLabelTextBlock"
|
||||
Text="镜像源"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<ComboBox x:Name="MirrorSourceComboBox"
|
||||
Width="240"
|
||||
SelectionChanged="OnMirrorSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="MirrorSourceDomesticItem"
|
||||
Tag="Domestic"
|
||||
Content="国内镜像" />
|
||||
<ComboBoxItem x:Name="MirrorSourceOverseasItem"
|
||||
Tag="Overseas"
|
||||
Content="国外镜像" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Text="当前源:国内镜像"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkSettingsWindow : UserControl
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _suppressEvents;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public string CurrentSource => GetSelectedSource();
|
||||
|
||||
public DailyArtworkSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
|
||||
var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
_suppressEvents = true;
|
||||
MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? 0
|
||||
: 1;
|
||||
_suppressEvents = false;
|
||||
UpdateSourceStatus(source);
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("artwork.settings.title", "每日图片设置");
|
||||
DescriptionTextBlock.Text = L("artwork.settings.desc", "切换每日图片的数据源。");
|
||||
MirrorSourceLabelTextBlock.Text = L("artwork.settings.source_label", "镜像源");
|
||||
MirrorSourceDomesticItem.Content = L("artwork.settings.source_domestic", "国内镜像");
|
||||
MirrorSourceOverseasItem.Content = L("artwork.settings.source_overseas", "国外镜像");
|
||||
UpdateSourceStatus(GetSelectedSource());
|
||||
}
|
||||
|
||||
private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = GetSelectedSource();
|
||||
var snapshot = _appSettingsService.Load();
|
||||
snapshot.DailyArtworkMirrorSource = source;
|
||||
_appSettingsService.Save(snapshot);
|
||||
|
||||
UpdateSourceStatus(source);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private string GetSelectedSource()
|
||||
{
|
||||
if (MirrorSourceComboBox.SelectedItem is ComboBoxItem comboBoxItem &&
|
||||
comboBoxItem.Tag is string tagValue)
|
||||
{
|
||||
return DailyArtworkMirrorSources.Normalize(tagValue);
|
||||
}
|
||||
|
||||
return DailyArtworkMirrorSources.Overseas;
|
||||
}
|
||||
|
||||
private void UpdateSourceStatus(string source)
|
||||
{
|
||||
if (StatusTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? L("artwork.settings.source_status_domestic", "当前源:国内镜像(优先中国网络)")
|
||||
: L("artwork.settings.source_status_overseas", "当前源:国外镜像(艺术馆推荐)");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -18,43 +18,32 @@
|
||||
<Border x:Name="ArtworkPanel"
|
||||
Grid.Column="0"
|
||||
ClipToBounds="True"
|
||||
Background="#B8AE9A">
|
||||
Background="#B8AE9A"
|
||||
PointerPressed="OnArtworkPanelPointerPressed">
|
||||
<Grid>
|
||||
<Image x:Name="ArtworkImage"
|
||||
Stretch="UniformToFill" />
|
||||
|
||||
<Border x:Name="ImageBottomShade"
|
||||
VerticalAlignment="Bottom"
|
||||
Height="132">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#AF000000"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="DateInfoStack"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="22,0,0,22"
|
||||
Margin="18,0,0,16"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Text="03/03"
|
||||
Foreground="#F9F9F9"
|
||||
FontSize="52"
|
||||
FontSize="44"
|
||||
FontWeight="Bold"
|
||||
FontFeatures="tnum"
|
||||
LineHeight="54" />
|
||||
TextTrimming="CharacterEllipsis"
|
||||
LineHeight="46" />
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Text="星期二"
|
||||
Foreground="#F9F9F9"
|
||||
FontSize="52"
|
||||
FontSize="44"
|
||||
FontWeight="Bold"
|
||||
LineHeight="54" />
|
||||
TextTrimming="CharacterEllipsis"
|
||||
LineHeight="46" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -62,7 +51,8 @@
|
||||
<Border Grid.Column="1"
|
||||
x:Name="InfoPanel"
|
||||
Background="#111418"
|
||||
Padding="18,14,18,14">
|
||||
Padding="18,14,18,14"
|
||||
PointerPressed="OnInfoPanelPointerPressed">
|
||||
<Grid>
|
||||
<Canvas x:Name="BrickPatternCanvas"
|
||||
IsHitTestVisible="False"
|
||||
@@ -90,6 +80,7 @@
|
||||
FontSize="44"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
@@ -110,6 +101,7 @@
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2" />
|
||||
<TextBlock x:Name="YearTextBlock"
|
||||
Text="1754"
|
||||
@@ -118,6 +110,7 @@
|
||||
FontWeight="Medium"
|
||||
FontFeatures="tnum"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
@@ -8,6 +9,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
@@ -62,6 +64,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private string? _currentArtworkSourceUrl;
|
||||
private string? _currentArtworkImageUrl;
|
||||
|
||||
public DailyArtworkWidget()
|
||||
{
|
||||
@@ -98,13 +102,11 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
Math.Clamp(14 * scale, 8, 22));
|
||||
|
||||
DateInfoStack.Margin = new Thickness(
|
||||
Math.Clamp(22 * scale, 10, 36),
|
||||
Math.Clamp(18 * scale, 8, 30),
|
||||
0,
|
||||
0,
|
||||
Math.Clamp(20 * scale, 10, 34));
|
||||
DateInfoStack.Spacing = Math.Clamp(2 * scale, 1, 6);
|
||||
|
||||
ImageBottomShade.Height = Math.Clamp(132 * scale, 64, 182);
|
||||
Math.Clamp(16 * scale, 8, 26));
|
||||
DateInfoStack.Spacing = Math.Clamp(4 * scale, 2, 10);
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 10, 24);
|
||||
|
||||
@@ -122,6 +124,15 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshArtworkAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
@@ -147,6 +158,28 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
await RefreshArtworkAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnArtworkPanelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = RefreshArtworkAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInfoPanelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenArtworkSourceUrl();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshArtworkAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
@@ -215,11 +248,13 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
ArtistTextBlock.Text = NormalizeCompactText(artist);
|
||||
|
||||
YearTextBlock.Text = ResolveYearText(snapshot);
|
||||
_currentArtworkSourceUrl = snapshot.ArtworkUrl;
|
||||
_currentArtworkImageUrl = snapshot.ImageUrl;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
UpdateAdaptiveLayout();
|
||||
|
||||
var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, cancellationToken);
|
||||
var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
@@ -229,39 +264,124 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
SetArtworkBitmap(bitmap);
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> TryLoadArtworkBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
|
||||
private static async Task<Bitmap?> TryLoadArtworkBitmapAsync(
|
||||
string? imageUrl,
|
||||
string? thumbnailDataUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var candidateUrl in BuildImageUrlCandidates(imageUrl))
|
||||
{
|
||||
var remoteBitmap = await TryDownloadBitmapAsync(candidateUrl, cancellationToken);
|
||||
if (remoteBitmap is not null)
|
||||
{
|
||||
return remoteBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
return TryDecodeBitmapFromDataUrl(thumbnailDataUrl);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildImageUrlCandidates(string? imageUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var normalizedUrl = imageUrl.Trim();
|
||||
yield return normalizedUrl;
|
||||
|
||||
const string preferredSizeSegment = "/full/843,/0/default.jpg";
|
||||
if (normalizedUrl.Contains(preferredSizeSegment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return normalizedUrl.Replace(
|
||||
preferredSizeSegment,
|
||||
"/full/1024,/0/default.jpg",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var withReferrer = await SendImageRequestAsync(imageUrl, includeReferrer: true, cancellationToken);
|
||||
if (withReferrer is not null)
|
||||
{
|
||||
return withReferrer;
|
||||
}
|
||||
|
||||
return await SendImageRequestAsync(imageUrl, includeReferrer: false, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> SendImageRequestAsync(
|
||||
string imageUrl,
|
||||
bool includeReferrer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||
if (includeReferrer && Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri))
|
||||
{
|
||||
request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
memory.Position = 0;
|
||||
return new Bitmap(memory);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl.Trim());
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||
if (Uri.TryCreate(imageUrl.Trim(), UriKind.Absolute, out var imageUri))
|
||||
{
|
||||
request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
private static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
memory.Position = 0;
|
||||
return new Bitmap(memory);
|
||||
var trimmed = dataUrl.Trim();
|
||||
var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var base64Payload = trimmed[(markerIndex + 7)..];
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64Payload);
|
||||
return new Bitmap(new MemoryStream(bytes));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
_currentArtworkSourceUrl = null;
|
||||
_currentArtworkImageUrl = null;
|
||||
StatusTextBlock.IsVisible = true;
|
||||
StatusTextBlock.Text = L("artwork.widget.loading", "Loading...");
|
||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.loading_title", "Daily Artwork"));
|
||||
@@ -272,6 +392,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
_currentArtworkSourceUrl = null;
|
||||
_currentArtworkImageUrl = null;
|
||||
StatusTextBlock.IsVisible = true;
|
||||
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
|
||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
|
||||
@@ -294,71 +416,94 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
var rightContentWidth = Math.Max(58, rightPanelWidth - InfoPanel.Padding.Left - InfoPanel.Padding.Right);
|
||||
var leftPanelWidth = Math.Max(84, totalWidth - rightPanelWidth);
|
||||
var leftContentWidth = Math.Max(52, leftPanelWidth - DateInfoStack.Margin.Left - 10);
|
||||
var leftContentHeight = Math.Max(30, totalHeight - DateInfoStack.Margin.Bottom - 10);
|
||||
|
||||
var dateBase = Math.Clamp(52 * scale, 18, 72);
|
||||
var dateStackSpacing = Math.Clamp(4 * scale, 2, 10);
|
||||
DateInfoStack.Spacing = dateStackSpacing;
|
||||
DateInfoStack.MaxWidth = leftContentWidth;
|
||||
var leftSingleLineHeight = Math.Max(12, (leftContentHeight - dateStackSpacing) / 2d);
|
||||
|
||||
var dateBase = Math.Clamp(44 * scale, 16, 62);
|
||||
DateTextBlock.FontSize = FitFontSize(
|
||||
DateTextBlock.Text,
|
||||
leftContentWidth,
|
||||
Math.Max(22, totalHeight * 0.22),
|
||||
leftSingleLineHeight,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(14, dateBase * 0.70),
|
||||
minFontSize: Math.Max(12, dateBase * 0.68),
|
||||
maxFontSize: dateBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.02);
|
||||
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.02;
|
||||
lineHeightFactor: 1.10);
|
||||
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.10;
|
||||
|
||||
WeekdayTextBlock.FontSize = FitFontSize(
|
||||
WeekdayTextBlock.Text,
|
||||
leftContentWidth,
|
||||
Math.Max(22, totalHeight * 0.24),
|
||||
leftSingleLineHeight,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(14, dateBase * 0.70),
|
||||
minFontSize: Math.Max(12, dateBase * 0.68),
|
||||
maxFontSize: dateBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.03);
|
||||
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.03;
|
||||
lineHeightFactor: 1.10);
|
||||
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.10;
|
||||
|
||||
var rightContentHeight = Math.Max(42, totalHeight - InfoPanel.Padding.Top - InfoPanel.Padding.Bottom);
|
||||
var titleBottomMargin = Math.Clamp(8 * scale, 4, 14);
|
||||
var separatorBottomMargin = Math.Clamp(10 * scale, 4, 14);
|
||||
var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8);
|
||||
var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3;
|
||||
var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight);
|
||||
var titleHeightBudget = Math.Max(16, textHeightBudget * 0.54);
|
||||
var bottomTextBudget = Math.Max(10, textHeightBudget - titleHeightBudget);
|
||||
var artistHeightBudget = Math.Max(8, bottomTextBudget * 0.66);
|
||||
var yearHeightBudget = Math.Max(8, bottomTextBudget - artistHeightBudget);
|
||||
|
||||
var titleBase = Math.Clamp(44 * scale, 16, 58);
|
||||
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
||||
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
|
||||
PaintingTitleTextBlock.FontSize = FitFontSize(
|
||||
PaintingTitleTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(20, totalHeight * 0.34),
|
||||
titleHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(12, titleBase * 0.62),
|
||||
maxFontSize: titleBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.08);
|
||||
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.08;
|
||||
lineHeightFactor: 1.12);
|
||||
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.12;
|
||||
|
||||
var artistBase = Math.Clamp(26 * scale, 11, 34);
|
||||
if (ArtistTextBlock.Parent is StackPanel artistInfoStack)
|
||||
{
|
||||
artistInfoStack.Spacing = bottomStackSpacing;
|
||||
}
|
||||
|
||||
ArtistTextBlock.MaxWidth = rightContentWidth;
|
||||
ArtistTextBlock.FontSize = FitFontSize(
|
||||
ArtistTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(18, totalHeight * 0.24),
|
||||
artistHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(10, artistBase * 0.72),
|
||||
maxFontSize: artistBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
lineHeightFactor: 1.12);
|
||||
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.12;
|
||||
lineHeightFactor: 1.14);
|
||||
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.14;
|
||||
|
||||
var yearBase = Math.Clamp(22 * scale, 10, 30);
|
||||
YearTextBlock.MaxWidth = rightContentWidth;
|
||||
YearTextBlock.FontSize = FitFontSize(
|
||||
YearTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(14, totalHeight * 0.12),
|
||||
yearHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(9.5, yearBase * 0.78),
|
||||
maxFontSize: yearBase,
|
||||
weight: FontWeight.Medium,
|
||||
lineHeightFactor: 1.04);
|
||||
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.04;
|
||||
lineHeightFactor: 1.08);
|
||||
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.08;
|
||||
|
||||
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
|
||||
RightPanelSeparator.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 14));
|
||||
RightPanelSeparator.Margin = new Thickness(0, 0, 0, separatorBottomMargin);
|
||||
|
||||
BrickPatternCanvas.Opacity = totalWidth < _currentCellSize * 4.2
|
||||
? 0.34
|
||||
@@ -388,6 +533,54 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
_currentArtworkBitmap = null;
|
||||
}
|
||||
|
||||
private void TryOpenArtworkSourceUrl()
|
||||
{
|
||||
var candidate = _currentArtworkSourceUrl;
|
||||
if (!TryNormalizeHttpUrl(candidate, out var normalizedUrl) &&
|
||||
!TryNormalizeHttpUrl(_currentArtworkImageUrl, out normalizedUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = normalizedUrl,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed URLs or shell launch failures.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeHttpUrl(string? rawUrl, out string normalizedUrl)
|
||||
{
|
||||
normalizedUrl = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = rawUrl.Trim();
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUrl = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -134,6 +134,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.weather_clock",
|
||||
() => new WeatherClockWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"component.world_clock",
|
||||
() => new WorldClockWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.30, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopTimer,
|
||||
"component.desktop_timer",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<Grid x:Name="SummaryInfoGrid"
|
||||
|
||||
@@ -11,15 +11,18 @@ using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) };
|
||||
private readonly DispatcherTimer _animationTimer = new() { Interval = TimeSpan.FromMilliseconds(48) };
|
||||
private readonly DispatcherTimer _animationTimer = new() { Interval = FluttermotionToken.WeatherAnimationFrameInterval };
|
||||
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
||||
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
@@ -29,6 +32,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private double _currentCellSize = 48;
|
||||
private double _phase;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay;
|
||||
@@ -39,10 +43,12 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private readonly TextBlock[] _dailyHighBlocks;
|
||||
private readonly TextBlock[] _dailyLowBlocks;
|
||||
private readonly Image[] _dailyIconBlocks;
|
||||
private readonly HyperOS3WeatherVisualKind[] _dailyIconKinds;
|
||||
|
||||
public ExtendedWeatherWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeMotionTransform();
|
||||
_hourlyTempBlocks =
|
||||
[
|
||||
HourlyTemp0, HourlyTemp1, HourlyTemp2, HourlyTemp3, HourlyTemp4, HourlyTemp5
|
||||
@@ -71,24 +77,13 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
[
|
||||
DailyIcon0, DailyIcon1, DailyIcon2, DailyIcon3, DailyIcon4
|
||||
];
|
||||
_dailyIconKinds = Enumerable.Repeat(HyperOS3WeatherVisualKind.CloudyDay, _dailyIconBlocks.Length).ToArray();
|
||||
ConfigureTextOverflowGuards();
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
_animationTimer.Tick += OnAnimationTick;
|
||||
AttachedToVisualTree += (_, _) =>
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_animationTimer.Start();
|
||||
_ = RefreshWeatherAsync(false);
|
||||
};
|
||||
DetachedFromVisualTree += (_, _) =>
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_animationTimer.Stop();
|
||||
CancelRefresh();
|
||||
};
|
||||
SizeChanged += (_, _) => ApplyCellSize(_currentCellSize);
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyVisualTheme(_activeVisualKind);
|
||||
ApplyFallback();
|
||||
@@ -109,7 +104,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
RangeTextBlock.MaxLines = 1;
|
||||
|
||||
TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.None;
|
||||
TemperatureTextBlock.MaxLines = 1;
|
||||
}
|
||||
|
||||
@@ -126,9 +121,11 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
BackgroundTintLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundLightLayer.CornerRadius = new CornerRadius(radius);
|
||||
BackgroundShadeLayer.CornerRadius = new CornerRadius(radius);
|
||||
var horizontalPadding = Math.Clamp(Math.Min(width * metrics.HorizontalPaddingScale * 0.30, width * 0.11), 4, 34);
|
||||
var verticalPadding = Math.Clamp(Math.Min(height * metrics.VerticalPaddingScale * 0.30, height * 0.11), 4, 34);
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
Math.Clamp(width * metrics.HorizontalPaddingScale * 0.30, 10, 30),
|
||||
Math.Clamp(height * metrics.VerticalPaddingScale * 0.30, 10, 30));
|
||||
horizontalPadding,
|
||||
verticalPadding);
|
||||
ApplyTypography(width, height);
|
||||
}
|
||||
|
||||
@@ -157,7 +154,20 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
||||
{
|
||||
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
||||
if (_isAttached)
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(false);
|
||||
}
|
||||
@@ -165,12 +175,34 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isAttached)
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
UpdateTimerState();
|
||||
CancelRefresh();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshWeatherAsync(false);
|
||||
@@ -178,18 +210,16 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void OnAnimationTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_phase += 0.018;
|
||||
if (_phase > Math.PI * 2) _phase -= Math.PI * 2;
|
||||
var sin = Math.Sin(_phase);
|
||||
var cos = Math.Cos(_phase * 0.83);
|
||||
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children = new Transforms
|
||||
{
|
||||
new ScaleTransform(1.05 + (sin * 0.01), 1.05 + (sin * 0.01)),
|
||||
new TranslateTransform(sin * 7.0, cos * 5.0)
|
||||
}
|
||||
};
|
||||
SetMotionTransform(sin * 7.0, cos * 5.0, 1.05 + (sin * 0.01));
|
||||
BackgroundMotionLayer.Opacity = Math.Clamp(0.27 + (cos * 0.05), 0.10, 0.90);
|
||||
BackgroundLightLayer.Opacity = Math.Clamp(0.62 + (sin * 0.06), 0.20, 0.95);
|
||||
BackgroundShadeLayer.Opacity = Math.Clamp(0.80 + (cos * 0.03), 0.45, 0.95);
|
||||
@@ -197,7 +227,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private async Task RefreshWeatherAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -274,7 +304,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var kind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight);
|
||||
ApplyVisualTheme(kind);
|
||||
SetLoadingSkeleton(false);
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(kind));
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
|
||||
CityTextBlock.Text = ResolveLocation(snapshot.LocationName, fallbackLocationName);
|
||||
ConditionTextBlock.Text = ResolveWeatherText(snapshot.Current.WeatherText, kind);
|
||||
TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC);
|
||||
@@ -302,7 +332,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
? L("weather.hourly.sunset", "Sunset")
|
||||
: FormatTemperature(item?.Source.TemperatureC ?? snapshot.Current.TemperatureC);
|
||||
_hourlyTimeBlocks[i].Text = target.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(hourKind));
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(hourKind));
|
||||
}
|
||||
|
||||
var todayDate = DateOnly.FromDateTime(now);
|
||||
@@ -316,7 +346,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_dailyLabelBlocks[i].Text = $"{ResolveDayLabel(date, i + 1)}·{dayText}";
|
||||
_dailyHighBlocks[i].Text = FormatTemperatureValue(daily?.HighTemperatureC);
|
||||
_dailyLowBlocks[i].Text = FormatTemperatureValue(daily?.LowTemperatureC);
|
||||
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(dayKind));
|
||||
_dailyIconKinds[i] = dayKind;
|
||||
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(dayKind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +355,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
ApplyVisualTheme(HyperOS3WeatherVisualKind.CloudyDay);
|
||||
SetLoadingSkeleton(false);
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
CityTextBlock.Text = L("weather.widget.location_unknown", "Unknown location");
|
||||
ConditionTextBlock.Text = L("weather.widget.loading", "Loading...");
|
||||
TemperatureTextBlock.Text = "--°";
|
||||
@@ -335,7 +366,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
_hourlyTempBlocks[i].Text = i == 3 ? L("weather.hourly.sunset", "Sunset") : "--°";
|
||||
_hourlyTimeBlocks[i].Text = timelineStart.AddHours(i).ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
}
|
||||
|
||||
for (var i = 0; i < _dailyLabelBlocks.Length; i++)
|
||||
@@ -343,7 +374,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_dailyLabelBlocks[i].Text = $"{ResolveDayLabel(DateOnly.FromDateTime(DateTime.Now).AddDays(i + 1), i + 1)}·{L("weather.widget.condition_cloudy", "Cloudy")}";
|
||||
_dailyHighBlocks[i].Text = "--";
|
||||
_dailyLowBlocks[i].Text = "--";
|
||||
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
_dailyIconKinds[i] = HyperOS3WeatherVisualKind.CloudyDay;
|
||||
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,84 +454,145 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void ApplyTypography(double width, double height)
|
||||
{
|
||||
var scale = ResolveScale(width, height);
|
||||
var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1);
|
||||
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
|
||||
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
|
||||
SummaryInfoGrid.RowSpacing = Math.Clamp(height * 0.003, 1, 4);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.2 * scale, 1, 6);
|
||||
ConditionRangeStack.Spacing = Math.Clamp(7 * scale, 4, 13);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
|
||||
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
var topScaleH = Math.Clamp(height / 640d, 0.62, 2.0);
|
||||
var topScaleW = Math.Clamp(width / 640d, 0.62, 2.0);
|
||||
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
|
||||
var cityFontSize = Math.Clamp(18 * topScale, 11, 26);
|
||||
var conditionFontSize = Math.Clamp(19 * topScale, 12, 27);
|
||||
var rangeFontSize = Math.Clamp(20 * topScale, 12, 30);
|
||||
var innerWidth = Math.Max(140, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right);
|
||||
var innerHeight = Math.Max(140, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom);
|
||||
var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 600d), 0.30, 3.20);
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.80);
|
||||
var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.80);
|
||||
var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1);
|
||||
|
||||
LayoutRoot.RowSpacing = Math.Clamp(8 * fitScale, 1, 22);
|
||||
SummaryGrid.ColumnSpacing = Math.Clamp(16 * fitScale, 4, 38);
|
||||
SummaryInfoGrid.RowSpacing = Math.Clamp(2 * fitScale, 0.2, 9);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2 * fitScale, 0.3, 14);
|
||||
ConditionRangeStack.Spacing = Math.Clamp(9 * fitScale, 1, 24);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(4 * fitScale, 0.5, 22);
|
||||
|
||||
var summaryHeight = Math.Clamp(innerHeight * 0.22, 34, Math.Max(34, innerHeight * 0.42));
|
||||
var hourlyHeight = Math.Clamp(innerHeight * 0.20, 34, Math.Max(34, innerHeight * 0.36));
|
||||
var separatorBandHeight = Math.Clamp(innerHeight * 0.03, 4, 40);
|
||||
if (LayoutRoot.RowDefinitions.Count >= 4)
|
||||
{
|
||||
LayoutRoot.RowDefinitions[0].Height = new GridLength(summaryHeight, GridUnitType.Pixel);
|
||||
LayoutRoot.RowDefinitions[1].Height = new GridLength(hourlyHeight, GridUnitType.Pixel);
|
||||
LayoutRoot.RowDefinitions[2].Height = new GridLength(separatorBandHeight, GridUnitType.Pixel);
|
||||
LayoutRoot.RowDefinitions[3].Height = new GridLength(1, GridUnitType.Star);
|
||||
}
|
||||
|
||||
var topScale = Math.Clamp(((summaryHeight / 118d) * 0.44) + (visualScale * 0.84), 0.24, 4.00);
|
||||
var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1);
|
||||
var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind);
|
||||
var iconSize = Math.Clamp(Lerp(90, 122, iconGrowth) * topScale * iconScaleBoost, 14, 360);
|
||||
iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.18, 0.26, iconGrowth)));
|
||||
var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text)
|
||||
? "00°"
|
||||
: TemperatureTextBlock.Text.Trim();
|
||||
var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6);
|
||||
var temperatureMaxWidth = Math.Max(30, innerWidth - iconSize - SummaryGrid.ColumnSpacing - 6);
|
||||
var rawTemperatureSize = Math.Clamp(Lerp(72, 102, iconGrowth) * topScale, 14, 340);
|
||||
var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 10, 340);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 380, emphasis));
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 30, Math.Max(300, innerWidth * 0.66));
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -12, 0), 0, 0);
|
||||
|
||||
var cityFontSize = Math.Clamp(18.5 * topScale, 7, 86);
|
||||
var conditionFontSize = Math.Clamp(20 * topScale, 7, 90);
|
||||
var rangeFontSize = Math.Clamp(20 * topScale, 7, 90);
|
||||
CityTextBlock.FontSize = cityFontSize;
|
||||
ConditionTextBlock.FontSize = conditionFontSize;
|
||||
RangeTextBlock.FontSize = rangeFontSize;
|
||||
CityTextBlock.FontWeight = ToVariableWeight(540);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(600);
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(620);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis));
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis));
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis));
|
||||
CityTextBlock.LineHeight = cityFontSize * 1.08;
|
||||
ConditionTextBlock.LineHeight = conditionFontSize * 1.06;
|
||||
RangeTextBlock.LineHeight = rangeFontSize * 1.06;
|
||||
var iconSize = Math.Clamp(height * 0.116, 36, 102);
|
||||
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 58, 220);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.30, 88, 270);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(width * 0.36, 112, 300);
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.4 * topScale, -12, 0), 0, 0);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.25, 28, 340);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 34, 380);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 34, 420);
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(4 * fitScale, 0, 18), 0, 0);
|
||||
|
||||
var hourlyBandHeight = Math.Clamp(height * 0.195, 74, 160);
|
||||
var hourlyCellWidth = Math.Max(34, (width - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * 5)) / 6d);
|
||||
var hourlyTempSize = Math.Clamp(hourlyBandHeight * 0.24, 10, 32);
|
||||
var hourlyTimeSize = Math.Clamp(hourlyBandHeight * 0.18, 8, 22);
|
||||
var hourlyIconSize = Math.Clamp(hourlyBandHeight * 0.20, 12, 30);
|
||||
var hourlyStackSpacing = Math.Clamp(hourlyBandHeight * 0.03, 1, 4);
|
||||
var hourlyCellWidth = Math.Max(12, (innerWidth - (HourlyGrid.ColumnSpacing * 5)) / 6d);
|
||||
var hourlyCellScale = Math.Clamp(
|
||||
Math.Min((visualScale * 0.44) + ((hourlyHeight / 120d) * 0.62), hourlyCellWidth / 76d),
|
||||
0.22,
|
||||
3.80);
|
||||
var hourlyTempSize = Math.Clamp(19 * hourlyCellScale, 6, 72);
|
||||
var hourlyTimeSize = Math.Clamp(14 * hourlyCellScale, 6, 52);
|
||||
var hourlyIconSize = Math.Clamp(42 * hourlyCellScale, 9, 140);
|
||||
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyCellWidth * 0.86));
|
||||
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyHeight * 0.56));
|
||||
var hourlyStackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
_hourlyTempBlocks[i].FontSize = hourlyTempSize;
|
||||
_hourlyTimeBlocks[i].FontSize = hourlyTimeSize;
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 610, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 530, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
_hourlyTempBlocks[i].MaxWidth = hourlyCellWidth;
|
||||
_hourlyTimeBlocks[i].MaxWidth = hourlyCellWidth;
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 650, emphasis));
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 560, emphasis));
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
||||
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
||||
_hourlyIconBlocks[i].Width = hourlyIconSize;
|
||||
_hourlyIconBlocks[i].Height = hourlyIconSize;
|
||||
if (_hourlyTempBlocks[i].Parent is StackPanel stack) stack.Spacing = hourlyStackSpacing;
|
||||
}
|
||||
|
||||
var dailyLabelSize = Math.Clamp(height * 0.041, 10, 30);
|
||||
var dailyTempSize = Math.Clamp(height * 0.043, 10, 33);
|
||||
var dailyIconSize = Math.Clamp(height * 0.040, 12, 30);
|
||||
var dailyLabelMaxWidth = Math.Clamp(width * (compactness > 0.3 ? 0.48 : 0.56), 120, 380);
|
||||
var dailyHighWidth = Math.Clamp(width * 0.11, 34, 72);
|
||||
var dailyLowWidth = Math.Clamp(width * 0.10, 30, 68);
|
||||
SeparatorLine.Margin = new Thickness(0, Math.Clamp(separatorBandHeight * 0.45, 1, 16), 0, 0);
|
||||
DailyGrid.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 0.5, 24), 0, 0);
|
||||
var dailyAreaHeight = Math.Max(50, innerHeight - summaryHeight - hourlyHeight - separatorBandHeight - (LayoutRoot.RowSpacing * 3) - DailyGrid.Margin.Top);
|
||||
var dailyRowSpacing = Math.Clamp(dailyAreaHeight * 0.028, 1, 22);
|
||||
DailyGrid.RowSpacing = dailyRowSpacing;
|
||||
var dailyRowHeight = Math.Max(8, (dailyAreaHeight - (dailyRowSpacing * 4)) / 5d);
|
||||
var dailyRowScale = Math.Clamp(((dailyRowHeight / 40d) * 0.62) + (visualScale * 0.44), 0.22, 3.80);
|
||||
|
||||
var dailyLabelSize = Math.Clamp(18.5 * dailyRowScale, 6, 70);
|
||||
var dailyTempSize = Math.Clamp(19 * dailyRowScale, 6, 72);
|
||||
var dailyIconSize = Math.Clamp(43 * dailyRowScale, 9, 132);
|
||||
dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, dailyRowHeight * 0.92));
|
||||
dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, innerWidth * 0.14));
|
||||
var dailyLabelMaxWidth = Math.Clamp(innerWidth * 0.52, 28, 460);
|
||||
var dailyHighWidth = Math.Clamp(innerWidth * 0.14, 14, 140);
|
||||
var dailyLowWidth = Math.Clamp(innerWidth * 0.11, 12, 120);
|
||||
var dailyHighRightGap = Math.Clamp(innerWidth * 0.018, 1, 28);
|
||||
for (var i = 0; i < _dailyLabelBlocks.Length; i++)
|
||||
{
|
||||
_dailyLabelBlocks[i].FontSize = dailyLabelSize;
|
||||
_dailyHighBlocks[i].FontSize = dailyTempSize;
|
||||
_dailyLowBlocks[i].FontSize = dailyTempSize;
|
||||
_dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 600, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
_dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 640, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
_dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 560, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
_dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 620, emphasis));
|
||||
_dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 680, emphasis));
|
||||
_dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 590, emphasis));
|
||||
_dailyLabelBlocks[i].MaxWidth = dailyLabelMaxWidth;
|
||||
_dailyHighBlocks[i].Width = dailyHighWidth;
|
||||
_dailyLowBlocks[i].Width = dailyLowWidth;
|
||||
_dailyHighBlocks[i].Margin = new Thickness(0, 0, dailyHighRightGap, 0);
|
||||
_dailyLowBlocks[i].Margin = new Thickness(0);
|
||||
_dailyHighBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||
_dailyLowBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||
_dailyHighBlocks[i].TextAlignment = TextAlignment.Right;
|
||||
_dailyLowBlocks[i].TextAlignment = TextAlignment.Right;
|
||||
_dailyIconBlocks[i].Width = dailyIconSize;
|
||||
_dailyIconBlocks[i].Height = dailyIconSize;
|
||||
if (_dailyIconBlocks[i].Parent is Grid dailyRowGrid)
|
||||
{
|
||||
dailyRowGrid.ColumnSpacing = Math.Clamp(9 * dailyRowScale, 4, 18);
|
||||
}
|
||||
|
||||
var dailyKind = i < _dailyIconKinds.Length
|
||||
? _dailyIconKinds[i]
|
||||
: HyperOS3WeatherVisualKind.CloudyDay;
|
||||
var dailyIconVisualSize = Math.Clamp(
|
||||
dailyIconSize * ResolveDailyMiniIconScaleBoost(dailyKind),
|
||||
8,
|
||||
148);
|
||||
dailyIconVisualSize = Math.Min(dailyIconVisualSize, Math.Max(10, dailyRowHeight * 0.94));
|
||||
_dailyIconBlocks[i].Width = dailyIconVisualSize;
|
||||
_dailyIconBlocks[i].Height = dailyIconVisualSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,6 +868,48 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private string L(string key, string fallback) => _localizationService.GetString(_languageCode, key, fallback);
|
||||
|
||||
private void InitializeMotionTransform()
|
||||
{
|
||||
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children = new Transforms
|
||||
{
|
||||
_backgroundMotionScaleTransform,
|
||||
_backgroundMotionTranslateTransform
|
||||
}
|
||||
};
|
||||
SetMotionTransform(0, 0, 1.05);
|
||||
}
|
||||
|
||||
private void SetMotionTransform(double translateX, double translateY, double scale)
|
||||
{
|
||||
_backgroundMotionScaleTransform.ScaleX = scale;
|
||||
_backgroundMotionScaleTransform.ScaleY = scale;
|
||||
_backgroundMotionTranslateTransform.X = translateX;
|
||||
_backgroundMotionTranslateTransform.Y = translateY;
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
if (!_animationTimer.IsEnabled)
|
||||
{
|
||||
_animationTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Stop();
|
||||
_animationTimer.Stop();
|
||||
}
|
||||
|
||||
private void CancelRefresh()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
@@ -784,6 +919,28 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private static double ResolveScale(double width, double height) => Math.Clamp(Math.Min(Math.Clamp(width / 620d, 0.42, 2.4), Math.Clamp(height / 620d, 0.42, 2.4)), 0.42, 2.4);
|
||||
private static double Lerp(double from, double to, double t) => from + ((to - from) * t);
|
||||
private static double ResolveHeroIconScaleBoost(HyperOS3WeatherVisualKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
HyperOS3WeatherVisualKind.RainLight or HyperOS3WeatherVisualKind.RainHeavy or HyperOS3WeatherVisualKind.Storm or HyperOS3WeatherVisualKind.Snow => 1.16,
|
||||
HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight => 1.08,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
private static double ResolveDailyMiniIconScaleBoost(HyperOS3WeatherVisualKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
HyperOS3WeatherVisualKind.CloudyDay => 1.30,
|
||||
HyperOS3WeatherVisualKind.CloudyNight => 1.28,
|
||||
HyperOS3WeatherVisualKind.ClearDay => 1.26,
|
||||
HyperOS3WeatherVisualKind.ClearNight => 1.24,
|
||||
HyperOS3WeatherVisualKind.Fog => 1.18,
|
||||
HyperOS3WeatherVisualKind.RainLight => 1.14,
|
||||
HyperOS3WeatherVisualKind.RainHeavy => 1.12,
|
||||
HyperOS3WeatherVisualKind.Snow => 1.12,
|
||||
HyperOS3WeatherVisualKind.Storm => 1.08,
|
||||
_ => 1.18
|
||||
};
|
||||
private static FontWeight ToVariableWeight(double weight) => (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
|
||||
private static IBrush CreateSolidBrush(string colorHex) => new SolidColorBrush(Color.Parse(colorHex));
|
||||
private static IBrush CreateSolidBrush(string colorHex, byte alpha) { var c = Color.Parse(colorHex); return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B)); }
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
|
||||
@@ -13,10 +13,11 @@ using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -89,7 +90,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private readonly DispatcherTimer _backgroundAnimationTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(48)
|
||||
Interval = FluttermotionToken.WeatherAnimationFrameInterval
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
@@ -99,6 +100,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _particleStates = new();
|
||||
private readonly Random _particleRandom = new();
|
||||
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
||||
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
private TimeZoneService? _timeZoneService;
|
||||
@@ -110,6 +113,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private double _animationPhase;
|
||||
private int _activeParticleCount;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
@@ -118,6 +122,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
public HourlyWeatherWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeMotionTransform();
|
||||
_hourlyTimeBlocks =
|
||||
[
|
||||
HourlyTime0, HourlyTime1, HourlyTime2, HourlyTime3, HourlyTime4, HourlyTime5
|
||||
@@ -159,13 +164,13 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
RangeTextBlock.MaxLines = 1;
|
||||
|
||||
TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.None;
|
||||
TemperatureTextBlock.MaxLines = 1;
|
||||
|
||||
foreach (var timeBlock in _hourlyTimeBlocks)
|
||||
{
|
||||
timeBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
timeBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
timeBlock.TextTrimming = TextTrimming.None;
|
||||
timeBlock.MaxLines = 1;
|
||||
timeBlock.TextAlignment = TextAlignment.Center;
|
||||
}
|
||||
@@ -173,7 +178,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
foreach (var tempBlock in _hourlyTempBlocks)
|
||||
{
|
||||
tempBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
tempBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
tempBlock.TextTrimming = TextTrimming.None;
|
||||
tempBlock.MaxLines = 1;
|
||||
tempBlock.TextAlignment = TextAlignment.Center;
|
||||
}
|
||||
@@ -200,7 +205,20 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
||||
{
|
||||
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
||||
if (_isAttached)
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
@@ -231,16 +249,17 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_backgroundAnimationTimer.Start();
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
UpdateTimerState();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
@@ -257,7 +276,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void OnBackgroundAnimationTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isAttached)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -320,7 +339,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private async Task RefreshWeatherAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -822,7 +841,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private void ApplyHourlyForecastItems(IReadOnlyList<HourlyForecastItem> items)
|
||||
{
|
||||
var fallbackIcon = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(_activeVisualKind)));
|
||||
HyperOS3WeatherTheme.ResolveMiniIconAsset(ToThemeKind(_activeVisualKind)));
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
if (i >= items.Count)
|
||||
@@ -836,7 +855,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
var item = items[i];
|
||||
_hourlyTimeBlocks[i].Text = item.TimeLabel;
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind));
|
||||
HyperOS3WeatherTheme.ResolveMiniIconAsset(item.IconKind));
|
||||
_hourlyTempBlocks[i].Text = item.TemperatureText;
|
||||
}
|
||||
}
|
||||
@@ -1168,68 +1187,86 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private void ApplyAdaptiveTypography()
|
||||
{
|
||||
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
||||
var scaleX = Math.Clamp(layoutWidth / 608d, 0.58, 1.90);
|
||||
var scaleY = Math.Clamp(layoutHeight / 288d, 0.58, 1.90);
|
||||
var innerWidth = Math.Max(120, layoutWidth);
|
||||
var innerHeight = Math.Max(72, layoutHeight);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.55, 0, 1);
|
||||
var innerHeight = Math.Max(56, layoutHeight);
|
||||
var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 284d), 0.30, 3.20);
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.60);
|
||||
var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.60);
|
||||
var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1);
|
||||
|
||||
ContentGrid.RowSpacing = Math.Clamp((4.2 - (compactness * 0.7)) * scaleY, 2, 8);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(8 * scaleX, 6, 13);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp((1.0 - (compactness * 0.4)) * scaleY, 0, 2));
|
||||
ContentGrid.RowSpacing = Math.Clamp(8 * fitScale, 1, 20);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(11 * fitScale, 3, 30);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.2 * fitScale, 0, 7));
|
||||
|
||||
var contentHeight = Math.Max(60, innerHeight - ContentGrid.RowSpacing);
|
||||
var topZoneRatio = Math.Clamp(0.38 + (compactness * 0.09), 0.36, 0.50);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * topZoneRatio, 60, 170);
|
||||
var bottomZoneHeight = Math.Max(42, contentHeight - topZoneHeight);
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 102d, 0.62, 2.0);
|
||||
var topScaleW = Math.Clamp(innerWidth / 620d, 0.62, 2.0);
|
||||
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 122d, 0.56, 2.0);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.74) + (scaleX * 0.26), 0.56, 1.95);
|
||||
var bodyHeight = bottomZoneHeight;
|
||||
var contentHeight = Math.Max(36, innerHeight - ContentGrid.RowSpacing);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * 0.47, 24, Math.Max(24, contentHeight - 12));
|
||||
var bottomZoneHeight = Math.Max(10, contentHeight - topZoneHeight);
|
||||
if (ContentGrid.RowDefinitions.Count >= 2)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel);
|
||||
ContentGrid.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star);
|
||||
}
|
||||
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(315);
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196);
|
||||
var topScale = Math.Clamp(((topZoneHeight / 116d) * 0.42) + (visualScale * 0.86), 0.24, 3.90);
|
||||
var bottomScale = Math.Clamp(((bottomZoneHeight / 156d) * 0.44) + (visualScale * 0.72), 0.24, 3.80);
|
||||
var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1);
|
||||
var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind);
|
||||
var iconSize = Math.Clamp(Lerp(88, 116, iconGrowth) * topScale * iconScaleBoost, 14, 360);
|
||||
iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.22, 0.32, iconGrowth)));
|
||||
var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text)
|
||||
? "00°"
|
||||
: TemperatureTextBlock.Text.Trim();
|
||||
var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6);
|
||||
var temperatureMaxWidth = Math.Max(28, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 4);
|
||||
var rawTemperatureSize = Math.Clamp(Lerp(64, 92, iconGrowth) * topScale, 12, 320);
|
||||
var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 9, 320);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis));
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.0 * topScale, -10, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 28, Math.Max(280, innerWidth * 0.68));
|
||||
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17);
|
||||
CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(540);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300);
|
||||
LocationIcon.FontSize = Math.Clamp(13 * topScale, 6, 52);
|
||||
CityTextBlock.FontSize = Math.Clamp(18.5 * topScale, 7, 88);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis));
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.37, 34, 460);
|
||||
|
||||
ConditionInfoBadge.Padding = new Thickness(0);
|
||||
ConditionInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
ConditionRangeStack.Spacing = Math.Clamp(7 * topScale, 4, 13);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27);
|
||||
RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(600);
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(620);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6);
|
||||
ConditionRangeStack.Spacing = Math.Clamp(8.5 * topScale, 1, 24);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 7, 78);
|
||||
RangeTextBlock.FontSize = Math.Clamp(21 * topScale, 7, 84);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis));
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis));
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 26, 320);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 32, 360);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.0 * topScale, 0.4, 14);
|
||||
|
||||
var iconSize = Math.Clamp(68 * topScale, 42, 98);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -10, 0), 0, 0);
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0);
|
||||
HourlyPanelBorder.Padding = new Thickness(0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 1, 24), 0, 0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(4 * fitScale, 0.5, 24);
|
||||
|
||||
var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length);
|
||||
var hourlyInnerWidth = Math.Max(
|
||||
96,
|
||||
innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
||||
var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount);
|
||||
var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4);
|
||||
var hourlyTempSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31);
|
||||
var hourlyTimeSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23);
|
||||
var hourlyIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35);
|
||||
32,
|
||||
innerWidth - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
||||
var hourlyCellWidth = Math.Max(12, hourlyInnerWidth / hourlyColumnCount);
|
||||
var hourlyCellScale = Math.Clamp(
|
||||
Math.Min((bottomScale * 0.66) + (visualScale * 0.44), hourlyCellWidth / 74d),
|
||||
0.22,
|
||||
3.60);
|
||||
var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
|
||||
var hourlyTempSize = Math.Clamp(19.5 * hourlyCellScale, 6, 72);
|
||||
var hourlyTimeSize = Math.Clamp(14.5 * hourlyCellScale, 6, 50);
|
||||
var hourlyIconSize = Math.Clamp(42 * hourlyCellScale, 9, 136);
|
||||
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyCellWidth * 0.86));
|
||||
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, bottomZoneHeight * 0.52));
|
||||
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
@@ -1237,10 +1274,10 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
_hourlyTimeBlocks[i].FontSize = hourlyTimeSize;
|
||||
_hourlyIconBlocks[i].Width = hourlyIconSize;
|
||||
_hourlyIconBlocks[i].Height = hourlyIconSize;
|
||||
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500);
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(590);
|
||||
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 240);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 240);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(500, 600, emphasis));
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(580, 690, emphasis));
|
||||
if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack)
|
||||
{
|
||||
hourlyStack.Spacing = stackSpacing;
|
||||
@@ -1253,10 +1290,20 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
return from + ((to - from) * t);
|
||||
}
|
||||
|
||||
private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16,
|
||||
WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private void SetMainWeatherIcon(WeatherVisualKind kind)
|
||||
{
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind)));
|
||||
HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind)));
|
||||
}
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
@@ -1313,15 +1360,43 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void SetMotionTransform(double translateX, double translateY, double scale)
|
||||
{
|
||||
var group = new TransformGroup
|
||||
_backgroundMotionScaleTransform.ScaleX = scale;
|
||||
_backgroundMotionScaleTransform.ScaleY = scale;
|
||||
_backgroundMotionTranslateTransform.X = translateX;
|
||||
_backgroundMotionTranslateTransform.Y = translateY;
|
||||
}
|
||||
|
||||
private void InitializeMotionTransform()
|
||||
{
|
||||
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children = new Transforms
|
||||
{
|
||||
new ScaleTransform(scale, scale),
|
||||
new TranslateTransform(translateX, translateY)
|
||||
_backgroundMotionScaleTransform,
|
||||
_backgroundMotionTranslateTransform
|
||||
}
|
||||
};
|
||||
BackgroundMotionLayer.RenderTransform = group;
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
_backgroundAnimationTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
|
||||
@@ -102,18 +102,32 @@ public static class HyperOS3WeatherTheme
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> IconAssets =
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> HeroIconAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp"
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, string> MiniIconAssets =
|
||||
new Dictionary<HyperOS3WeatherVisualKind, string>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png",
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png",
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png",
|
||||
[HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png",
|
||||
[HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png",
|
||||
[HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette> Palettes =
|
||||
@@ -319,7 +333,17 @@ public static class HyperOS3WeatherTheme
|
||||
|
||||
public static string? ResolveIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return IconAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
return ResolveMiniIconAsset(kind);
|
||||
}
|
||||
|
||||
public static string? ResolveHeroIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return HeroIconAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
}
|
||||
|
||||
public static string? ResolveMiniIconAsset(HyperOS3WeatherVisualKind kind)
|
||||
{
|
||||
return MiniIconAssets.TryGetValue(kind, out var asset) ? asset : null;
|
||||
}
|
||||
|
||||
public static string ResolveSunCoreAsset()
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
|
||||
@@ -11,10 +11,11 @@ using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -87,7 +88,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private readonly DispatcherTimer _backgroundAnimationTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(48)
|
||||
Interval = FluttermotionToken.WeatherAnimationFrameInterval
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
@@ -97,6 +98,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _particleStates = new();
|
||||
private readonly Random _particleRandom = new();
|
||||
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
||||
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
private TimeZoneService? _timeZoneService;
|
||||
@@ -108,6 +111,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private double _animationPhase;
|
||||
private int _activeParticleCount;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
@@ -116,6 +120,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
public MultiDayWeatherWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeMotionTransform();
|
||||
_hourlyTimeBlocks =
|
||||
[
|
||||
HourlyTime0, HourlyTime1, HourlyTime2, HourlyTime3, HourlyTime4
|
||||
@@ -157,13 +162,13 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
RangeTextBlock.MaxLines = 1;
|
||||
|
||||
TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.None;
|
||||
TemperatureTextBlock.MaxLines = 1;
|
||||
|
||||
foreach (var timeBlock in _hourlyTimeBlocks)
|
||||
{
|
||||
timeBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
timeBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
timeBlock.TextTrimming = TextTrimming.None;
|
||||
timeBlock.MaxLines = 1;
|
||||
timeBlock.TextAlignment = TextAlignment.Center;
|
||||
}
|
||||
@@ -171,7 +176,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
foreach (var tempBlock in _hourlyTempBlocks)
|
||||
{
|
||||
tempBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
tempBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
tempBlock.TextTrimming = TextTrimming.None;
|
||||
tempBlock.MaxLines = 1;
|
||||
tempBlock.TextAlignment = TextAlignment.Center;
|
||||
}
|
||||
@@ -198,7 +203,20 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
||||
{
|
||||
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
||||
if (_isAttached)
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
@@ -229,16 +247,17 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_backgroundAnimationTimer.Start();
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
UpdateTimerState();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
@@ -255,7 +274,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void OnBackgroundAnimationTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isAttached)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -318,7 +337,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private async Task RefreshWeatherAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -812,14 +831,14 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_hourlyTimeBlocks[i].Text = "--";
|
||||
_hourlyTempBlocks[i].Text = "--°/--°";
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(_activeVisualKind)));
|
||||
HyperOS3WeatherTheme.ResolveMiniIconAsset(ToThemeKind(_activeVisualKind)));
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = items[i];
|
||||
_hourlyTimeBlocks[i].Text = item.TimeLabel;
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind));
|
||||
HyperOS3WeatherTheme.ResolveMiniIconAsset(item.IconKind));
|
||||
_hourlyTempBlocks[i].Text = item.TemperatureText;
|
||||
}
|
||||
}
|
||||
@@ -1015,68 +1034,87 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private void ApplyAdaptiveTypography()
|
||||
{
|
||||
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
||||
var scaleX = Math.Clamp(layoutWidth / 608d, 0.58, 1.90);
|
||||
var scaleY = Math.Clamp(layoutHeight / 288d, 0.58, 1.90);
|
||||
var innerWidth = Math.Max(120, layoutWidth);
|
||||
var innerHeight = Math.Max(72, layoutHeight);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.55, 0, 1);
|
||||
var innerHeight = Math.Max(56, layoutHeight);
|
||||
var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 284d), 0.30, 3.20);
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.60);
|
||||
var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.60);
|
||||
var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1);
|
||||
|
||||
ContentGrid.RowSpacing = Math.Clamp((4.2 - (compactness * 0.7)) * scaleY, 2, 8);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(8 * scaleX, 6, 13);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp((1.0 - (compactness * 0.4)) * scaleY, 0, 2));
|
||||
ContentGrid.RowSpacing = Math.Clamp(8 * fitScale, 1, 20);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(11 * fitScale, 3, 30);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.2 * fitScale, 0, 7));
|
||||
|
||||
var separatorHeight = Math.Clamp(6 * scaleY, 2, 10);
|
||||
var contentHeight = Math.Max(60, innerHeight - ContentGrid.RowSpacing - separatorHeight);
|
||||
var topZoneRatio = Math.Clamp(0.38 + (compactness * 0.09), 0.36, 0.50);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * topZoneRatio, 60, 170);
|
||||
var bottomZoneHeight = Math.Max(42, contentHeight - topZoneHeight);
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 102d, 0.62, 2.0);
|
||||
var topScaleW = Math.Clamp(innerWidth / 620d, 0.62, 2.0);
|
||||
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 122d, 0.56, 2.0);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.74) + (scaleX * 0.26), 0.56, 1.95);
|
||||
var bodyHeight = bottomZoneHeight;
|
||||
var separatorHeight = Math.Clamp(2.0 * fitScale, 1, 8);
|
||||
var contentHeight = Math.Max(36, innerHeight - ContentGrid.RowSpacing - separatorHeight);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * 0.47, 24, Math.Max(24, contentHeight - 12));
|
||||
var bottomZoneHeight = Math.Max(10, contentHeight - topZoneHeight);
|
||||
if (ContentGrid.RowDefinitions.Count >= 3)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel);
|
||||
ContentGrid.RowDefinitions[1].Height = new GridLength(separatorHeight, GridUnitType.Pixel);
|
||||
ContentGrid.RowDefinitions[2].Height = new GridLength(1, GridUnitType.Star);
|
||||
}
|
||||
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(315);
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196);
|
||||
var topScale = Math.Clamp(((topZoneHeight / 116d) * 0.42) + (visualScale * 0.86), 0.24, 3.90);
|
||||
var bottomScale = Math.Clamp(((bottomZoneHeight / 156d) * 0.44) + (visualScale * 0.72), 0.24, 3.80);
|
||||
var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1);
|
||||
var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind);
|
||||
var iconSize = Math.Clamp(Lerp(88, 116, iconGrowth) * topScale * iconScaleBoost, 14, 360);
|
||||
iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.22, 0.32, iconGrowth)));
|
||||
var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text)
|
||||
? "00°"
|
||||
: TemperatureTextBlock.Text.Trim();
|
||||
var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6);
|
||||
var temperatureMaxWidth = Math.Max(28, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 4);
|
||||
var rawTemperatureSize = Math.Clamp(Lerp(64, 92, iconGrowth) * topScale, 12, 320);
|
||||
var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 9, 320);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis));
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.0 * topScale, -10, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 28, Math.Max(280, innerWidth * 0.68));
|
||||
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17);
|
||||
CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(540);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300);
|
||||
LocationIcon.FontSize = Math.Clamp(13 * topScale, 6, 52);
|
||||
CityTextBlock.FontSize = Math.Clamp(18.5 * topScale, 7, 88);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis));
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.37, 34, 460);
|
||||
|
||||
ConditionInfoBadge.Padding = new Thickness(0);
|
||||
ConditionInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
ConditionIconStack.Spacing = Math.Clamp(7 * topScale, 4, 13);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27);
|
||||
RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(600);
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(620);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6);
|
||||
ConditionIconStack.Spacing = Math.Clamp(8.5 * topScale, 1, 24);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 7, 78);
|
||||
RangeTextBlock.FontSize = Math.Clamp(21 * topScale, 7, 84);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis));
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis));
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 26, 320);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 32, 360);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.0 * topScale, 0.4, 14);
|
||||
|
||||
var iconSize = Math.Clamp(68 * topScale, 42, 98);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -10, 0), 0, 0);
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0);
|
||||
HourlyPanelBorder.Padding = new Thickness(0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 1, 24), 0, 0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(5 * fitScale, 0.5, 28);
|
||||
var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length);
|
||||
var hourlyInnerWidth = Math.Max(
|
||||
96,
|
||||
innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
||||
var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount);
|
||||
var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4);
|
||||
var forecastRangeSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31);
|
||||
var forecastLabelSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23);
|
||||
var forecastIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35);
|
||||
32,
|
||||
innerWidth - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
||||
var hourlyCellWidth = Math.Max(12, hourlyInnerWidth / hourlyColumnCount);
|
||||
var hourlyCellScale = Math.Clamp(
|
||||
Math.Min((bottomScale * 0.66) + (visualScale * 0.44), hourlyCellWidth / 78d),
|
||||
0.22,
|
||||
3.60);
|
||||
var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
|
||||
var forecastRangeSize = Math.Clamp(18.0 * hourlyCellScale, 6, 62);
|
||||
var forecastLabelSize = Math.Clamp(13.8 * hourlyCellScale, 6, 48);
|
||||
var forecastIconSize = Math.Clamp(40 * hourlyCellScale, 9, 124);
|
||||
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, hourlyCellWidth * 0.88));
|
||||
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, bottomZoneHeight * 0.50));
|
||||
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
@@ -1084,10 +1122,10 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_hourlyTempBlocks[i].FontSize = forecastRangeSize;
|
||||
_hourlyIconBlocks[i].Width = forecastIconSize;
|
||||
_hourlyIconBlocks[i].Height = forecastIconSize;
|
||||
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500);
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(590);
|
||||
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(500, 600, emphasis));
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(580, 690, emphasis));
|
||||
_hourlyTimeBlocks[i].TextAlignment = TextAlignment.Center;
|
||||
_hourlyTempBlocks[i].TextAlignment = TextAlignment.Center;
|
||||
if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack)
|
||||
@@ -1102,10 +1140,20 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
return from + ((to - from) * t);
|
||||
}
|
||||
|
||||
private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16,
|
||||
WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private void SetMainWeatherIcon(WeatherVisualKind kind)
|
||||
{
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind)));
|
||||
HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind)));
|
||||
}
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
@@ -1162,15 +1210,43 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void SetMotionTransform(double translateX, double translateY, double scale)
|
||||
{
|
||||
var group = new TransformGroup
|
||||
_backgroundMotionScaleTransform.ScaleX = scale;
|
||||
_backgroundMotionScaleTransform.ScaleY = scale;
|
||||
_backgroundMotionTranslateTransform.X = translateX;
|
||||
_backgroundMotionTranslateTransform.Y = translateY;
|
||||
}
|
||||
|
||||
private void InitializeMotionTransform()
|
||||
{
|
||||
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children = new Transforms
|
||||
{
|
||||
new ScaleTransform(scale, scale),
|
||||
new TranslateTransform(translateX, translateY)
|
||||
_backgroundMotionScaleTransform,
|
||||
_backgroundMotionTranslateTransform
|
||||
}
|
||||
};
|
||||
BackgroundMotionLayer.RenderTransform = group;
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
_backgroundAnimationTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
Opacity="0.62"
|
||||
Stretch="UniformToFill">
|
||||
<Image.Effect>
|
||||
<BlurEffect Radius="42" />
|
||||
<BlurEffect Radius="{DynamicResource FluttermotionToken.BackdropBlurRadiusStrong}" />
|
||||
</Image.Effect>
|
||||
</Image>
|
||||
</Border>
|
||||
|
||||
@@ -17,7 +17,7 @@ using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
public partial class MusicControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private const Symbol PlaySymbol = Symbol.Play;
|
||||
private const Symbol PauseSymbol = Symbol.Pause;
|
||||
@@ -38,6 +38,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _isExecutingCommand;
|
||||
private double _progressRatio;
|
||||
@@ -126,17 +127,33 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateRefreshTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_ = RefreshStateAsync();
|
||||
UpdateRefreshTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
UpdateRefreshTimerState();
|
||||
CancelRefreshRequest();
|
||||
DisposeCoverBitmap();
|
||||
}
|
||||
@@ -211,7 +228,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private async Task RefreshStateAsync()
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -257,6 +274,21 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRefreshTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyState(MusicPlaybackState state)
|
||||
{
|
||||
var hasMediaSession = state.IsSupported && state.HasSession;
|
||||
|
||||
@@ -9,11 +9,12 @@ using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private const int WaveBarCount = 22;
|
||||
|
||||
@@ -23,6 +24,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
};
|
||||
|
||||
private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateRecorder();
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<Border> _waveBars = [];
|
||||
@@ -32,6 +34,8 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
private string _lastSavedFilePath = string.Empty;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _pausedStudyMonitoringForRecording;
|
||||
|
||||
public RecordingWidget()
|
||||
{
|
||||
@@ -103,10 +107,24 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
UpdateWaveformVisual();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateUiTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
ReloadLanguageCode();
|
||||
RefreshVisual();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_uiTimer.Start();
|
||||
UpdateUiTimerState();
|
||||
ReloadLanguageCode();
|
||||
RefreshVisual();
|
||||
}
|
||||
@@ -114,7 +132,13 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_uiTimer.Stop();
|
||||
UpdateUiTimerState();
|
||||
|
||||
var snapshot = _audioRecorderService.GetSnapshot();
|
||||
if (snapshot.State is not AudioRecorderRuntimeState.Recording and not AudioRecorderRuntimeState.Paused)
|
||||
{
|
||||
ResumeStudyMonitoringIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -124,7 +148,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnUiTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isAttached)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -132,6 +156,21 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateUiTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
}
|
||||
|
||||
private void OnDiscardButtonPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
@@ -140,6 +179,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
}
|
||||
|
||||
_audioRecorderService.Discard();
|
||||
ResumeStudyMonitoringIfNeeded();
|
||||
RefreshVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
@@ -165,7 +205,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
}
|
||||
else
|
||||
{
|
||||
_audioRecorderService.StartOrResume();
|
||||
_ = TryStartRecordingWithMonitoringHandoff();
|
||||
}
|
||||
|
||||
RefreshVisual();
|
||||
@@ -201,6 +241,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
}
|
||||
|
||||
_ = _audioRecorderService.StopAndSave(outputPath);
|
||||
ResumeStudyMonitoringIfNeeded();
|
||||
RefreshVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
@@ -208,6 +249,12 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _audioRecorderService.GetSnapshot();
|
||||
if (_pausedStudyMonitoringForRecording &&
|
||||
snapshot.State is AudioRecorderRuntimeState.Ready or AudioRecorderRuntimeState.Error or AudioRecorderRuntimeState.Unsupported)
|
||||
{
|
||||
ResumeStudyMonitoringIfNeeded();
|
||||
snapshot = _audioRecorderService.GetSnapshot();
|
||||
}
|
||||
|
||||
TitleTextBlock.Text = L("recording.widget.title", "Recorder");
|
||||
TimerTextBlock.Text = FormatDuration(snapshot.Duration);
|
||||
@@ -300,6 +347,60 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
HintTextBlock.Text = L("recording.widget.hint.ready", "Tap red button to record");
|
||||
}
|
||||
|
||||
private bool TryStartRecordingWithMonitoringHandoff()
|
||||
{
|
||||
if (_audioRecorderService.StartOrResume())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryPauseStudyMonitoringForRecording())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_audioRecorderService.StartOrResume())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ResumeStudyMonitoringIfNeeded();
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryPauseStudyMonitoringForRecording()
|
||||
{
|
||||
if (_pausedStudyMonitoringForRecording)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
if (snapshot.State != StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_studyAnalyticsService.PauseMonitoring())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_pausedStudyMonitoringForRecording = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResumeStudyMonitoringIfNeeded()
|
||||
{
|
||||
if (!_pausedStudyMonitoringForRecording)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pausedStudyMonitoringForRecording = false;
|
||||
_ = _studyAnalyticsService.StartOrResumeMonitoring();
|
||||
}
|
||||
|
||||
private void InitializeWaveBars()
|
||||
{
|
||||
if (_waveBars.Count > 0)
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Top"
|
||||
Margin="-1,-7,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextTrimming="None"
|
||||
MaxLines="1" />
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
|
||||
@@ -13,10 +13,11 @@ using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -83,7 +84,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
|
||||
private readonly DispatcherTimer _backgroundAnimationTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(48)
|
||||
Interval = FluttermotionToken.WeatherAnimationFrameInterval
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
@@ -93,6 +94,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
private readonly List<Border> _particleVisuals = new();
|
||||
private readonly List<ParticleState> _particleStates = new();
|
||||
private readonly Random _particleRandom = new();
|
||||
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
||||
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
private TimeZoneService? _timeZoneService;
|
||||
@@ -104,11 +107,13 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
private double _animationPhase;
|
||||
private int _activeParticleCount;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
|
||||
public WeatherWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeMotionTransform();
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
_backgroundAnimationTimer.Tick += OnBackgroundAnimationTick;
|
||||
@@ -143,7 +148,20 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
||||
{
|
||||
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
||||
if (_isAttached)
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdateTimerState();
|
||||
|
||||
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
@@ -154,6 +172,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Realtime2x2);
|
||||
var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(80, _currentCellSize * 2);
|
||||
var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(80, _currentCellSize * 2);
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 26, 46);
|
||||
var horizontalPadding = Math.Clamp(_currentCellSize * metrics.HorizontalPaddingScale, 10, 24);
|
||||
var verticalPadding = Math.Clamp(_currentCellSize * metrics.VerticalPaddingScale, 10, 24);
|
||||
@@ -165,8 +185,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
Math.Clamp(horizontalPadding * scale, 10, 24),
|
||||
Math.Clamp(verticalPadding * scale, 10, 24));
|
||||
Math.Clamp(Math.Min(horizontalPadding * scale, hostWidth * 0.12), 3, 24),
|
||||
Math.Clamp(Math.Min(verticalPadding * scale, hostHeight * 0.12), 3, 24));
|
||||
ApplyAdaptiveTypography();
|
||||
ResetParticles();
|
||||
}
|
||||
@@ -174,16 +194,17 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_backgroundAnimationTimer.Start();
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
UpdateTimerState();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
@@ -200,7 +221,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
|
||||
private void OnBackgroundAnimationTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isAttached)
|
||||
if (!_isAttached || !_isOnActivePage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -263,7 +284,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
|
||||
private async Task RefreshWeatherAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -472,7 +493,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xD6 : (byte)0xC2);
|
||||
isNightVisual ? (byte)0xC4 : (byte)0xAE);
|
||||
var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor);
|
||||
LocationIcon.Foreground = tertiary;
|
||||
CityTextBlock.Foreground = tertiary;
|
||||
@@ -815,25 +836,19 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
{
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 2;
|
||||
var innerWidth = Math.Max(90, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right);
|
||||
var innerHeight = Math.Max(90, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom);
|
||||
var scaleX = Math.Clamp(innerWidth / 288d, 0.56, 2.2);
|
||||
var scaleY = Math.Clamp(innerHeight / 288d, 0.56, 2.2);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.60, 0, 1);
|
||||
var innerWidth = Math.Max(56, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right);
|
||||
var innerHeight = Math.Max(56, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom);
|
||||
var fitScale = Math.Clamp(Math.Min(innerWidth / 288d, innerHeight / 288d), 0.30, 3.20);
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.80);
|
||||
var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.80);
|
||||
var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1);
|
||||
|
||||
ContentGrid.RowSpacing = Math.Clamp((2.8 - (compactness * 0.5)) * scaleY, 1, 6);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13);
|
||||
ContentGrid.RowSpacing = Math.Clamp(2.2 * fitScale, 0.5, 9);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(6.0 * fitScale, 2, 20);
|
||||
|
||||
var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2));
|
||||
var topZoneRatio = Math.Clamp(0.52 + ((1 - compactness) * 0.03), 0.48, 0.56);
|
||||
var bottomZoneRatio = Math.Clamp(0.36 - (compactness * 0.02), 0.32, 0.40);
|
||||
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 44, availableHeight - 30);
|
||||
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 34, availableHeight - topZoneHeight - 6);
|
||||
if (topZoneHeight + bottomZoneHeight > availableHeight - 6)
|
||||
{
|
||||
bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6);
|
||||
topZoneHeight = Math.Max(42, availableHeight - bottomZoneHeight - 6);
|
||||
}
|
||||
var availableHeight = Math.Max(40, innerHeight - (ContentGrid.RowSpacing * 2));
|
||||
var topZoneHeight = Math.Clamp(availableHeight * 0.60, 22, Math.Max(22, availableHeight - 16));
|
||||
var bottomZoneHeight = Math.Max(12, availableHeight - topZoneHeight - 2);
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 3)
|
||||
{
|
||||
@@ -842,46 +857,38 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.58, 2.2);
|
||||
var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2);
|
||||
var topScale = Math.Clamp((topScaleH * 0.70) + (topScaleW * 0.30), 0.58, 2.2);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 80d, 0.62, 2.2);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.80) + (scaleX * 0.20), 0.62, 2.2);
|
||||
|
||||
var iconSize = Math.Clamp(
|
||||
Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)),
|
||||
52,
|
||||
136);
|
||||
var topScale = Math.Clamp(((topZoneHeight / 170d) * 0.42) + (visualScale * 0.84), 0.24, 4.00);
|
||||
var bottomScale = Math.Clamp(((bottomZoneHeight / 84d) * 0.46) + (visualScale * 0.66), 0.24, 3.90);
|
||||
var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1);
|
||||
var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind);
|
||||
var iconSize = Math.Clamp(Lerp(96, 124, iconGrowth) * topScale * iconScaleBoost, 18, 360);
|
||||
iconSize = Math.Min(iconSize, Math.Max(18, innerWidth * Lerp(0.34, 0.44, iconGrowth)));
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0);
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-4.2 * topScale, -14, 0), 0, 0);
|
||||
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(
|
||||
Math.Max(52, topZoneHeight * 0.69) * (0.74 + (topScale * 0.24)),
|
||||
50,
|
||||
146);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(310);
|
||||
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0);
|
||||
var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(
|
||||
innerWidth - iconSize - TopRowGrid.ColumnSpacing - 8,
|
||||
90,
|
||||
temperatureMaxWidthLimit);
|
||||
var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text)
|
||||
? "00°"
|
||||
: TemperatureTextBlock.Text.Trim();
|
||||
var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6);
|
||||
var temperatureMaxWidth = Math.Max(34, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 2);
|
||||
var rawTemperatureSize = Math.Clamp(Lerp(94, 118, iconGrowth) * topScale, 22, 340);
|
||||
var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 10, 340);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis));
|
||||
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-1.4 * topScale, -6, 0), Math.Clamp(-7.6 * topScale, -16, -1), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 34, Math.Max(34, innerWidth * 0.76));
|
||||
|
||||
var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 1, 4);
|
||||
var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 0.6, 8);
|
||||
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4));
|
||||
BottomInfoStack.MaxHeight = Math.Max(32, bottomZoneHeight);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.4 * fitScale, 0, 6));
|
||||
BottomInfoStack.MaxHeight = Math.Max(10, bottomZoneHeight);
|
||||
|
||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(56, innerWidth * 0.84));
|
||||
var conditionStackSpacing = Math.Clamp(1.4 + (2.1 * bottomScale), 1.2, 7);
|
||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(36, innerWidth * 0.86));
|
||||
var conditionStackSpacing = Math.Clamp(1.2 + (2.0 * bottomScale), 0.5, 12);
|
||||
ConditionStack.Spacing = conditionStackSpacing;
|
||||
ConditionStack.Margin = new Thickness(0);
|
||||
var infoFontSizeRaw = Math.Clamp(
|
||||
Math.Max(14, bottomZoneHeight * 0.38) * (0.82 + (bottomScale * 0.24)),
|
||||
15,
|
||||
42);
|
||||
var infoFontSize = infoFontSizeRaw;
|
||||
var infoFontSize = Math.Clamp(27 * bottomScale, 7, 86);
|
||||
const double infoLineHeightFactor = 1.10;
|
||||
var estimatedBottomUsedHeight =
|
||||
(infoFontSize * infoLineHeightFactor * 3) +
|
||||
@@ -890,35 +897,35 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
2;
|
||||
if (estimatedBottomUsedHeight > bottomZoneHeight)
|
||||
{
|
||||
var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.58, 1.0);
|
||||
infoFontSize = Math.Max(11, infoFontSize * shrink);
|
||||
conditionStackSpacing = Math.Max(0.8, conditionStackSpacing * shrink);
|
||||
bottomStackSpacing = Math.Max(0.8, bottomStackSpacing * shrink);
|
||||
var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.36, 1.0);
|
||||
infoFontSize = Math.Max(6, infoFontSize * shrink);
|
||||
conditionStackSpacing = Math.Max(0.3, conditionStackSpacing * shrink);
|
||||
bottomStackSpacing = Math.Max(0.3, bottomStackSpacing * shrink);
|
||||
ConditionStack.Spacing = conditionStackSpacing;
|
||||
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||
}
|
||||
|
||||
var infoFontWeight = ToVariableWeight(590);
|
||||
ConditionTextBlock.FontSize = infoFontSize;
|
||||
var infoFontWeight = ToVariableWeight(Lerp(580, 690, emphasis));
|
||||
ConditionTextBlock.FontSize = Math.Max(6, infoFontSize * 0.96);
|
||||
ConditionTextBlock.FontWeight = infoFontWeight;
|
||||
ConditionTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
ConditionTextBlock.LineHeight = ConditionTextBlock.FontSize * infoLineHeightFactor;
|
||||
ConditionTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
RangeTextBlock.FontSize = infoFontSize;
|
||||
RangeTextBlock.FontSize = Math.Max(6, infoFontSize * 1.03);
|
||||
RangeTextBlock.FontWeight = infoFontWeight;
|
||||
RangeTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
RangeTextBlock.LineHeight = RangeTextBlock.FontSize * infoLineHeightFactor;
|
||||
RangeTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
CityInfoBadge.MaxWidth = bottomTextMaxWidth;
|
||||
LocationIcon.FontSize = Math.Clamp(
|
||||
Math.Max(9, bottomZoneHeight * 0.16) * (0.76 + (bottomScale * 0.22)),
|
||||
9,
|
||||
18);
|
||||
12 * bottomScale,
|
||||
6,
|
||||
34);
|
||||
LocationIcon.FontSize = Math.Min(LocationIcon.FontSize, infoFontSize * 0.72);
|
||||
CityTextBlock.FontSize = infoFontSize;
|
||||
CityTextBlock.FontWeight = infoFontWeight;
|
||||
CityTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
CityTextBlock.FontSize = Math.Max(6, infoFontSize * 0.84);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, emphasis));
|
||||
CityTextBlock.LineHeight = CityTextBlock.FontSize * infoLineHeightFactor;
|
||||
CityTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
}
|
||||
|
||||
@@ -927,10 +934,20 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
return from + ((to - from) * t);
|
||||
}
|
||||
|
||||
private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16,
|
||||
WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private void SetWeatherIcon(WeatherVisualKind kind)
|
||||
{
|
||||
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind)));
|
||||
HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind)));
|
||||
}
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
@@ -982,15 +999,43 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
|
||||
private void SetMotionTransform(double translateX, double translateY, double scale)
|
||||
{
|
||||
var group = new TransformGroup
|
||||
_backgroundMotionScaleTransform.ScaleX = scale;
|
||||
_backgroundMotionScaleTransform.ScaleY = scale;
|
||||
_backgroundMotionTranslateTransform.X = translateX;
|
||||
_backgroundMotionTranslateTransform.Y = translateY;
|
||||
}
|
||||
|
||||
private void InitializeMotionTransform()
|
||||
{
|
||||
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children = new Transforms
|
||||
{
|
||||
new ScaleTransform(scale, scale),
|
||||
new TranslateTransform(translateX, translateY)
|
||||
_backgroundMotionScaleTransform,
|
||||
_backgroundMotionTranslateTransform
|
||||
}
|
||||
};
|
||||
BackgroundMotionLayer.RenderTransform = group;
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
_backgroundAnimationTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Stop();
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
|
||||
21
LanMountainDesktop/Views/Components/WorldClockWidget.axaml
Normal file
@@ -0,0 +1,21 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="210"
|
||||
x:Class="LanMountainDesktop.Views.Components.WorldClockWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#F4F5F7"
|
||||
BorderBrush="#16000000"
|
||||
BorderThickness="1"
|
||||
CornerRadius="26"
|
||||
ClipToBounds="True"
|
||||
Padding="10,8">
|
||||
<Grid x:Name="ClockHostGrid"
|
||||
ColumnDefinitions="*,*,*,*"
|
||||
ColumnSpacing="8" />
|
||||
</Border>
|
||||
</UserControl>
|
||||
671
LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
Normal file
@@ -0,0 +1,671 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
{
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private const double BaseCellSize = 48;
|
||||
private const double DialDesignSize = 100;
|
||||
private const double DialCenter = DialDesignSize / 2d;
|
||||
|
||||
private static readonly FontFamily MiSansFontFamily =
|
||||
new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "北京",
|
||||
["Asia/Shanghai"] = "北京",
|
||||
["GMT Standard Time"] = "伦敦",
|
||||
["Europe/London"] = "伦敦",
|
||||
["AUS Eastern Standard Time"] = "悉尼",
|
||||
["Australia/Sydney"] = "悉尼",
|
||||
["Eastern Standard Time"] = "纽约",
|
||||
["America/New_York"] = "纽约",
|
||||
["Tokyo Standard Time"] = "东京",
|
||||
["Asia/Tokyo"] = "东京",
|
||||
["UTC"] = "协调世界时",
|
||||
["Etc/UTC"] = "协调世界时"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EnCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "Beijing",
|
||||
["Asia/Shanghai"] = "Beijing",
|
||||
["GMT Standard Time"] = "London",
|
||||
["Europe/London"] = "London",
|
||||
["AUS Eastern Standard Time"] = "Sydney",
|
||||
["Australia/Sydney"] = "Sydney",
|
||||
["Eastern Standard Time"] = "New York",
|
||||
["America/New_York"] = "New York",
|
||||
["Tokyo Standard Time"] = "Tokyo",
|
||||
["Asia/Tokyo"] = "Tokyo",
|
||||
["UTC"] = "UTC",
|
||||
["Etc/UTC"] = "UTC"
|
||||
};
|
||||
|
||||
private sealed class ClockEntryVisual
|
||||
{
|
||||
public required StackPanel Host { get; init; }
|
||||
|
||||
public required Border DialBorder { get; init; }
|
||||
|
||||
public required Canvas TickCanvas { get; init; }
|
||||
|
||||
public required Canvas NumberCanvas { get; init; }
|
||||
|
||||
public required Line HourHand { get; init; }
|
||||
|
||||
public required Line MinuteHand { get; init; }
|
||||
|
||||
public required Line SecondHand { get; init; }
|
||||
|
||||
public required Ellipse CenterOuter { get; init; }
|
||||
|
||||
public required TextBlock CityTextBlock { get; init; }
|
||||
|
||||
public required TextBlock DayTextBlock { get; init; }
|
||||
|
||||
public required TextBlock OffsetTextBlock { get; init; }
|
||||
|
||||
public bool? IsNightApplied { get; set; }
|
||||
}
|
||||
|
||||
private readonly DispatcherTimer _clockTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
|
||||
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
|
||||
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private DateTime _nextLanguageProbeUtc = DateTime.MinValue;
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
|
||||
public WorldClockWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BuildClockEntryVisuals();
|
||||
LoadFromSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateClockVisuals();
|
||||
|
||||
_clockTimer.Tick += OnClockTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
_timeZoneService = timeZoneService;
|
||||
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
||||
UpdateClockVisuals();
|
||||
}
|
||||
|
||||
public void ClearTimeZoneService()
|
||||
{
|
||||
if (_timeZoneService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
||||
_timeZoneService = null;
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadFromSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
UpdateClockVisuals();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
var horizontalPadding = Math.Clamp(10 * scale, 4, 26);
|
||||
var verticalPadding = Math.Clamp(8 * scale, 3, 22);
|
||||
RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding);
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 10, 46));
|
||||
|
||||
var usableWidth = Math.Max(48, totalWidth - horizontalPadding * 2);
|
||||
var usableHeight = Math.Max(28, totalHeight - verticalPadding * 2);
|
||||
|
||||
var columnSpacing = Math.Clamp(usableWidth * 0.015, 2, 14);
|
||||
ClockHostGrid.ColumnSpacing = columnSpacing;
|
||||
var widthPerClock = Math.Max(18, (usableWidth - columnSpacing * 3) / WorldClockTimeZoneCatalog.ClockCount);
|
||||
|
||||
var secondaryFont = Math.Clamp(10.5 * scale * (widthPerClock / 46d), 7, 18);
|
||||
var cityFont = Math.Clamp(secondaryFont * 1.42, 9, 24);
|
||||
var textSpacing = Math.Clamp(2.8 * scale, 1, 7);
|
||||
|
||||
var estimatedTextHeight = cityFont * 1.2 + secondaryFont * 2.35 + textSpacing * 3;
|
||||
var dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight - estimatedTextHeight), 18, 108);
|
||||
if (dialSize < 18)
|
||||
{
|
||||
dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight * 0.56), 16, 108);
|
||||
}
|
||||
|
||||
foreach (var entry in _entryVisuals)
|
||||
{
|
||||
entry.Host.Spacing = textSpacing;
|
||||
entry.DialBorder.Width = dialSize;
|
||||
entry.DialBorder.Height = dialSize;
|
||||
entry.DialBorder.CornerRadius = new CornerRadius(dialSize / 2d);
|
||||
|
||||
entry.CityTextBlock.FontSize = cityFont;
|
||||
entry.DayTextBlock.FontSize = secondaryFont;
|
||||
entry.OffsetTextBlock.FontSize = secondaryFont;
|
||||
|
||||
var maxTextWidth = Math.Max(16, widthPerClock + 10);
|
||||
entry.CityTextBlock.MaxWidth = maxTextWidth;
|
||||
entry.DayTextBlock.MaxWidth = maxTextWidth;
|
||||
entry.OffsetTextBlock.MaxWidth = maxTextWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
LoadFromSettings();
|
||||
ApplySecondHandTimerInterval();
|
||||
UpdateClockVisuals();
|
||||
_clockTimer.Start();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_clockTimer.Stop();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
UpdateClockVisuals();
|
||||
}
|
||||
|
||||
private void OnClockTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
UpdateClockVisuals();
|
||||
}
|
||||
|
||||
private void BuildClockEntryVisuals()
|
||||
{
|
||||
ClockHostGrid.Children.Clear();
|
||||
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
|
||||
{
|
||||
var entry = CreateClockEntryVisual();
|
||||
_entryVisuals[index] = entry;
|
||||
ClockHostGrid.Children.Add(entry.Host);
|
||||
Grid.SetColumn(entry.Host, index);
|
||||
Grid.SetRow(entry.Host, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private ClockEntryVisual CreateClockEntryVisual()
|
||||
{
|
||||
var tickCanvas = new Canvas
|
||||
{
|
||||
Width = DialDesignSize,
|
||||
Height = DialDesignSize,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
var numberCanvas = new Canvas
|
||||
{
|
||||
Width = DialDesignSize,
|
||||
Height = DialDesignSize,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
var handsCanvas = new Canvas
|
||||
{
|
||||
Width = DialDesignSize,
|
||||
Height = DialDesignSize,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var hourHand = CreateHandLine("#2B3242", 5.0);
|
||||
var minuteHand = CreateHandLine("#40495E", 3.2);
|
||||
var secondHand = CreateHandLine("#1A74F2", 2.2);
|
||||
handsCanvas.Children.Add(hourHand);
|
||||
handsCanvas.Children.Add(minuteHand);
|
||||
handsCanvas.Children.Add(secondHand);
|
||||
|
||||
var centerOuter = new Ellipse
|
||||
{
|
||||
Width = 11,
|
||||
Height = 11,
|
||||
Fill = CreateBrush("#4F7BC0"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
var centerInner = new Ellipse
|
||||
{
|
||||
Width = 4.5,
|
||||
Height = 4.5,
|
||||
Fill = CreateBrush("#1A74F2"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var dialRoot = new Grid
|
||||
{
|
||||
Width = DialDesignSize,
|
||||
Height = DialDesignSize
|
||||
};
|
||||
dialRoot.Children.Add(tickCanvas);
|
||||
dialRoot.Children.Add(numberCanvas);
|
||||
dialRoot.Children.Add(handsCanvas);
|
||||
dialRoot.Children.Add(centerOuter);
|
||||
dialRoot.Children.Add(centerInner);
|
||||
|
||||
var dialBorder = new Border
|
||||
{
|
||||
Width = 56,
|
||||
Height = 56,
|
||||
CornerRadius = new CornerRadius(28),
|
||||
BorderThickness = new Thickness(1),
|
||||
Background = CreateBrush("#FAFBFD"),
|
||||
BorderBrush = CreateBrush("#DADFE8"),
|
||||
ClipToBounds = true,
|
||||
Child = new Viewbox
|
||||
{
|
||||
Stretch = Stretch.Uniform,
|
||||
Child = dialRoot
|
||||
}
|
||||
};
|
||||
|
||||
var cityTextBlock = new TextBlock
|
||||
{
|
||||
Text = string.Empty,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = CreateBrush("#20232A"),
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
var dayTextBlock = new TextBlock
|
||||
{
|
||||
Text = string.Empty,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = 10.5,
|
||||
FontWeight = FontWeight.Medium,
|
||||
Foreground = CreateBrush("#646C79"),
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
var offsetTextBlock = new TextBlock
|
||||
{
|
||||
Text = string.Empty,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = 10.5,
|
||||
FontWeight = FontWeight.Medium,
|
||||
Foreground = CreateBrush("#7A7F89"),
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
var host = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Spacing = 3,
|
||||
Children =
|
||||
{
|
||||
dialBorder,
|
||||
cityTextBlock,
|
||||
dayTextBlock,
|
||||
offsetTextBlock
|
||||
}
|
||||
};
|
||||
|
||||
var entry = new ClockEntryVisual
|
||||
{
|
||||
Host = host,
|
||||
DialBorder = dialBorder,
|
||||
TickCanvas = tickCanvas,
|
||||
NumberCanvas = numberCanvas,
|
||||
HourHand = hourHand,
|
||||
MinuteHand = minuteHand,
|
||||
SecondHand = secondHand,
|
||||
CenterOuter = centerOuter,
|
||||
CityTextBlock = cityTextBlock,
|
||||
DayTextBlock = dayTextBlock,
|
||||
OffsetTextBlock = offsetTextBlock
|
||||
};
|
||||
|
||||
ApplyDialTheme(entry, isNight: false);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static void BuildDialTicks(ClockEntryVisual entry, bool isNight)
|
||||
{
|
||||
entry.TickCanvas.Children.Clear();
|
||||
var majorColor = isNight ? "#E3E7F2" : "#2D3341";
|
||||
var minorColor = isNight ? "#9EA7B8" : "#9AA4B3";
|
||||
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var isMajor = i % 5 == 0;
|
||||
var angle = (i * 6 - 90) * Math.PI / 180d;
|
||||
var outerRadius = DialCenter - 6.5;
|
||||
var innerRadius = outerRadius - (isMajor ? 9 : 4.5);
|
||||
|
||||
var x1 = DialCenter + Math.Cos(angle) * innerRadius;
|
||||
var y1 = DialCenter + Math.Sin(angle) * innerRadius;
|
||||
var x2 = DialCenter + Math.Cos(angle) * outerRadius;
|
||||
var y2 = DialCenter + Math.Sin(angle) * outerRadius;
|
||||
|
||||
entry.TickCanvas.Children.Add(new Line
|
||||
{
|
||||
StartPoint = new Point(x1, y1),
|
||||
EndPoint = new Point(x2, y2),
|
||||
Stroke = CreateBrush(isMajor ? majorColor : minorColor),
|
||||
StrokeThickness = isMajor ? 1.9 : 0.8,
|
||||
StrokeLineCap = PenLineCap.Round
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildDialNumbers(ClockEntryVisual entry, bool isNight)
|
||||
{
|
||||
entry.NumberCanvas.Children.Clear();
|
||||
var numberColor = isNight ? "#F2F5FB" : "#1B202A";
|
||||
var radius = 36;
|
||||
for (var number = 1; number <= 12; number++)
|
||||
{
|
||||
var angle = (number * 30 - 90) * Math.PI / 180d;
|
||||
var x = DialCenter + Math.Cos(angle) * radius;
|
||||
var y = DialCenter + Math.Sin(angle) * radius;
|
||||
var text = number.ToString(CultureInfo.InvariantCulture);
|
||||
var isDoubleDigit = number >= 10;
|
||||
var width = isDoubleDigit ? 14 : 10;
|
||||
var height = 12;
|
||||
var numberText = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
Width = width,
|
||||
Height = height,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = 9,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = CreateBrush(numberColor),
|
||||
TextAlignment = TextAlignment.Center
|
||||
};
|
||||
|
||||
Canvas.SetLeft(numberText, x - width / 2d);
|
||||
Canvas.SetTop(numberText, y - height / 2d);
|
||||
entry.NumberCanvas.Children.Add(numberText);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadFromSettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
|
||||
var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(snapshot.WorldClockTimeZoneIds);
|
||||
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
|
||||
{
|
||||
var resolvedId = ids[index];
|
||||
_entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId);
|
||||
}
|
||||
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplySecondHandTimerInterval()
|
||||
{
|
||||
_clockTimer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode)
|
||||
? TimeSpan.FromMilliseconds(16)
|
||||
: TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
private void UpdateClockVisuals()
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
ProbeLanguageCodeIfNeeded(utcNow);
|
||||
|
||||
var baseZone = _timeZoneService?.CurrentTimeZone ?? TimeZoneInfo.Local;
|
||||
var baseNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, baseZone);
|
||||
var baseOffset = baseZone.GetUtcOffset(utcNow);
|
||||
|
||||
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
|
||||
{
|
||||
var entry = _entryVisuals[index];
|
||||
var zone = _entryTimeZones[index] ?? TimeZoneInfo.Local;
|
||||
var zonedNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, zone);
|
||||
var isNight = IsNightForLocalTime(zonedNow);
|
||||
ApplyDialTheme(entry, isNight);
|
||||
|
||||
var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode)
|
||||
? zonedNow.Second + zonedNow.Millisecond / 1000d
|
||||
: zonedNow.Second;
|
||||
var minuteValue = zonedNow.Minute + secondValue / 60d;
|
||||
var hourValue = (zonedNow.Hour % 12) + minuteValue / 60d;
|
||||
|
||||
var hourAngle = hourValue * 30d;
|
||||
var minuteAngle = minuteValue * 6d;
|
||||
var secondAngle = secondValue * 6d;
|
||||
|
||||
SetHandGeometry(entry.HourHand, hourAngle, forwardLength: 24, backwardLength: 4.8);
|
||||
SetHandGeometry(entry.MinuteHand, minuteAngle, forwardLength: 33, backwardLength: 6);
|
||||
SetHandGeometry(entry.SecondHand, secondAngle, forwardLength: 37, backwardLength: 8.5);
|
||||
|
||||
entry.CityTextBlock.Text = ResolveCityName(zone);
|
||||
entry.DayTextBlock.Text = ResolveRelativeDayLabel((zonedNow.Date - baseNow.Date).Days);
|
||||
|
||||
var offsetDelta = zone.GetUtcOffset(utcNow) - baseOffset;
|
||||
entry.OffsetTextBlock.Text = ResolveOffsetLabel(offsetDelta);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyDialTheme(ClockEntryVisual entry, bool isNight)
|
||||
{
|
||||
if (entry.IsNightApplied.HasValue && entry.IsNightApplied.Value == isNight)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entry.IsNightApplied = isNight;
|
||||
entry.DialBorder.Background = CreateBrush(isNight ? "#2D313A" : "#FAFBFD");
|
||||
entry.DialBorder.BorderBrush = CreateBrush(isNight ? "#262A33" : "#DADFE8");
|
||||
entry.HourHand.Stroke = CreateBrush(isNight ? "#F5F8FF" : "#2B3242");
|
||||
entry.MinuteHand.Stroke = CreateBrush(isNight ? "#DDE4F0" : "#40495E");
|
||||
entry.SecondHand.Stroke = CreateBrush("#1A74F2");
|
||||
entry.CenterOuter.Fill = CreateBrush(isNight ? "#97B4EA" : "#4F7BC0");
|
||||
|
||||
BuildDialTicks(entry, isNight);
|
||||
BuildDialNumbers(entry, isNight);
|
||||
}
|
||||
|
||||
private void ProbeLanguageCodeIfNeeded(DateTime utcNow)
|
||||
{
|
||||
if (utcNow < _nextLanguageProbeUtc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nextLanguageProbeUtc = utcNow.AddSeconds(25);
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveCityName(TimeZoneInfo timeZone)
|
||||
{
|
||||
var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? ZhCityNames
|
||||
: EnCityNames;
|
||||
if (cityNames.TryGetValue(timeZone.Id, out var cityName))
|
||||
{
|
||||
return cityName;
|
||||
}
|
||||
|
||||
var normalized = timeZone.Id;
|
||||
var slashIndex = normalized.LastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
|
||||
{
|
||||
normalized = normalized[(slashIndex + 1)..];
|
||||
}
|
||||
|
||||
normalized = normalized.Replace('_', ' ').Trim();
|
||||
normalized = normalized
|
||||
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
|
||||
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
|
||||
}
|
||||
|
||||
private string ResolveRelativeDayLabel(int dayDelta)
|
||||
{
|
||||
if (dayDelta < 0)
|
||||
{
|
||||
return L("worldclock.widget.yesterday", "昨天");
|
||||
}
|
||||
|
||||
if (dayDelta > 0)
|
||||
{
|
||||
return L("worldclock.widget.tomorrow", "明天");
|
||||
}
|
||||
|
||||
return L("worldclock.widget.today", "今天");
|
||||
}
|
||||
|
||||
private string ResolveOffsetLabel(TimeSpan delta)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(delta.TotalMinutes);
|
||||
if (totalMinutes == 0)
|
||||
{
|
||||
return L("worldclock.widget.offset_same", "0 小时");
|
||||
}
|
||||
|
||||
var absMinutes = Math.Abs(totalMinutes);
|
||||
var hours = absMinutes / 60;
|
||||
var minutes = absMinutes % 60;
|
||||
var isAhead = totalMinutes > 0;
|
||||
|
||||
if (minutes == 0)
|
||||
{
|
||||
return isAhead
|
||||
? Lf("worldclock.widget.offset_ahead_hours", "早 {0} 小时", hours)
|
||||
: Lf("worldclock.widget.offset_behind_hours", "晚 {0} 小时", hours);
|
||||
}
|
||||
|
||||
return isAhead
|
||||
? Lf("worldclock.widget.offset_ahead_hm", "早 {0} 小时 {1} 分", hours, minutes)
|
||||
: Lf("worldclock.widget.offset_behind_hm", "晚 {0} 小时 {1} 分", hours, minutes);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private string Lf(string key, string fallback, params object[] args)
|
||||
{
|
||||
var template = L(key, fallback);
|
||||
return string.Format(template, args);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.5);
|
||||
var widthScale = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.52, 2.4)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.52, 2.4)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.50, 2.4);
|
||||
}
|
||||
|
||||
private static bool IsNightForLocalTime(DateTime localTime)
|
||||
{
|
||||
var hour = localTime.Hour + localTime.Minute / 60d;
|
||||
return hour < 6 || hour >= 18;
|
||||
}
|
||||
|
||||
private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength)
|
||||
{
|
||||
var radians = (angleDeg - 90) * Math.PI / 180d;
|
||||
var cos = Math.Cos(radians);
|
||||
var sin = Math.Sin(radians);
|
||||
|
||||
hand.StartPoint = new Point(
|
||||
DialCenter - cos * backwardLength,
|
||||
DialCenter - sin * backwardLength);
|
||||
hand.EndPoint = new Point(
|
||||
DialCenter + cos * forwardLength,
|
||||
DialCenter + sin * forwardLength);
|
||||
}
|
||||
|
||||
private static Line CreateHandLine(string colorHex, double thickness)
|
||||
{
|
||||
return new Line
|
||||
{
|
||||
StartPoint = new Point(DialCenter, DialCenter),
|
||||
EndPoint = new Point(DialCenter, DialCenter - 32),
|
||||
Stroke = CreateBrush(colorHex),
|
||||
StrokeThickness = thickness,
|
||||
StrokeLineCap = PenLineCap.Round
|
||||
};
|
||||
}
|
||||
|
||||
private static IBrush CreateBrush(string colorHex)
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorHex));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="560"
|
||||
d:DesignHeight="380"
|
||||
x:Class="LanMountainDesktop.Views.Components.WorldClockWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="世界时钟设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="分别为四个时钟选择时区。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="10"
|
||||
Margin="0,0,6,0">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SecondHandModeLabelTextBlock"
|
||||
Text="秒针方式"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<RadioButton x:Name="SecondHandTickRadioButton"
|
||||
GroupName="world_clock_second_mode"
|
||||
Content="跳针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
<RadioButton x:Name="SecondHandSweepRadioButton"
|
||||
GroupName="world_clock_second_mode"
|
||||
Content="扫针"
|
||||
Checked="OnSecondHandModeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockOneLabelTextBlock"
|
||||
Text="时钟 1"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockOneTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockTwoLabelTextBlock"
|
||||
Text="时钟 2"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockTwoTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockThreeLabelTextBlock"
|
||||
Text="时钟 3"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockThreeTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="ClockFourLabelTextBlock"
|
||||
Text="时钟 4"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="ClockFourTimeZoneComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnTimeZoneSelectionChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["China Standard Time"] = "中国标准时间",
|
||||
["Asia/Shanghai"] = "中国标准时间",
|
||||
["GMT Standard Time"] = "格林威治标准时间",
|
||||
["Europe/London"] = "格林威治标准时间",
|
||||
["AUS Eastern Standard Time"] = "澳大利亚东部标准时间",
|
||||
["Australia/Sydney"] = "澳大利亚东部标准时间",
|
||||
["Eastern Standard Time"] = "美国东部标准时间",
|
||||
["America/New_York"] = "美国东部标准时间",
|
||||
["Tokyo Standard Time"] = "日本标准时间",
|
||||
["Asia/Tokyo"] = "日本标准时间",
|
||||
["UTC"] = "协调世界时",
|
||||
["Etc/UTC"] = "协调世界时"
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private readonly ComboBox[] _timeZoneComboBoxes;
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private IReadOnlyList<string> _selectedTimeZoneIds = Array.Empty<string>();
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public WorldClockWidgetSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_timeZoneComboBoxes =
|
||||
[
|
||||
ClockOneTimeZoneComboBox,
|
||||
ClockTwoTimeZoneComboBox,
|
||||
ClockThreeTimeZoneComboBox,
|
||||
ClockFourTimeZoneComboBox
|
||||
];
|
||||
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBoxes();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
.GetAllTimeZones()
|
||||
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
|
||||
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
_selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
||||
snapshot.WorldClockTimeZoneIds,
|
||||
_allTimeZones);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("worldclock.settings.title", "世界时钟设置");
|
||||
DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。");
|
||||
|
||||
ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1");
|
||||
ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2");
|
||||
ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3");
|
||||
ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4");
|
||||
SecondHandModeLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式");
|
||||
SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针");
|
||||
SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
|
||||
}
|
||||
|
||||
private void PopulateTimeZoneComboBoxes()
|
||||
{
|
||||
_suppressEvents = true;
|
||||
try
|
||||
{
|
||||
foreach (var comboBox in _timeZoneComboBoxes)
|
||||
{
|
||||
comboBox.Items.Clear();
|
||||
foreach (var timeZone in _allTimeZones)
|
||||
{
|
||||
comboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = timeZone.Id,
|
||||
Content = GetLocalizedTimeZoneDisplayName(timeZone)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (var index = 0; index < _timeZoneComboBoxes.Length; index++)
|
||||
{
|
||||
var comboBox = _timeZoneComboBoxes[index];
|
||||
var targetId = index < _selectedTimeZoneIds.Count
|
||||
? _selectedTimeZoneIds[index]
|
||||
: TimeZoneInfo.Local.Id;
|
||||
|
||||
var selected = comboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
comboBox.SelectedItem = selected ?? comboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode);
|
||||
SecondHandTickRadioButton.IsChecked = string.Equals(
|
||||
normalizedMode,
|
||||
ClockSecondHandMode.Tick,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
SecondHandSweepRadioButton.IsChecked = string.Equals(
|
||||
normalizedMode,
|
||||
ClockSecondHandMode.Sweep,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var selectedIds = GetSelectedTimeZoneIds();
|
||||
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
|
||||
snapshot.WorldClockSecondHandMode = _secondHandMode;
|
||||
_appSettingsService.Save(snapshot);
|
||||
|
||||
_selectedTimeZoneIds = normalizedIds;
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private string GetSelectedSecondHandMode()
|
||||
{
|
||||
return SecondHandSweepRadioButton.IsChecked == true
|
||||
? ClockSecondHandMode.Sweep
|
||||
: ClockSecondHandMode.Tick;
|
||||
}
|
||||
|
||||
private List<string> GetSelectedTimeZoneIds()
|
||||
{
|
||||
var selectedIds = new List<string>(_timeZoneComboBoxes.Length);
|
||||
foreach (var comboBox in _timeZoneComboBoxes)
|
||||
{
|
||||
if (comboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string timeZoneId &&
|
||||
!string.IsNullOrWhiteSpace(timeZoneId))
|
||||
{
|
||||
selectedIds.Add(timeZoneId.Trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedIds.Add(TimeZoneInfo.Local.Id);
|
||||
}
|
||||
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
|
||||
var sign = offset >= TimeSpan.Zero ? "+" : "-";
|
||||
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
|
||||
var hours = totalMinutes / 60;
|
||||
var minutes = totalMinutes % 60;
|
||||
|
||||
var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? ResolveZhDisplayName(timeZone)
|
||||
: ResolveEnDisplayName(timeZone);
|
||||
|
||||
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}";
|
||||
}
|
||||
|
||||
private static string ResolveZhDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
|
||||
{
|
||||
return localizedName;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(timeZone.StandardName)
|
||||
? timeZone.DisplayName
|
||||
: timeZone.StandardName;
|
||||
}
|
||||
|
||||
private static string ResolveEnDisplayName(TimeZoneInfo timeZone)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timeZone.StandardName))
|
||||
{
|
||||
return timeZone.StandardName;
|
||||
}
|
||||
|
||||
return timeZone.DisplayName;
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -408,7 +409,7 @@ public partial class MainWindow
|
||||
{
|
||||
OpenSettingsPage();
|
||||
}
|
||||
}, TimeSpan.FromMilliseconds(200));
|
||||
}, FluttermotionToken.Slow);
|
||||
}
|
||||
|
||||
private void InitializeDesktopComponentDragHandlers()
|
||||
@@ -700,12 +701,30 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopClock)
|
||||
{
|
||||
OpenDesktopClockComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule)
|
||||
{
|
||||
OpenClassScheduleComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopWorldClock)
|
||||
{
|
||||
OpenWorldClockComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork)
|
||||
{
|
||||
OpenDailyArtworkComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
||||
{
|
||||
OpenStudyEnvironmentComponentSettings();
|
||||
@@ -744,6 +763,38 @@ public partial class MainWindow
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenDesktopClockComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new AnalogClockWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDesktopClockSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenWorldClockComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new WorldClockWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnWorldClockSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenStudyEnvironmentComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
@@ -760,6 +811,22 @@ public partial class MainWindow
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenDailyArtworkComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new DailyArtworkSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDailyArtworkSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_selectedDesktopComponentHost is null)
|
||||
@@ -773,6 +840,30 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDesktopClockSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||
{
|
||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||
{
|
||||
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetContentHost(host)?.Child is AnalogClockWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
@@ -788,6 +879,58 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDailyArtworkSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
_dailyArtworkMirrorSource = sender is DailyArtworkSettingsWindow settingsWindow
|
||||
? DailyArtworkMirrorSources.Normalize(settingsWindow.CurrentSource)
|
||||
: DailyArtworkMirrorSources.Normalize(_appSettingsService.Load().DailyArtworkMirrorSource);
|
||||
|
||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||
{
|
||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||
{
|
||||
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetContentHost(host)?.Child is DailyArtworkWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||
{
|
||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||
{
|
||||
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetContentHost(host)?.Child is WorldClockWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void CloseComponentSettingsWindow()
|
||||
{
|
||||
if (ComponentSettingsWindow is null)
|
||||
@@ -800,11 +943,26 @@ public partial class MainWindow
|
||||
classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is AnalogClockWidgetSettingsWindow analogClockSettingsWindow)
|
||||
{
|
||||
analogClockSettingsWindow.SettingsChanged -= OnDesktopClockSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is StudyEnvironmentWidgetSettingsWindow studyEnvironmentSettingsWindow)
|
||||
{
|
||||
studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is DailyArtworkSettingsWindow dailyArtworkSettingsWindow)
|
||||
{
|
||||
dailyArtworkSettingsWindow.SettingsChanged -= OnDailyArtworkSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is WorldClockWidgetSettingsWindow worldClockSettingsWindow)
|
||||
{
|
||||
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
|
||||
}
|
||||
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
|
||||
DispatcherTimer.RunOnce(() =>
|
||||
@@ -817,7 +975,7 @@ public partial class MainWindow
|
||||
{
|
||||
ComponentSettingsContentHost.Content = null;
|
||||
}
|
||||
}, TimeSpan.FromMilliseconds(200));
|
||||
}, FluttermotionToken.Slow);
|
||||
}
|
||||
|
||||
private void AddDesktopPage()
|
||||
@@ -1216,6 +1374,14 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep world clock widget at 2:1 ratio: 4x2, 6x3, 8x4...
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyScoreOverview, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep score overview widget square: 4x4, 5x5, 6x6...
|
||||
|
||||
@@ -262,6 +262,13 @@ public partial class MainWindow
|
||||
"settings.about.font_format",
|
||||
"Font: {0}",
|
||||
AppFontName);
|
||||
AboutStartupSettingsExpander.Header = L("settings.about.startup_header", "Windows Startup");
|
||||
AboutStartupSettingsExpander.Description = L(
|
||||
"settings.about.startup_desc",
|
||||
"Launch the app automatically when signing in to Windows.");
|
||||
AutoStartWithWindowsToggleSwitch.Content = L(
|
||||
"settings.about.startup_toggle",
|
||||
"Launch at Windows sign-in");
|
||||
|
||||
if (WallpaperPlacementComboBox?.ItemCount >= 5)
|
||||
{
|
||||
|
||||
@@ -658,6 +658,8 @@ public partial class MainWindow
|
||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
||||
WeatherIconPackId = _weatherIconPackId,
|
||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
||||
DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource),
|
||||
AutoStartWithWindows = _autoStartWithWindows,
|
||||
TopStatusComponentIds = _topStatusComponentIds.ToList(),
|
||||
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
|
||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||
@@ -790,14 +792,37 @@ public partial class MainWindow
|
||||
weatherCode: null,
|
||||
temperatureText: "--",
|
||||
updatedAt: null);
|
||||
|
||||
UpdateWeatherLocationModePanels();
|
||||
UpdateWeatherLocationStatusText();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressWeatherLocationEvents = false;
|
||||
}
|
||||
|
||||
UpdateWeatherLocationModePanels();
|
||||
UpdateWeatherLocationStatusText();
|
||||
}
|
||||
|
||||
private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
_autoStartWithWindows = OperatingSystem.IsWindows()
|
||||
? _windowsStartupService.IsEnabled()
|
||||
: snapshot.AutoStartWithWindows;
|
||||
|
||||
if (AutoStartWithWindowsToggleSwitch is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_suppressAutoStartToggleEvents = true;
|
||||
try
|
||||
{
|
||||
AutoStartWithWindowsToggleSwitch.IsEnabled = OperatingSystem.IsWindows();
|
||||
AutoStartWithWindowsToggleSwitch.IsChecked = _autoStartWithWindows;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAutoStartToggleEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
|
||||
@@ -1022,6 +1047,51 @@ public partial class MainWindow
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void OnAutoStartWithWindowsToggled(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_suppressAutoStartToggleEvents || AutoStartWithWindowsToggleSwitch is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var requested = AutoStartWithWindowsToggleSwitch.IsChecked == true;
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_autoStartWithWindows = false;
|
||||
_suppressAutoStartToggleEvents = true;
|
||||
try
|
||||
{
|
||||
AutoStartWithWindowsToggleSwitch.IsEnabled = false;
|
||||
AutoStartWithWindowsToggleSwitch.IsChecked = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAutoStartToggleEvents = false;
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
var applied = _windowsStartupService.SetEnabled(requested);
|
||||
_autoStartWithWindows = _windowsStartupService.IsEnabled();
|
||||
|
||||
if (!applied || _autoStartWithWindows != requested)
|
||||
{
|
||||
_suppressAutoStartToggleEvents = true;
|
||||
try
|
||||
{
|
||||
AutoStartWithWindowsToggleSwitch.IsChecked = _autoStartWithWindows;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAutoStartToggleEvents = false;
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null)
|
||||
@@ -1836,7 +1906,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
SettingsPage.IsVisible = false;
|
||||
}, TimeSpan.FromMilliseconds(200));
|
||||
}, TimeSpan.FromMilliseconds(SettingsTransitionDurationMs));
|
||||
}
|
||||
|
||||
private void InitializeSettingsIcons()
|
||||
@@ -1942,6 +2012,15 @@ public partial class MainWindow
|
||||
};
|
||||
}
|
||||
|
||||
if (AboutStartupSettingsExpander is not null)
|
||||
{
|
||||
AboutStartupSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = Symbol.Play,
|
||||
IconVariant = variant
|
||||
};
|
||||
}
|
||||
|
||||
UpdateThemeModeIcon();
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
@@ -349,7 +349,7 @@
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
<TranslateTransform Y="30">
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Y" Duration="0:0:0.24" />
|
||||
<DoubleTransition Property="Y" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
@@ -1390,6 +1390,19 @@
|
||||
<TextBlock x:Name="FontInfoTextBlock" Text="Font: MiSans" FontSize="12" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="AboutStartupSettingsExpander"
|
||||
Header="Windows Startup"
|
||||
Description="Launch the app automatically when signing in to Windows."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="AutoStartWithWindowsToggleSwitch"
|
||||
Checked="OnAutoStartWithWindowsToggled"
|
||||
Unchecked="OnAutoStartWithWindowsToggled"
|
||||
Content="Launch at Windows sign-in" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -1458,7 +1471,7 @@
|
||||
PointerReleased="OnComponentLibraryWindowPointerReleased">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.2" />
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Slow}" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
|
||||
@@ -1569,7 +1582,7 @@
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="0:0:0.22" />
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
|
||||
@@ -58,7 +58,7 @@ public partial class MainWindow : Window
|
||||
private const int MinEdgeInsetPercent = 0;
|
||||
private const int MaxEdgeInsetPercent = 30;
|
||||
private const int DefaultEdgeInsetPercent = 18;
|
||||
private const int SettingsTransitionDurationMs = 240;
|
||||
private static readonly int SettingsTransitionDurationMs = (int)FluttermotionToken.Page.TotalMilliseconds;
|
||||
private const double WallpaperPreviewMaxWidth = 520;
|
||||
private const double LightBackgroundLuminanceThreshold = 0.57;
|
||||
private const string TaskbarLayoutBottomFullRowMacStyle = "BottomFullRowMacStyle";
|
||||
@@ -90,6 +90,7 @@ public partial class MainWindow : Window
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private readonly WindowsStartupService _windowsStartupService = new();
|
||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
||||
@@ -151,6 +152,9 @@ public partial class MainWindow : Window
|
||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||
private string _weatherIconPackId = "FluentRegular";
|
||||
private bool _weatherNoTlsRequests;
|
||||
private string _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Overseas;
|
||||
private bool _autoStartWithWindows;
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
private string _weatherSearchKeyword = string.Empty;
|
||||
private bool _isWeatherSearchInProgress;
|
||||
private bool _isWeatherPreviewInProgress;
|
||||
@@ -225,6 +229,8 @@ public partial class MainWindow : Window
|
||||
ApplyTaskbarSettings(snapshot);
|
||||
InitializeLocalization(snapshot.LanguageCode);
|
||||
InitializeWeatherSettings(snapshot);
|
||||
_dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||
InitializeDesktopSurfaceState(snapshot);
|
||||
InitializeDesktopComponentPlacements(snapshot);
|
||||
InitializeSettingsIcons();
|
||||
|
||||
@@ -48,6 +48,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
Name: "startup"; Description: "Launch LanMountainDesktop when you sign in to Windows"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
@@ -56,5 +57,53 @@ Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
const
|
||||
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
||||
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
|
||||
|
||||
function IsWebView2RuntimeInstalled(): Boolean;
|
||||
var
|
||||
VersionValue: string;
|
||||
begin
|
||||
Result :=
|
||||
RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
|
||||
RegQueryStringValue(HKLM32, WebView2RuntimeKeyPath, 'pv', VersionValue) or
|
||||
RegQueryStringValue(HKCU64, WebView2RuntimeKeyPath, 'pv', VersionValue) or
|
||||
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
|
||||
end;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ErrorCode: Integer;
|
||||
begin
|
||||
if IsWebView2RuntimeInstalled() then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
|
||||
if MsgBox(
|
||||
'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 +
|
||||
'Click "Yes" to open the official download page. Install it first, then run this installer again.',
|
||||
mbConfirmation,
|
||||
MB_YESNO) = IDYES then
|
||||
begin
|
||||
if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
||||
begin
|
||||
MsgBox(
|
||||
'Unable to open the download page automatically.'#13#10 +
|
||||
'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl,
|
||||
mbError,
|
||||
MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
Result := False;
|
||||
end;
|
||||
|
||||