diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5c9f96eba10f..1246b552de13 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -136,9 +136,9 @@ When working with public API changes: 1. **NEVER commit directly to `main`** - Always create a feature branch for your work. Direct commits to `main` are strictly prohibited. -2. **Do NOT rebase, squash, or force-push** unless explicitly requested by the user. These operations rewrite git history and can cause problems for other contributors. Default behavior should be regular commits and pushes. +2. **When amending an existing PR, work on the PR's branch directly** - Do NOT create a separate branch off a PR branch. The PR branch already IS a feature branch. Creating a new branch off it means CI won't run on the original PR, defeating the purpose. Use `gh pr checkout` to switch to the PR branch, make your changes, commit, **then** ask before pushing so the user can review locally first. -3. **When amending an existing PR, do NOT automatically push** - After making changes to an existing PR branch, ask the user before pushing. This allows the user to review the changes locally first. Exception: If the user's instructions explicitly include pushing, proceed without asking. +3. **Do NOT rebase, squash, or force-push** unless explicitly requested by the user. These operations rewrite git history and can cause problems for other contributors. Default behavior should be regular commits and pushes. **Safe Git Workflow:** ```bash @@ -157,9 +157,16 @@ git push ``` **When asked to update an existing PR:** -1. Make the requested changes -2. Stage and commit the changes -3. **STOP and ask the user** before pushing: "Changes are committed locally. Would you like me to push these changes to the PR?" +```bash +# Check out the PR branch directly (do NOT create a new branch off it) +gh pr checkout 12345 + +# Make fixes and commit to the PR branch +git add . +git commit -m "Fix: Description of the change" +``` +1. **STOP and ask the user** before pushing: "Changes are committed locally. Would you like me to push these changes to the PR?" +2. Exception: If the user's instructions explicitly include pushing, proceed without asking. ### Documentation - Update XML documentation for public APIs diff --git a/.github/workflows/dogfood-comment.yml b/.github/workflows/dogfood-comment.yml index 92e67a46ebc9..b2a823d75d64 100644 --- a/.github/workflows/dogfood-comment.yml +++ b/.github/workflows/dogfood-comment.yml @@ -1,14 +1,9 @@ name: Add Dogfooding Comment on: - # Use pull_request_target to run in the context of the base branch - # This allows commenting on PRs from forks - pull_request_target: - types: [opened, reopened, synchronize] - branches: - - 'main' - - 'net*' - - 'release/**' + # Trigger when the maui-pr build check completes + check_run: + types: [completed] # Allow manual triggering workflow_dispatch: @@ -20,13 +15,23 @@ on: # Ensure only one instance runs at a time per PR to prevent duplicate comments concurrency: - group: dogfood-comment-${{ github.event.pull_request.number || github.event.inputs.pr_number }} + group: dogfood-comment-${{ github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || 'unknown' }} cancel-in-progress: true jobs: add-dogfood-comment: - # Only run on the dotnet org to avoid running on forks - if: ${{ github.repository_owner == 'dotnet' }} + # Only run on the dotnet org, for the maui-pr check, when it completes successfully + if: | + github.repository_owner == 'dotnet' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'check_run' && + github.event.check_run.name == 'maui-pr (Pack .NET MAUI Pack Windows)' && + github.event.check_run.conclusion == 'success' && + github.event.check_run.pull_requests[0] != null + ) + ) runs-on: ubuntu-latest permissions: pull-requests: write @@ -36,8 +41,8 @@ jobs: uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | - // Get PR number from either the PR event or manual input - const prNumber = context.payload.number || context.payload.inputs?.pr_number; + // Get PR number from either the check_run event or manual input + const prNumber = context.payload.check_run?.pull_requests?.[0]?.number || context.payload.inputs?.pr_number; const bashScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh'; const psScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1'; diff --git a/eng/pipelines/arcade/setup-test-env.yml b/eng/pipelines/arcade/setup-test-env.yml index 2941477fb0b7..634e1d4579fa 100644 --- a/eng/pipelines/arcade/setup-test-env.yml +++ b/eng/pipelines/arcade/setup-test-env.yml @@ -12,6 +12,8 @@ steps: fetchDepth: 1 clean: true +- template: /eng/pipelines/common/enable-kvm.yml@self + - template: /eng/pipelines/common/provision.yml@self parameters: checkoutDirectory: '$(System.DefaultWorkingDirectory)' diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml index 7787a888117b..79e25c41229c 100644 --- a/eng/pipelines/ci.yml +++ b/eng/pipelines/ci.yml @@ -121,6 +121,20 @@ parameters: demands: - ImageOverride -equals 1ESPT-Ubuntu22.04 +- name: AndroidPoolLinux + type: object + default: + name: MAUI-DNCENG + demands: + - ImageOverride -equals 1ESPT-Ubuntu22.04 + +- name: AndroidPoolLinux + type: object + default: + name: MAUI-DNCENG + demands: + - ImageOverride -equals 1ESPT-Ubuntu22.04 + # Condition for MacOSPool comparison lanes (non-ARM64) # Currently disabled - both pools use MAUI self-hosted since Xcode 26.2 isn't available on Azure Pipelines x64. diff --git a/eng/pipelines/common/device-tests-steps.yml b/eng/pipelines/common/device-tests-steps.yml index de6643ac81bb..e708c47e0576 100644 --- a/eng/pipelines/common/device-tests-steps.yml +++ b/eng/pipelines/common/device-tests-steps.yml @@ -35,14 +35,8 @@ steps: continueOnError: true timeoutInMinutes: 60 -# Enable KVM for Android builds on Linux - ${{ if and(ne(parameters.buildType, 'buildOnly'), eq(parameters.platform, 'android')) }}: - - bash: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - displayName: Enable KVM - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - template: enable-kvm.yml # Provision the various SDKs that are needed - template: provision.yml diff --git a/eng/pipelines/common/enable-kvm.yml b/eng/pipelines/common/enable-kvm.yml new file mode 100644 index 000000000000..9bb2050cc14c --- /dev/null +++ b/eng/pipelines/common/enable-kvm.yml @@ -0,0 +1,8 @@ +# Enable KVM for Android tests on Linux +steps: +- bash: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + displayName: Enable KVM + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) diff --git a/eng/pipelines/common/ui-tests-steps.yml b/eng/pipelines/common/ui-tests-steps.yml index be2d650f4711..e3c1186e329c 100644 --- a/eng/pipelines/common/ui-tests-steps.yml +++ b/eng/pipelines/common/ui-tests-steps.yml @@ -50,14 +50,8 @@ steps: continueOnError: true timeoutInMinutes: 60 -# Enable KVM for Android builds on Linux - ${{ if and(ne(parameters.buildType, 'buildOnly'), eq(parameters.platform, 'android')) }}: - - bash: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - displayName: Enable KVM - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - template: enable-kvm.yml - ${{ if eq(parameters.platform, 'catalyst')}}: - bash: | diff --git a/eng/scripts/get-maui-pr.ps1 b/eng/scripts/get-maui-pr.ps1 index edc3ddc3c989..e20f81a86828 100644 --- a/eng/scripts/get-maui-pr.ps1 +++ b/eng/scripts/get-maui-pr.ps1 @@ -44,7 +44,10 @@ param( [int]$PrNumber, [Parameter(Mandatory = $false)] - [string]$ProjectPath = "" + [string]$ProjectPath = "", + + [Parameter(Mandatory = $false)] + [switch]$Yes ) $ErrorActionPreference = "Stop" @@ -52,10 +55,21 @@ $ProgressPreference = "SilentlyContinue" # Configuration - Allow override via environment variable $GitHubRepo = if ($env:MAUI_REPO) { $env:MAUI_REPO } else { "dotnet/maui" } -$AzureDevOpsOrg = "xamarin" +$AzureDevOpsOrg = "dnceng-public" $AzureDevOpsProject = "public" $PackageName = "Microsoft.Maui.Controls" +# Build GitHub auth headers (GITHUB_TOKEN env var or gh CLI) +$GitHubHeaders = @{ "User-Agent" = "MAUI-PR-Script" } +if ($env:GITHUB_TOKEN) { + $GitHubHeaders["Authorization"] = "token $($env:GITHUB_TOKEN)" +} elseif (Get-Command gh -ErrorAction SilentlyContinue) { + try { + $ghToken = gh auth token 2>$null + if ($ghToken) { $GitHubHeaders["Authorization"] = "token $ghToken" } + } catch { } +} + # Color output functions function Write-Info { param([string]$Message) @@ -67,12 +81,12 @@ function Write-Success { Write-Host "✅ $Message" -ForegroundColor Green } -function Write-Warning { +function Write-Warn { param([string]$Message) Write-Host "⚠️ $Message" -ForegroundColor Yellow } -function Write-Error { +function Write-Err { param([string]$Message) Write-Host "❌ $Message" -ForegroundColor Red } @@ -117,7 +131,7 @@ function Get-PullRequestInfo { try { $prUrl = "https://api.github.com/repos/$GitHubRepo/pulls/$PrNumber" - $pr = Invoke-RestMethod -Uri $prUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } + $pr = Invoke-RestMethod -Uri $prUrl -Headers $GitHubHeaders -TimeoutSec 30 return @{ Number = $pr.number @@ -140,14 +154,13 @@ function Get-BuildInfo { try { $checksUrl = "https://api.github.com/repos/$GitHubRepo/commits/$SHA/check-runs" - $response = Invoke-RestMethod -Uri $checksUrl -Headers @{ - "User-Agent" = "MAUI-PR-Script" + $response = Invoke-RestMethod -Uri $checksUrl -Headers ($GitHubHeaders + @{ "Accept" = "application/vnd.github.v3+json" - } + }) -TimeoutSec 30 - # Look for the main MAUI build check + # Look for the main MAUI build check (not uitests) $buildCheck = $response.check_runs | Where-Object { - $_.name -eq "MAUI-public" -and $_.status -eq "completed" + $_.name -like "maui-pr*" -and $_.name -notlike "*uitests*" -and $_.status -eq "completed" -and $_.details_url -match 'buildId=' } | Select-Object -First 1 if (-not $buildCheck) { @@ -155,18 +168,22 @@ function Get-BuildInfo { } if ($buildCheck.conclusion -ne "success") { - Write-Warning "Build completed with status: $($buildCheck.conclusion)" - $continue = Read-Host "Do you want to continue anyway? (y/N)" - if ($continue -ne "y" -and $continue -ne "Y") { - throw "Build was not successful. Aborting." + Write-Warn "Build completed with status: $($buildCheck.conclusion)" + if (-not $Yes) { + $continue = Read-Host "Do you want to continue anyway? (y/N)" + if ($continue -ne "y" -and $continue -ne "Y") { + throw "Build was not successful. Aborting." + } } } # Extract build ID from details URL if ($buildCheck.details_url -match 'buildId=(\d+)') { - $buildId = $Matches[1] - Write-Success "Found build ID: $buildId" - return $buildId + return @{ + BuildId = $Matches[1] + Status = $buildCheck.conclusion + Url = $buildCheck.details_url + } } throw "Could not extract build ID from check run details." @@ -184,13 +201,13 @@ function Get-BuildArtifacts { try { $artifactsUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_apis/build/builds/$BuildId/artifacts?api-version=7.1" - $response = Invoke-RestMethod -Uri $artifactsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } + $response = Invoke-RestMethod -Uri $artifactsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } -TimeoutSec 30 - # Look for nuget artifact - $artifact = $response.value | Where-Object { $_.name -eq "nuget" } | Select-Object -First 1 + # Look for PackageArtifacts artifact + $artifact = $response.value | Where-Object { $_.name -eq "PackageArtifacts" } | Select-Object -First 1 if (-not $artifact) { - throw "No 'nuget' artifact found in build $BuildId" + throw "No 'PackageArtifacts' artifact found in build $BuildId" } return $artifact.resource.downloadUrl @@ -225,7 +242,19 @@ function Get-Artifacts { Write-Info "Downloading artifacts (this may take a moment)..." try { - Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipFile -UseBasicParsing + # Use curl on non-Windows (Invoke-WebRequest is extremely slow for large files on macOS/Linux) + if ((-not $IsWindows) -and $env:OS -ne "Windows_NT" -and (Get-Command curl -ErrorAction SilentlyContinue)) { + $curlExit = 0 + & curl -sL -o $zipFile $DownloadUrl + $curlExit = $LASTEXITCODE + if ($curlExit -ne 0) { + throw "curl download failed with exit code $curlExit" + } + } else { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipFile -UseBasicParsing + $ProgressPreference = 'Continue' + } Write-Success "Downloaded artifacts" Write-Info "Extracting artifacts..." @@ -240,6 +269,9 @@ function Get-Artifacts { throw "Could not find NuGet packages in the extracted artifacts" } + # Clean up zip file to save disk space + Remove-Item $zipFile -Force -ErrorAction SilentlyContinue + return $nupkgDir.FullName } catch { @@ -252,14 +284,14 @@ function Get-PackageVersion { param([string]$PackagesDir) $package = Get-ChildItem -Path $PackagesDir -Filter "$PackageName.*.nupkg" -File | - Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' } | + Where-Object { $_.Name -notmatch '\.symbols\.nupkg$' -and $_.Name -match "$([regex]::Escape($PackageName))\.\d" } | Select-Object -First 1 if (-not $package) { throw "Could not find $PackageName package in artifacts" } - if ($package.Name -match "$PackageName\.(.+)\.nupkg") { + if ($package.Name -match "$([regex]::Escape($PackageName))\.(.+)\.nupkg") { return $Matches[1] } @@ -273,7 +305,7 @@ function Get-TargetFrameworkVersion { $content = Get-Content $ProjectPath -Raw # Look for TargetFramework or TargetFrameworks - if ($content -match '([^<]+)') { + if ($content -match '(.*?)') { $tfms = $Matches[1] # Extract .NET version (e.g., net9.0, net10.0) @@ -326,7 +358,7 @@ function Update-TargetFrameworks { Set-Content -Path $ProjectPath -Value $content -NoNewline Write-Success "Updated target frameworks to .NET $NewNetVersion.0" - Write-Warning "You may need to update other package dependencies to match .NET $NewNetVersion.0" + Write-Warn "You may need to update other package dependencies to match .NET $NewNetVersion.0" } # Create or update NuGet.config @@ -334,7 +366,7 @@ function Update-NuGetConfig { param([string]$ProjectDir, [string]$PackagesDir) $nugetConfigPath = Join-Path $ProjectDir "NuGet.config" - $sourceName = "maui-pr-build" + $sourceName = "maui-pr-$PrNumber" if (Test-Path $nugetConfigPath) { Write-Info "Updating existing NuGet.config..." @@ -428,7 +460,7 @@ try { Write-Info "State: $($prInfo.State)" if ($prInfo.State -ne "open" -and $prInfo.State -ne "closed") { - Write-Warning "PR state is '$($prInfo.State)'. Continuing anyway..." + Write-Warn "PR state is '$($prInfo.State)'. Continuing anyway..." } Write-Step "Detecting target framework" @@ -436,11 +468,11 @@ try { Write-Info "Current target framework: .NET $targetNetVersion.0" Write-Step "Finding build artifacts" - $buildId = Get-BuildInfo -SHA $prInfo.SHA + $buildInfo = Get-BuildInfo -SHA $prInfo.SHA Write-Step "Downloading artifacts" - $downloadUrl = Get-BuildArtifacts -BuildId $buildId - $packagesDir = Get-Artifacts -DownloadUrl $downloadUrl -BuildId $buildId + $downloadUrl = Get-BuildArtifacts -BuildId $buildInfo.BuildId + $packagesDir = Get-Artifacts -DownloadUrl $downloadUrl -BuildId $buildInfo.BuildId Write-Step "Extracting package information" $version = Get-PackageVersion -PackagesDir $packagesDir @@ -453,17 +485,22 @@ try { $compatible = Test-VersionCompatibility -Version $version -TargetNetVersion $targetNetVersion -PackageNetVersion $packageNetVersion $willUpdateTfm = $false if (-not $compatible) { - Write-Warning "This PR build may target a newer .NET version than your project" + Write-Warn "This PR build may target a newer .NET version than your project" Write-Info "Your project targets: .NET $targetNetVersion.0" Write-Info "This PR build targets: .NET $packageNetVersion.0" - $response = Read-Host "`nDo you want to update your project to .NET $packageNetVersion.0? (y/N)" - if ($response -eq "y" -or $response -eq "Y") { + if ($Yes) { $willUpdateTfm = $true - Write-Warning "Note: You may need to manually update other package dependencies to versions compatible with .NET $packageNetVersion.0" + } else { + $response = Read-Host "`nDo you want to update your project to .NET $packageNetVersion.0? (y/N)" + $willUpdateTfm = ($response -eq "y" -or $response -eq "Y") + } + + if ($willUpdateTfm) { + Write-Warn "Note: You may need to manually update other package dependencies to versions compatible with .NET $packageNetVersion.0" } else { - Write-Warning "Continuing without updating target framework. The package may not be compatible." + Write-Warn "Continuing without updating target framework. The package may not be compatible." } } @@ -475,7 +512,7 @@ try { Write-Host "" Write-Host "By continuing, you will apply the PR artifacts to your project." -ForegroundColor Cyan Write-Host "" - Write-Warning "This should NOT be used in production and is for testing purposes only." + Write-Warn "This should NOT be used in production and is for testing purposes only." Write-Host "" Write-Host "TIP: Create a separate Git branch for testing!" -ForegroundColor Cyan Write-Host " git checkout -b test-pr-$PrNumber" -ForegroundColor Gray @@ -487,28 +524,25 @@ try { Write-Host "Changes to be applied:" -ForegroundColor White Write-Host " • Project: $projectName" -ForegroundColor Gray Write-Host " • Package version: $version" -ForegroundColor Gray - - # Extract .NET version from package version (e.g., 10.0.20-ci.main.25607.5 -> 10) - $packageDotNetVersion = $null - if ($version -match '^(\d+)\.') { - $packageDotNetVersion = $Matches[1] - } - if ($willUpdateTfm) { - $targetVersionForDisplay = if ($packageDotNetVersion) { "$packageDotNetVersion.0" } else { "$packageNetVersion.0" } + $targetVersionForDisplay = if ($packageNetVersion) { "$packageNetVersion.0" } else { "10.0" } Write-Host " • Target framework: Will be updated to .NET $targetVersionForDisplay" -ForegroundColor Gray } Write-Host "" - $response = Read-Host "Do you want to continue? (y/N)" - if ($response -ne "y" -and $response -ne "Y") { - Write-Warning "Operation cancelled by user" - exit 0 + if ($Yes) { + Write-Info "Auto-accepting confirmation (-Yes flag)" + } else { + $response = Read-Host "Do you want to continue? (y/N)" + if ($response -ne "y" -and $response -ne "Y") { + Write-Warn "Operation cancelled by user" + return + } } Write-Host "" if ($willUpdateTfm) { - $targetNetVersionToApply = if ($packageDotNetVersion) { [int]$packageDotNetVersion } else { 10 } + $targetNetVersionToApply = if ($packageNetVersion) { [int]$packageNetVersion } else { 10 } Update-TargetFrameworks -ProjectPath $projectPath -NewNetVersion $targetNetVersionToApply $targetNetVersion = $targetNetVersionToApply } @@ -519,16 +553,6 @@ try { Write-Step "Updating package reference" Update-PackageReference -ProjectPath $projectPath -Version $version - # Get latest stable version for revert instructions - try { - $nugetResponse = Invoke-RestMethod -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.maui.controls/index.json" -UseBasicParsing - $stableVersions = $nugetResponse.versions | Where-Object { $_ -notmatch '-' } | Sort-Object -Descending - $latestStable = $stableVersions[0] - } - catch { - $latestStable = "X.Y.Z" - } - Write-Host @" ╔═══════════════════════════════════════════════════════════╗ @@ -548,16 +572,30 @@ try { Write-Info "Local package source: $packagesDir" Write-Host "" + # Get latest stable version for revert instructions + $stableVersion = "X.Y.Z" + try { + $nugetResponse = Invoke-RestMethod -Uri "https://api.nuget.org/v3-flatcontainer/$($PackageName.ToLower())/index.json" -TimeoutSec 30 + if ($nugetResponse -and $nugetResponse.versions) { + $stableVersions = $nugetResponse.versions | Where-Object { $_ -notmatch '-' } + if ($stableVersions) { + $stableVersion = $stableVersions | Sort-Object { [Version]$_ } -Descending | Select-Object -First 1 + } + } + } catch { + # If we can't fetch, just use placeholder + } + Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Yellow Write-Host " TO REVERT TO PRODUCTION VERSION" -ForegroundColor Yellow Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Yellow Write-Host "" Write-Host "1. Edit $projectName and change the version:" -ForegroundColor White Write-Host " From: Version=`"$version`"" -ForegroundColor Gray - Write-Host " To: Version=`"X.Y.Z`"" -ForegroundColor Gray + Write-Host " To: Version=`"$stableVersion`"" -ForegroundColor Gray Write-Host " (Check https://www.nuget.org/packages/$PackageName for latest)" -ForegroundColor DarkGray Write-Host "" - Write-Host "2. In NuGet.config, remove or comment out the 'maui-pr-build' source" -ForegroundColor White + Write-Host "2. In NuGet.config, remove or comment out the 'maui-pr-$PrNumber' source" -ForegroundColor White Write-Host "" Write-Host "3. Run: dotnet restore --force" -ForegroundColor White Write-Host "" @@ -567,7 +605,7 @@ try { } catch { - Write-Error "Failed to apply PR build: $_" + Write-Err "Failed to apply PR build: $_" Write-Host "" Write-Info "Troubleshooting tips:" Write-Host " • Make sure you're in a directory containing a .NET MAUI project" -ForegroundColor Gray @@ -575,6 +613,5 @@ catch { Write-Host " • Check if there's a completed build for this PR (look for green checkmarks)" -ForegroundColor Gray Write-Host " • Check your internet connection" -ForegroundColor Gray Write-Host " • Visit: https://github.com/dotnet/maui/wiki/Testing-PR-Builds" -ForegroundColor Gray - exit 1 } diff --git a/eng/scripts/get-maui-pr.sh b/eng/scripts/get-maui-pr.sh index d01ad1b7f01c..9541a0556644 100644 --- a/eng/scripts/get-maui-pr.sh +++ b/eng/scripts/get-maui-pr.sh @@ -10,11 +10,12 @@ # # Usage: # curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 33002 -# ./get-maui-pr.sh [PROJECT_PATH] +# ./get-maui-pr.sh [-y|--yes] [PROJECT_PATH] # # Examples: # ./get-maui-pr.sh 33002 # ./get-maui-pr.sh 33002 ./MyApp/MyApp.csproj +# ./get-maui-pr.sh -y 33002 # Skip confirmation prompts # # Requirements: # - .NET SDK installed @@ -51,10 +52,21 @@ handle_error() { # Configuration - Allow override via environment variable GITHUB_REPO="${MAUI_REPO:-dotnet/maui}" -AZURE_DEVOPS_ORG="xamarin" +AZURE_DEVOPS_ORG="dnceng-public" AZURE_DEVOPS_PROJECT="public" PACKAGE_NAME="Microsoft.Maui.Controls" +# Build GitHub auth header if token available (GITHUB_TOKEN or gh CLI) +GITHUB_AUTH_HEADER="" +if [ -n "$GITHUB_TOKEN" ]; then + GITHUB_AUTH_HEADER="Authorization: token $GITHUB_TOKEN" +elif command -v gh &> /dev/null && gh auth status &> /dev/null; then + GITHUB_TOKEN=$(gh auth token 2>/dev/null) + if [ -n "$GITHUB_TOKEN" ]; then + GITHUB_AUTH_HEADER="Authorization: token $GITHUB_TOKEN" + fi +fi + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -69,23 +81,23 @@ NC='\033[0m' # No Color # Output functions info() { - echo -e "${CYAN}ℹ️ $1${NC}" + echo -e "${CYAN}ℹ️ $1${NC}" >&2 } success() { - echo -e "${GREEN}✅ $1${NC}" + echo -e "${GREEN}✅ $1${NC}" >&2 } warning() { - echo -e "${YELLOW}⚠️ $1${NC}" + echo -e "${YELLOW}⚠️ $1${NC}" >&2 } error() { - echo -e "${RED}❌ $1${NC}" + echo -e "${RED}❌ $1${NC}" >&2 } step() { - echo -e "\n${BLUE}▶️ $1${NC}" + echo -e "\n${BLUE}▶️ $1${NC}" >&2 } # Check dependencies @@ -155,7 +167,8 @@ get_pr_info() { info "Fetching PR #$pr_number information from GitHub..." local pr_url="https://api.github.com/repos/$GITHUB_REPO/pulls/$pr_number" - local pr_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$pr_url") + local pr_json + pr_json=$(curl -s -H "User-Agent: MAUI-PR-Script" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$pr_url") if [ -z "$pr_json" ] || echo "$pr_json" | jq -e '.message' > /dev/null 2>&1; then error "Failed to fetch PR information. Make sure PR #$pr_number exists." @@ -172,10 +185,11 @@ get_build_info() { info "Looking for build artifacts for commit ${sha:0:7}..." local checks_url="https://api.github.com/repos/$GITHUB_REPO/commits/$sha/check-runs" - local checks_json=$(curl -s -H "User-Agent: MAUI-PR-Script" -H "Accept: application/vnd.github.v3+json" "$checks_url") + local checks_json + checks_json=$(curl -s -H "User-Agent: MAUI-PR-Script" -H "Accept: application/vnd.github.v3+json" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$checks_url") - # Find the main MAUI build check - local build_check=$(echo "$checks_json" | jq -r '.check_runs[] | select(.name == "MAUI-public" and .status == "completed") | @json' | head -n 1) + # Find the main MAUI build check (not uitests) + local build_check=$(echo "$checks_json" | jq -r '.check_runs[] | select((.name | startswith("maui-pr")) and (.name | contains("uitests") | not) and .status == "completed" and (.details_url | contains("buildId="))) | @json' | head -n 1) if [ -z "$build_check" ] || [ "$build_check" == "null" ]; then error "No completed build found for this PR" @@ -186,11 +200,15 @@ get_build_info() { local conclusion=$(echo "$build_check" | jq -r '.conclusion') if [ "$conclusion" != "success" ]; then warning "Build completed with status: $conclusion" - read -p "Do you want to continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - error "Build was not successful. Aborting." - exit 1 + if [ "$YES_FLAG" = true ]; then + info "Auto-accepting non-successful build (-y flag)" + else + read -p "Do you want to continue anyway? (y/N) " -n 1 -r + echo >&2 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + error "Build was not successful. Aborting." + exit 1 + fi fi fi @@ -216,11 +234,11 @@ get_build_artifacts() { local artifacts_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds/$build_id/artifacts?api-version=7.1" local artifacts_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$artifacts_url") - # Look for nuget artifact - local download_url=$(echo "$artifacts_json" | jq -r '.value[] | select(.name == "nuget") | .resource.downloadUrl' | head -n 1) + # Look for PackageArtifacts artifact + local download_url=$(echo "$artifacts_json" | jq -r '.value[] | select(.name == "PackageArtifacts") | .resource.downloadUrl' | head -n 1) if [ -z "$download_url" ] || [ "$download_url" == "null" ]; then - error "No 'nuget' artifact found in build $build_id" + error "No 'PackageArtifacts' artifact found in build $build_id" exit 1 fi @@ -262,6 +280,9 @@ get_artifacts() { exit 1 fi + # Clean up zip file to save disk space + rm -f "$zip_file" + echo "$nupkg_dir" } @@ -269,7 +290,7 @@ get_artifacts() { get_package_version() { local packages_dir="$1" - local package_file=$(find "$packages_dir" -type f -name "$PACKAGE_NAME.*.nupkg" -not -name "*.symbols.nupkg" | head -n 1) + local package_file=$(find "$packages_dir" -type f -name "$PACKAGE_NAME.*.nupkg" -not -name "*.symbols.nupkg" | grep -E "$PACKAGE_NAME\.[0-9]" | head -n 1) if [ -z "$package_file" ]; then error "Could not find $PACKAGE_NAME package in artifacts" @@ -343,12 +364,9 @@ update_target_frameworks() { cp "$project_path" "$project_path.bak" # Update all netX.0-* references (including in conditional TargetFrameworks) - sed -i.tmp "s/net[0-9]\+\.0-/net$new_net_version.0-/g" "$project_path" + sed -i.tmp -E "s/net[0-9]+\.0-/net${new_net_version}.0-/g" "$project_path" rm -f "$project_path.tmp" - # Cleanup backup file on success - rm -f "$project_path.bak" - success "Updated target frameworks to .NET $new_net_version.0" warning "You may need to update other package dependencies to match .NET $new_net_version.0" } @@ -359,16 +377,16 @@ update_nuget_config() { local packages_dir="$2" local nuget_config="$project_dir/NuGet.config" - local source_name="maui-pr-build" + local source_name="maui-pr-$pr_number" if [ -f "$nuget_config" ]; then info "Updating existing NuGet.config..." # Remove existing source with same name if it exists - sed -i.tmp "/| \n |" "$nuget_config" + sed -i.tmp "s|| \n |" "$nuget_config" rm -f "$nuget_config.tmp" else @@ -404,32 +422,35 @@ update_package_reference() { fi # Replace the version in PackageReference - sed -i.tmp "s|\(\)|\1$version\2|g" "$project_path" + sed -i.tmp -E "s#( /dev/null 2>&1; then - # Cleanup backup file on success - rm -f "$project_path.bak" - success "Updated $PACKAGE_NAME to version $version" - else - # Restore backup and report error - mv "$project_path.bak" "$project_path" - error "Could not find $PACKAGE_NAME package reference in project file" - exit 1 - fi + success "Updated $PACKAGE_NAME to version $version" } +# Global flag for non-interactive mode (set by -y/--yes) +YES_FLAG=false + # Main execution main() { + # Parse flags + local positional_args=() + for arg in "$@"; do + case "$arg" in + -y|--yes) YES_FLAG=true ;; + *) positional_args+=("$arg") ;; + esac + done + # Check arguments - if [ $# -lt 1 ]; then - error "Usage: $0 [PROJECT_PATH]" + if [ ${#positional_args[@]} -lt 1 ]; then + error "Usage: $0 [-y|--yes] [PROJECT_PATH]" exit 1 fi - pr_number="$1" # Global for error handler - local project_path_arg="${2:-}" + pr_number="${positional_args[0]}" # Global for error handler + local project_path_arg="${positional_args[1]:-}" # Check dependencies check_dependencies @@ -448,54 +469,82 @@ EOF echo -e "${NC}" step "Finding MAUI project" - local project_path=$(find_maui_project "$project_path_arg") - local project_dir=$(dirname "$project_path") - local project_name=$(basename "$project_path") + local project_path + project_path=$(find_maui_project "$project_path_arg") + local project_dir + project_dir=$(dirname "$project_path") + local project_name + project_name=$(basename "$project_path") success "Found project: $project_name" step "Fetching PR information" - local pr_json=$(get_pr_info "$pr_number") - local pr_title=$(echo "$pr_json" | jq -r '.title') - local pr_state=$(echo "$pr_json" | jq -r '.state') - local pr_sha=$(echo "$pr_json" | jq -r '.head.sha') + local pr_json + pr_json=$(get_pr_info "$pr_number") + local pr_title + pr_title=$(echo "$pr_json" | jq -r '.title') + local pr_state + pr_state=$(echo "$pr_json" | jq -r '.state') + local pr_sha + pr_sha=$(echo "$pr_json" | jq -r '.head.sha') info "PR #$pr_number: $pr_title" info "State: $pr_state" step "Detecting target framework" - local target_net_version=$(get_target_framework_version "$project_path") + local target_net_version + target_net_version=$(get_target_framework_version "$project_path") info "Current target framework: .NET $target_net_version.0" step "Finding build artifacts" - local build_id=$(get_build_info "$pr_sha") + local build_id + build_id=$(get_build_info "$pr_sha") step "Downloading artifacts" - local download_url=$(get_build_artifacts "$build_id") - local packages_dir=$(get_artifacts "$download_url" "$build_id") + local download_url + download_url=$(get_build_artifacts "$build_id") + local packages_dir + packages_dir=$(get_artifacts "$download_url" "$build_id") step "Extracting package information" - local version=$(get_package_version "$packages_dir") + local version + version=$(get_package_version "$packages_dir") success "Found package version: $version" # Extract .NET version from package version (e.g., 10.0.20-ci.main.25607.5 -> 10) - local package_dotnet_version - package_dotnet_version=$(get_package_dotnet_version "$version") + local package_dotnet_version="" + if [[ $version =~ ^([0-9]+)\. ]]; then + package_dotnet_version="${BASH_REMATCH[1]}" + fi + + # Get package .NET version + local package_net_version + package_net_version=$(get_package_dotnet_version "$version") # Check compatibility local will_update_tfm=false - local target_version="$package_dotnet_version.0" - if ! test_version_compatibility "$version" "$target_net_version" "$package_dotnet_version"; then + local target_version="$package_net_version.0" + if ! test_version_compatibility "$version" "$target_net_version" "$package_net_version"; then warning "This PR build may target a newer .NET version than your project" info "Your project targets: .NET $target_net_version.0" - info "This PR build targets: .NET $package_dotnet_version.0" + if [[ -n "$package_dotnet_version" ]]; then + info "This PR build targets: .NET $package_dotnet_version.0" + target_version="$package_dotnet_version.0" + else + info "This PR build targets: .NET $package_net_version.0" + fi - read -p "Do you want to update your project to .NET $target_version? (y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then + if [ "$YES_FLAG" = true ]; then will_update_tfm=true warning "Note: You may need to manually update other package dependencies to versions compatible with .NET $target_version" else - warning "Continuing without updating target framework. The package may not be compatible." + read -p "Do you want to update your project to .NET $target_version? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + will_update_tfm=true + warning "Note: You may need to manually update other package dependencies to versions compatible with .NET $target_version" + else + warning "Continuing without updating target framework. The package may not be compatible." + fi fi fi @@ -524,11 +573,15 @@ EOF fi echo "" - read -p "Do you want to continue? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - warning "Operation cancelled by user" - exit 0 + if [ "$YES_FLAG" = true ]; then + info "Auto-accepting confirmation (-y flag)" + else + read -p "Do you want to continue? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + warning "Operation cancelled by user" + exit 0 + fi fi echo "" @@ -547,13 +600,6 @@ EOF step "Updating package reference" update_package_reference "$project_path" "$version" - # Get latest stable version for revert instructions - local latest_stable=$(curl -s "https://api.nuget.org/v3-flatcontainer/microsoft.maui.controls/index.json" | \ - jq -r '.versions[]' | grep -v '-' | tail -1) - if [ -z "$latest_stable" ]; then - latest_stable="X.Y.Z" - fi - echo -e "${GREEN}" cat << EOF @@ -575,16 +621,30 @@ EOF info "Local package source: $packages_dir" echo "" + # Get latest stable version for revert instructions + local stable_version="X.Y.Z" + local package_lower=$(echo "$PACKAGE_NAME" | tr '[:upper:]' '[:lower:]') + if command -v curl >/dev/null 2>&1; then + local nuget_response=$(curl -s "https://api.nuget.org/v3-flatcontainer/$package_lower/index.json" 2>/dev/null || echo "") + if [[ -n "$nuget_response" ]]; then + # Extract stable versions (those without -) + stable_version=$(echo "$nuget_response" | grep -o '"[0-9]\+\.[0-9]\+\.[0-9]\+"' | grep -v '-' | tail -1 | tr -d '"') + if [[ -z "$stable_version" ]]; then + stable_version="X.Y.Z" + fi + fi + fi + echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} TO REVERT TO PRODUCTION VERSION${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo "" echo -e "${WHITE}1. Edit $project_name and change the version:${NC}" echo -e "${GRAY} From: Version=\"$version\"${NC}" - echo -e "${GRAY} To: Version=\"X.Y.Z\"${NC}" + echo -e "${GRAY} To: Version=\"$stable_version\"${NC}" echo -e "${DGRAY} (Check https://www.nuget.org/packages/$PACKAGE_NAME for latest)${NC}" echo "" - echo -e "${WHITE}2. In NuGet.config, remove or comment out the 'maui-pr-build' source${NC}" + echo -e "${WHITE}2. In NuGet.config, remove or comment out the 'maui-pr-$pr_number' source${NC}" echo "" echo -e "${WHITE}3. Run: dotnet restore --force${NC}" echo "" diff --git a/src/Controls/Maps/src/Circle.cs b/src/Controls/Maps/src/Circle.cs index a33ac2c4e154..d07785908df2 100644 --- a/src/Controls/Maps/src/Circle.cs +++ b/src/Controls/Maps/src/Circle.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.Maui.Devices.Sensors; using Microsoft.Maui.Graphics; using Microsoft.Maui.Maps; @@ -56,5 +57,16 @@ public Color FillColor get => (Color)GetValue(FillColorProperty); set => SetValue(FillColorProperty, value); } + + /// + /// Occurs when the user clicks/taps on the circle element + /// + public event EventHandler? CircleClicked; + + void IMapElement.Clicked() + { + CircleClicked?.Invoke(this, EventArgs.Empty); + } + } } diff --git a/src/Controls/Maps/src/HandlerImpl/MapElement.Impl.cs b/src/Controls/Maps/src/HandlerImpl/MapElement.Impl.cs index a7df3ff2d211..fab67d2cf4e0 100644 --- a/src/Controls/Maps/src/HandlerImpl/MapElement.Impl.cs +++ b/src/Controls/Maps/src/HandlerImpl/MapElement.Impl.cs @@ -19,5 +19,7 @@ public partial class MapElement : IMapElement float IStroke.StrokeDashOffset => throw new NotImplementedException(); float IStroke.StrokeMiterLimit => throw new NotImplementedException(); + + void IMapElement.Clicked() => throw new NotImplementedException(); } } diff --git a/src/Controls/Maps/src/MapElement.cs b/src/Controls/Maps/src/MapElement.cs index 6d469f7de041..31d15554ff10 100644 --- a/src/Controls/Maps/src/MapElement.cs +++ b/src/Controls/Maps/src/MapElement.cs @@ -26,6 +26,20 @@ public partial class MapElement : Element typeof(MapElement), 5f); + /// Bindable property for . + public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create( + nameof(IsVisible), + typeof(bool), + typeof(MapElement), + true); + + /// Bindable property for . + public static readonly BindableProperty ZIndexProperty = BindableProperty.Create( + nameof(ZIndex), + typeof(int), + typeof(MapElement), + 0); + /// /// Gets or sets the stroke color. This is a bindable property. /// @@ -45,6 +59,28 @@ public float StrokeWidth set => SetValue(StrokeWidthProperty, value); } + /// + /// Gets or sets a value indicating whether the map element is visible on the map. + /// The default value is . + /// This is a bindable property. + /// + public bool IsVisible + { + get => (bool)GetValue(IsVisibleProperty); + set => SetValue(IsVisibleProperty, value); + } + + /// + /// Gets or sets the z-index of the map element, which controls its draw order relative to other elements. + /// Higher values are drawn on top of lower values. The default value is 0. + /// This is a bindable property. + /// + public int ZIndex + { + get => (int)GetValue(ZIndexProperty); + set => SetValue(ZIndexProperty, value); + } + /// /// Gets or sets the platform counterpart of this map element. /// diff --git a/src/Controls/Maps/src/Polygon.cs b/src/Controls/Maps/src/Polygon.cs index 99eabdd222d5..71e85efa8895 100644 --- a/src/Controls/Maps/src/Polygon.cs +++ b/src/Controls/Maps/src/Polygon.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Microsoft.Maui.Devices.Sensors; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Maps; namespace Microsoft.Maui.Controls.Maps { @@ -31,6 +33,16 @@ public Color FillColor /// public IList Geopath { get; } + /// + /// Occurs when the user clicks/taps on the polygon element + /// + public event EventHandler? PolygonClicked; + + void IMapElement.Clicked() + { + PolygonClicked?.Invoke(this, EventArgs.Empty); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Controls/Maps/src/Polyline.cs b/src/Controls/Maps/src/Polyline.cs index 3f5029182fb9..2337238c1f98 100644 --- a/src/Controls/Maps/src/Polyline.cs +++ b/src/Controls/Maps/src/Polyline.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using Microsoft.Maui.Devices.Sensors; +using Microsoft.Maui.Maps; namespace Microsoft.Maui.Controls.Maps { @@ -14,6 +16,16 @@ public partial class Polyline : MapElement /// public IList Geopath { get; } + /// + /// Occurs when the user clicks/taps on the polyline element + /// + public event EventHandler? PolylineClicked; + + void IMapElement.Clicked() + { + PolylineClicked?.Invoke(this, EventArgs.Empty); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 31029602ec64..5b49a0a43882 100644 --- a/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -1,4 +1,14 @@ + #nullable enable -static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +Microsoft.Maui.Controls.Maps.Circle.CircleClicked -> System.EventHandler? Microsoft.Maui.Controls.Maps.Map.Region.get -> Microsoft.Maui.Maps.MapSpan? Microsoft.Maui.Controls.Maps.Map.Region.set -> void +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.get -> bool +Microsoft.Maui.Controls.Maps.MapElement.IsVisible.set -> void +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.get -> int +Microsoft.Maui.Controls.Maps.MapElement.ZIndex.set -> void +Microsoft.Maui.Controls.Maps.Polygon.PolygonClicked -> System.EventHandler? +Microsoft.Maui.Controls.Maps.Polyline.PolylineClicked -> System.EventHandler? +static readonly Microsoft.Maui.Controls.Maps.Map.RegionProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.IsVisibleProperty -> Microsoft.Maui.Controls.BindableProperty! +static readonly Microsoft.Maui.Controls.Maps.MapElement.ZIndexProperty -> Microsoft.Maui.Controls.BindableProperty! diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml new file mode 100644 index 000000000000..907be9b8f4b9 --- /dev/null +++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml @@ -0,0 +1,32 @@ + + + + + diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml.cs b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml.cs new file mode 100644 index 000000000000..457475c48196 --- /dev/null +++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementClickGallery.xaml.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Maps; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Maps; +using Position = Microsoft.Maui.Devices.Sensors.Location; + +namespace Maui.Controls.Sample.Pages.MapsGalleries +{ + public partial class MapElementClickGallery : ContentPage + { + public MapElementClickGallery() + { + InitializeComponent(); + AddMapElements(); + } + + void AddMapElements() + { + // Add a circle + var circle = new Circle + { + Center = new Position(37.79752, -122.40183), + Radius = new Distance(200), + StrokeColor = Color.FromArgb("#88FF0000"), + StrokeWidth = 8, + FillColor = Color.FromArgb("#88FFC0CB") + }; + circle.CircleClicked += OnCircleClicked; + map.MapElements.Add(circle); + + // Add a polygon (triangle) + var polygon = new Polygon + { + StrokeColor = Color.FromArgb("#880000FF"), + StrokeWidth = 8, + FillColor = Color.FromArgb("#8800FF00") + }; + polygon.Geopath.Add(new Position(37.7997, -122.4050)); + polygon.Geopath.Add(new Position(37.7997, -122.3980)); + polygon.Geopath.Add(new Position(37.7950, -122.4015)); + polygon.PolygonClicked += OnPolygonClicked; + map.MapElements.Add(polygon); + + // Add a polyline + var polyline = new Polyline + { + StrokeColor = Color.FromArgb("#FF6600"), + StrokeWidth = 10 + }; + polyline.Geopath.Add(new Position(37.7930, -122.4100)); + polyline.Geopath.Add(new Position(37.7940, -122.4050)); + polyline.Geopath.Add(new Position(37.7935, -122.4000)); + polyline.Geopath.Add(new Position(37.7950, -122.3950)); + polyline.PolylineClicked += OnPolylineClicked; + map.MapElements.Add(polyline); + } + + void OnCircleClicked(object? sender, EventArgs e) + { + StatusLabel.Text = "Circle clicked!"; + StatusLabel.TextColor = Colors.Red; + } + + void OnPolygonClicked(object? sender, EventArgs e) + { + StatusLabel.Text = "Polygon clicked!"; + StatusLabel.TextColor = Colors.Green; + } + + void OnPolylineClicked(object? sender, EventArgs e) + { + StatusLabel.Text = "Polyline clicked!"; + StatusLabel.TextColor = Colors.Orange; + } + } +} diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementVisibilityGallery.cs b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementVisibilityGallery.cs new file mode 100644 index 000000000000..33d4bdb8d03b --- /dev/null +++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapElementVisibilityGallery.cs @@ -0,0 +1,149 @@ +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Maps; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Maps; +using GeoLocation = Microsoft.Maui.Devices.Sensors.Location; + +namespace Maui.Controls.Sample.Pages.MapsGalleries +{ + public class MapElementVisibilityGallery : ContentPage + { + readonly Polygon _polygon; + readonly Polyline _polyline; + readonly Circle _circle; + readonly Label _statusLabel; + + public MapElementVisibilityGallery() + { + Title = "Element Visibility & ZIndex"; + + var center = new GeoLocation(47.6062, -122.3321); // Seattle + + _polygon = new Polygon + { + StrokeColor = Colors.Blue, + StrokeWidth = 3, + FillColor = Color.FromRgba(0, 0, 255, 64), + ZIndex = 1, + }; + _polygon.Geopath.Add(new GeoLocation(47.615, -122.345)); + _polygon.Geopath.Add(new GeoLocation(47.615, -122.320)); + _polygon.Geopath.Add(new GeoLocation(47.600, -122.320)); + _polygon.Geopath.Add(new GeoLocation(47.600, -122.345)); + + _polyline = new Polyline + { + StrokeColor = Colors.Red, + StrokeWidth = 5, + ZIndex = 2, + }; + _polyline.Geopath.Add(new GeoLocation(47.610, -122.350)); + _polyline.Geopath.Add(new GeoLocation(47.610, -122.315)); + + _circle = new Circle + { + Center = center, + Radius = new Distance(500), + StrokeColor = Colors.Green, + StrokeWidth = 3, + FillColor = Color.FromRgba(0, 255, 0, 64), + ZIndex = 3, + }; + + var map = new Microsoft.Maui.Controls.Maps.Map(new MapSpan(center, 0.03, 0.03)); + map.MapElements.Add(_polygon); + map.MapElements.Add(_polyline); + map.MapElements.Add(_circle); + + _statusLabel = new Label + { + Text = "All elements visible. ZIndex: Polygon=1, Polyline=2, Circle=3", + HorizontalTextAlignment = TextAlignment.Center, + AutomationId = "StatusLabel" + }; + + var togglePolygonBtn = new Button { Text = "Toggle Polygon", AutomationId = "TogglePolygon" }; + togglePolygonBtn.Clicked += (s, e) => + { + _polygon.IsVisible = !_polygon.IsVisible; + UpdateStatus(); + }; + + var togglePolylineBtn = new Button { Text = "Toggle Polyline", AutomationId = "TogglePolyline" }; + togglePolylineBtn.Clicked += (s, e) => + { + _polyline.IsVisible = !_polyline.IsVisible; + UpdateStatus(); + }; + + var toggleCircleBtn = new Button { Text = "Toggle Circle", AutomationId = "ToggleCircle" }; + toggleCircleBtn.Clicked += (s, e) => + { + _circle.IsVisible = !_circle.IsVisible; + UpdateStatus(); + }; + + var bringPolygonTopBtn = new Button { Text = "Polygon to Top (Z=10)", AutomationId = "PolygonTop" }; + bringPolygonTopBtn.Clicked += (s, e) => + { + _polygon.ZIndex = 10; + UpdateStatus(); + }; + + var resetZIndexBtn = new Button { Text = "Reset ZIndex", AutomationId = "ResetZIndex" }; + resetZIndexBtn.Clicked += (s, e) => + { + _polygon.ZIndex = 1; + _polyline.ZIndex = 2; + _circle.ZIndex = 3; + UpdateStatus(); + }; + + var controls = new VerticalStackLayout + { + Spacing = 4, + Padding = new Thickness(8), + Children = + { + _statusLabel, + new HorizontalStackLayout + { + Spacing = 4, + HorizontalOptions = LayoutOptions.Center, + Children = { togglePolygonBtn, togglePolylineBtn, toggleCircleBtn } + }, + new HorizontalStackLayout + { + Spacing = 4, + HorizontalOptions = LayoutOptions.Center, + Children = { bringPolygonTopBtn, resetZIndexBtn } + } + } + }; + + Content = new Grid + { + RowDefinitions = + { + new RowDefinition(GridLength.Star), + new RowDefinition(GridLength.Auto), + }, + Children = + { + map, + controls + } + }; + + Grid.SetRow(controls, 1); + } + + void UpdateStatus() + { + _statusLabel.Text = $"Polygon:{(_polygon.IsVisible ? "ON" : "OFF")}(Z={_polygon.ZIndex}) " + + $"Polyline:{(_polyline.IsVisible ? "ON" : "OFF")}(Z={_polyline.ZIndex}) " + + $"Circle:{(_circle.IsVisible ? "ON" : "OFF")}(Z={_circle.ZIndex})"; + } + } +} diff --git a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapsGallery.cs b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapsGallery.cs index ee6bfdf593e1..5241116db69e 100644 --- a/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapsGallery.cs +++ b/src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapsGallery.cs @@ -19,6 +19,8 @@ public MapsGallery() GalleryBuilder.NavButton("Pins ItemsSource", () => new PinItemsSourceGallery(), Navigation), GalleryBuilder.NavButton("Circle", () => new CircleGallery(), Navigation), GalleryBuilder.NavButton("Polygon", () => new PolygonsGallery(), Navigation), + GalleryBuilder.NavButton("Element Visibility & ZIndex", () => new MapElementVisibilityGallery(), Navigation), + GalleryBuilder.NavButton("MapElement Click Events", () => new MapElementClickGallery(), Navigation), } } }; diff --git a/src/Controls/src/BindingSourceGen/ITypeSymbolExtensions.cs b/src/Controls/src/BindingSourceGen/ITypeSymbolExtensions.cs index 43ea23d060b6..226f25475a3a 100644 --- a/src/Controls/src/BindingSourceGen/ITypeSymbolExtensions.cs +++ b/src/Controls/src/BindingSourceGen/ITypeSymbolExtensions.cs @@ -5,6 +5,11 @@ namespace Microsoft.Maui.Controls.BindingSourceGen; public static class ITypeSymbolExtensions { + static readonly SymbolDisplayFormat FullyQualifiedNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + public static bool IsTypeNullable(this ITypeSymbol typeInfo, bool enabledNullable) { if (!enabledNullable && typeInfo.IsReferenceType) @@ -39,10 +44,16 @@ private static string GetGlobalName(this ITypeSymbol typeSymbol, bool isNullable if (isNullable && isValueType) { // Strips the "?" from the type name - return ((INamedTypeSymbol)typeSymbol).TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return ((INamedTypeSymbol)typeSymbol).TypeArguments[0].ToDisplayString(FullyQualifiedNullableFormat); } - return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var globalName = typeSymbol.ToDisplayString(FullyQualifiedNullableFormat); + + // Keep nullable annotations in generic arguments but avoid nullable top-level type syntax (e.g. typeof(Foo?)). + if (globalName.EndsWith("?", StringComparison.Ordinal)) + globalName = globalName.Substring(0, globalName.Length - 1); + + return globalName; } /// @@ -68,24 +79,30 @@ public static bool TryGetRelayCommandPropertyType(this ITypeSymbol symbol, strin // Extract the method name (property name without "Command" suffix) var methodName = propertyName.Substring(0, propertyName.Length - "Command".Length); - // Look for a method with the base name - search in the type and base types - var methods = GetAllMethods(symbol, methodName); - - foreach (var method in methods) + // CommunityToolkit.Mvvm command naming supports these patterns: + // - Save => SaveCommand + // - SaveAsync => SaveCommand + // - OnSave => SaveCommand + // - OnSaveAsync => SaveCommand + foreach (var candidateMethodName in GetRelayCommandMethodNameCandidates(methodName)) { - // Check if the method has the RelayCommand attribute - var hasRelayCommand = method.GetAttributes().Any(attr => - attr.AttributeClass?.Name == "RelayCommandAttribute" || - attr.AttributeClass?.ToDisplayString() == "CommunityToolkit.Mvvm.Input.RelayCommandAttribute"); - - if (hasRelayCommand) + var methods = GetAllMethods(symbol, candidateMethodName); + foreach (var method in methods) { - // Try to find the ICommand interface type - var icommandType = compilation.GetTypeByMetadataName("System.Windows.Input.ICommand"); - if (icommandType != null) + // Check if the method has the RelayCommand attribute + var hasRelayCommand = method.GetAttributes().Any(attr => + attr.AttributeClass?.Name == "RelayCommandAttribute" || + attr.AttributeClass?.ToDisplayString() == "CommunityToolkit.Mvvm.Input.RelayCommandAttribute"); + + if (hasRelayCommand) { - commandType = icommandType; - return true; + // Try to find the ICommand interface type + var icommandType = compilation.GetTypeByMetadataName("System.Windows.Input.ICommand"); + if (icommandType != null) + { + commandType = icommandType; + return true; + } } } } @@ -93,6 +110,28 @@ public static bool TryGetRelayCommandPropertyType(this ITypeSymbol symbol, strin return false; } + private static System.Collections.Generic.IEnumerable GetRelayCommandMethodNameCandidates(string methodName) + { + // CommunityToolkit strips "On" prefix: OnSave() → SaveCommand, not OnSaveCommand. + // So if methodName starts with "On", the base name would only match methods that generate + // a *different* command property (e.g., "OnLoad" method → "LoadCommand", not "OnLoadCommand"). + // We skip these candidates to avoid false-positive diagnostic suppression. + if (!methodName.StartsWith("On", System.StringComparison.Ordinal)) + { + yield return methodName; + yield return methodName + "Async"; + } + + if (methodName.Length > 0 + && char.IsUpper(methodName[0]) + && !methodName.StartsWith("On", System.StringComparison.Ordinal)) + { + var onMethodName = "On" + methodName; + yield return onMethodName; + yield return onMethodName + "Async"; + } + } + /// /// Checks if a property name could be generated by CommunityToolkit.Mvvm's [ObservableProperty] attribute, /// and returns the inferred property type if found. diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets index 22d79df04b92..43830be2d9aa 100644 --- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets +++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets @@ -10,6 +10,9 @@ <_MauiXamlInflator Condition="' $(MauiXamlInflator)' != '' ">$(MauiXamlInflator) <_MauiXamlInflator Condition=" '$(MauiXamlInflator)' == '' ">SourceGen + + Legacy + $(EnableMauiDiagnostics) $(EnableDiagnostics) true @@ -73,6 +76,23 @@ Condition="'$(_MauiTargetsImportedAgain)' == 'True'" /> + + + + <_MauiXamlHotReloadUpper>$([System.String]::Copy('$(MauiXamlHotReload)').Trim().ToUpperInvariant()) + + + + + + + node is ElementNode elementNode && elementNode.XmlType.RepresentsType(namespaceUri, name); -} \ No newline at end of file +} diff --git a/src/Controls/src/SourceGen/SetterValueProvider.cs b/src/Controls/src/SourceGen/SetterValueProvider.cs index bff218b7b482..557bb2ed43a5 100644 --- a/src/Controls/src/SourceGen/SetterValueProvider.cs +++ b/src/Controls/src/SourceGen/SetterValueProvider.cs @@ -165,6 +165,7 @@ private static bool TryGetBindablePropertyNameAndType(IFieldSymbol? bpRef, Value INode? valueNode = null; if (!node.Properties.TryGetValue(new XmlName("", "Value"), out valueNode) && !node.Properties.TryGetValue(new XmlName(XamlParser.MauiUri, "Value"), out valueNode) && + !node.Properties.TryGetValue(new XmlName(XamlParser.MauiGlobalUri, "Value"), out valueNode) && node.CollectionItems.Count == 1) valueNode = node.CollectionItems[0]; diff --git a/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs b/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs index 53aed8c94373..c971bdd1d48d 100644 --- a/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs +++ b/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs @@ -43,9 +43,10 @@ public static void CreateValue(ElementNode node, IndentedTextWriter writer, IDic if (node.IsOnPlatformDefaultValue) { var variableName = NamingHelpers.CreateUniqueVariableName(Context, type); - writer.WriteLine($"{type.ToFQDisplayString()} {variableName} = default;"); + // Reference-type defaults are null; use default! so generated code does not emit nullable warnings. + var defaultValue = type.IsReferenceType ? "default!" : "default"; + writer.WriteLine($"{type.ToFQDisplayString()} {variableName} = {defaultValue};"); variables[node] = new LocalVariable(type, variableName); - node.RegisterSourceInfo(Context, writer); return; } diff --git a/src/Controls/src/SourceGen/Visitors/SetNamescopesAndRegisterNames.cs b/src/Controls/src/SourceGen/Visitors/SetNamescopesAndRegisterNames.cs index c8b949660cda..d7447da28625 100644 --- a/src/Controls/src/SourceGen/Visitors/SetNamescopesAndRegisterNames.cs +++ b/src/Controls/src/SourceGen/Visitors/SetNamescopesAndRegisterNames.cs @@ -62,6 +62,12 @@ public void Visit(ElementNode node, INode parentNode) namesInNamescope = Context.Scopes[parentNode].namesInScope; } + if (node.IsOnPlatformDefaultValue) + { + Context.Scopes[node] = (namescope, namesInNamescope); + return; + } + if (setNameScope && Context.Variables[node].Type.InheritsFrom(Context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.BindableObject")!, Context)) using (PrePost.NewConditional(Writer, "!_MAUIXAML_SG_NAMESCOPE_DISABLE")) { diff --git a/src/Controls/tests/BindingSourceGen.UnitTests/RelayCommandTests.cs b/src/Controls/tests/BindingSourceGen.UnitTests/RelayCommandTests.cs index 27c33ea98aa0..4ed821d03628 100644 --- a/src/Controls/tests/BindingSourceGen.UnitTests/RelayCommandTests.cs +++ b/src/Controls/tests/BindingSourceGen.UnitTests/RelayCommandTests.cs @@ -126,6 +126,58 @@ private void Save() Assert.Equal("System.Windows.Input.ICommand", commandType!.ToDisplayString()); } + [Fact] + public void DetectsRelayCommandMethodWithOnPrefixAndAsyncSuffix() + { + var source = @" + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CSharp; + using System.Linq; + using System.Threading.Tasks; + + namespace System.Windows.Input + { + public interface ICommand + { + event System.EventHandler CanExecuteChanged; + bool CanExecute(object parameter); + void Execute(object parameter); + } + } + + namespace CommunityToolkit.Mvvm.Input + { + [System.AttributeUsage(System.AttributeTargets.Method)] + public class RelayCommandAttribute : System.Attribute { } + } + + namespace TestApp + { + public class MyViewModel + { + [CommunityToolkit.Mvvm.Input.RelayCommand] + private Task OnSaveAsync() + { + return Task.CompletedTask; + } + } + } + "; + + var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create("test") + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(source)) + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + + var myViewModelType = compilation.GetTypeByMetadataName("TestApp.MyViewModel"); + Assert.NotNull(myViewModelType); + + var canInfer = myViewModelType.TryGetRelayCommandPropertyType("SaveCommand", compilation, out var commandType); + + Assert.True(canInfer, "Should infer SaveCommand from OnSaveAsync method with [RelayCommand]."); + Assert.NotNull(commandType); + Assert.Equal("System.Windows.Input.ICommand", commandType!.ToDisplayString()); + } + [Fact] public void DoesNotDetectCommandPropertyWithoutAttribute() { diff --git a/src/Controls/tests/Core.UnitTests/BindingUnitTests.cs b/src/Controls/tests/Core.UnitTests/BindingUnitTests.cs index 0fe13d5ab574..65b565770bf1 100644 --- a/src/Controls/tests/Core.UnitTests/BindingUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/BindingUnitTests.cs @@ -2532,5 +2532,170 @@ public object Convert(object value, Type targetType, object parameter, CultureIn public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); } + + [Fact] + // https://github.com/dotnet/maui/issues/8342 + public void TwoWayBindingToIntPropertyWithEmptyStringRetainsLastValidValue() + { + // This test reproduces the issue where when the user clears an Entry + // that is bound to an int property, the int property retains the first + // digit of the last entered value instead of keeping the last valid value. + // + // The expected behavior is that when an empty string cannot be converted + // to int, the source property should retain its last valid value. + + var vm = new IntViewModel { IntValue = 0 }; + var entry = new Entry { BindingContext = vm }; + entry.SetBinding(Entry.TextProperty, "IntValue", BindingMode.TwoWay); + + // Simulate user entering "456" + entry.SetValueFromRenderer(Entry.TextProperty, "456"); + Assert.Equal(456, vm.IntValue); + + // Simulate user backspacing to "45" + entry.SetValueFromRenderer(Entry.TextProperty, "45"); + Assert.Equal(45, vm.IntValue); + + // Simulate user backspacing to "4" + entry.SetValueFromRenderer(Entry.TextProperty, "4"); + Assert.Equal(4, vm.IntValue); + + // Simulate user backspacing to empty string + // The binding should fail to convert "" to int + // and the source property should retain its last valid value (4) + entry.SetValueFromRenderer(Entry.TextProperty, ""); + + // This is the key assertion - after clearing the Entry, the IntValue + // should still be 4 (the last successfully converted value) + Assert.Equal(4, vm.IntValue); + + // The Entry.Text will be "" because that's what was set from the renderer + // This creates a mismatch between Entry.Text ("") and ViewModel.IntValue (4) + // which is the core of the bug reported in issue #8342 + Assert.Equal("", entry.Text); + } + + [Fact] + // https://github.com/dotnet/maui/issues/8342 + public void TwoWayBindingToNullableIntPropertyWithEmptyStringBecomesNull() + { + // When binding to a nullable int, empty string should be converted to null + var vm = new NullableIntViewModel { IntValue = 123 }; + var entry = new Entry { BindingContext = vm }; + entry.SetBinding(Entry.TextProperty, "IntValue", BindingMode.TwoWay); + + // Verify initial binding + Assert.Equal("123", entry.Text); + + // Clear the entry - for nullable int, empty string should result in null + entry.SetValueFromRenderer(Entry.TextProperty, ""); + + // Nullable int should become null when empty string is entered + Assert.Null(vm.IntValue); + // Entry.Text becomes null because the binding writes back null from vm.IntValue + // This is expected - Entry displays empty for both null and "" text + Assert.Null(entry.Text); + } + + [Fact] + // https://github.com/dotnet/maui/issues/8342 + public void TwoWayBindingToNullableIntPropertyWithWhitespaceRetainsPreviousValue() + { + // Whitespace-only strings should fail conversion, not silently become null + var vm = new NullableIntViewModel { IntValue = 123 }; + var entry = new Entry { BindingContext = vm }; + entry.SetBinding(Entry.TextProperty, "IntValue", BindingMode.TwoWay); + + entry.SetValueFromRenderer(Entry.TextProperty, " "); + + // Whitespace should not convert to null — value should be retained + Assert.Equal(123, vm.IntValue); + } + + [Fact] + // https://github.com/dotnet/maui/issues/8342 + public void TwoWayBindingToNullableDoublePropertyWithEmptyStringBecomesNull() + { + var vm = new NullableDoubleViewModel { Value = 3.14 }; + var entry = new Entry { BindingContext = vm }; + entry.SetBinding(Entry.TextProperty, "Value", BindingMode.TwoWay); + + Assert.Equal("3.14", entry.Text); + + entry.SetValueFromRenderer(Entry.TextProperty, ""); + + Assert.Null(vm.Value); + } + + [Fact] + // https://github.com/dotnet/maui/issues/8342 + public void TwoWayBindingToNullableIntPropertyReentersValueAfterClearing() + { + var vm = new NullableIntViewModel { IntValue = 123 }; + var entry = new Entry { BindingContext = vm }; + entry.SetBinding(Entry.TextProperty, "IntValue", BindingMode.TwoWay); + + // Clear + entry.SetValueFromRenderer(Entry.TextProperty, ""); + Assert.Null(vm.IntValue); + + // Re-enter a value + entry.SetValueFromRenderer(Entry.TextProperty, "456"); + Assert.Equal(456, vm.IntValue); + } + + internal class IntViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + int _intValue; + public int IntValue + { + get => _intValue; + set + { + if (_intValue == value) + return; + _intValue = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); + } + } + } + + internal class NullableIntViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + int? _intValue; + public int? IntValue + { + get => _intValue; + set + { + if (_intValue == value) + return; + _intValue = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); + } + } + } + + internal class NullableDoubleViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + double? _value; + public double? Value + { + get => _value; + set + { + if (_value == value) + return; + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + } } } \ No newline at end of file diff --git a/src/Controls/tests/Core.UnitTests/MapTests.cs b/src/Controls/tests/Core.UnitTests/MapTests.cs index f938498d57ea..b796710e3aa0 100644 --- a/src/Controls/tests/Core.UnitTests/MapTests.cs +++ b/src/Controls/tests/Core.UnitTests/MapTests.cs @@ -410,6 +410,57 @@ public void WorksWithNullItems() Assert.True(IsMapWithItemsSource(itemsSource, map)); } + [Fact] + public void MapElementIsVisibleDefaultIsTrue() + { + var polygon = new Polygon(); + Assert.True(polygon.IsVisible); + } + + [Fact] + public void MapElementIsVisibleCanBeSet() + { + var polygon = new Polygon(); + polygon.IsVisible = false; + Assert.False(polygon.IsVisible); + } + + [Fact] + public void MapElementZIndexDefaultIsZero() + { + var polyline = new Polyline(); + Assert.Equal(0, polyline.ZIndex); + } + + [Fact] + public void MapElementZIndexCanBeSet() + { + var circle = new Circle + { + Center = new Location(0, 0), + Radius = new Distance(100) + }; + circle.ZIndex = 5; + Assert.Equal(5, circle.ZIndex); + } + + [Fact] + public void MapElementIsVisibleWorksOnAllTypes() + { + var polygon = new Polygon { IsVisible = false }; + var polyline = new Polyline { IsVisible = false }; + var circle = new Circle + { + Center = new Location(0, 0), + Radius = new Distance(100), + IsVisible = false + }; + + Assert.False(polygon.IsVisible); + Assert.False(polyline.IsVisible); + Assert.False(circle.IsVisible); + } + // Checks if for every item in the items source there's a corresponding pin static bool IsMapWithItemsSource(IEnumerable itemsSource, Map map) { @@ -477,5 +528,96 @@ public MockViewModel(IEnumerable itemsSource) Items = itemsSource; } } + + [Fact] + public void CircleClickedEventFires() + { + var circle = new Circle + { + Center = new Location(37.79752, -122.40183), + Radius = new Distance(200), + }; + + bool eventFired = false; + circle.CircleClicked += (s, e) => eventFired = true; + + ((IMapElement)circle).Clicked(); + + Assert.True(eventFired); + } + + [Fact] + public void PolygonClickedEventFires() + { + var polygon = new Polygon(); + polygon.Geopath.Add(new Location(37.7997, -122.4050)); + polygon.Geopath.Add(new Location(37.7997, -122.3980)); + polygon.Geopath.Add(new Location(37.7950, -122.4015)); + + bool eventFired = false; + polygon.PolygonClicked += (s, e) => eventFired = true; + + ((IMapElement)polygon).Clicked(); + + Assert.True(eventFired); + } + + [Fact] + public void PolylineClickedEventFires() + { + var polyline = new Polyline(); + polyline.Geopath.Add(new Location(37.7930, -122.4100)); + polyline.Geopath.Add(new Location(37.7940, -122.4050)); + + bool eventFired = false; + polyline.PolylineClicked += (s, e) => eventFired = true; + + ((IMapElement)polyline).Clicked(); + + Assert.True(eventFired); + } + + [Fact] + public void CircleClickedEventSenderIsCircle() + { + var circle = new Circle + { + Center = new Location(37.79752, -122.40183), + Radius = new Distance(200), + }; + + object sender = null; + circle.CircleClicked += (s, e) => sender = s; + + ((IMapElement)circle).Clicked(); + + Assert.Same(circle, sender); + } + + [Fact] + public void PolygonClickedEventSenderIsPolygon() + { + var polygon = new Polygon(); + + object sender = null; + polygon.PolygonClicked += (s, e) => sender = s; + + ((IMapElement)polygon).Clicked(); + + Assert.Same(polygon, sender); + } + + [Fact] + public void PolylineClickedEventSenderIsPolyline() + { + var polyline = new Polyline(); + + object sender = null; + polyline.PolylineClicked += (s, e) => sender = s; + + ((IMapElement)polyline).Clicked(); + + Assert.Same(polyline, sender); + } } } diff --git a/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs b/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs index 42ef7c303189..e106ffcddcb3 100644 --- a/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs +++ b/src/Controls/tests/SourceGen.UnitTests/BindingDiagnosticsTests.cs @@ -54,6 +54,48 @@ public class ViewModel Assert.Contains("ViewModel", message, System.StringComparison.Ordinal); } + [Fact] + public void BindingToRelayCommandGeneratedFromOnAsyncMethod_DoesNotReportPropertyNotFound() + { + var xaml = +""" + + + + +"""; + + const string TestCode = """ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +public partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +"""; + + [Fact] + public void SetterWithComplexValueInTriggerIsAdded() + { + // Reproduction from https://github.com/dotnet/maui/issues/34039 + // When is a property element, GetValueNode() must find it + // regardless of the namespace URI on the property element. + var compilation = CreateMauiCompilation() + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(TestCode)) + .AddSyntaxTrees(CSharpSyntaxTree.ParseText("[assembly: global::Microsoft.Maui.Controls.Xaml.Internals.AllowImplicitXmlnsDeclaration]")); + + var workingDirectory = Environment.CurrentDirectory; + var xamlFile = new AdditionalXamlFile( + System.IO.Path.Combine(workingDirectory, "Test.xaml"), TestXaml, + RelativePath: "Test.xaml", + ManifestResourceName: $"{compilation.AssemblyName}.Test.xaml"); + var result = RunGenerator(compilation, xamlFile); + var generated = result.Results.SingleOrDefault().GeneratedSources + .SingleOrDefault(gs => gs.HintName.EndsWith(".xsg.cs")).SourceText?.ToString(); + + Assert.NotNull(generated); + Assert.False(result.Diagnostics.Any(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error), + $"Generator produced errors: {string.Join(", ", result.Diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error))}"); + + // The setter must be added to the trigger's Setters collection. + // Without the fix, GetValueNode() fails to find the Value property element, + // causing the setter to be removed from Variables and the .Add() call to be skipped. + Assert.Contains("Setters).Add(", generated, StringComparison.Ordinal); + } +} diff --git a/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/SimplifyOnPlatform.cs b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/SimplifyOnPlatform.cs index 0793c116a791..039267cb20e8 100644 --- a/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/SimplifyOnPlatform.cs +++ b/src/Controls/tests/SourceGen.UnitTests/InitializeComponent/SimplifyOnPlatform.cs @@ -391,4 +391,48 @@ public TestPage() // The generated code should include: double double0 = default; Assert.Contains("double double0 = default;", generated, StringComparison.Ordinal); } -} \ No newline at end of file + + [Fact] + public void OnPlatformViewWithMissingTargetPlatformShouldNotEmitNullabilityWarnings() + { + var xaml = +""" + + + + + + + +"""; + + var code = +""" +using System; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; + +namespace Test; + +[XamlProcessing(XamlInflator.SourceGen)] +public partial class TestPage : ContentPage +{ + public TestPage() + { + InitializeComponent(); + } +} +"""; + + var (result, generated) = RunGenerator(xaml, code, targetFramework: "net10.0-android"); + + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "CS8600" || d.Id == "CS8602"); + Assert.Contains("global::Microsoft.Maui.Controls.View", generated, StringComparison.Ordinal); + Assert.Contains("default!;", generated, StringComparison.Ordinal); + Assert.DoesNotContain(".transientNamescope", generated, StringComparison.Ordinal); + } +} diff --git a/src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj b/src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj index 17f0b8fc79d4..1767e782eee7 100644 --- a/src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj +++ b/src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj @@ -13,13 +13,6 @@ true - - DEBUG - prompt - full - true - - diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..6d5158999702 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..fe4be74dc09a Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs new file mode 100644 index 000000000000..b9f5498ed4d3 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue32886.cs @@ -0,0 +1,45 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 32886, "[Android, iOS, Mac] Entry ClearButton not visible on dark theme", PlatformAffected.Android | PlatformAffected.iOS | PlatformAffected.macOS)] +public class Issue32886 : TestContentPage +{ + protected override void Init() + { + Title = "Issue32886"; + + // Create the UITestEntry with ClearButtonVisibility + var entry = new UITestEntry + { + Text = "Entry Text", + IsCursorVisible = false, + IsSpellCheckEnabled = false, + IsTextPredictionEnabled = false, + AutomationId = "TestEntry", + ClearButtonVisibility = ClearButtonVisibility.WhileEditing + }; + + var button = new Button + { + Text = "Change theme", + AutomationId = "ThemeButton" + }; + button.Clicked += Button_Clicked; + + var layout = new VerticalStackLayout(); + layout.Children.Add(entry); + layout.Children.Add(button); + + Content = layout; + + // Set background color based on app theme + this.SetAppThemeColor(BackgroundColorProperty, Colors.White, Colors.Black); + } + + private void Button_Clicked(object sender, EventArgs e) + { + if (Application.Current is not null) + { + Application.Current.UserAppTheme = Application.Current.UserAppTheme != AppTheme.Dark ? AppTheme.Dark : AppTheme.Light; + } + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..12e0dfe930eb Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..56990c3225f6 Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs new file mode 100644 index 000000000000..9cadff37384f --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32886.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue32886 : _IssuesUITest +{ + public Issue32886(TestDevice device) : base(device) + { + } + + public override string Issue => "[Android, iOS, Mac] Entry ClearButton not visible on dark theme"; + + [Test, Order(1)] + [Category(UITestCategories.Entry)] + public void EntryClearButtonShouldBeVisibleOnLightTheme() + { + App.WaitForElement("TestEntry"); + App.Tap("TestEntry"); +#if ANDROID // On Android, to address CI flakiness, the keyboard is dismissed. + if (App.WaitForKeyboardToShow(timeout: TimeSpan.FromSeconds(1))) + { + App.DismissKeyboard(); + } +#endif + +#if IOS + // On iOS, the virtual keyboard appears inconsistent with keyboard characters casing, can cause flaky test results. As this test verifying only the entry clear button color, crop the bottom portion of the screenshot to exclude the keyboard. + // Using DismissKeyboard() would unfocus the control in iOS, so we're using cropping instead to maintain focus during testing. + VerifyScreenshot(cropBottom: 1550); +#else + VerifyScreenshot(); +#endif + } + + [Test, Order(2)] + [Category(UITestCategories.Entry)] + public void EntryClearButtonShouldBeVisibleOnDarkTheme() + { + App.WaitForElement("TestEntry"); + App.Tap("ThemeButton"); +#if WINDOWS // On Windows, the clear button isn't visible when Entry loses focus, so manually focused to check its icon color. + App.Tap("TestEntry"); +#endif + +#if IOS + // On iOS, the virtual keyboard appears inconsistent with keyboard characters casing, can cause flaky test results. As this test verifying only the entry clear button color, crop the bottom portion of the screenshot to exclude the keyboard. + // Using DismissKeyboard() would unfocus the control in iOS, so we're using cropping instead to maintain focus during testing. + VerifyScreenshot(cropBottom: 1550); +#else + VerifyScreenshot(); +#endif + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..2e31c8a77b08 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..b971b924bc2c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png new file mode 100644 index 000000000000..a52d3ac13dcb Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnDarkTheme.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png new file mode 100644 index 000000000000..545e17b916f8 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/EntryClearButtonShouldBeVisibleOnLightTheme.png differ diff --git a/src/Controls/tests/Xaml.UnitTests/Controls.Xaml.UnitTests.csproj b/src/Controls/tests/Xaml.UnitTests/Controls.Xaml.UnitTests.csproj index 78a1f5e72a3a..443c5e8d898d 100644 --- a/src/Controls/tests/Xaml.UnitTests/Controls.Xaml.UnitTests.csproj +++ b/src/Controls/tests/Xaml.UnitTests/Controls.Xaml.UnitTests.csproj @@ -21,13 +21,6 @@ True - - $(DefineConstants);DEBUG - prompt - full - true - - diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml new file mode 100644 index 000000000000..76069d72ca19 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml @@ -0,0 +1,20 @@ + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml.cs new file mode 100644 index 000000000000..45adffd1f50c --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34039.xaml.cs @@ -0,0 +1,27 @@ +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public partial class Maui34039 : ContentPage +{ + public Maui34039() => InitializeComponent(); + + [Collection("Issue")] + public class Tests + { + [Theory] + [XamlInflatorData] + internal void SetterWithPropertyElementValueInTriggerIsAdded(XamlInflator inflator) + { + var page = new Maui34039(inflator); + Assert.NotNull(page); + // Verify the trigger has a setter with a FontImageSource value + var style = page.button.Style; + Assert.NotNull(style); + Assert.Single(style.Triggers); + var trigger = (Trigger)style.Triggers[0]; + Assert.Single(trigger.Setters); + Assert.IsType(trigger.Setters[0].Value); + } + } +} diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml new file mode 100644 index 000000000000..68d081eea748 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml.cs new file mode 100644 index 000000000000..abde19e99ed3 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34074.xaml.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.Maui.Controls.Core.UnitTests; +using Microsoft.Maui.Devices; +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public partial class Maui34074 : ContentPage +{ + public Maui34074() => InitializeComponent(); + + [Collection("Issue")] + public class Tests : IDisposable + { + readonly MockDeviceInfo _mockDeviceInfo; + + public Tests() + { + Application.SetCurrentApplication(new MockApplication()); + DeviceInfo.SetCurrent(_mockDeviceInfo = new MockDeviceInfo()); + } + + public void Dispose() => DeviceInfo.SetCurrent(null); + + [Theory] + [XamlInflatorData] + internal void OnPlatformViewMissingTargetUsesNullDefault(XamlInflator inflator) + { + _mockDeviceInfo.Platform = DevicePlatform.MacCatalyst; + var page = new Maui34074(inflator); + Assert.Null(page.Content); + } + + [Theory] + [XamlInflatorData] + internal void OnPlatformViewMatchingTargetStillWorks(XamlInflator inflator) + { + _mockDeviceInfo.Platform = DevicePlatform.WinUI; + var page = new Maui34074(inflator); + var label = Assert.IsType object? MapElementId { get; set; } + + /// + /// Gets a value indicating whether the map element is visible on the map. + /// + bool IsVisible { get; } + + /// + /// Gets the z-index of the map element, which controls its draw order relative to other elements. + /// Higher values are drawn on top of lower values. + /// + int ZIndex { get; } + + /// + /// Method called by the handler when user clicks on the element. + /// + void Clicked(); } } diff --git a/src/Core/maps/src/Handlers/Map/MapHandler.Android.cs b/src/Core/maps/src/Handlers/Map/MapHandler.Android.cs index 3efed6f29e67..14f48081c807 100644 --- a/src/Core/maps/src/Handlers/Map/MapHandler.Android.cs +++ b/src/Core/maps/src/Handlers/Map/MapHandler.Android.cs @@ -62,6 +62,9 @@ protected override void DisconnectHandler(MapView platformView) if (Map != null) { Map.SetOnCameraMoveListener(null); + Map.SetOnPolygonClickListener(null); + Map.SetOnCircleClickListener(null); + Map.SetOnPolylineClickListener(null); Map.MarkerClick -= OnMarkerClick; Map.InfoWindowClick -= OnInfoWindowClick; Map.MapClick -= OnMapClick; @@ -154,6 +157,12 @@ void PolygonOnPropertyChanged(IGeoPathMapElement mauiPolygon) nativePolygon.StrokeWidth = (float)mauiPolygon.StrokeThickness; nativePolygon.Points = mauiPolygon.Select(position => new LatLng(position.Latitude, position.Longitude)).ToList(); + + if (mauiPolygon is IMapElement mapElement) + { + nativePolygon.Visible = mapElement.IsVisible; + nativePolygon.ZIndex = mapElement.ZIndex; + } } void PolylineOnPropertyChanged(IGeoPathMapElement mauiPolyline) @@ -168,6 +177,12 @@ void PolylineOnPropertyChanged(IGeoPathMapElement mauiPolyline) nativePolyline.Width = (float)mauiPolyline.StrokeThickness; nativePolyline.Points = mauiPolyline.Select(position => new LatLng(position.Latitude, position.Longitude)).ToList(); + + if (mauiPolyline is IMapElement mapElement) + { + nativePolyline.Visible = mapElement.IsVisible; + nativePolyline.ZIndex = mapElement.ZIndex; + } } @@ -189,6 +204,11 @@ void CircleOnPropertyChanged(ICircleMapElement mauiCircle) nativeCircle.Radius = mauiCircle.Radius.Meters; nativeCircle.StrokeWidth = (float)mauiCircle.StrokeThickness; + if (mauiCircle is IMapElement mapElement) + { + nativeCircle.Visible = mapElement.IsVisible; + nativeCircle.ZIndex = mapElement.ZIndex; + } } protected APolyline? GetNativePolyline(IGeoPathMapElement polyline) @@ -283,6 +303,9 @@ internal void OnMapReady(GoogleMap map) Map = map; map.SetOnCameraMoveListener(_mapReady); + map.SetOnPolygonClickListener(_mapReady); + map.SetOnCircleClickListener(_mapReady); + map.SetOnPolylineClickListener(_mapReady); map.MarkerClick += OnMarkerClick; map.InfoWindowClick += OnInfoWindowClick; @@ -529,6 +552,13 @@ void AddPolyline(IGeoPathMapElement polyline) var nativePolyline = map.AddPolyline(options); polyline.MapElementId = nativePolyline.Id; + nativePolyline.Clickable = true; + + if (polyline is IMapElement mapElement) + { + nativePolyline.Visible = mapElement.IsVisible; + nativePolyline.ZIndex = mapElement.ZIndex; + } _polylines.Add(nativePolyline); } @@ -551,6 +581,13 @@ void AddPolygon(IGeoPathMapElement polygon) var nativePolygon = map.AddPolygon(options); polygon.MapElementId = nativePolygon.Id; + nativePolygon.Clickable = true; + + if (polygon is IMapElement mapElement) + { + nativePolygon.Visible = mapElement.IsVisible; + nativePolygon.ZIndex = mapElement.ZIndex; + } _polygons.Add(nativePolygon); } @@ -572,12 +609,19 @@ void AddCircle(ICircleMapElement circle) var nativeCircle = map.AddCircle(options); circle.MapElementId = nativeCircle.Id; + nativeCircle.Clickable = true; + + if (circle is IMapElement mapElement) + { + nativeCircle.Visible = mapElement.IsVisible; + nativeCircle.ZIndex = mapElement.ZIndex; + } _circles.Add(nativeCircle); } } - class MapCallbackHandler : Java.Lang.Object, GoogleMap.IOnCameraMoveListener, IOnMapReadyCallback + class MapCallbackHandler : Java.Lang.Object, GoogleMap.IOnCameraMoveListener, IOnMapReadyCallback, GoogleMap.IOnPolygonClickListener, GoogleMap.IOnCircleClickListener, GoogleMap.IOnPolylineClickListener { MapHandler? _handler; GoogleMap? _googleMap; @@ -611,6 +655,18 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + + public void OnCircleClick(ACircle circle) => SendElementClickEvent(circle.Id); + + public void OnPolygonClick(APolygon polygon) => SendElementClickEvent(polygon.Id); + + public void OnPolylineClick(APolyline polyline) => SendElementClickEvent(polyline.Id); + + void SendElementClickEvent(string elementId) + { + var element = _handler?.VirtualView.Elements.FirstOrDefault(x => x.MapElementId?.ToString() == elementId); + element?.Clicked(); + } } } diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Android.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Android.cs index f715a2fdf58c..5bc4c33b3577 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Android.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Android.cs @@ -117,5 +117,19 @@ public static void MapCenter(IMapElementHandler handler, IMapElement mapElement) circleOptions.InvokeCenter(new LatLng(circleMapElement.Center.Latitude, circleMapElement.Center.Longitude)); } + + public static void MapIsVisible(IMapElementHandler handler, IMapElement mapElement) + { + // Visibility is applied on the native object after it is added to the map, + // via the UpdateMapElement path in MapHandler.Android.cs. + // PolygonOptions/PolylineOptions/CircleOptions don't expose a Visible setter. + } + + public static void MapZIndex(IMapElementHandler handler, IMapElement mapElement) + { + // ZIndex is applied on the native object after it is added to the map, + // via the UpdateMapElement path in MapHandler.Android.cs. + // PolygonOptions/PolylineOptions/CircleOptions don't expose a ZIndex setter. + } } } diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Standard.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Standard.cs index 234a9f4ca869..b55446d6e93c 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Standard.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Standard.cs @@ -7,5 +7,7 @@ public partial class MapElementHandler : ElementHandler public static void MapStroke(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapStrokeThickness(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapFill(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapIsVisible(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapZIndex(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); } } diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Tizen.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Tizen.cs index 234a9f4ca869..b55446d6e93c 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Tizen.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Tizen.cs @@ -7,5 +7,7 @@ public partial class MapElementHandler : ElementHandler public static void MapStroke(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapStrokeThickness(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapFill(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapIsVisible(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapZIndex(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); } } diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Windows.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Windows.cs index eaf8d85e3e59..6cb6cff20db7 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Windows.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.Windows.cs @@ -8,5 +8,7 @@ public partial class MapElementHandler : ElementHandler public static void MapStroke(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapStrokeThickness(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); public static void MapFill(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapIsVisible(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); + public static void MapZIndex(IMapElementHandler handler, IMapElement mapElement) => throw new System.NotImplementedException(); } } diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.cs index 74bc219cca5b..c8cb453477f8 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.cs @@ -26,6 +26,8 @@ public partial class MapElementHandler : IMapElementHandler [nameof(IMapElement.Stroke)] = MapStroke, [nameof(IMapElement.StrokeThickness)] = MapStrokeThickness, [nameof(IFilledMapElement.Fill)] = MapFill, + [nameof(IMapElement.IsVisible)] = MapIsVisible, + [nameof(IMapElement.ZIndex)] = MapZIndex, #if MONOANDROID ["Geopath"] = MapGeopath, [nameof(ICircleMapElement.Radius)] = MapRadius, diff --git a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.iOS.cs b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.iOS.cs index ddec558fce9e..0d6ebed492bd 100644 --- a/src/Core/maps/src/Handlers/MapElement/MapElementHandler.iOS.cs +++ b/src/Core/maps/src/Handlers/MapElement/MapElementHandler.iOS.cs @@ -59,5 +59,17 @@ public static void MapFill(IMapElementHandler handler, IMapElement mapElement) if (handler.PlatformView is MKCircleRenderer circleRenderer) circleRenderer.FillColor = platformColor; } + + public static void MapIsVisible(IMapElementHandler handler, IMapElement mapElement) + { + handler.PlatformView.Alpha = mapElement.IsVisible ? 1 : 0; + } + + public static void MapZIndex(IMapElementHandler handler, IMapElement mapElement) + { + // MapKit does not support fine-grained ZIndex on overlays. + // Overlays are drawn in the order they are added to the map. + // The property is accepted but has no visual effect on iOS/MacCatalyst. + } } } diff --git a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs index 9c2137dee456..bdb3c383c22d 100644 --- a/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs +++ b/src/Core/maps/src/Platform/iOS/MauiMKMapView.cs @@ -334,6 +334,67 @@ static void OnMapClicked(UITapGestureRecognizer recognizer) if (mauiMkMapView._handlerRef.TryGetTarget(out IMapHandler? handler)) handler?.VirtualView.Clicked(new Devices.Sensors.Location(tapGPS.Latitude, tapGPS.Longitude)); + + void SendClickEvent(IMKOverlay overlay) + { + handler?.VirtualView.Elements + .FirstOrDefault(x => x.MapElementId == overlay)? + .Clicked(); + } + + // Hit-test overlays in order: Circle > Polygon > Polyline (first match wins) + foreach (var overlay in mauiMkMapView.Overlays) + { + if (overlay is MKCircle circle) + { + var center = new CLLocation(circle.Coordinate.Latitude, circle.Coordinate.Longitude); + var touch = new CLLocation(tapGPS.Latitude, tapGPS.Longitude); + var distance = center.DistanceFrom(touch); + + if (distance <= circle.Radius) + { + SendClickEvent(overlay); + break; + } + } + else if (overlay is MKPolygon polygon) + { + var tapCoord = new CLLocationCoordinate2D(tapGPS.Latitude, tapGPS.Longitude); + var renderer = mauiMkMapView.GetViewForOverlayDelegate(mauiMkMapView, polygon) as MKPolygonRenderer; + + if (renderer?.Path is not null) + { + var mapPoint = MKMapPoint.FromCoordinate(tapCoord); + var pointInRenderer = renderer.PointForMapPoint(mapPoint); + + if (renderer.Path.ContainsPoint(pointInRenderer, true)) + { + SendClickEvent(overlay); + break; + } + } + } + else if (overlay is MKPolyline polyline) + { + var renderer = mauiMkMapView.GetViewForOverlayDelegate(mauiMkMapView, polyline) as MKPolylineRenderer; + + if (renderer?.Path is not null) + { + var tapCoord = new CLLocationCoordinate2D(tapGPS.Latitude, tapGPS.Longitude); + var mapPoint = MKMapPoint.FromCoordinate(tapCoord); + var pointInRenderer = renderer.PointForMapPoint(mapPoint); + + // Use a minimum tap target width for easier polyline interaction + var hitTestWidth = renderer.LineWidth < 44 ? (nfloat)44 : renderer.LineWidth; + using var strokedPath = renderer.Path.CopyByStrokingPath(hitTestWidth, CoreGraphics.CGLineCap.Round, CoreGraphics.CGLineJoin.Round, 1); + if (strokedPath?.ContainsPoint(pointInRenderer, true) == true) + { + SendClickEvent(overlay); + break; + } + } + } + } } } } diff --git a/src/Core/maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/net/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 9bd77ba275b9..c8621db4917b 100644 --- a/src/Core/maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Core/maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -1,7 +1,13 @@ + #nullable enable +Microsoft.Maui.Maps.IMapElement.Clicked() -> void +Microsoft.Maui.Maps.IMapElement.IsVisible.get -> bool +Microsoft.Maui.Maps.IMapElement.ZIndex.get -> int Microsoft.Maui.Maps.MapSpanTypeConverter Microsoft.Maui.Maps.MapSpanTypeConverter.MapSpanTypeConverter() -> void override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Type! sourceType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.CanConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Type? destinationType) -> bool override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertFrom(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object! value) -> object? override Microsoft.Maui.Maps.MapSpanTypeConverter.ConvertTo(System.ComponentModel.ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, System.Type! destinationType) -> object? +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapIsVisible(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void +static Microsoft.Maui.Maps.Handlers.MapElementHandler.MapZIndex(Microsoft.Maui.Maps.Handlers.IMapElementHandler! handler, Microsoft.Maui.Maps.IMapElement! mapElement) -> void diff --git a/src/Core/src/Platform/Android/EditTextExtensions.cs b/src/Core/src/Platform/Android/EditTextExtensions.cs index bbb99c029ab4..293571ac8043 100644 --- a/src/Core/src/Platform/Android/EditTextExtensions.cs +++ b/src/Core/src/Platform/Android/EditTextExtensions.cs @@ -231,7 +231,23 @@ internal static void UpdateClearButtonColor(this EditText editText, Graphics.Col } else { - clearButtonDrawable?.ClearColorFilter(); + if (OperatingSystem.IsAndroidVersionAtLeast(23) && editText.Context?.Theme is Resources.Theme theme) + { + using var ta = theme.ObtainStyledAttributes([global::Android.Resource.Attribute.TextColorPrimary]); + var cs = ta.GetColorStateList(0); + + if (cs is not null) + { + // Clear button is only visible when enabled, so just use the enabled state + int[] enabledState = [global::Android.Resource.Attribute.StateEnabled]; + var color = new global::Android.Graphics.Color(cs.GetColorForState(enabledState, Colors.Black.ToPlatform())); + clearButtonDrawable?.SetColorFilter(color, FilterMode.SrcIn); + } + } + else + { + clearButtonDrawable?.ClearColorFilter(); + } } } diff --git a/src/Core/src/Platform/iOS/TextFieldExtensions.cs b/src/Core/src/Platform/iOS/TextFieldExtensions.cs index 7a501d0d8f83..880bbdf886c1 100644 --- a/src/Core/src/Platform/iOS/TextFieldExtensions.cs +++ b/src/Core/src/Platform/iOS/TextFieldExtensions.cs @@ -215,8 +215,8 @@ internal static void UpdateClearButtonColor(this UITextField textField, IEntry e if (entry.TextColor is null) { - clearButton.SetImage(defaultClearImage, UIControlState.Normal); - clearButton.SetImage(defaultClearImage, UIControlState.Highlighted); + // Setting TintColor to null allows the system to automatically apply the appropriate color based on the current theme (light or dark mode) + clearButton.TintColor = null; } else { diff --git a/src/DotNet/DotNet.csproj b/src/DotNet/DotNet.csproj index 908fb6a7a43b..5a7f6ca64f7c 100644 --- a/src/DotNet/DotNet.csproj +++ b/src/DotNet/DotNet.csproj @@ -80,7 +80,9 @@ <_WorkloadSource Include="$(NugetArtifactsPath)" /> - <_LocalWorkloadIds Include="maui" /> + + <_LocalWorkloadIds Include="maui-android" Condition="$([MSBuild]::IsOSPlatform('linux'))" /> + <_LocalWorkloadIds Include="maui" Condition="!$([MSBuild]::IsOSPlatform('linux'))" /> <_LocalWorkloadIds Include="tizen" Condition=" '$(IncludeTizenTargetFrameworks)' == 'true' " /> diff --git a/src/Essentials/test/UnitTests/Essentials.UnitTests.csproj b/src/Essentials/test/UnitTests/Essentials.UnitTests.csproj index 14e2324edc84..615042d15570 100644 --- a/src/Essentials/test/UnitTests/Essentials.UnitTests.csproj +++ b/src/Essentials/test/UnitTests/Essentials.UnitTests.csproj @@ -4,7 +4,6 @@ $(_MauiDotNetTfm) false Microsoft.Maui.Essentials.UnitTests - portable Debug;Release diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs index 2e8027bcfdfd..a4115a3f182d 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/AndroidTemplateTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using Microsoft.Maui.IntegrationTests.Android; namespace Microsoft.Maui.IntegrationTests @@ -94,6 +95,12 @@ public void RunOnAndroid(string id, string framework, string config, string? tri Assert.True(DotnetInternal.New(id, projectDir, framework, output: _output), $"Unable to create template {id}. Check test output for errors."); + // On Linux, only the maui-android workload is installed. Previous .NET + // templates may still include iOS/macOS TFMs causing NETSDK1178 errors + // during restore. Strip them so only Android remains. + if (TestEnvironment.IsLinux) + StripNonAndroidTfms(projectFile, framework); + var buildProps = BuildProps; if (!string.IsNullOrEmpty(trimMode)) { @@ -128,5 +135,20 @@ void AddInstrumentation(string projectDir) "MainLauncher = true, Name = \"com.microsoft.mauitemplate.MainActivity\""); } + static void StripNonAndroidTfms(string projectFile, string framework) + { + var content = File.ReadAllText(projectFile); + var androidTfm = $"{framework}-android"; + // Remove conditional TargetFrameworks lines (iOS/macOS/Windows additions) + content = Regex.Replace(content, + @"\s*[^<]*", + ""); + // Set the base TargetFrameworks to Android only + content = Regex.Replace(content, + @"[^<]*", + $"{androidTfm}"); + File.WriteAllText(projectFile, content); + } + } }